Files
ngit-grasp/src/main.rs
T
DanConwayDev 3f22012729 fix: disable ANSI log colors when stdout is not a terminal
Motivation: four sync integration tests failed deterministically in
some environments: proactive_sync_plus::
missing_relay_list_uses_bounded_fallback_coverage,
maintainer_reprocessing::
unresolved_repositories_share_one_dependency_poll_per_relay,
metrics::test_live_sync_event_count, and reconnect_backoff::
flapping_relay_handshakes_do_not_reset_exponential_backoff. Each waits
for a plain `field=value` substring (e.g. `consecutive_failures=1`) in
the relay subprocess log written by the TestRelay fixture. The tracing
fmt layer colorizes by default, so the redirected log contained
`consecutive_failures^[[0m^[[2m=^[[0m1`, which never matches.
tracing-subscriber honours NO_COLOR, so the same tests passed or failed
depending on the invoking environment: runs with NO_COLOR set produced
clean logs, interactive-launched runs produced ANSI logs and
deterministic failures.

Approach: initialize the fmt layer with
`.with_ansi(std::io::stdout().is_terminal())`. Colors are emitted only
when a human is watching a terminal; redirected output (test fixtures,
journald, pipelines) stays plain. This is a logging hygiene fix in the
binary, not a test accommodation: the defect was writing terminal
control sequences to a non-terminal stream.

Correctness assumptions: the relay binary is the only process whose
logs the fixtures scrape, and tests always redirect its stdout to a
file, so is_terminal() is deterministically false there and the log
format no longer varies with the parent environment.

Excluded scope: several sync tests remain flaky under full-suite
parallel load but pass repeatedly in isolation
(req_concurrency::startup_historic_sync_stays_within_relay_req_concurrency_limit,
live_sync::live_sync_regroups_after_filter_count_refusal,
tag_variations::test_layer3_sync_with_lowercase_e_tag). They assert on
metrics or proxy behaviour, not log text; their bounded deadlines are
exceeded when the whole suite competes for CPU. Left undiagnosed rather
than papered over with wider timeouts.

Validation: the four log-scraping tests pass individually and in full
runs after the fix; cargo fmt makes no changes; cargo clippy
--workspace --all-targets -- -D warnings is clean; full cargo test is
green except the pre-existing load flakes above, each of which passes
3/3 when run in isolation.
2026-08-14 22:49:37 +00:00

123 lines
4.5 KiB
Rust

use std::io::IsTerminal;
use anyhow::Result;
use clap::Parser;
use tokio::signal;
use tracing::info;
use tracing_subscriber::{filter::FilterExt, layer::SubscriberExt, EnvFilter, Layer};
use ngit_grasp::{
cleanup_empty_repos, config::Config, logging::SuppressRoutineDisconnects, nostr,
server::RelayServer,
};
/// Top-level CLI dispatcher.
///
/// With no subcommand the binary runs the relay (all relay flags apply).
/// With a subcommand it runs the requested maintenance tool instead.
#[derive(Debug, Parser)]
#[command(author, version, about = "ngit-grasp GRASP relay", long_about = None)]
#[command(propagate_version = true)]
enum Cli {
/// Run the GRASP relay server (default when no subcommand is given).
#[command(name = "serve")]
Serve(Box<Config>),
/// Remove kind 30617/30618 events whose bare git repository is empty or missing.
///
/// Runs in dry-run mode by default. Pass --execute to make changes.
/// Stop the relay service before running with --execute.
CleanupEmptyRepos(cleanup_empty_repos::CleanupArgs),
/// Permanently eject deleted repository data from holding/archive stores.
///
/// This is an operator/admin maintenance command and is idempotent.
HoldingEject(nostr::lifecycle::HoldingEjectArgs),
}
#[tokio::main]
async fn main() -> Result<()> {
// Load .env file before clap parses, so env vars are available.
dotenvy::dotenv().ok();
// Peek at argv[1] to decide whether a subcommand was explicitly provided.
// If not, prepend the implicit "serve" subcommand so that clap routes to Cli::Serve
// and all relay flags are parsed normally (preserving backward compatibility).
let mut args: Vec<String> = std::env::args().collect();
let known_subcommands = ["serve", "cleanup-empty-repos", "holding-eject", "help"];
let has_subcommand = args.get(1).is_some_and(|a| {
known_subcommands.contains(&a.as_str())
|| matches!(a.as_str(), "-h" | "--help" | "-V" | "--version")
});
if !has_subcommand {
args.insert(1, "serve".to_string());
}
match Cli::parse_from(args) {
Cli::CleanupEmptyRepos(cleanup_args) => cleanup_empty_repos::run(&cleanup_args).await,
Cli::HoldingEject(eject_args) => nostr::lifecycle::run_holding_eject(eject_args).await,
Cli::Serve(config) => {
let mut config = *config;
config.relay_owner_nsec = Some(Config::load_relay_owner_key()?);
run_relay(config).await
}
}
}
/// Run the relay until an OS shutdown signal arrives.
///
/// All relay wiring lives in [`ngit_grasp::server::RelayServer`];
/// this function only owns concerns specific to the standalone binary:
/// the global tracing subscriber and signal handling.
async fn run_relay(config: Config) -> Result<()> {
// Initialize tracing with configured log level
let filter = EnvFilter::new(&config.log_level).and(SuppressRoutineDisconnects);
// Only colorize when stdout is a terminal: ANSI escape codes in redirected
// logs corrupt journald/file output and break log-scraping consumers.
let subscriber = tracing_subscriber::registry().with(
tracing_subscriber::fmt::layer()
.with_ansi(std::io::stdout().is_terminal())
.with_filter(filter),
);
tracing::subscriber::set_global_default(subscriber)?;
info!("Starting ngit-grasp with log level: {}", config.log_level);
let server = RelayServer::start(config).await?;
server.run_until(shutdown_signal()).await
}
/// Resolves when the process receives SIGINT (Ctrl+C) or, on unix, SIGTERM.
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal as unix_signal, SignalKind};
let mut sigterm = match unix_signal(SignalKind::terminate()) {
Ok(sigterm) => sigterm,
Err(e) => {
tracing::error!("Failed to install SIGTERM handler: {}", e);
// Fall back to Ctrl+C only.
let _ = signal::ctrl_c().await;
info!("Received SIGINT (Ctrl+C), cleaning up...");
return;
}
};
tokio::select! {
_ = signal::ctrl_c() => {
info!("Received SIGINT (Ctrl+C), cleaning up...");
}
_ = sigterm.recv() => {
info!("Received SIGTERM, cleaning up...");
}
}
}
#[cfg(not(unix))]
{
let _ = signal::ctrl_c().await;
info!("Received SIGINT (Ctrl+C), cleaning up...");
}
}