Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
Matt W1//! `dogfood-web` — the HTTP server.
Matt W2
Matt W3use std::sync::Arc;
Matt W4
Matt W5use anyhow::{Context, Result};
Matt W6use axum::routing::{delete, get, post};
Matt W7use axum::Router;
Matt W8use tower_http::catch_panic::CatchPanicLayer;
Matt W9use tower_http::trace::TraceLayer;
Matt W10
Matt W11mod config;
Matt W12mod error;
Matt W13mod git_http;
Matt W14mod highlight_cache;
Matt W15mod hooks;
Matt W16mod middleware;
Matt W17mod ratelimit;
Matt W18mod repo_ctx;
Matt W19mod revset;
Matt W20mod routes;
Matt W21#[cfg(test)]
Matt W22mod security_tests;
Matt W23mod state;
Matt W24mod views;
Matt W25
Matt W26use config::Config;
Matt W27use state::{AppState, Inner};
Matt W28
Matt W29#[tokio::main]
Matt W30async fn main() -> Result<()> {
Matt W31 // `.env` is a convenience for local runs; in Docker the values arrive as
Matt W32 // real environment variables and this is a no-op.
Matt W33 let _ = dotenvy::dotenv();
Matt W34
Matt W35 init_tracing();
Matt W36
Matt W37 let config = Config::from_env().context("loading configuration")?;
Matt W38 tracing::info!(base_url = %config.base_url, "starting dogfood-web");
Matt W39
Matt W40 let db = df_db::connect(&config.database_url, config.database_max_connections)
Matt W41 .await
Matt W42 .context("connecting to the database")?;
Matt W43
Matt W44 // Migrations run on web startup under an advisory lock (spec §10).
Matt W45 df_db::migrate(&db).await.context("running migrations")?;
Matt W46
Matt W47 // Mint the admin bootstrap token if nobody is admin yet. Logged once; only
Matt W48 // its hash is stored, so it cannot be recovered afterwards.
Matt W49 match df_auth::provisioning::ensure_setup_token(&db).await {
Matt W50 Ok(Some(token)) => {
Matt W51 tracing::warn!(
Matt W52 "\n\n\
Matt W53 ┌───────────────────────────────────────────────────────────────┐\n\
Matt W54 │ SETUP TOKEN — claim site admin at {}/setup\n\
Matt W55 │ {}\n\
Matt W56 │ Shown once. Only its hash is stored.\n\
Matt W57 └───────────────────────────────────────────────────────────────┘\n",
Matt W58 config.base_url,
Matt W59 token
Matt W60 );
Matt W61 }
Matt W62 Ok(None) => tracing::debug!("no setup token needed"),
Matt W63 Err(e) => tracing::error!("could not prepare setup token: {e:#}"),
Matt W64 }
Matt W65
Matt W66 let oidc = df_auth::Oidc::discover(
Matt W67 &config.oidc_issuer,
Matt W68 &config.oidc_client_id,
Matt W69 &config.oidc_client_secret,
Matt W70 &config.oidc_redirect_url,
Matt W71 &config.oidc_scopes,
Matt W72 )
Matt W73 .await
Matt W74 .context("OIDC discovery")?;
Matt W75 tracing::info!(issuer = %config.oidc_issuer, "OIDC relying party ready");
Matt W76
Matt W77 let store: Arc<dyn df_store::RepoStore> = Arc::new(
Matt W78 df_store::GitStore::new(&config.repo_root)
Matt W79 .with_max_blob_bytes(config.max_blob_render_bytes as u64 * 8),
Matt W80 );
Matt W81 tracing::info!(root = %config.repo_root, "repository storage ready");
Matt W82
Matt W83 let bind = config.bind.clone();
Matt W84 let state = AppState(Arc::new(Inner {
Matt W85 db,
Matt W86 oidc,
Matt W87 config,
Matt W88 store,
Matt W89 limiter: ratelimit::Limiter::new(),
Matt W90 }));
Matt W91
Matt W92 // Reinstall the pre-receive hook everywhere. A missing hook fails *open* —
Matt W93 // pushes stop being validated with no error anywhere — so this runs on
Matt W94 // every boot rather than only at repository creation (spec §4, §9).
Matt W95 hooks::sweep_in_background(
Matt W96 state.db.clone(),
Matt W97 state.store.clone(),
Matt W98 state.config.hook_binary.clone(),
Matt W99 );
Matt W100
Matt W101 let app = build_router(state);
Matt W102
Matt W103 let listener = tokio::net::TcpListener::bind(&bind)
Matt W104 .await
Matt W105 .with_context(|| format!("binding {bind}"))?;
Matt W106 tracing::info!("listening on {bind}");
Matt W107
Matt W108 axum::serve(
Matt W109 listener,
Matt W110 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
Matt W111 )
Matt W112 .with_graceful_shutdown(shutdown_signal())
Matt W113 .await
Matt W114 .context("server error")?;
Matt W115
Matt W116 Ok(())
Matt W117}
Matt W118
Matt W119fn build_router(state: AppState) -> Router {
Matt W120 Router::new()
Matt W121 // ─── pages ───────────────────────────────────────────────────────────
Matt W122 .route("/", get(routes::home::index))
Matt W123 .route("/dashboard", get(routes::home::dashboard))
Matt W124 .route("/design", get(routes::design::sheet))
Matt W125 .route("/design/rationale", get(routes::design::rationale))
Matt W126 .route("/login", get(routes::auth::login))
Matt W127 .route("/auth/callback", get(routes::auth::callback))
Matt W128 .route("/auth/handle", post(routes::auth::choose_handle))
Matt W129 .route("/logout", post(routes::auth::logout))
Matt W130 .route("/setup", get(routes::setup::show).post(routes::setup::claim))
Matt W131 // ─── repositories ────────────────────────────────────────────────────
Matt W132 .route("/new", get(routes::repo::new_form))
Matt W133 .route("/repos", post(routes::repo::create))
Matt W134 // ─── the signed-in user's own settings ───────────────────────────────
Matt W135 // The POST aliases exist because a browser form cannot emit DELETE and
Matt W136 // spec §7 makes working without JavaScript a hard requirement; the
Matt W137 // DELETE routes are the same handlers, for API clients.
Matt W138 .route("/search", get(routes::search::search))
Matt W139 .route("/orgs/new", get(routes::org::new_form))
Matt W140 .route("/orgs", post(routes::org::create))
Matt W141 .route("/settings", get(routes::settings::show))
Matt W142 .route("/settings/keys", post(routes::settings::add_key))
Matt W143 .route("/settings/keys/{id}", delete(routes::settings::delete_key))
Matt W144 .route("/settings/keys/{id}/delete", post(routes::settings::delete_key))
Matt W145 .route("/settings/tokens", post(routes::settings::create_token))
Matt W146 .route("/settings/tokens/{id}", delete(routes::settings::delete_token))
Matt W147 .route("/settings/tokens/{id}/delete", post(routes::settings::delete_token))
Matt W148 // ─── assets ──────────────────────────────────────────────────────────
Matt W149 .route("/assets/app.css", get(serve_css))
Matt W150 .route("/assets/fonts/{name}", get(serve_font))
Matt W151 .route("/assets/htmx.min.js", get(serve_htmx))
Matt W152 .route("/assets/theme-init.js", get(serve_theme_init_js))
Matt W153 .route("/assets/theme.js", get(serve_theme_js))
Matt W154 .route("/assets/palette.js", get(serve_palette_js))
Matt W155 .route("/assets/terminal.js", get(serve_terminal_js))
Matt W156 .route("/assets/editor.js", get(serve_editor_js))
Matt W157 // ─── operational ─────────────────────────────────────────────────────
Matt W158 // Repository routes are declared last so their `{owner}` wildcard
Matt W159 // cannot shadow a fixed path like /new or /settings.
Matt W160 .route("/{owner}", get(routes::profile::show))
Matt W161 // Org membership lives under `/-/` so it cannot collide with a
Matt W162 // repository named `members`.
Matt W163 .route(
Matt W164 "/{owner}/-/members",
Matt W165 get(routes::org::members).post(routes::org::add_member),
Matt W166 )
Matt W167 .route("/{owner}/-/members/remove", post(routes::org::remove_member))
Matt W168 .route("/{owner}/{repo}", get(routes::repo::index))
Matt W169 .route("/{owner}/{repo}/settings", get(routes::repo_settings::show))
Matt W170 .route("/{owner}/{repo}/settings/general", post(routes::repo_settings::update_general))
Matt W171 .route(
Matt W172 "/{owner}/{repo}/settings/collaborators",
Matt W173 post(routes::repo_settings::upsert_collaborator),
Matt W174 )
Matt W175 .route(
Matt W176 "/{owner}/{repo}/settings/collaborators/remove",
Matt W177 post(routes::repo_settings::remove_collaborator),
Matt W178 )
Matt W179 .route("/{owner}/{repo}/settings/bookmarks", post(routes::repo_settings::protect_bookmark))
Matt W180 .route("/{owner}/{repo}/settings/archive", post(routes::repo_settings::archive))
Matt W181 .route("/{owner}/{repo}/settings/delete", post(routes::repo_settings::delete))
Matt W182 .route("/{owner}/{repo}/tree/{rev}/{*path}", get(routes::repo::tree))
Matt W183 .route("/{owner}/{repo}/blob/{rev}/{*path}", get(routes::repo::blob))
Matt W184 .route("/{owner}/{repo}/raw/{rev}/{*path}", get(routes::repo::raw))
Matt W185 // In-browser editing. A write-level action on a bookmark, which is why
Matt W186 // it sits beside the browse routes rather than under /settings.
Matt W187 .route(
Matt W188 "/{owner}/{repo}/edit/{rev}/{*path}",
Matt W189 get(routes::edit::show).post(routes::edit::save),
Matt W190 )
Matt W191 .route("/{owner}/{repo}/log", get(routes::repo::log))
Matt W192 .route("/{owner}/{repo}/commit/{rev}", get(routes::repo::commit))
Matt W193 .route(
Matt W194 "/{owner}/{repo}/changes",
Matt W195 get(routes::change::list).post(routes::change::create),
Matt W196 )
Matt W197 .route("/{owner}/{repo}/changes/new", get(routes::change::new_form))
Matt W198 // ─── one change ──────────────────────────────────────────────────────
Matt W199 .route("/{owner}/{repo}/changes/{reference}", get(routes::review::overview))
Matt W200 .route("/{owner}/{repo}/changes/{reference}/files", get(routes::review::files))
Matt W201 .route("/{owner}/{repo}/changes/{reference}/revisions", get(routes::review::revisions))
Matt W202 .route("/{owner}/{repo}/changes/{reference}/checks", get(routes::review::checks))
Matt W203 .route("/{owner}/{repo}/changes/{reference}/conflicts", get(routes::review::conflicts))
Matt W204 .route("/{owner}/{repo}/changes/{reference}/comments", post(routes::review::create_comment))
Matt W205 .route(
Matt W206 "/{owner}/{repo}/changes/{reference}/comments/{id}/resolve",
Matt W207 post(routes::review::resolve_comment),
Matt W208 )
Matt W209 .route("/{owner}/{repo}/changes/{reference}/reviews", post(routes::review::create_review))
Matt W210 .route("/{owner}/{repo}/changes/{reference}/edit", post(routes::review::edit))
Matt W211 .route("/{owner}/{repo}/changes/{reference}/state", post(routes::review::set_state))
Matt W212 .route("/{owner}/{repo}/changes/{reference}/merge", post(routes::review::merge))
Matt W213 // ─── stacks ──────────────────────────────────────────────────────────
Matt W214 .route("/{owner}/{repo}/stacks/{change_id}", get(routes::review::stack))
Matt W215 .route("/{owner}/{repo}/stacks/{change_id}/merge", post(routes::review::merge_stack))
Matt W216 .route("/{owner}/{repo}/bookmarks", get(routes::repo::bookmarks))
Matt W217 // ─── issues ──────────────────────────────────────────────────────────
Matt W218 .route(
Matt W219 "/{owner}/{repo}/issues",
Matt W220 get(routes::issue::list).post(routes::issue::create),
Matt W221 )
Matt W222 .route("/{owner}/{repo}/issues/new", get(routes::issue::new_form))
Matt W223 .route("/{owner}/{repo}/issues/{number}", get(routes::issue::detail))
Matt W224 .route("/{owner}/{repo}/issues/{number}/comments", post(routes::issue::comment))
Matt W225 .route("/{owner}/{repo}/issues/{number}/state", post(routes::issue::set_state))
Matt W226 .route("/{owner}/{repo}/issues/{number}/labels", post(routes::issue::set_labels))
Matt W227 .route("/{owner}/{repo}/issues/{number}/assignees", post(routes::issue::set_assignees))
Matt W228 // ─── git smart HTTP ──────────────────────────────────────────────────
Matt W229 .route("/{owner}/{repo}/info/refs", get(git_http::info_refs))
Matt W230 // `DefaultBodyLimit` is 2 MB, which is far below any real pack — with
Matt W231 // it in place every push over a couple of megabytes fails with a 413
Matt W232 // and `MAX_PACK_BYTES` never gets a chance to apply. It is disabled
Matt W233 // here and the real cap is enforced on the stream inside `git_http`,
Matt W234 // where it counts bytes rather than trusting Content-Length.
Matt W235 .route(
Matt W236 "/{owner}/{repo}/git-upload-pack",
Matt W237 post(git_http::upload_pack).layer(axum::extract::DefaultBodyLimit::disable()),
Matt W238 )
Matt W239 .route(
Matt W240 "/{owner}/{repo}/git-receive-pack",
Matt W241 post(git_http::receive_pack).layer(axum::extract::DefaultBodyLimit::disable()),
Matt W242 )
Matt W243 // ─── operational ─────────────────────────────────────────────────────
Matt W244 .route("/healthz", get(routes::health::healthz))
Matt W245 .route("/readyz", get(routes::health::readyz))
Matt W246 // Loopback-only, enforced per request from the real peer address
Matt W247 // (spec §7, §10).
Matt W248 .route("/metrics", get(routes::metrics::metrics))
Matt W249 // Order matters. Layers run outermost-last in this builder, so the
Matt W250 // effective order per request is: security → edge limit → session →
Matt W251 // rate limit → handler.
Matt W252 //
Matt W253 // The fine-grained limiter runs *after* the session so an authenticated
Matt W254 // request gets its own bucket rather than sharing its neighbours'
Matt W255 // address. That leaves session resolution — a database round trip —
Matt W256 // ahead of it, so a coarse per-address limit runs before the session to
Matt W257 // bound what an unauthenticated flood can force. Both sit inside the
Matt W258 // security layer, so a 429 still carries the security headers.
Matt W259 .layer(axum::middleware::from_fn_with_state(
Matt W260 state.clone(),
Matt W261 ratelimit::layer,
Matt W262 ))
Matt W263 .layer(axum::middleware::from_fn_with_state(
Matt W264 state.clone(),
Matt W265 middleware::session_layer,
Matt W266 ))
Matt W267 .layer(axum::middleware::from_fn_with_state(
Matt W268 state.clone(),
Matt W269 ratelimit::edge_layer,
Matt W270 ))
Matt W271 .layer(axum::middleware::from_fn_with_state(
Matt W272 state.clone(),
Matt W273 middleware::security_layer,
Matt W274 ))
Matt W275 .layer(CatchPanicLayer::new())
Matt W276 .layer(TraceLayer::new_for_http())
Matt W277 .fallback(not_found)
Matt W278 .with_state(state)
Matt W279}
Matt W280
Matt W281/// Assets are embedded in the binary so the runtime image needs no asset
Matt W282/// volume and cannot serve a file the build did not produce.
Matt W283async fn serve_css() -> impl axum::response::IntoResponse {
Matt W284 (
Matt W285 [
Matt W286 (axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8"),
Matt W287 (
Matt W288 axum::http::header::CACHE_CONTROL,
Matt W289 "public, max-age=3600",
Matt W290 ),
Matt W291 ],
Matt W292 include_str!("../assets/app.css"),
Matt W293 )
Matt W294}
Matt W295
Matt W296/// The CodeMirror bundle, served only to the edit page.
Matt W297///
Matt W298/// Large (a few hundred KB gzipped) and therefore deliberately not referenced
Matt W299/// from any other page. Cached hard: the content changes only when the image
Matt W300/// does.
Matt W301async fn serve_editor_js() -> impl axum::response::IntoResponse {
Matt W302 (
Matt W303 [
Matt W304 (
Matt W305 axum::http::header::CONTENT_TYPE,
Matt W306 "application/javascript; charset=utf-8",
Matt W307 ),
Matt W308 (
Matt W309 axum::http::header::CACHE_CONTROL,
Matt W310 "public, max-age=604800, immutable",
Matt W311 ),
Matt W312 ],
Matt W313 include_str!("../assets/editor.min.js"),
Matt W314 )
Matt W315}
Matt W316
Matt W317/// Web fonts, embedded like every other asset.
Matt W318///
Matt W319/// One route with a match rather than eight routes: the table *is* the
Matt W320/// allowlist, so a request for anything the build did not embed is a 404 and
Matt W321/// never touches the filesystem.
Matt W322///
Matt W323/// All eight are Latin subsets. The full families run 90KB+ per weight, which
Matt W324/// is not a reasonable thing to put in front of every page of a code forge.
Matt W325async fn serve_font(
Matt W326 axum::extract::Path(name): axum::extract::Path<String>,
Matt W327) -> axum::response::Response {
Matt W328 use axum::response::IntoResponse;
Matt W329
Matt W330 const FONTS: &[(&str, &[u8])] = &[
Matt W331 (
Matt W332 "plex-condensed-500.woff2",
Matt W333 include_bytes!("../assets/fonts/plex-condensed-500.woff2"),
Matt W334 ),
Matt W335 (
Matt W336 "plex-condensed-600.woff2",
Matt W337 include_bytes!("../assets/fonts/plex-condensed-600.woff2"),
Matt W338 ),
Matt W339 (
Matt W340 "plex-condensed-700.woff2",
Matt W341 include_bytes!("../assets/fonts/plex-condensed-700.woff2"),
Matt W342 ),
Matt W343 (
Matt W344 "plex-sans-400.woff2",
Matt W345 include_bytes!("../assets/fonts/plex-sans-400.woff2"),
Matt W346 ),
Matt W347 (
Matt W348 "plex-sans-500.woff2",
Matt W349 include_bytes!("../assets/fonts/plex-sans-500.woff2"),
Matt W350 ),
Matt W351 (
Matt W352 "plex-sans-600.woff2",
Matt W353 include_bytes!("../assets/fonts/plex-sans-600.woff2"),
Matt W354 ),
Matt W355 (
Matt W356 "jetbrains-mono-400.woff2",
Matt W357 include_bytes!("../assets/fonts/jetbrains-mono-400.woff2"),
Matt W358 ),
Matt W359 (
Matt W360 "jetbrains-mono-500.woff2",
Matt W361 include_bytes!("../assets/fonts/jetbrains-mono-500.woff2"),
Matt W362 ),
Matt W363 ];
Matt W364
Matt W365 let Some((_, bytes)) = FONTS.iter().find(|(n, _)| *n == name) else {
Matt W366 return error::AppError::NotFound.into_response();
Matt W367 };
Matt W368
Matt W369 (
Matt W370 [
Matt W371 (axum::http::header::CONTENT_TYPE, "font/woff2"),
Matt W372 // A font file never changes under a given deploy — the name is
Matt W373 // fixed and the bytes come from the image — so it can be cached as
Matt W374 // hard as the editor bundle.
Matt W375 (
Matt W376 axum::http::header::CACHE_CONTROL,
Matt W377 "public, max-age=31536000, immutable",
Matt W378 ),
Matt W379 ],
Matt W380 *bytes,
Matt W381 )
Matt W382 .into_response()
Matt W383}
Matt W384
Matt W385async fn serve_htmx() -> impl axum::response::IntoResponse {
Matt W386 (
Matt W387 [
Matt W388 (
Matt W389 axum::http::header::CONTENT_TYPE,
Matt W390 "application/javascript; charset=utf-8",
Matt W391 ),
Matt W392 (
Matt W393 axum::http::header::CACHE_CONTROL,
Matt W394 "public, max-age=86400",
Matt W395 ),
Matt W396 ],
Matt W397 include_str!("../assets/htmx.min.js"),
Matt W398 )
Matt W399}
Matt W400
Matt W401/// The theme flash-guard. Loaded blocking, in `<head>`, so a stored "light"
Matt W402/// preference is applied before the default dark theme paints.
Matt W403async fn serve_theme_init_js() -> impl axum::response::IntoResponse {
Matt W404 (
Matt W405 [
Matt W406 (
Matt W407 axum::http::header::CONTENT_TYPE,
Matt W408 "application/javascript; charset=utf-8",
Matt W409 ),
Matt W410 (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
Matt W411 ],
Matt W412 include_str!("../assets/theme-init.js"),
Matt W413 )
Matt W414}
Matt W415
Matt W416/// The theme toggle button's behaviour. Deferred, like htmx — the button is
Matt W417/// enhancement only.
Matt W418async fn serve_theme_js() -> impl axum::response::IntoResponse {
Matt W419 (
Matt W420 [
Matt W421 (
Matt W422 axum::http::header::CONTENT_TYPE,
Matt W423 "application/javascript; charset=utf-8",
Matt W424 ),
Matt W425 (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
Matt W426 ],
Matt W427 include_str!("../assets/theme.js"),
Matt W428 )
Matt W429}
Matt W430
Matt W431/// The ⌘K palette's behaviour. Deferred, like htmx — without it the masthead
Matt W432/// control stays a plain link to `/search`.
Matt W433async fn serve_palette_js() -> impl axum::response::IntoResponse {
Matt W434 (
Matt W435 [
Matt W436 (
Matt W437 axum::http::header::CONTENT_TYPE,
Matt W438 "application/javascript; charset=utf-8",
Matt W439 ),
Matt W440 (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
Matt W441 ],
Matt W442 include_str!("../assets/palette.js"),
Matt W443 )
Matt W444}
Matt W445
Matt W446/// The homepage terminal's typewriter animation. Deferred, like the other
Matt W447/// enhancement scripts — without it the example session just renders as
Matt W448/// static text.
Matt W449async fn serve_terminal_js() -> impl axum::response::IntoResponse {
Matt W450 (
Matt W451 [
Matt W452 (
Matt W453 axum::http::header::CONTENT_TYPE,
Matt W454 "application/javascript; charset=utf-8",
Matt W455 ),
Matt W456 (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
Matt W457 ],
Matt W458 include_str!("../assets/terminal.js"),
Matt W459 )
Matt W460}
Matt W461
Matt W462async fn not_found() -> error::AppError {
Matt W463 error::AppError::NotFound
Matt W464}
Matt W465
Matt W466fn init_tracing() {
Matt W467 use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
Matt W468
Matt W469 let filter = EnvFilter::try_from_default_env()
Matt W470 .unwrap_or_else(|_| EnvFilter::new("info,df_web=debug"));
Matt W471
Matt W472 // Structured JSON to stdout (spec §10). Falls back to a human-readable
Matt W473 // format when a TTY is attached, which is what you want when running it by
Matt W474 // hand.
Matt W475 let json = !std::io::IsTerminal::is_terminal(&std::io::stdout());
Matt W476
Matt W477 let registry = tracing_subscriber::registry().with(filter);
Matt W478 if json {
Matt W479 registry.with(tracing_subscriber::fmt::layer().json()).init();
Matt W480 } else {
Matt W481 registry.with(tracing_subscriber::fmt::layer()).init();
Matt W482 }
Matt W483}
Matt W484
Matt W485async fn shutdown_signal() {
Matt W486 let ctrl_c = async {
Matt W487 tokio::signal::ctrl_c()
Matt W488 .await
Matt W489 .expect("installing Ctrl+C handler");
Matt W490 };
Matt W491
Matt W492 #[cfg(unix)]
Matt W493 let terminate = async {
Matt W494 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
Matt W495 .expect("installing SIGTERM handler")
Matt W496 .recv()
Matt W497 .await;
Matt W498 };
Matt W499
Matt W500 #[cfg(not(unix))]
Matt W501 let terminate = std::future::pending::<()>();
Matt W502
Matt W503 tokio::select! {
Matt W504 _ = ctrl_c => tracing::info!("received SIGINT, shutting down"),
Matt W505 _ = terminate => tracing::info!("received SIGTERM, shutting down"),
Matt W506 }
Matt W507}

507 lines · Rust