Skip to content

Commit 7089c7c

Browse files
committed
fix(ops): close aug14 gateway bugs and detach agent-owned dev:admin
Attribute Unix SIGTERM to the sender, orphan pnpm dev:admin from Cursor Helper so Glass idle cannot kill the gateway, and quiet the expected session/startup noise from the planning doc. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 5de93d5 commit 7089c7c

13 files changed

Lines changed: 391 additions & 55 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ mcpmux-core.workspace = true
5353
mcpmux-gateway.workspace = true
5454
mcpmux-storage.workspace = true
5555

56+
[target.'cfg(unix)'.dependencies]
57+
libc = "0.2"
58+
5659
[target.'cfg(target_os = "macos")'.dependencies]
5760
objc2 = "0.6"
5861
objc2-foundation = "0.3"

apps/desktop/src-tauri/src/lib.rs

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ mod commands;
1212
mod macos_dock;
1313
mod macos_permissions;
1414
mod main_window;
15+
#[cfg(unix)]
16+
mod unix_signal;
1517
mod services;
1618
mod state;
1719
mod tray;
@@ -898,25 +900,7 @@ pub fn run() {
898900
tauri::async_runtime::spawn(async move {
899901
#[cfg(unix)]
900902
{
901-
use tokio::signal::unix::{signal, SignalKind};
902-
let mut sigterm = match signal(SignalKind::terminate()) {
903-
Ok(s) => s,
904-
Err(e) => {
905-
warn!("[Signal] Failed to install SIGTERM handler: {}", e);
906-
return;
907-
}
908-
};
909-
let mut sigint = match signal(SignalKind::interrupt()) {
910-
Ok(s) => s,
911-
Err(e) => {
912-
warn!("[Signal] Failed to install SIGINT handler: {}", e);
913-
return;
914-
}
915-
};
916-
tokio::select! {
917-
_ = sigterm.recv() => info!("[Signal] SIGTERM — requesting exit"),
918-
_ = sigint.recv() => info!("[Signal] SIGINT — requesting exit"),
919-
}
903+
crate::unix_signal::wait_for_term().await;
920904
}
921905
#[cfg(windows)]
922906
{

apps/desktop/src-tauri/src/macos_permissions.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ use tracing::{debug, info, warn};
3030
pub fn ensure_contacts_registered() {
3131
use objc2_contacts::{CNAuthorizationStatus, CNContactStore, CNEntityType};
3232

33+
// TCC never persists a decision for the unsigned `tauri dev` binary (its
34+
// code signature/path changes across rebuilds), so every dev launch
35+
// re-prompts and immediately reports Access Denied. Skip in debug builds;
36+
// production (signed .app) behavior is unaffected.
37+
if cfg!(debug_assertions) {
38+
debug!("[Permissions] Contacts: skipping request in debug build");
39+
return;
40+
}
41+
3342
// SAFETY: `authorizationStatusForEntityType` is a pure read of the
3443
// system TCC database — no side effects, no main-thread requirement.
3544
let status =
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//! Capture who sent SIGTERM/SIGINT (`si_pid`) without fighting tokio's waiter.
2+
//!
3+
//! A `SA_SIGINFO` handler records the sender, then writes one byte to a socket
4+
//! pair so the async setup task can log and exit. We do not use `tokio::signal`
5+
//! on Unix — the last `sigaction` wins, and we need `si_pid`.
6+
7+
use std::os::fd::AsRawFd;
8+
use std::os::unix::net::UnixStream;
9+
use std::sync::atomic::{AtomicI32, Ordering};
10+
11+
use tracing::info;
12+
13+
static SENDER_PID: AtomicI32 = AtomicI32::new(0);
14+
static SIGNAL_NO: AtomicI32 = AtomicI32::new(0);
15+
static SIGNAL_CODE: AtomicI32 = AtomicI32::new(0);
16+
static PIPE_WRITE_FD: AtomicI32 = AtomicI32::new(-1);
17+
18+
/// Install the recorder and wait until SIGTERM or SIGINT arrives.
19+
///
20+
/// Returns after the first termination signal. Caller should then exit.
21+
pub async fn wait_for_term() {
22+
let read_end = match install() {
23+
Ok(fd) => fd,
24+
Err(e) => {
25+
tracing::warn!("[Signal] Failed to install SA_SIGINFO handler: {e}");
26+
return;
27+
}
28+
};
29+
30+
let mut reader = match tokio::net::UnixStream::from_std(read_end) {
31+
Ok(s) => s,
32+
Err(e) => {
33+
tracing::warn!("[Signal] Failed to wrap self-pipe: {e}");
34+
return;
35+
}
36+
};
37+
let mut buf = [0u8; 1];
38+
use tokio::io::AsyncReadExt;
39+
let _ = reader.read_exact(&mut buf).await;
40+
41+
let pid = std::process::id();
42+
let ppid = unsafe { libc::getppid() };
43+
let sender = SENDER_PID.load(Ordering::SeqCst);
44+
let sig = SIGNAL_NO.load(Ordering::SeqCst);
45+
let code = SIGNAL_CODE.load(Ordering::SeqCst);
46+
let name = if sig == libc::SIGINT { "SIGINT" } else { "SIGTERM" };
47+
48+
info!(
49+
pid,
50+
ppid,
51+
parent = %describe_pid(ppid),
52+
sender_pid = sender,
53+
sender = %describe_pid(sender),
54+
si_code = code,
55+
"[Signal] {name} — requesting exit"
56+
);
57+
}
58+
59+
fn install() -> std::io::Result<UnixStream> {
60+
let (read, write) = UnixStream::pair()?;
61+
read.set_nonblocking(true)?;
62+
write.set_nonblocking(true)?;
63+
PIPE_WRITE_FD.store(write.as_raw_fd(), Ordering::SeqCst);
64+
// Handler writes to this fd for the life of the process.
65+
std::mem::forget(write);
66+
67+
unsafe {
68+
let mut sa: libc::sigaction = std::mem::zeroed();
69+
sa.sa_sigaction = record_sender as *const () as usize;
70+
sa.sa_flags = libc::SA_SIGINFO | libc::SA_RESTART;
71+
libc::sigemptyset(&mut sa.sa_mask);
72+
if libc::sigaction(libc::SIGTERM, &sa, std::ptr::null_mut()) != 0 {
73+
return Err(std::io::Error::last_os_error());
74+
}
75+
if libc::sigaction(libc::SIGINT, &sa, std::ptr::null_mut()) != 0 {
76+
return Err(std::io::Error::last_os_error());
77+
}
78+
}
79+
Ok(read)
80+
}
81+
82+
unsafe extern "C" fn record_sender(
83+
sig: libc::c_int,
84+
info: *mut libc::siginfo_t,
85+
_ctx: *mut libc::c_void,
86+
) {
87+
if !info.is_null() {
88+
// SAFETY: kernel-filled siginfo for this delivery.
89+
let info = unsafe { &*info };
90+
SENDER_PID.store(unsafe { info.si_pid() }, Ordering::SeqCst);
91+
SIGNAL_CODE.store(info.si_code, Ordering::SeqCst);
92+
}
93+
SIGNAL_NO.store(sig, Ordering::SeqCst);
94+
let fd = PIPE_WRITE_FD.load(Ordering::SeqCst);
95+
if fd >= 0 {
96+
let byte = [1u8];
97+
unsafe {
98+
libc::write(fd, byte.as_ptr() as *const libc::c_void, 1);
99+
}
100+
}
101+
}
102+
103+
/// Best-effort path/name for a live pid. `"unknown"` if pid is empty or gone.
104+
fn describe_pid(pid: libc::pid_t) -> String {
105+
if pid <= 0 {
106+
return "unknown".to_string();
107+
}
108+
#[cfg(target_os = "macos")]
109+
{
110+
let mut buf = [0u8; 4096];
111+
extern "C" {
112+
fn proc_pidpath(pid: i32, buffer: *mut libc::c_void, buffersize: u32) -> i32;
113+
}
114+
let n = unsafe { proc_pidpath(pid, buf.as_mut_ptr() as *mut _, buf.len() as u32) };
115+
if n > 0 {
116+
return String::from_utf8_lossy(&buf[..n as usize]).into_owned();
117+
}
118+
}
119+
#[cfg(target_os = "linux")]
120+
{
121+
if let Ok(path) = std::fs::read_link(format!("/proc/{pid}/exe")) {
122+
return path.display().to_string();
123+
}
124+
if let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) {
125+
return comm.trim().to_string();
126+
}
127+
}
128+
format!("pid:{pid}")
129+
}
130+
131+
#[cfg(test)]
132+
mod tests {
133+
use super::*;
134+
135+
#[test]
136+
fn describe_pid_self_is_nonempty() {
137+
let me = std::process::id() as libc::pid_t;
138+
let desc = describe_pid(me);
139+
assert!(!desc.is_empty(), "self pid should resolve");
140+
assert_ne!(desc, "unknown");
141+
}
142+
143+
#[test]
144+
fn describe_pid_zero_is_unknown() {
145+
assert_eq!(describe_pid(0), "unknown");
146+
}
147+
}

crates/mcpmux-gateway/src/mcp/oauth_middleware.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,11 @@ pub async fn mcp_oauth_middleware(
280280
_ => {}
281281
}
282282

283+
// Captured before `request` is consumed below — needed to recognize the
284+
// spec-correct GET shapes (pre-init SSE, post-timeout reconnect) when
285+
// deciding whether to warn on the response status.
286+
let http_method = request.method().clone();
287+
283288
// Extract MCP method from body if POST
284289
let mcp_method = if request.method() == axum::http::Method::POST {
285290
use axum::body::to_bytes;
@@ -322,9 +327,17 @@ pub async fn mcp_oauth_middleware(
322327

323328
let response = next.run(request).await;
324329

325-
// Log errors only
330+
// Log errors only — except two rmcp spec-correct shapes that are not
331+
// gateway problems: a GET without Mcp-Session-Id (client opening the SSE
332+
// stream before initialize) and any request against a session rmcp has
333+
// already closed (idle keep-alive timeout or explicit termination). Both
334+
// are expected client reconnect behavior. See
335+
// docs/planning/aug14-gateway-ops-bugs.md Decision 4.
326336
let status = response.status();
327-
if status.is_server_error() || status.is_client_error() {
337+
let is_expected_session_noise = (http_method == axum::http::Method::GET
338+
&& status == StatusCode::BAD_REQUEST)
339+
|| status == StatusCode::NOT_FOUND;
340+
if (status.is_server_error() || status.is_client_error()) && !is_expected_session_noise {
328341
warn!(
329342
trace_id = %trace_id,
330343
status = %status,

crates/mcpmux-gateway/src/pool/features/discovery.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ use tracing::{debug, info, warn};
99
use super::{convert_to_feature, resource_to_feature, CachedFeatures};
1010
use crate::pool::instance::McpClient;
1111
use mcpmux_core::ServerFeatureRepository;
12+
use rmcp::{model::ErrorCode, ServiceError};
13+
14+
/// True when the error is a JSON-RPC `-32601 Method not found` — a server
15+
/// advertised a capability but never implemented the corresponding list
16+
/// method, not a transport/connection problem.
17+
fn is_method_not_found(e: &ServiceError) -> bool {
18+
matches!(e, ServiceError::McpError(err) if err.code == ErrorCode::METHOD_NOT_FOUND)
19+
}
1220

1321
/// Handles feature discovery and caching from MCP clients
1422
pub struct FeatureDiscoveryService {
@@ -124,7 +132,20 @@ impl FeatureDiscoveryService {
124132
discovered.resources.len()
125133
);
126134
}
127-
Some(Err(e)) => warn!("[FeatureDiscovery] Failed to list resources: {}", e),
135+
Some(Err(e)) => {
136+
// Some servers (notably the Atlassian family) advertise
137+
// `resources: {}` at initialize but never implemented
138+
// resources/list. That's a server quirk we already
139+
// tolerate — only the log level was wrong.
140+
if is_method_not_found(&e) {
141+
debug!(
142+
"[FeatureDiscovery] resources/list not implemented despite advertised capability: {}",
143+
e
144+
);
145+
} else {
146+
warn!("[FeatureDiscovery] Failed to list resources: {}", e);
147+
}
148+
}
128149
None => {}
129150
}
130151
} else {
@@ -195,4 +216,18 @@ mod tests {
195216
let out = FeatureDiscoveryService::with_list_timeout("resources/list", never).await;
196217
assert!(out.is_none());
197218
}
219+
220+
#[test]
221+
fn is_method_not_found_matches_only_32601() {
222+
let not_found = ServiceError::McpError(rmcp::ErrorData::new(
223+
ErrorCode::METHOD_NOT_FOUND,
224+
"nope",
225+
None,
226+
));
227+
assert!(is_method_not_found(&not_found));
228+
229+
let invalid_params =
230+
ServiceError::McpError(rmcp::ErrorData::invalid_params("bad params", None));
231+
assert!(!is_method_not_found(&invalid_params));
232+
}
198233
}

0 commit comments

Comments
 (0)