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
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
//! 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};
use super::supervisor_floor::SupervisorFloor;
/// 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 })
}
/// Wrap an already-built driver — tests only, for a scripted fake tmux.
#[cfg(test)]
pub(crate) fn from_driver_for_test(driver: TmuxDriver) -> Self {
Self { driver }
}
}
impl ManagedTmuxDriver for RealTmuxDriver {
/// #8942: signal under the same floor the wrapped driver kills under.
fn supervisor_floor(&self) -> SupervisorFloor {
self.driver.supervisor_floor().clone()
}
/// 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();
}
/// #8942: the floor's refusal comes back typed, as
/// [`ManagedError::KillRefused`], so a teardown aborts on it.
fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
let floor = self.driver.supervisor_floor();
if let Some(why) = floor.refuse(name, "RealTmuxDriver::kill_session") {
return Err(ManagedError::KillRefused(why));
}
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}"
)))
}
}
/// #9101: `rename-session -t $N <new>`; a `session_id` that is not a `$N`
/// id is refused before tmux runs, so no name ever reaches the target.
fn rename_session_id(
&self,
name: &str,
session_id: &str,
new: &str,
) -> Result<(), ManagedError> {
if !(session_id.starts_with('$') && trusty_common::tmux::is_immutable_id(session_id)) {
return Err(ManagedError::InvalidState(
name.to_owned(),
format!("{session_id:?} is not a tmux session id; '{name}' was not renamed"),
));
}
self.rename_session(session_id, new)
}
/// 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)
}
/// #9313: the id column of `list-panes -s`; a failed query is `Err`.
fn session_pane_ids(&self, name: &str) -> Result<Vec<String>, ManagedError> {
let rows = self
.driver
.list_panes(name)
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))?;
Ok(rows
.iter()
.filter_map(|row| row.split(':').next())
.filter(|id| id.starts_with('%'))
.map(str::to_owned)
.collect())
}
/// #9004: one `display-message -t %N`, parsed strictly — a missing pane
/// answers exit 0 with empty fields, which parses as `Err`.
fn pane_identity(
&self,
pane_id: &str,
) -> Result<super::pane_identity::PaneIdentity, ManagedError> {
let line = self
.driver
.pane_identity_line(pane_id)
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))?;
super::pane_identity::PaneIdentity::parse(pane_id, &line)
.map_err(ManagedError::TmuxUnavailable)
}
/// #9566: one `display-message -t %N '#{pane_current_command}'`.
fn pane_foreground_command(&self, pane_id: &str) -> Result<String, ManagedError> {
self.driver
.pane_current_command(pane_id)
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
/// #9004: `kill-session -t $N`, after the floor answers for `name`.
fn kill_session_id(&self, name: &str, session_id: &str) -> Result<(), ManagedError> {
let floor = self.driver.supervisor_floor();
if let Some(why) = floor.refuse(name, "RealTmuxDriver::kill_session_id") {
return Err(ManagedError::KillRefused(why));
}
self.driver
.kill_session_id(name, session_id)
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
}
/// 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()
}
/// #8942: the tri-state probe, pane process included.
fn pane_claude(&self, name: &str) -> crate::core::process::PaneClaude {
crate::core::process::pane_claude(name)
}
/// 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). #9101:
/// `get_pane_id` mints `%<name>` and `pane_identity` places that pane in
/// session `<name>` on one fake server, so pane operations on its records
/// pass the ownership gate.
/// 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())
}
/// #9101: a pane id naming its session, so a record this driver created
/// can prove ownership of its pane; this driver keeps no state.
fn get_pane_id(&self, name: &str) -> Option<String> {
Some(format!("%{name}"))
}
/// #9101: the pane [`Self::get_pane_id`] minted, on one fake server.
fn pane_identity(
&self,
pane_id: &str,
) -> Result<super::pane_identity::PaneIdentity, ManagedError> {
Ok(super::pane_identity::PaneIdentity {
pane_id: pane_id.to_owned(),
session_id: "$0".into(),
server: "1:1".into(),
session_name: pane_id.trim_start_matches('%').to_owned(),
})
}
fn send_keys_literal_to_pane(&self, _n: &str, _p: &str, _t: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_interrupt_to_pane(&self, _name: &str, _pane_id: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture_pane(&self, _n: &str, _p: &str, _lines: usize) -> Result<String, ManagedError> {
Ok(String::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"
);
}
}