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
//! Graceful-shutdown helpers for the session manager.
//!
//! Why: `manager.rs` is near the 500-SLOC production cap; placing the
//! shutdown operation in a sibling file keeps both files well under the limit
//! while keeping related lifecycle logic co-located in the `session_manager`
//! module tree.
//! What: `impl SessionManager { shutdown }` — gracefully stops every live
//! (non-terminal) managed session the manager proves it owns (#9101). Each
//! owned pane gets ONE signal — SIGTERM when its `claude` PID is known
//! (#1595 PR B), else a single Ctrl-C to that pane — then one shared 2 s
//! `tokio::time::sleep` grace window (never a blocking `std::thread::sleep`),
//! then a kill by `$N` session id after a second ownership check.
//! Test: `shutdown_stops_an_owned_active_session` (session_manager/tests.rs),
//! `a_stale_record_after_a_server_restart_is_never_killed_by_shutdown`.
use std::time::Duration;
use tracing::{info, warn};
use super::manager::{ManagedError, SessionManager};
use super::record::{ManagedSessionState, SessionRecord};
use super::runtime_identity::{self, RuntimeOwnership, RuntimeTeardown};
/// Grace window given to each session between signal and tmux kill.
///
/// Why: 2 s is the industry convention (matches systemd's default
/// `TimeoutStopSec` and launchd's `ExitTimeOut`) and is enough for claude to
/// flush its in-memory state to disk without stalling the overall shutdown.
/// In test builds, the grace window is 0 s so unit tests are not blocked by
/// the sleep (eliminating the need for tokio's `test-util` feature).
#[cfg(not(test))]
const SIGTERM_GRACE_SECS: u64 = 2;
#[cfg(test)]
const SIGTERM_GRACE_SECS: u64 = 0;
impl SessionManager {
/// Gracefully stop every live managed session this manager owns, on
/// daemon shutdown.
///
/// Why: the graceful-shutdown path in `daemon/mod.rs` needs to give every
/// running session a chance to persist its state before the process exits.
/// #9101: it used to signal and kill each record's session by NAME, so a
/// stale record after a tmux server restart took down whichever session
/// now carried the name. It now acts only on a session proven to hold the
/// record's own pane on the record's own server, the proof
/// [`Self::graceful_terminate_runtime`] uses.
/// What: collects all Active/Provisioning records, minus the #8942
/// protected kinds and names the kill floor refuses. Each is classified by
/// [`runtime_identity::runtime_ownership`]; anything but `Owned` is
/// skipped and logged at `warn`, with no signal and no kill. Each owned
/// pane gets `signal_terminate_pane` (SIGTERM to its `claude` PID, else
/// one Ctrl-C to that pane); then one [`SIGTERM_GRACE_SECS`] async grace
/// window for all; then a second ownership check and `kill_session_id` on
/// the `$N` id it read. A session gone by then counts as stopped. Fails
/// open per session; logs a summary.
/// Test: `shutdown_stops_an_owned_active_session` in tests.rs;
/// `shutdown_skips_a_supervisor_session`;
/// `a_stale_record_after_a_server_restart_is_never_killed_by_shutdown`,
/// `shutdown_skips_a_record_whose_pane_identity_is_unreadable`.
pub async fn shutdown(&self) {
let records = self.list().await;
// Only stop sessions that have a live runtime: Active and Provisioning.
// #8942: the Architect's sessions are left out, and so is any name the
// floor cannot clear.
let live: Vec<_> = records
.into_iter()
.filter(|r| {
matches!(
r.state,
ManagedSessionState::Active | ManagedSessionState::Provisioning
) && !super::supervisor::skip_protected(r, "shutdown")
&& self
.kill_gate(&r.tmux_name, "SessionManager::shutdown")
.is_ok()
})
.collect();
// #9101: signal only a pane proven to be the record's own;
// `owned_pane` logs every other verdict.
let mut owned: Vec<&SessionRecord> = Vec::new();
for record in &live {
let (ownership, liveness_known) = self.classify_runtime(record);
if let Ok((pane_id, _)) =
owned_pane(record, "shutdown", ownership, liveness_known, false)
{
let name = record.tmux_name.as_str();
let pid = crate::core::process::find_claude_pid_in_pane(name, &pane_id);
self.tmux.signal_terminate_pane(name, &pane_id, pid);
owned.push(record);
}
}
if owned.is_empty() {
info!("shutdown: no owned live managed sessions to stop");
return;
}
// Single async grace window for ALL sessions — bounds shutdown at O(1)
// and lets the claude processes flush state before the kill.
tokio::time::sleep(Duration::from_secs(SIGTERM_GRACE_SECS)).await;
let mut stopped = 0usize;
for record in owned {
// #9101: the grace window is long enough for the name to change
// hands; kill the `$N` session the re-check proves, never the name.
let (ownership, liveness_known) = self.classify_runtime(record);
let session_id = match owned_pane(record, "shutdown", ownership, liveness_known, true) {
Ok((_, session_id)) => session_id,
Err(RuntimeTeardown::Absent) => {
stopped += 1;
continue;
}
Err(_) => continue,
};
match self.tmux.kill_session_id(&record.tmux_name, &session_id) {
Ok(()) => stopped += 1,
Err(e) => warn!(
name = %record.tmux_name, session_id = %session_id,
"shutdown: kill by session id failed (may already be gone): {e}"
),
}
}
info!("shutdown: gracefully stopped {stopped} live managed session(s)");
}
/// Gracefully terminate ONE record's runtime: signal → grace → kill.
///
/// Why: CLI-initiated `stop`/`decommission` tear down a single session at a
/// time (unlike [`Self::shutdown`], which batches one grace window across the
/// whole fleet) and were previously abrupt — a bare `kill_session` gave the
/// inner `claude` process no chance to flush state (memory snapshot, git
/// index, session record) before its pane was reclaimed. This drains one
/// session the same way `shutdown` drains the fleet, honouring the repo's
/// graceful-shutdown convention (SIGTERM drain, not an abrupt kill).
/// #8935: a tmux name is reusable, so the teardown first proves the live
/// session holds the record's own `%N` pane
/// ([`runtime_identity::runtime_ownership`]). A session that does not, or
/// whose ownership cannot be proved, is neither signalled nor killed.
/// What: classifies the runtime. `Absent` → [`RuntimeTeardown::Absent`], no
/// signal, no grace sleep. Any live name → [`Self::kill_gate`] first.
/// `Foreign` → warns under `caller` and returns [`RuntimeTeardown::Foreign`];
/// `Unverifiable`, or a probe error → [`RuntimeTeardown::Unproven`]. `Owned`
/// → the `claude` PID of the record's pane, `signal_terminate_pane`
/// (SIGTERM when the PID is known, else one Ctrl-C to that pane), a
/// [`SIGTERM_GRACE_SECS`] async grace window (0 s in tests), a second
/// ownership check, and a kill only when the pane is still the
/// session's; a session gone by then reports `Terminated`. #9004: the kill
/// is `kill_session_id` on the `$N` id that re-check read, never the name.
/// A kill failure is logged, not returned, because
/// the caller still needs to mark the record `Stopped` / decommissioned.
/// #8942 (critic HIGH): the one failure that IS returned is a kill-floor
/// refusal, [`ManagedError::KillRefused`] — from [`Self::kill_gate`] under
/// `caller`'s name before any signal, or from the driver's own floor at the
/// kill — so the caller aborts before it removes a workspace or moves the
/// record.
/// Test: `graceful_terminate_runtime_signals_then_kills` in restart_tests.rs;
/// `stopping_a_stale_record_never_signals_the_live_session_that_reused_its_name`,
/// `an_unlistable_pane_set_leaves_the_runtime_running`,
/// `stopping_a_record_whose_pane_is_live_still_kills_it`,
/// `a_pane_that_changes_hands_during_the_grace_window_is_not_killed`,
/// `a_session_gone_after_the_grace_window_is_terminated_without_a_kill` in
/// runtime_identity_tests.rs; the refusal by
/// `an_undeterminable_floor_aborts_stop_and_decommission_of_an_ordinary_session`.
pub(crate) async fn graceful_terminate_runtime(
&self,
record: &SessionRecord,
caller: &str,
) -> Result<RuntimeTeardown, ManagedError> {
let tmux_name = record.tmux_name.as_str();
// #8935: prove the live session is this record's before any signal.
// The fast path (`Absent`) skips the grace sleep, so callers looping
// over dead sessions do not pay SIGTERM_GRACE_SECS each.
let (ownership, liveness_known) = self.classify_runtime(record);
if ownership == RuntimeOwnership::Absent {
return Ok(RuntimeTeardown::Absent);
}
// #8942: a live name the floor cannot clear refuses the teardown,
// typed, whichever session holds it.
self.kill_gate(tmux_name, caller)?;
// #8935: the driver's own floor answers here too, before the ownership
// verdict can turn a protected name into a quiet record-only stop.
if let Some(why) = self.tmux.supervisor_floor().refuse(tmux_name, caller) {
return Err(ManagedError::KillRefused(why));
}
let (pane_id, _) = match owned_pane(record, caller, ownership, liveness_known, false) {
Ok(owned) => owned,
Err(teardown) => return Ok(teardown),
};
// #8935: the record's own pane's claude, not the session's active pane;
// with no pid the Ctrl-C fallback targets that pane too.
let pid = crate::core::process::find_claude_pid_in_pane(tmux_name, &pane_id);
self.tmux.signal_terminate_pane(tmux_name, &pane_id, pid);
tokio::time::sleep(Duration::from_secs(SIGTERM_GRACE_SECS)).await;
// #8935: the grace window is long enough for the name to change hands.
// #8935 delta critic: an unproven verdict here says the pane was signalled.
let (ownership, liveness_known) = self.classify_runtime(record);
let session_id = match owned_pane(record, caller, ownership, liveness_known, true) {
Ok((_, session_id)) => session_id,
Err(teardown) => {
return Ok(match teardown {
RuntimeTeardown::Absent => RuntimeTeardown::Terminated,
other => other,
});
}
};
// #9004: kill the `$N` session the re-check just proved holds the
// record's pane. A by-name kill here would reach a session that took
// the name after the re-check; a new session never gets this `$N`.
match self.tmux.kill_session_id(tmux_name, &session_id) {
Err(refused @ ManagedError::KillRefused(_)) => return Err(refused),
Err(e) => warn!(
name = %tmux_name, session_id = %session_id,
"graceful_terminate_runtime: kill by session id failed (may already be gone): {e}"
),
Ok(()) => {}
}
Ok(RuntimeTeardown::Terminated)
}
/// [`Self::graceful_terminate_runtime`] for a caller that destroys state
/// after the teardown, refusing a teardown whose ownership is unproven.
///
/// Why (#8935 critic round): decommission removes the workspace and
/// tombstones the record, and the idle reaper marks a record `Stopped`.
/// Doing either under a live claude that may be this record's own loses
/// work. A `Foreign` verdict is safe: the live session is another one.
/// What: runs the teardown; [`RuntimeTeardown::Unproven`] becomes
/// [`ManagedError::InvalidState`] naming the reason, whether a signal was
/// sent, and the two ways out (#8935 delta critic), so the caller returns
/// before it changes anything. Every other verdict passes through.
/// Test: `decommission_with_an_unlistable_pane_set_changes_nothing`,
/// `a_pane_list_lost_after_the_signal_refuses_decommission`,
/// `the_idle_reaper_stop_skips_an_unproven_runtime`.
pub(crate) async fn terminate_proven_runtime(
&self,
record: &SessionRecord,
caller: &str,
) -> Result<RuntimeTeardown, ManagedError> {
match self.graceful_terminate_runtime(record, caller).await? {
RuntimeTeardown::Unproven {
why,
liveness_known,
signalled,
} => Err(ManagedError::InvalidState(
record.id.to_string(),
unproven_refusal(record, caller, &why, liveness_known, signalled),
)),
teardown => Ok(teardown),
}
}
/// [`runtime_identity::runtime_ownership`] for a teardown, with whether
/// liveness is known: a probe error counts as unverifiable with liveness
/// unknown, so a teardown that cannot prove ownership kills nothing (#8935).
/// Test: `an_unreadable_session_probe_leaves_the_runtime_running`.
fn classify_runtime(&self, record: &SessionRecord) -> (RuntimeOwnership, bool) {
match runtime_identity::runtime_ownership(record, self.tmux.as_ref()) {
Ok(ownership) => (ownership, true),
Err(e) => (
RuntimeOwnership::Unverifiable(format!(
"the tmux probe for session '{}' failed ({e})",
record.tmux_name
)),
false,
),
}
}
}
/// The operator-facing text of an unproven-teardown refusal (#8935 delta
/// critic): the reason, what was done, and the two ways out.
/// Test: `a_pane_list_lost_after_the_signal_refuses_decommission`.
fn unproven_refusal(
record: &SessionRecord,
caller: &str,
why: &str,
liveness_known: bool,
signalled: bool,
) -> String {
let name = &record.tmux_name;
let doubt = if liveness_known {
format!("so the live tmux session '{name}' may still be this record's")
} else {
format!("so liveness unknown: tmux cannot say whether session '{name}' runs")
};
let done = if signalled {
"This record's pane was signalled; its workspace and record were kept."
} else {
"The workspace and the record were left as they are."
};
format!(
"{caller} refused: {why}, {doubt}. {done} If that session is this \
record's, end it yourself and rerun; otherwise run \
`tm session delete --force {id}` to drop the record only.",
id = record.id
)
}
/// `Ok((pane_id, session_id))` when `ownership` proves `record` owns its live
/// tmux session; otherwise the [`RuntimeTeardown`] to report, with a warning
/// naming `caller` (#8935, #9004).
/// Test: `stopping_a_stale_record_never_signals_the_live_session_that_reused_its_name`.
fn owned_pane(
record: &SessionRecord,
caller: &str,
ownership: RuntimeOwnership,
liveness_known: bool,
signalled: bool,
) -> Result<(String, String), RuntimeTeardown> {
match ownership {
RuntimeOwnership::Owned {
pane_id,
session_id,
} => Ok((pane_id, session_id)),
RuntimeOwnership::Absent => Err(RuntimeTeardown::Absent),
// #8935 critic round: the two verdicts stay apart so a destructive
// caller can refuse an unproven one.
RuntimeOwnership::Foreign(why) => {
warn!(
id = %record.id, name = %record.tmux_name, caller,
"{why}; nothing was killed (#8935)"
);
Err(RuntimeTeardown::Foreign(why))
}
RuntimeOwnership::Unverifiable(why) => {
warn!(
id = %record.id, name = %record.tmux_name, caller,
"{why}; nothing was killed (#8935)"
);
Err(RuntimeTeardown::Unproven {
why,
liveness_known,
signalled,
})
}
}
}