@team-agent/installer 0.5.115 → 0.5.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/mod.rs +143 -12
- package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
- package/crates/team-agent/src/contract_runtime/backend.rs +88 -8
- package/crates/team-agent/src/contract_runtime/commands.rs +88 -7
- package/crates/team-agent/src/contract_runtime/discovery.rs +42 -10
- package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +2 -0
- package/crates/team-agent/src/contract_runtime/registry.rs +86 -9
- package/crates/team-agent/src/contract_runtime/workspace.rs +79 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +101 -72
- package/crates/team-agent/src/lifecycle/restart/selection.rs +30 -8
- package/crates/team-agent/src/provider/session/capture.rs +24 -1
- package/crates/team-agent/src/state/persist.rs +72 -2
- package/crates/team-agent/src/state/projection.rs +29 -0
- package/crates/team-agent/src/state/repository/tests.rs +70 -0
- package/crates/team-agent/src/state/repository.rs +5 -1
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -57,6 +57,8 @@ pub(crate) const STATUS_REMINDER: &str =
|
|
|
57
57
|
"Next: use team-agent inbox leader -n 3 for replies. Status is not proof of task completion; do not inspect worker terminal content.";
|
|
58
58
|
|
|
59
59
|
pub mod adapters;
|
|
60
|
+
#[cfg(unix)]
|
|
61
|
+
mod shutdown_socket;
|
|
60
62
|
pub mod attach_app_server_leader;
|
|
61
63
|
pub mod diagnose;
|
|
62
64
|
pub mod emit;
|
|
@@ -1193,13 +1195,17 @@ pub mod lifecycle_port {
|
|
|
1193
1195
|
}
|
|
1194
1196
|
let workspace_socket = crate::tmux_backend::socket_name_for_workspace(workspace);
|
|
1195
1197
|
let source = state.get("tmux_socket_source").and_then(Value::as_str);
|
|
1196
|
-
let owned = source == Some("workspace")
|
|
1197
|
-
|
|
1198
|
-
&& endpoint_matches_workspace_socket(&endpoint, workspace, &workspace_socket);
|
|
1198
|
+
let owned = (source == Some("workspace") || source.is_none())
|
|
1199
|
+
&& endpoint_matches_workspace_socket(&endpoint, workspace, &workspace_socket);
|
|
1199
1200
|
if !owned {
|
|
1200
1201
|
return None;
|
|
1201
1202
|
}
|
|
1202
1203
|
let socket_file = socket_file_for_endpoint(&endpoint);
|
|
1204
|
+
if socket_file.as_ref().and_then(|path| path.file_name())
|
|
1205
|
+
!= Some(std::ffi::OsStr::new(&workspace_socket))
|
|
1206
|
+
{
|
|
1207
|
+
return None;
|
|
1208
|
+
}
|
|
1203
1209
|
Some(OwnedShutdownEndpoint {
|
|
1204
1210
|
endpoint,
|
|
1205
1211
|
socket_file,
|
|
@@ -1234,9 +1240,18 @@ pub mod lifecycle_port {
|
|
|
1234
1240
|
event_log: &crate::event_log::EventLog,
|
|
1235
1241
|
) -> OwnedEndpointCleanup {
|
|
1236
1242
|
let mut cleanup = OwnedEndpointCleanup::default();
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1243
|
+
let targets = match transport.list_targets() {
|
|
1244
|
+
Ok(targets) => targets,
|
|
1245
|
+
Err(error) => {
|
|
1246
|
+
cleanup.error = Some(format!("owned endpoint session probe failed: {error}"));
|
|
1247
|
+
if let Some(path) = endpoint.socket_file.as_ref() {
|
|
1248
|
+
cleanup.skipped_files.push(path.display().to_string());
|
|
1249
|
+
cleanup.residual_files.push(path.display().to_string());
|
|
1250
|
+
}
|
|
1251
|
+
return cleanup;
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
cleanup.remaining_sessions = targets
|
|
1240
1255
|
.into_iter()
|
|
1241
1256
|
.map(|pane| pane.session.as_str().to_string())
|
|
1242
1257
|
.collect::<std::collections::BTreeSet<_>>()
|
|
@@ -1257,20 +1272,136 @@ pub mod lifecycle_port {
|
|
|
1257
1272
|
);
|
|
1258
1273
|
return cleanup;
|
|
1259
1274
|
}
|
|
1260
|
-
//
|
|
1261
|
-
//
|
|
1262
|
-
//
|
|
1263
|
-
|
|
1275
|
+
// Empty enumeration is not server-death proof. Never kill-server:
|
|
1276
|
+
// remove only the exact workspace socket after an independent refused
|
|
1277
|
+
// nonblocking connection and fresh owned parent/socket inode fences.
|
|
1278
|
+
#[cfg(unix)]
|
|
1279
|
+
if let Some(path) = endpoint.socket_file.as_ref() {
|
|
1280
|
+
use super::shutdown_socket::{remove_dead_socket, SocketCleanup};
|
|
1281
|
+
match remove_dead_socket(path) {
|
|
1282
|
+
Ok(SocketCleanup::Removed) => {
|
|
1283
|
+
cleanup.removed_files.push(path.display().to_string());
|
|
1284
|
+
}
|
|
1285
|
+
Ok(SocketCleanup::Absent) => {}
|
|
1286
|
+
Ok(SocketCleanup::Preserved) => {
|
|
1287
|
+
cleanup.skipped_files.push(path.display().to_string());
|
|
1288
|
+
cleanup.residual_files.push(path.display().to_string());
|
|
1289
|
+
}
|
|
1290
|
+
Err(error) => {
|
|
1291
|
+
cleanup.skipped_files.push(path.display().to_string());
|
|
1292
|
+
cleanup.residual_files.push(path.display().to_string());
|
|
1293
|
+
cleanup.error = Some(format!("owned socket cleanup refused: {error}"));
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1264
1297
|
let _ = event_log.write(
|
|
1265
|
-
"shutdown.
|
|
1298
|
+
"shutdown.owned_endpoint_cleanup",
|
|
1266
1299
|
json!({
|
|
1267
1300
|
"endpoint": endpoint.endpoint,
|
|
1268
|
-
"
|
|
1301
|
+
"removed_files": cleanup.removed_files,
|
|
1302
|
+
"skipped_files": cleanup.skipped_files,
|
|
1303
|
+
"residual_files": cleanup.residual_files,
|
|
1304
|
+
"error": cleanup.error,
|
|
1269
1305
|
}),
|
|
1270
1306
|
);
|
|
1271
1307
|
cleanup
|
|
1272
1308
|
}
|
|
1273
1309
|
|
|
1310
|
+
#[cfg(all(test, unix))]
|
|
1311
|
+
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
1312
|
+
mod shutdown_socket_tests {
|
|
1313
|
+
use super::*;
|
|
1314
|
+
use crate::transport::test_support::OfflineTransport;
|
|
1315
|
+
use crate::transport::{PaneId, PaneInfo};
|
|
1316
|
+
use std::os::unix::fs::PermissionsExt;
|
|
1317
|
+
use std::os::unix::net::UnixListener;
|
|
1318
|
+
|
|
1319
|
+
#[test]
|
|
1320
|
+
fn shutdown_socket_selection_rejects_shared_external_and_false_workspace_sources() {
|
|
1321
|
+
let workspace = Path::new("/tmp").join(format!("ta-shutdown-select-{}-{}",
|
|
1322
|
+
std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap()));
|
|
1323
|
+
std::fs::create_dir(&workspace).unwrap();
|
|
1324
|
+
let workspace = workspace.canonicalize().unwrap();
|
|
1325
|
+
let name = crate::tmux_backend::socket_name_for_workspace(&workspace);
|
|
1326
|
+
let transport = OfflineTransport::new();
|
|
1327
|
+
let selected = json!({"tmux_socket": name, "tmux_socket_source": "workspace"});
|
|
1328
|
+
assert!(owned_shutdown_endpoint(&workspace, &selected, &transport).is_some());
|
|
1329
|
+
for delta in [
|
|
1330
|
+
json!({"is_external_leader": true}),
|
|
1331
|
+
json!({"tmux_socket_source": "leader_env"}),
|
|
1332
|
+
json!({"tmux_socket": "default"}),
|
|
1333
|
+
json!({"tmux_socket": "/foreign/ta-not-this-workspace"}),
|
|
1334
|
+
json!({"tmux_socket_source": "shared"}),
|
|
1335
|
+
] {
|
|
1336
|
+
let mut state = selected.clone();
|
|
1337
|
+
state.as_object_mut().unwrap().extend(delta.as_object().unwrap().clone());
|
|
1338
|
+
assert!(owned_shutdown_endpoint(&workspace, &state, &transport).is_none());
|
|
1339
|
+
}
|
|
1340
|
+
std::fs::remove_dir(workspace).unwrap();
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
#[test]
|
|
1344
|
+
fn shutdown_socket_cleanup_removes_only_dead_socket_and_reports_live_residual() {
|
|
1345
|
+
let root = Path::new("/tmp").join(format!("ta-root-shutdown-{}-{}",
|
|
1346
|
+
std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap()));
|
|
1347
|
+
std::fs::create_dir(&root).unwrap();
|
|
1348
|
+
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
1349
|
+
let root = root.canonicalize().unwrap();
|
|
1350
|
+
let path = root.join("owned.sock");
|
|
1351
|
+
let endpoint = OwnedShutdownEndpoint {
|
|
1352
|
+
endpoint: path.display().to_string(), socket_file: Some(path.clone()),
|
|
1353
|
+
};
|
|
1354
|
+
let log = crate::event_log::EventLog::new(&root);
|
|
1355
|
+
let transport = OfflineTransport::new();
|
|
1356
|
+
let listener = UnixListener::bind(&path).unwrap();
|
|
1357
|
+
let live = cleanup_owned_empty_endpoint(&transport, &endpoint, &log);
|
|
1358
|
+
assert_eq!(live.residual_files, vec![path.display().to_string()]);
|
|
1359
|
+
assert!(live.removed_files.is_empty());
|
|
1360
|
+
assert!(path.exists());
|
|
1361
|
+
drop(listener);
|
|
1362
|
+
let dead = cleanup_owned_empty_endpoint(&transport, &endpoint, &log);
|
|
1363
|
+
assert_eq!(dead.removed_files, vec![path.display().to_string()]);
|
|
1364
|
+
assert!(dead.residual_files.is_empty());
|
|
1365
|
+
assert!(dead.error.is_none());
|
|
1366
|
+
assert!(!path.exists());
|
|
1367
|
+
assert!(!transport.calls().contains(&"kill_server"));
|
|
1368
|
+
std::fs::remove_dir_all(root).unwrap();
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
#[test]
|
|
1372
|
+
fn shutdown_socket_cleanup_preserves_sibling_and_unknown_session_probe() {
|
|
1373
|
+
let root = Path::new("/tmp").join(format!("ta-shutdown-sibling-{}-{}",
|
|
1374
|
+
std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap()));
|
|
1375
|
+
std::fs::create_dir(&root).unwrap();
|
|
1376
|
+
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
1377
|
+
let root = root.canonicalize().unwrap();
|
|
1378
|
+
let path = root.join("owned.sock");
|
|
1379
|
+
drop(UnixListener::bind(&path).unwrap());
|
|
1380
|
+
let endpoint = OwnedShutdownEndpoint {
|
|
1381
|
+
endpoint: path.display().to_string(), socket_file: Some(path.clone()),
|
|
1382
|
+
};
|
|
1383
|
+
let log = crate::event_log::EventLog::new(&root);
|
|
1384
|
+
let sibling = OfflineTransport::new().with_targets(vec![PaneInfo {
|
|
1385
|
+
pane_id: PaneId::new("%1"), session: crate::transport::SessionName::new("foreign"),
|
|
1386
|
+
window_index: None, window_name: None, pane_index: None, tty: None,
|
|
1387
|
+
current_command: None, current_path: None, active: true, pane_pid: None,
|
|
1388
|
+
leader_env: std::collections::BTreeMap::new(),
|
|
1389
|
+
}]);
|
|
1390
|
+
let kept = cleanup_owned_empty_endpoint(&sibling, &endpoint, &log);
|
|
1391
|
+
assert_eq!(kept.remaining_sessions, vec!["foreign".to_string()]);
|
|
1392
|
+
assert!(kept.removed_files.is_empty());
|
|
1393
|
+
assert!(path.exists());
|
|
1394
|
+
let failed = OfflineTransport::new().with_list_targets_error("unknown");
|
|
1395
|
+
let refused = cleanup_owned_empty_endpoint(&failed, &endpoint, &log);
|
|
1396
|
+
assert!(refused.error.is_some());
|
|
1397
|
+
assert_eq!(refused.residual_files, vec![path.display().to_string()]);
|
|
1398
|
+
assert!(path.exists());
|
|
1399
|
+
assert!(!sibling.calls().contains(&"kill_server"));
|
|
1400
|
+
assert!(!failed.calls().contains(&"kill_server"));
|
|
1401
|
+
std::fs::remove_dir_all(root).unwrap();
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1274
1405
|
pub fn shutdown_with_transport(
|
|
1275
1406
|
workspace: &Path,
|
|
1276
1407
|
keep_logs: bool,
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
//! Remove only a positively dead, owned Unix socket; never stop its server.
|
|
2
|
+
use std::ffi::CString;
|
|
3
|
+
use std::fs::{File, Metadata, OpenOptions};
|
|
4
|
+
use std::io;
|
|
5
|
+
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
|
6
|
+
use std::os::unix::ffi::OsStrExt;
|
|
7
|
+
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
|
|
8
|
+
use std::path::Path;
|
|
9
|
+
|
|
10
|
+
#[derive(Debug, PartialEq, Eq)]
|
|
11
|
+
pub(super) enum SocketCleanup {
|
|
12
|
+
Absent,
|
|
13
|
+
Removed,
|
|
14
|
+
Preserved,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
pub(super) fn remove_dead_socket(path: &Path) -> io::Result<SocketCleanup> {
|
|
18
|
+
remove_with_probe(path, listener_refused)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fn identity(metadata: &Metadata) -> (u64, u64, u32, u32, u64) {
|
|
22
|
+
(
|
|
23
|
+
metadata.dev(),
|
|
24
|
+
metadata.ino(),
|
|
25
|
+
metadata.mode(),
|
|
26
|
+
metadata.uid(),
|
|
27
|
+
metadata.nlink(),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fn invalid(reason: &str) -> io::Error {
|
|
32
|
+
io::Error::new(io::ErrorKind::PermissionDenied, reason)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fn remove_with_probe(
|
|
36
|
+
path: &Path,
|
|
37
|
+
probe: impl FnOnce(&Path) -> io::Result<bool>,
|
|
38
|
+
) -> io::Result<SocketCleanup> {
|
|
39
|
+
let socket = match std::fs::symlink_metadata(path) {
|
|
40
|
+
Ok(metadata) => metadata,
|
|
41
|
+
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(SocketCleanup::Absent),
|
|
42
|
+
Err(error) => return Err(error),
|
|
43
|
+
};
|
|
44
|
+
// SAFETY: geteuid has no pointer arguments or ownership effects.
|
|
45
|
+
let uid = unsafe { libc::geteuid() };
|
|
46
|
+
if !socket.file_type().is_socket() || socket.uid() != uid || socket.nlink() != 1 {
|
|
47
|
+
return Err(invalid("shutdown socket is not an owned single-link socket"));
|
|
48
|
+
}
|
|
49
|
+
let parent = path
|
|
50
|
+
.parent()
|
|
51
|
+
.ok_or_else(|| invalid("socket parent missing"))?;
|
|
52
|
+
let metadata = std::fs::symlink_metadata(parent)?;
|
|
53
|
+
if !metadata.is_dir()
|
|
54
|
+
|| metadata.uid() != uid
|
|
55
|
+
|| metadata.mode() & 0o077 != 0
|
|
56
|
+
|| parent.canonicalize()? != parent
|
|
57
|
+
{
|
|
58
|
+
return Err(invalid(
|
|
59
|
+
"shutdown socket parent is not a canonical private owned directory",
|
|
60
|
+
));
|
|
61
|
+
}
|
|
62
|
+
let directory: File = OpenOptions::new()
|
|
63
|
+
.read(true)
|
|
64
|
+
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
|
|
65
|
+
.open(parent)?;
|
|
66
|
+
if identity(&directory.metadata()?) != identity(&metadata) {
|
|
67
|
+
return Err(invalid("shutdown socket parent changed"));
|
|
68
|
+
}
|
|
69
|
+
if !probe(path)? {
|
|
70
|
+
return Ok(SocketCleanup::Preserved);
|
|
71
|
+
}
|
|
72
|
+
if identity(&std::fs::symlink_metadata(parent)?) != identity(&metadata) {
|
|
73
|
+
return Err(invalid("shutdown socket parent changed after death probe"));
|
|
74
|
+
}
|
|
75
|
+
let name = CString::new(
|
|
76
|
+
path.file_name()
|
|
77
|
+
.ok_or_else(|| invalid("socket name missing"))?
|
|
78
|
+
.as_bytes(),
|
|
79
|
+
)
|
|
80
|
+
.map_err(|_| invalid("socket name contains NUL"))?;
|
|
81
|
+
// SAFETY: stat is an all-integer C record; directory/name are valid for the call.
|
|
82
|
+
let mut fresh: libc::stat = unsafe { std::mem::zeroed() };
|
|
83
|
+
let result = unsafe {
|
|
84
|
+
libc::fstatat(
|
|
85
|
+
directory.as_raw_fd(),
|
|
86
|
+
name.as_ptr(),
|
|
87
|
+
&mut fresh,
|
|
88
|
+
libc::AT_SYMLINK_NOFOLLOW,
|
|
89
|
+
)
|
|
90
|
+
};
|
|
91
|
+
if result != 0 {
|
|
92
|
+
let error = io::Error::last_os_error();
|
|
93
|
+
return if error.kind() == io::ErrorKind::NotFound {
|
|
94
|
+
Ok(SocketCleanup::Absent)
|
|
95
|
+
} else {
|
|
96
|
+
Err(error)
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if fresh.st_dev as u64 != socket.dev()
|
|
100
|
+
|| fresh.st_ino as u64 != socket.ino()
|
|
101
|
+
|| fresh.st_mode as u32 != socket.mode()
|
|
102
|
+
|| fresh.st_uid != socket.uid()
|
|
103
|
+
|| fresh.st_nlink as u64 != socket.nlink()
|
|
104
|
+
{
|
|
105
|
+
return Err(invalid("shutdown socket identity changed after death probe"));
|
|
106
|
+
}
|
|
107
|
+
// SAFETY: unlink is relative to the pinned owned directory and freshly fenced socket.
|
|
108
|
+
if unsafe { libc::unlinkat(directory.as_raw_fd(), name.as_ptr(), 0) } != 0 {
|
|
109
|
+
return Err(io::Error::last_os_error());
|
|
110
|
+
}
|
|
111
|
+
Ok(SocketCleanup::Removed)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fn listener_refused(path: &Path) -> io::Result<bool> {
|
|
115
|
+
// SAFETY: socket has no borrowed pointer arguments; successful fd ownership moves below.
|
|
116
|
+
let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) };
|
|
117
|
+
if fd < 0 {
|
|
118
|
+
return Err(io::Error::last_os_error());
|
|
119
|
+
}
|
|
120
|
+
// SAFETY: fd is newly created and exclusively owned here.
|
|
121
|
+
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
|
|
122
|
+
// SAFETY: fcntl acts on our owned fd; no pointer arguments.
|
|
123
|
+
if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
|
|
124
|
+
return Err(io::Error::last_os_error());
|
|
125
|
+
}
|
|
126
|
+
let flags = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFL) };
|
|
127
|
+
if flags < 0 || unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
|
|
128
|
+
return Err(io::Error::last_os_error());
|
|
129
|
+
}
|
|
130
|
+
// SAFETY: sockaddr_un is a C integer/byte record; zero initializes all fields/padding.
|
|
131
|
+
let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() };
|
|
132
|
+
address.sun_family = libc::AF_UNIX as libc::sa_family_t;
|
|
133
|
+
let bytes = path.as_os_str().as_bytes();
|
|
134
|
+
if bytes.len() >= address.sun_path.len() || bytes.contains(&0) {
|
|
135
|
+
return Err(io::Error::new(
|
|
136
|
+
io::ErrorKind::InvalidInput,
|
|
137
|
+
"shutdown socket path exceeds Unix address bounds",
|
|
138
|
+
));
|
|
139
|
+
}
|
|
140
|
+
for (target, byte) in address.sun_path.iter_mut().zip(bytes) {
|
|
141
|
+
*target = *byte as libc::c_char;
|
|
142
|
+
}
|
|
143
|
+
#[cfg(any(
|
|
144
|
+
target_os = "macos",
|
|
145
|
+
target_os = "ios",
|
|
146
|
+
target_os = "freebsd",
|
|
147
|
+
target_os = "openbsd",
|
|
148
|
+
target_os = "netbsd",
|
|
149
|
+
target_os = "dragonfly"
|
|
150
|
+
))]
|
|
151
|
+
{
|
|
152
|
+
address.sun_len = std::mem::size_of_val(&address) as u8;
|
|
153
|
+
}
|
|
154
|
+
// SAFETY: address points to the fully initialized sockaddr_un of the supplied size.
|
|
155
|
+
let result = unsafe {
|
|
156
|
+
libc::connect(
|
|
157
|
+
fd.as_raw_fd(),
|
|
158
|
+
(&address as *const libc::sockaddr_un).cast(),
|
|
159
|
+
std::mem::size_of_val(&address) as libc::socklen_t,
|
|
160
|
+
)
|
|
161
|
+
};
|
|
162
|
+
if result == 0 {
|
|
163
|
+
return Ok(false);
|
|
164
|
+
}
|
|
165
|
+
match io::Error::last_os_error().raw_os_error() {
|
|
166
|
+
Some(libc::ECONNREFUSED) => Ok(true),
|
|
167
|
+
Some(libc::EINPROGRESS | libc::EAGAIN | libc::ENOENT) => Ok(false),
|
|
168
|
+
_ => Err(io::Error::last_os_error()),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#[cfg(test)]
|
|
173
|
+
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
174
|
+
mod tests {
|
|
175
|
+
use super::*;
|
|
176
|
+
use std::os::unix::fs::{symlink, PermissionsExt};
|
|
177
|
+
use std::os::unix::net::UnixListener;
|
|
178
|
+
|
|
179
|
+
struct Fixture(std::path::PathBuf);
|
|
180
|
+
impl Fixture {
|
|
181
|
+
fn new() -> Self {
|
|
182
|
+
let path = Path::new("/tmp").join(format!(
|
|
183
|
+
"ta-shutdown-{}-{}",
|
|
184
|
+
std::process::id(),
|
|
185
|
+
std::time::SystemTime::now()
|
|
186
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
187
|
+
.unwrap()
|
|
188
|
+
.as_nanos()
|
|
189
|
+
));
|
|
190
|
+
std::fs::create_dir(&path).unwrap();
|
|
191
|
+
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
192
|
+
Self(path.canonicalize().unwrap())
|
|
193
|
+
}
|
|
194
|
+
fn socket(&self) -> std::path::PathBuf {
|
|
195
|
+
self.0.join("owned.sock")
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
impl Drop for Fixture {
|
|
199
|
+
fn drop(&mut self) {
|
|
200
|
+
let _ = std::fs::remove_dir_all(&self.0);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#[test]
|
|
205
|
+
fn shutdown_socket_removes_dead_inode_and_repeated_cleanup_is_absent() {
|
|
206
|
+
let fixture = Fixture::new();
|
|
207
|
+
let path = fixture.socket();
|
|
208
|
+
drop(UnixListener::bind(&path).unwrap());
|
|
209
|
+
assert_eq!(remove_dead_socket(&path).unwrap(), SocketCleanup::Removed);
|
|
210
|
+
assert!(!path.exists());
|
|
211
|
+
assert_eq!(remove_dead_socket(&path).unwrap(), SocketCleanup::Absent);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#[test]
|
|
215
|
+
fn shutdown_socket_preserves_live_listener_and_unsafe_objects() {
|
|
216
|
+
let fixture = Fixture::new();
|
|
217
|
+
let path = fixture.socket();
|
|
218
|
+
let listener = UnixListener::bind(&path).unwrap();
|
|
219
|
+
assert_eq!(remove_dead_socket(&path).unwrap(), SocketCleanup::Preserved);
|
|
220
|
+
assert!(path.exists());
|
|
221
|
+
drop(listener);
|
|
222
|
+
std::fs::remove_file(&path).unwrap();
|
|
223
|
+
std::fs::write(&path, b"not a socket").unwrap();
|
|
224
|
+
assert!(remove_dead_socket(&path).is_err());
|
|
225
|
+
std::fs::remove_file(&path).unwrap();
|
|
226
|
+
std::fs::create_dir(&path).unwrap();
|
|
227
|
+
assert!(remove_dead_socket(&path).is_err());
|
|
228
|
+
std::fs::remove_dir(&path).unwrap();
|
|
229
|
+
let target = fixture.0.join("target.sock");
|
|
230
|
+
drop(UnixListener::bind(&target).unwrap());
|
|
231
|
+
symlink(&target, &path).unwrap();
|
|
232
|
+
assert!(remove_dead_socket(&path).is_err());
|
|
233
|
+
assert!(target.exists());
|
|
234
|
+
std::fs::remove_file(&path).unwrap();
|
|
235
|
+
std::fs::hard_link(&target, &path).unwrap();
|
|
236
|
+
assert!(remove_dead_socket(&path).is_err());
|
|
237
|
+
std::fs::remove_file(&path).unwrap();
|
|
238
|
+
std::fs::set_permissions(&fixture.0, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
239
|
+
assert!(remove_dead_socket(&target).is_err());
|
|
240
|
+
assert!(target.exists());
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#[test]
|
|
244
|
+
fn shutdown_socket_refuses_failed_probe_and_inode_or_parent_replacement() {
|
|
245
|
+
let fixture = Fixture::new();
|
|
246
|
+
let path = fixture.socket();
|
|
247
|
+
drop(UnixListener::bind(&path).unwrap());
|
|
248
|
+
assert!(remove_with_probe(&path, |_| Err(io::Error::new(
|
|
249
|
+
io::ErrorKind::PermissionDenied,
|
|
250
|
+
"probe failed"
|
|
251
|
+
)))
|
|
252
|
+
.is_err());
|
|
253
|
+
assert!(path.exists());
|
|
254
|
+
let original = fixture.0.join("original.sock");
|
|
255
|
+
assert!(remove_with_probe(&path, |_| {
|
|
256
|
+
std::fs::rename(&path, &original)?;
|
|
257
|
+
drop(UnixListener::bind(&path)?);
|
|
258
|
+
Ok(true)
|
|
259
|
+
})
|
|
260
|
+
.is_err());
|
|
261
|
+
assert!(path.exists());
|
|
262
|
+
assert_ne!(
|
|
263
|
+
std::fs::symlink_metadata(&path).unwrap().ino(),
|
|
264
|
+
std::fs::symlink_metadata(&original).unwrap().ino()
|
|
265
|
+
);
|
|
266
|
+
std::fs::remove_file(&path).unwrap();
|
|
267
|
+
drop(UnixListener::bind(&path).unwrap());
|
|
268
|
+
assert!(remove_with_probe(&path, |_| {
|
|
269
|
+
std::fs::remove_file(&path)?;
|
|
270
|
+
std::fs::write(&path, b"replacement")?;
|
|
271
|
+
Ok(true)
|
|
272
|
+
})
|
|
273
|
+
.is_err());
|
|
274
|
+
assert_eq!(std::fs::read(&path).unwrap(), b"replacement");
|
|
275
|
+
std::fs::remove_file(&path).unwrap();
|
|
276
|
+
drop(UnixListener::bind(&path).unwrap());
|
|
277
|
+
let moved = fixture.0.with_extension("old");
|
|
278
|
+
assert!(remove_with_probe(&path, |_| {
|
|
279
|
+
std::fs::rename(&fixture.0, &moved)?;
|
|
280
|
+
std::fs::create_dir(&fixture.0)?;
|
|
281
|
+
std::fs::set_permissions(&fixture.0, std::fs::Permissions::from_mode(0o700))?;
|
|
282
|
+
Ok(true)
|
|
283
|
+
})
|
|
284
|
+
.is_err());
|
|
285
|
+
assert!(moved.join("owned.sock").exists());
|
|
286
|
+
std::fs::remove_dir_all(moved).unwrap();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -720,11 +720,18 @@ fn shutdown_invocation_diagnostic(
|
|
|
720
720
|
);
|
|
721
721
|
}
|
|
722
722
|
let terminal = &appended[terminals[0]];
|
|
723
|
-
|
|
724
|
-
|
|
723
|
+
// These diagnostics use deliberately unverified fixture paths, not the
|
|
724
|
+
// computed workspace endpoint. A source label cannot grant ownership.
|
|
725
|
+
let workspace_socket = crate::tmux_backend::socket_name_for_workspace(ws);
|
|
726
|
+
assert_ne!(
|
|
727
|
+
socket.file_name(),
|
|
728
|
+
Some(std::ffi::OsStr::new(&workspace_socket)),
|
|
729
|
+
"diagnostic fixture unexpectedly uses the owned workspace socket"
|
|
730
|
+
);
|
|
731
|
+
if !terminal["owned_endpoint"].is_null() {
|
|
725
732
|
panic!(
|
|
726
|
-
"shutdown diagnostic endpoint
|
|
727
|
-
{result_summary}; terminal={terminal}",
|
|
733
|
+
"shutdown diagnostic falsely claimed fixture endpoint ownership: \
|
|
734
|
+
iteration={iteration} socket={} {result_summary}; terminal={terminal}",
|
|
728
735
|
socket.display()
|
|
729
736
|
);
|
|
730
737
|
}
|