Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,10 +502,16 @@ Gotchas specific to the image:
which `--rm` throws away; with one that uid 65532 cannot write, startup
fails outright, and over HTTP the default fail mode is `closed_all`, so a
directory that becomes unwritable later stops the server serving.
- **Use an init process.** bugwarden installs no `SIGTERM` handler, and PID 1
does not get the default terminate action, so without `--init` (or
`init: true` in compose) `docker stop` waits out its full timeout and ends
in `SIGKILL`.
- **Signals.** The process handles `SIGINT` and `SIGTERM`. Over HTTP both
cancel the transport token and let axum drain. Over stdio both end the
process immediately (status 0): rmcp reads stdin on a blocking thread
that cannot be cancelled while a client still holds the pipe, so
returning from `main` would hang the runtime the same way the missing
handler did. As container PID 1 this is what makes `docker stop` /
`podman stop` / a Kubernetes SIGTERM a clean exit instead of waiting
out the runtime grace period and ending in SIGKILL (137). `--init` /
`init: true` remains useful as defense in depth — it reaps any
unexpected child — but it is no longer required for a timely stop.

There is no `HEALTHCHECK`: `/bin` and `/usr/bin` are empty in this base, so
there is no binary to run one with. Use a TCP check on the port, or an
Expand Down
5 changes: 3 additions & 2 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ services:
# Run `mkdir -p ./audit && chown 65532:65532 ./audit` first.
# - ./audit.toml:/etc/bugwarden/audit.toml:ro
# - ./audit:/var/log/bugwarden
# PID 1 does not get SIGTERM's default terminate action and bugwarden
# installs no handler, so without an init `compose down` ends in SIGKILL.
# Defense in depth: bugwarden handles SIGTERM itself, so compose down
# without an init is a graceful stop. An init process still reaps
# unexpected children if the runtime ever grows any.
init: true
read_only: true
cap_drop:
Expand Down
78 changes: 73 additions & 5 deletions crates/bugwarden/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,39 @@ async fn main() -> anyhow::Result<()> {

match cfg.transport {
Transport::Stdio => {
// Two stages: an unused stdio container sits in `serve`
// (handshake). After initialize it sits in `waiting`.
// Handlers register on the first poll of `shutdown`, which
// is this `select!` immediately after the startup line.
let shutdown = shutdown_signal();
tokio::pin!(shutdown);
tracing::info!("Starting Bugzilla MCP server on stdio");
let service = server.serve(stdio()).await.inspect_err(|e| {
tracing::error!("serving error: {:?}", e);
})?;
service.waiting().await?;
let service = tokio::select! {
result = server.serve(stdio()) => {
result.inspect_err(|e| {
tracing::error!("serving error: {:?}", e);
})?
}
() = &mut shutdown => {
tracing::info!("received shutdown signal");
// serve() is already blocked in tokio::io::stdin()'s
// uncancellable read. Returning from main drops the
// runtime onto that blocking thread.
std::process::exit(0);
}
};
let cancel = service.cancellation_token();
tokio::select! {
result = service.waiting() => {
result?;
}
() = shutdown => {
tracing::info!("received shutdown signal");
cancel.cancel();
// Same blocking stdin read as the handshake arm.
std::process::exit(0);
}
}
}
Transport::Http => {
let ct = tokio_util::sync::CancellationToken::new();
Expand Down Expand Up @@ -174,7 +202,11 @@ async fn main() -> anyhow::Result<()> {
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(async move {
let _ = tokio::signal::ctrl_c().await;
shutdown_signal().await;
tracing::info!("received shutdown signal");
// Tear the live streamable-HTTP transport down with the
// listener: axum's graceful shutdown alone waits for
// in-flight connections, and an open MCP session is one.
ct.cancel();
})
.await?;
Expand All @@ -183,3 +215,39 @@ async fn main() -> anyhow::Result<()> {

Ok(())
}

/// Wait until the process should stop serving.
///
/// `SIGINT` (`ctrl_c`) and, on Unix, `SIGTERM`. As container PID 1 the
/// kernel does not apply SIGTERM's default terminate action, so without
/// this waiter `docker stop` / `podman stop` wait out the runtime grace
/// period and SIGKILL (issue #114). Both signals take the same path: the
/// caller then cancels whatever is serving (the HTTP transport token, or
/// the stdio `select!`). A failed SIGTERM install is logged and the
/// waiter falls back to SIGINT only — refusing to start would be worse
/// than a container that still needs `--init`.
async fn shutdown_signal() {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
{
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut term) => {
tokio::select! {
_ = ctrl_c => {}
_ = term.recv() => {}
}
}
Err(err) => {
tracing::error!(
error = %err,
"failed to install SIGTERM handler; only SIGINT will stop the process"
);
let _ = ctrl_c.await;
}
}
}
#[cfg(not(unix))]
{
let _ = ctrl_c.await;
}
}
3 changes: 2 additions & 1 deletion crates/bugwarden/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1492,7 +1492,8 @@ impl BugWarden {
/// decide this one too rather than leave it behind. The remaining
/// fields, and why each stays inherited, are inventoried in DESIGN.md
/// under "rmcp 3.1 usage notes"; the caller in `main` adds
/// `cancellation_token` so shutdown reaches the live transport.
/// `cancellation_token` so a SIGINT or SIGTERM reaches the live
/// transport (issue #114).
pub fn http_server_config(&self) -> StreamableHttpServerConfig {
let config = StreamableHttpServerConfig::default()
.disable_allowed_hosts()
Expand Down
Loading