@team-agent/installer 0.5.2 → 0.5.3
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 +3 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +20 -0
- package/crates/team-agent/src/cli/emit.rs +5 -0
- package/crates/team-agent/src/cli/mod.rs +121 -56
- package/crates/team-agent/src/cli/tests/named_address.rs +4 -0
- package/crates/team-agent/src/codex_app_server.rs +62 -1
- package/crates/team-agent/src/conpty/backend.rs +120 -8
- package/crates/team-agent/src/coordinator/backoff.rs +88 -2
- package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
- package/crates/team-agent/src/coordinator/health.rs +97 -11
- package/crates/team-agent/src/coordinator/mod.rs +8 -0
- package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
- package/crates/team-agent/src/diagnose/orphans.rs +18 -7
- package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
- package/crates/team-agent/src/leader/tests/lease_api.rs +7 -0
- package/crates/team-agent/src/lib.rs +14 -1
- package/crates/team-agent/src/lifecycle/launch.rs +80 -91
- package/crates/team-agent/src/lifecycle/lock.rs +40 -33
- package/crates/team-agent/src/lifecycle/restart/agent.rs +10 -18
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
- package/crates/team-agent/src/mcp_server/wire.rs +6 -7
- package/crates/team-agent/src/messaging/tests/runtime.rs +5 -0
- package/crates/team-agent/src/packaging/tests.rs +41 -6
- package/crates/team-agent/src/packaging/types.rs +31 -3
- package/crates/team-agent/src/platform/argv.rs +324 -0
- package/crates/team-agent/src/platform/errors.rs +95 -0
- package/crates/team-agent/src/platform/file_lock.rs +418 -0
- package/crates/team-agent/src/platform/mod.rs +66 -0
- package/crates/team-agent/src/platform/process.rs +555 -0
- package/crates/team-agent/src/state/persist.rs +205 -25
- package/crates/team-agent/src/tmux_backend.rs +63 -13
- package/crates/team-agent/src/transport_factory.rs +124 -5
- package/package.json +4 -4
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
//! Cross-platform exclusive file lock primitive.
|
|
2
|
+
//!
|
|
3
|
+
//! ## Batch 2 real implementation (leader msg_c833639b61b8)
|
|
4
|
+
//!
|
|
5
|
+
//! Batch 2 promotes this module from a signature-only scaffold to a
|
|
6
|
+
//! **byte-preserving migration** of the file-lock primitives used by
|
|
7
|
+
//! `state/persist.rs::RuntimeLock` (state-save lock) and
|
|
8
|
+
//! `lifecycle/lock.rs::LifecycleLockGuard` (per-workspace agent
|
|
9
|
+
//! lifecycle lock).
|
|
10
|
+
//!
|
|
11
|
+
//! ## Two-layer API
|
|
12
|
+
//!
|
|
13
|
+
//! The existing callers wrap their own polling loop + timeout +
|
|
14
|
+
//! `held_long` event emission around the raw `flock` syscall. To
|
|
15
|
+
//! preserve the exact caller behavior (persist.rs's 50ms poll + typed
|
|
16
|
+
//! `StateError::Locked`; lifecycle/lock.rs's waiter file + 5s
|
|
17
|
+
//! held-long event + N38 three-line timeout error), platform::file_lock
|
|
18
|
+
//! exposes **two primitives**, not one convenience `try_lock_exclusive`:
|
|
19
|
+
//!
|
|
20
|
+
//! 1. `try_lock_once_nonblocking(&File) -> Result<bool, io::Error>` —
|
|
21
|
+
//! non-blocking exclusive lock attempt. Returns `Ok(true)` on
|
|
22
|
+
//! success, `Ok(false)` on "would block" (both Unix `EWOULDBLOCK`
|
|
23
|
+
//! and Windows `ERROR_LOCK_VIOLATION`), `Err(...)` on real I/O
|
|
24
|
+
//! error. Callers keep their own polling loops so the outer
|
|
25
|
+
//! timeout/event/error shape is byte-preserving.
|
|
26
|
+
//! 2. `unlock(&File)` — release the lock. Called from `Drop` in both
|
|
27
|
+
//! caller sites.
|
|
28
|
+
//!
|
|
29
|
+
//! A high-level `try_lock_exclusive(path, timeout) -> FileLockGuard`
|
|
30
|
+
//! wrapper is also provided (uses the same primitives + a bare polling
|
|
31
|
+
//! loop) for future callers that don't need the metadata/waiter
|
|
32
|
+
//! machinery — but the two existing product callers keep their own
|
|
33
|
+
//! loops per this batch's byte-preserving constraint.
|
|
34
|
+
//!
|
|
35
|
+
//! ## Windows implementation
|
|
36
|
+
//!
|
|
37
|
+
//! Windows implementation uses `LockFileEx` /
|
|
38
|
+
//! `UnlockFileEx` via `windows-sys`. The lock is `LOCKFILE_EXCLUSIVE_LOCK
|
|
39
|
+
//! | LOCKFILE_FAIL_IMMEDIATELY` so it maps 1:1 to
|
|
40
|
+
//! `flock(LOCK_EX|LOCK_NB)`. Range is `[0, u64::MAX)` (whole-file lock).
|
|
41
|
+
//!
|
|
42
|
+
//! ## CR anchors
|
|
43
|
+
//!
|
|
44
|
+
//! - **C-2**: consuming the two callers via these primitives eliminates
|
|
45
|
+
//! the `state/persist.rs::not_yet_implemented` fallback (persist.rs
|
|
46
|
+
//! loop becomes cfg-free) AND the `lifecycle/lock.rs::lock_timeout_error`
|
|
47
|
+
//! non-Unix stub. The `platform_fallback_burndown_batch0.rs` grep
|
|
48
|
+
//! guard is updated to flip its persist.rs assertion from
|
|
49
|
+
//! "fallback present + FIXME marker" to "fallback removed".
|
|
50
|
+
//! - **C-6 N38 (held_long event)**: the 5s `write_lock_held_long_event`
|
|
51
|
+
//! in `lifecycle/lock.rs` stays in the caller (this module is
|
|
52
|
+
//! pure OS primitives). Windows sees the SAME event because the
|
|
53
|
+
//! caller's polling loop is now cfg-free.
|
|
54
|
+
|
|
55
|
+
use std::fs::File;
|
|
56
|
+
use std::io;
|
|
57
|
+
use std::path::Path;
|
|
58
|
+
use std::time::Duration;
|
|
59
|
+
|
|
60
|
+
/// Try to acquire an exclusive advisory lock on `file` without
|
|
61
|
+
/// blocking. Returns `Ok(true)` if the lock was acquired, `Ok(false)`
|
|
62
|
+
/// if the file is already locked by someone else, `Err(...)` on real
|
|
63
|
+
/// I/O error. Callers own the polling loop + timeout + event emission.
|
|
64
|
+
///
|
|
65
|
+
/// Unix: `flock(fd, LOCK_EX|LOCK_NB)`. `EWOULDBLOCK` → `Ok(false)`.
|
|
66
|
+
/// Windows: `LockFileEx(handle, LOCKFILE_EXCLUSIVE_LOCK|LOCKFILE_FAIL_IMMEDIATELY, 0, u32::MAX, u32::MAX, &mut overlapped)`.
|
|
67
|
+
/// `ERROR_LOCK_VIOLATION` / `ERROR_IO_PENDING` → `Ok(false)`.
|
|
68
|
+
pub fn try_lock_once_nonblocking(file: &File) -> io::Result<bool> {
|
|
69
|
+
#[cfg(unix)]
|
|
70
|
+
{
|
|
71
|
+
try_lock_once_unix(file)
|
|
72
|
+
}
|
|
73
|
+
#[cfg(windows)]
|
|
74
|
+
{
|
|
75
|
+
try_lock_once_windows(file)
|
|
76
|
+
}
|
|
77
|
+
#[cfg(not(any(unix, windows)))]
|
|
78
|
+
{
|
|
79
|
+
// Neither Unix nor Windows — return io error so callers
|
|
80
|
+
// surface honest failure instead of silent-ok.
|
|
81
|
+
let _ = file;
|
|
82
|
+
Err(io::Error::new(
|
|
83
|
+
io::ErrorKind::Unsupported,
|
|
84
|
+
"platform::file_lock: unsupported host (neither unix nor windows)",
|
|
85
|
+
))
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Release the exclusive lock held on `file`. Called from `Drop` in
|
|
90
|
+
/// caller code so the release runs even on panic.
|
|
91
|
+
///
|
|
92
|
+
/// Unix: `flock(fd, LOCK_UN)`.
|
|
93
|
+
/// Windows: `UnlockFileEx(handle, 0, u32::MAX, u32::MAX, &mut overlapped)`.
|
|
94
|
+
///
|
|
95
|
+
/// Errors are best-effort: the OS will release the lock when the
|
|
96
|
+
/// file handle closes anyway. Returning `io::Result` gives tests a
|
|
97
|
+
/// hook.
|
|
98
|
+
pub fn unlock(file: &File) -> io::Result<()> {
|
|
99
|
+
#[cfg(unix)]
|
|
100
|
+
{
|
|
101
|
+
unlock_unix(file)
|
|
102
|
+
}
|
|
103
|
+
#[cfg(windows)]
|
|
104
|
+
{
|
|
105
|
+
unlock_windows(file)
|
|
106
|
+
}
|
|
107
|
+
#[cfg(not(any(unix, windows)))]
|
|
108
|
+
{
|
|
109
|
+
let _ = file;
|
|
110
|
+
Ok(())
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
115
|
+
// Unix impl (byte-preserving migration of the current inline flock
|
|
116
|
+
// code in `state/persist.rs` and `lifecycle/lock.rs`).
|
|
117
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
#[cfg(unix)]
|
|
120
|
+
fn try_lock_once_unix(file: &File) -> io::Result<bool> {
|
|
121
|
+
use std::os::unix::io::AsRawFd;
|
|
122
|
+
// SAFETY: fd is owned by `file` for the duration of this call.
|
|
123
|
+
// LOCK_EX | LOCK_NB matches the byte-for-byte behavior in
|
|
124
|
+
// `state/persist.rs:172-173` and `lifecycle/lock.rs:108`.
|
|
125
|
+
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
|
126
|
+
if rc == 0 {
|
|
127
|
+
return Ok(true);
|
|
128
|
+
}
|
|
129
|
+
let err = io::Error::last_os_error();
|
|
130
|
+
match err.raw_os_error() {
|
|
131
|
+
Some(code) if code == libc::EWOULDBLOCK => Ok(false),
|
|
132
|
+
Some(code) if code == libc::EAGAIN => Ok(false),
|
|
133
|
+
_ => Err(err),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#[cfg(unix)]
|
|
138
|
+
fn unlock_unix(file: &File) -> io::Result<()> {
|
|
139
|
+
use std::os::unix::io::AsRawFd;
|
|
140
|
+
// SAFETY: same fd as above; LOCK_UN releases our own lock.
|
|
141
|
+
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
|
|
142
|
+
if rc == 0 {
|
|
143
|
+
Ok(())
|
|
144
|
+
} else {
|
|
145
|
+
Err(io::Error::last_os_error())
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
150
|
+
// Windows impl (LockFileEx/UnlockFileEx, semantic 1:1 with flock).
|
|
151
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
#[cfg(windows)]
|
|
154
|
+
fn try_lock_once_windows(file: &File) -> io::Result<bool> {
|
|
155
|
+
use std::os::windows::io::AsRawHandle;
|
|
156
|
+
use windows::Win32::Foundation::{ERROR_IO_PENDING, ERROR_LOCK_VIOLATION, HANDLE};
|
|
157
|
+
use windows::Win32::Storage::FileSystem::{
|
|
158
|
+
LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY,
|
|
159
|
+
};
|
|
160
|
+
use windows::Win32::System::IO::OVERLAPPED;
|
|
161
|
+
let handle = HANDLE(file.as_raw_handle() as *mut _);
|
|
162
|
+
// LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY:
|
|
163
|
+
// exclusive lock, do not block if already held. Semantic 1:1 with
|
|
164
|
+
// `flock(LOCK_EX | LOCK_NB)`.
|
|
165
|
+
//
|
|
166
|
+
// Range covers `[0, u32::MAX + u32::MAX<<32)` — the entire file
|
|
167
|
+
// extent — the same coverage `flock` gives (whole-file advisory
|
|
168
|
+
// lock).
|
|
169
|
+
let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
|
|
170
|
+
let result = unsafe {
|
|
171
|
+
LockFileEx(
|
|
172
|
+
handle,
|
|
173
|
+
LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
|
|
174
|
+
Some(0),
|
|
175
|
+
u32::MAX,
|
|
176
|
+
u32::MAX,
|
|
177
|
+
&mut overlapped,
|
|
178
|
+
)
|
|
179
|
+
};
|
|
180
|
+
match result {
|
|
181
|
+
Ok(()) => Ok(true),
|
|
182
|
+
Err(err) => {
|
|
183
|
+
let raw = err.code().0 as i32;
|
|
184
|
+
let raw_u32 = raw as u32;
|
|
185
|
+
if raw_u32 == ERROR_LOCK_VIOLATION.0 || raw_u32 == ERROR_IO_PENDING.0 {
|
|
186
|
+
Ok(false)
|
|
187
|
+
} else {
|
|
188
|
+
Err(io::Error::from_raw_os_error(raw))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#[cfg(windows)]
|
|
195
|
+
fn unlock_windows(file: &File) -> io::Result<()> {
|
|
196
|
+
use std::os::windows::io::AsRawHandle;
|
|
197
|
+
use windows::Win32::Foundation::HANDLE;
|
|
198
|
+
use windows::Win32::Storage::FileSystem::UnlockFileEx;
|
|
199
|
+
use windows::Win32::System::IO::OVERLAPPED;
|
|
200
|
+
let handle = HANDLE(file.as_raw_handle() as *mut _);
|
|
201
|
+
let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
|
|
202
|
+
let result = unsafe { UnlockFileEx(handle, Some(0), u32::MAX, u32::MAX, &mut overlapped) };
|
|
203
|
+
result.map_err(|e| io::Error::from_raw_os_error(e.code().0 as i32))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
207
|
+
// High-level convenience wrapper.
|
|
208
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/// Owns the underlying `File` handle + platform lock. `Drop` unlocks
|
|
211
|
+
/// via `unlock(&file)`.
|
|
212
|
+
///
|
|
213
|
+
/// Callers that don't need the lifecycle-lock metadata/waiter/held_long
|
|
214
|
+
/// event machinery can use `try_lock_exclusive(path, timeout)` directly.
|
|
215
|
+
/// The existing product callers (`state/persist.rs`, `lifecycle/lock.rs`)
|
|
216
|
+
/// use `try_lock_once_nonblocking` + `unlock` directly and keep their
|
|
217
|
+
/// own polling loops for byte-preserving behavior.
|
|
218
|
+
pub struct FileLockGuard {
|
|
219
|
+
file: Option<File>,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
impl std::fmt::Debug for FileLockGuard {
|
|
223
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
224
|
+
f.debug_struct("FileLockGuard")
|
|
225
|
+
.field("held", &self.file.is_some())
|
|
226
|
+
.finish()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
impl FileLockGuard {
|
|
231
|
+
/// Test-only accessor for the wrapped file handle.
|
|
232
|
+
#[cfg(test)]
|
|
233
|
+
fn file(&self) -> &File {
|
|
234
|
+
self.file.as_ref().expect("file present until Drop")
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
impl Drop for FileLockGuard {
|
|
239
|
+
fn drop(&mut self) {
|
|
240
|
+
if let Some(file) = self.file.take() {
|
|
241
|
+
// Best-effort unlock. If it fails the OS still releases on
|
|
242
|
+
// handle close.
|
|
243
|
+
let _ = unlock(&file);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#[derive(Debug, thiserror::Error)]
|
|
249
|
+
pub enum LockError {
|
|
250
|
+
#[error("file lock timeout after {timeout_secs:.2}s on {path}")]
|
|
251
|
+
Timeout { timeout_secs: f64, path: String },
|
|
252
|
+
#[error("file lock io error on {path}: {source}")]
|
|
253
|
+
Io {
|
|
254
|
+
path: String,
|
|
255
|
+
#[source]
|
|
256
|
+
source: io::Error,
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// High-level: acquire an exclusive lock on `path` within `timeout`,
|
|
261
|
+
/// polling every 50ms. Returns a guard that unlocks on `Drop`.
|
|
262
|
+
///
|
|
263
|
+
/// Existing product callers do NOT use this — they need their own
|
|
264
|
+
/// metadata/waiter/held_long event machinery. This wrapper exists for
|
|
265
|
+
/// simple future callers.
|
|
266
|
+
pub fn try_lock_exclusive(
|
|
267
|
+
path: &Path,
|
|
268
|
+
timeout: Duration,
|
|
269
|
+
) -> Result<FileLockGuard, LockError> {
|
|
270
|
+
let file = File::options()
|
|
271
|
+
.read(true)
|
|
272
|
+
.write(true)
|
|
273
|
+
.create(true)
|
|
274
|
+
.truncate(false)
|
|
275
|
+
.open(path)
|
|
276
|
+
.map_err(|e| LockError::Io {
|
|
277
|
+
path: path.display().to_string(),
|
|
278
|
+
source: e,
|
|
279
|
+
})?;
|
|
280
|
+
let start = std::time::Instant::now();
|
|
281
|
+
loop {
|
|
282
|
+
match try_lock_once_nonblocking(&file) {
|
|
283
|
+
Ok(true) => return Ok(FileLockGuard { file: Some(file) }),
|
|
284
|
+
Ok(false) => {}
|
|
285
|
+
Err(e) => {
|
|
286
|
+
return Err(LockError::Io {
|
|
287
|
+
path: path.display().to_string(),
|
|
288
|
+
source: e,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if start.elapsed() >= timeout {
|
|
293
|
+
return Err(LockError::Timeout {
|
|
294
|
+
timeout_secs: timeout.as_secs_f64(),
|
|
295
|
+
path: path.display().to_string(),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
std::thread::sleep(Duration::from_millis(50));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
303
|
+
// Tests: primitive + convenience wrapper on the host platform.
|
|
304
|
+
// Both branches (unix + windows) exercise the same trait shape via
|
|
305
|
+
// the top-level `try_lock_once_nonblocking` / `unlock` functions —
|
|
306
|
+
// the test source is cfg-free.
|
|
307
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
#[cfg(test)]
|
|
310
|
+
mod tests {
|
|
311
|
+
use super::*;
|
|
312
|
+
|
|
313
|
+
#[test]
|
|
314
|
+
fn primitives_acquire_and_release_on_host_platform() {
|
|
315
|
+
// Batch 2 anchor: the OS primitive must work on the CURRENT
|
|
316
|
+
// host (macOS/Linux in dev, both macOS/Linux/Windows in CI).
|
|
317
|
+
// This test compiles on both cfg branches and validates the
|
|
318
|
+
// 1:1 shape mapping between `flock` and `LockFileEx`.
|
|
319
|
+
let dir = std::env::temp_dir().join("ta-b2-primitives");
|
|
320
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
321
|
+
let path = dir.join("primitives-lock.tmp");
|
|
322
|
+
let file = File::options()
|
|
323
|
+
.read(true)
|
|
324
|
+
.write(true)
|
|
325
|
+
.create(true)
|
|
326
|
+
.truncate(false)
|
|
327
|
+
.open(&path)
|
|
328
|
+
.unwrap();
|
|
329
|
+
assert!(
|
|
330
|
+
try_lock_once_nonblocking(&file).unwrap(),
|
|
331
|
+
"fresh lock file should acquire on first attempt"
|
|
332
|
+
);
|
|
333
|
+
assert!(
|
|
334
|
+
unlock(&file).is_ok(),
|
|
335
|
+
"unlock must succeed for our own lock"
|
|
336
|
+
);
|
|
337
|
+
let _ = std::fs::remove_file(&path);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#[test]
|
|
341
|
+
fn convenience_try_lock_exclusive_acquires_and_drop_releases() {
|
|
342
|
+
// The high-level convenience wrapper is what future callers
|
|
343
|
+
// will use. Verify the drop-releases-lock invariant.
|
|
344
|
+
let dir = std::env::temp_dir().join("ta-b2-convenience");
|
|
345
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
346
|
+
let path = dir.join("convenience-lock.tmp");
|
|
347
|
+
{
|
|
348
|
+
let guard = try_lock_exclusive(&path, Duration::from_secs(1))
|
|
349
|
+
.expect("first acquire must succeed");
|
|
350
|
+
let _ = guard.file();
|
|
351
|
+
}
|
|
352
|
+
let guard2 = try_lock_exclusive(&path, Duration::from_secs(1))
|
|
353
|
+
.expect("post-drop reacquire must succeed");
|
|
354
|
+
drop(guard2);
|
|
355
|
+
let _ = std::fs::remove_file(&path);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#[test]
|
|
359
|
+
fn second_acquire_blocks_until_first_releases() {
|
|
360
|
+
// C-6 N38 anchor: the primitive returns `Ok(false)` for the
|
|
361
|
+
// "already held" case on BOTH unix and windows (mapped from
|
|
362
|
+
// EWOULDBLOCK or ERROR_LOCK_VIOLATION respectively). Callers
|
|
363
|
+
// use this to drive their polling loop; without the mapping
|
|
364
|
+
// they would treat the transient contention as a hard error.
|
|
365
|
+
let dir = std::env::temp_dir().join("ta-b2-contention");
|
|
366
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
367
|
+
let path = dir.join("contention-lock.tmp");
|
|
368
|
+
let file1 = File::options()
|
|
369
|
+
.read(true)
|
|
370
|
+
.write(true)
|
|
371
|
+
.create(true)
|
|
372
|
+
.truncate(false)
|
|
373
|
+
.open(&path)
|
|
374
|
+
.unwrap();
|
|
375
|
+
let file2 = File::options()
|
|
376
|
+
.read(true)
|
|
377
|
+
.write(true)
|
|
378
|
+
.create(true)
|
|
379
|
+
.truncate(false)
|
|
380
|
+
.open(&path)
|
|
381
|
+
.unwrap();
|
|
382
|
+
assert!(try_lock_once_nonblocking(&file1).unwrap());
|
|
383
|
+
// A second attempt from a DIFFERENT file handle must see
|
|
384
|
+
// Ok(false), not an error.
|
|
385
|
+
assert!(
|
|
386
|
+
!try_lock_once_nonblocking(&file2).unwrap(),
|
|
387
|
+
"second-holder attempt must return Ok(false) (would-block), not error"
|
|
388
|
+
);
|
|
389
|
+
unlock(&file1).unwrap();
|
|
390
|
+
// Now the second holder can acquire.
|
|
391
|
+
assert!(
|
|
392
|
+
try_lock_once_nonblocking(&file2).unwrap(),
|
|
393
|
+
"post-unlock reacquire from second holder must succeed"
|
|
394
|
+
);
|
|
395
|
+
unlock(&file2).unwrap();
|
|
396
|
+
let _ = std::fs::remove_file(&path);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#[test]
|
|
400
|
+
fn timeout_error_carries_path_and_seconds() {
|
|
401
|
+
// Convenience wrapper's timeout error shape.
|
|
402
|
+
let dir = std::env::temp_dir().join("ta-b2-timeout");
|
|
403
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
404
|
+
let path = dir.join("timeout-lock.tmp");
|
|
405
|
+
let _hold = try_lock_exclusive(&path, Duration::from_secs(1))
|
|
406
|
+
.expect("first acquire must succeed");
|
|
407
|
+
let err = try_lock_exclusive(&path, Duration::from_millis(150))
|
|
408
|
+
.expect_err("second acquire must timeout");
|
|
409
|
+
match err {
|
|
410
|
+
LockError::Timeout { timeout_secs, path: p } => {
|
|
411
|
+
assert!(timeout_secs > 0.0);
|
|
412
|
+
assert!(p.ends_with("timeout-lock.tmp"), "path suffix: {p}");
|
|
413
|
+
}
|
|
414
|
+
other => panic!("expected Timeout, got {other:?}"),
|
|
415
|
+
}
|
|
416
|
+
let _ = std::fs::remove_file(&path);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//! 0.5.x Windows portability Batch 0: platform abstraction layer.
|
|
2
|
+
//!
|
|
3
|
+
//! Truth sources (READ-ONLY):
|
|
4
|
+
//! - Design: `.team/artifacts/0.5.x-windows-portability-survey-design.md`
|
|
5
|
+
//! (§Target Design + §Ordered Migration Plan)
|
|
6
|
+
//! - CR verdict: `.team/artifacts/0.5.x-windows-portability-cr-verdict.md`
|
|
7
|
+
//! (6 constraints; C-1 through C-6 anchor into this module)
|
|
8
|
+
//!
|
|
9
|
+
//! ## Scope of Batch 0 (this commit)
|
|
10
|
+
//!
|
|
11
|
+
//! - Add the `platform` module scaffold + Unix-side implementations
|
|
12
|
+
//! populated from the current inline `libc`/`std::os::unix` code
|
|
13
|
+
//! (byte-equivalent behavior on macOS/Linux).
|
|
14
|
+
//! - No caller migration yet — the three legacy fallback sites
|
|
15
|
+
//! (`mcp_server/wire.rs`, `lifecycle/restart/agent.rs`,
|
|
16
|
+
//! `state/persist.rs`) stay in place with `FIXME(portability)`
|
|
17
|
+
//! comments pointing at this design. Batch 2/3 replaces them.
|
|
18
|
+
//! - Windows side is empty stubs today; every fn is `unimplemented!()`
|
|
19
|
+
//! with a `# Safety:` / `# Windows:` doc note explaining what
|
|
20
|
+
//! Batch 2/3/4 will supply.
|
|
21
|
+
//! - CI adds a Windows compile-only gate (`cargo check --target
|
|
22
|
+
//! x86_64-pc-windows-msvc`), reporting RED as the burn-down baseline
|
|
23
|
+
//! for Batches 1-4. The Windows job uses `continue-on-error: true`
|
|
24
|
+
//! so it does not block the Ubuntu/macOS/Windows-binaries jobs.
|
|
25
|
+
//!
|
|
26
|
+
//! ## CR anchors
|
|
27
|
+
//!
|
|
28
|
+
//! - **C-1**: `packaging/types.rs::WindowsX8664 → PlatformSupport::Native`
|
|
29
|
+
//! claim is FALSE while `cargo check --target x86_64-pc-windows-msvc`
|
|
30
|
+
//! is RED. Downgraded to `PreviewCompileOnly` in this batch; will be
|
|
31
|
+
//! promoted back to `Native` only after Batch 6/7 real-machine gates.
|
|
32
|
+
//! - **C-2**: three legacy non-Unix fallbacks
|
|
33
|
+
//! (`wire.rs::parent_pid=0`, `restart/agent.rs::pid_is_alive=true`,
|
|
34
|
+
//! `persist.rs::runtime_lock=not_implemented`) get
|
|
35
|
+
//! `FIXME(portability)` comments referencing this design. Batch 2
|
|
36
|
+
//! removes the persist.rs one; Batch 3 removes the wire.rs +
|
|
37
|
+
//! restart/agent.rs ones. Grep guards verify no reintroduction.
|
|
38
|
+
//! - **C-4**: `platform::process` is NOT importable from
|
|
39
|
+
//! `messaging/` (team-worker liveness must go through the backend
|
|
40
|
+
//! `Transport`, not a shell-out process snapshot). Grep guard test
|
|
41
|
+
//! walks the source tree.
|
|
42
|
+
//! - **C-6**: hard-terminate on Windows lacks Unix grace semantics —
|
|
43
|
+
//! the `terminate_pid`/`terminate_group` signatures include a
|
|
44
|
+
//! `SignalKind` enum + a caller-visible `TerminationOutcome` so
|
|
45
|
+
//! Windows callers can emit a `platform.terminate_force_only` event
|
|
46
|
+
//! when they downgrade from `TerminateGraceful` to `TerminateForce`.
|
|
47
|
+
//!
|
|
48
|
+
//! ## Module layout
|
|
49
|
+
//!
|
|
50
|
+
//! - `process` — process lifecycle + snapshot + termination
|
|
51
|
+
//! - `file_lock` — cross-platform exclusive file lock
|
|
52
|
+
//! - `errors` — retryable error classifier + os_error_name
|
|
53
|
+
//! - `argv` — process argv/environ query (Batch 4)
|
|
54
|
+
//!
|
|
55
|
+
//! Every submodule follows the same pattern: a `unix.rs` file with the
|
|
56
|
+
//! current libc code, a `windows.rs` file with `unimplemented!()`
|
|
57
|
+
//! stubs + Windows API sketch, and a `mod.rs` that re-exports the
|
|
58
|
+
//! platform-appropriate impl via `cfg`.
|
|
59
|
+
//!
|
|
60
|
+
//! Callers see a single `crate::platform::process::pid_liveness(pid)`
|
|
61
|
+
//! (etc.) with no `cfg` at the callsite.
|
|
62
|
+
|
|
63
|
+
pub mod argv;
|
|
64
|
+
pub mod errors;
|
|
65
|
+
pub mod file_lock;
|
|
66
|
+
pub mod process;
|