use std::path::PathBuf;
use std::time::Duration;
use serde::{Deserialize, Serialize};
const READY_TIMEOUT: Duration = Duration::from_secs(30);
const STOP_GRACE: Duration = Duration::from_secs(10);
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
const LOG_MAX_BYTES: u64 = 64 * 1024 * 1024;
const STATE_FILE_RETENTION: Duration = Duration::from_hours(24);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct Instance {
pub(super) pid: u32,
pub(super) port: u16,
#[serde(default)]
pub(super) version: Option<String>,
#[serde(default)]
pub(super) credentials: String,
#[serde(default)]
pub(super) start: u64,
}
struct Owned {
child: tokio::process::Child,
pid: u32,
}
static OWNED: std::sync::OnceLock<tokio::sync::Mutex<Option<Owned>>> = std::sync::OnceLock::new();
fn owned() -> &'static tokio::sync::Mutex<Option<Owned>> {
OWNED.get_or_init(|| tokio::sync::Mutex::new(None))
}
static ENSURE: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
fn ensure_lock() -> &'static tokio::sync::Mutex<()> {
ENSURE.get_or_init(|| tokio::sync::Mutex::new(()))
}
pub(super) fn root() -> Option<PathBuf> {
Some(
crate::config::default_config_dir()
.ok()?
.join("telegram-bot-api"),
)
}
pub(super) fn state_dir() -> Option<PathBuf> {
Some(root()?.join("state"))
}
fn files_dir() -> Option<PathBuf> {
Some(root()?.join("files"))
}
const FILES_DIR_ENV: &str = "MAHBOT_TGAPI_FILES_DIR";
pub(super) fn download_root() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os(FILES_DIR_ENV) {
return Some(PathBuf::from(dir));
}
files_dir()
}
fn payload_dirs() -> Vec<PathBuf> {
[files_dir(), temp_dir()].into_iter().flatten().collect()
}
fn temp_dir() -> Option<PathBuf> {
Some(root()?.join("tmp"))
}
fn log_path() -> Option<PathBuf> {
Some(root()?.join("server.log"))
}
fn stderr_path() -> Option<PathBuf> {
Some(root()?.join("server.stderr.log"))
}
fn record_path() -> Option<PathBuf> {
Some(root()?.join("server.json"))
}
pub(super) fn read_record() -> Option<Instance> {
let body = std::fs::read_to_string(record_path()?).ok()?;
serde_json::from_str(&body).ok()
}
fn write_record(instance: &Instance) -> Result<(), String> {
let Some(path) = record_path() else {
return Err("the server's record path is unavailable".to_string());
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("the server's directory could not be created ({})", e.kind()))?;
}
let body = serde_json::to_vec(instance)
.map_err(|_| "the server's record could not be encoded".to_string())?;
let tmp = path.with_extension("json.new");
std::fs::write(&tmp, body)
.map_err(|e| format!("the server's record could not be written ({})", e.kind()))?;
std::fs::rename(&tmp, &path)
.map_err(|e| format!("the server's record could not be placed ({})", e.kind()))
}
fn clear_record() {
if let Some(path) = record_path() {
let _ = std::fs::remove_file(path);
}
}
pub(super) fn credentials_fingerprint(api_id: &str, api_hash: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(api_id.as_bytes());
hasher.update(b":");
hasher.update(api_hash.as_bytes());
format!("{:x}", hasher.finalize())
}
pub(super) async fn probe(port: u16) -> bool {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let Ok(Ok(mut stream)) = tokio::time::timeout(
PROBE_TIMEOUT,
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
else {
return false;
};
let request = b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
if tokio::time::timeout(PROBE_TIMEOUT, stream.write_all(request))
.await
.is_err()
{
return false;
}
let mut head = [0u8; 5];
matches!(
tokio::time::timeout(PROBE_TIMEOUT, stream.read_exact(&mut head)).await,
Ok(Ok(_))
) && &head == b"HTTP/"
}
fn pick_port() -> Result<u16, String> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
.map_err(|e| format!("no loopback port could be reserved ({})", e.kind()))?;
let port = listener
.local_addr()
.map_err(|e| format!("the reserved port could not be read ({})", e.kind()))?
.port();
drop(listener);
Ok(port)
}
pub(super) async fn reported_version() -> Option<String> {
reported_version_at(&super::install::binary_path()?).await
}
pub(super) async fn reported_version_at(path: &std::path::Path) -> Option<String> {
let mut command = tokio::process::Command::new(path);
#[cfg(target_os = "windows")]
command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
command
.arg("--version")
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let output = tokio::time::timeout(Duration::from_secs(10), command.output())
.await
.ok()?
.ok()?;
if !output.status.success() {
return None;
}
let mut text = String::from_utf8_lossy(&output.stderr).into_owned();
text.push_str(&String::from_utf8_lossy(&output.stdout));
let lines: Vec<&str> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
lines
.iter()
.find(|line| line.starts_with("Bot API "))
.or(lines.first())
.map(|line| (*line).to_string())
}
pub(super) async fn ensure(credentials: &str) -> Result<Instance, String> {
let _guard = ensure_lock().lock().await;
if let Some(instance) = read_record()
&& instance.credentials == credentials
&& verify(&instance)
&& probe(instance.port).await
{
tracing::info!(
port = instance.port,
"telegram-bot-api: adopting the running server"
);
return Ok(instance);
}
stop().await;
if let Some(left) = read_record() {
return Err(format!(
"the previous server process (pid {}) is still running, so a second one was not started",
left.pid
));
}
if !super::install::present() {
super::install::install(super::install::latest().await?).await?;
}
let version = if let Some(version) = reported_version().await {
version
} else {
tracing::warn!("telegram-bot-api: the installed server cannot run; reinstalling");
super::install::install(super::install::latest().await?).await?;
reported_version()
.await
.ok_or_else(|| "the server binary could not be started".to_string())?
};
let port = pick_port()?;
let (pid, start) = spawn(port).await?;
if let Err(reason) = write_record(&Instance {
pid,
port,
version: Some(version.clone()),
credentials: credentials.to_string(),
start,
}) {
stop().await;
return Err(reason);
}
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
loop {
if probe(port).await && exit_status().await.is_none() {
break;
}
if let Some(status) = exit_status().await {
let reason = startup_failure(&format!("it exited with {status}"));
stop().await;
return Err(reason);
}
if tokio::time::Instant::now() >= deadline {
let reason = startup_failure("no answer on its port");
stop().await;
return Err(reason);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
Ok(Instance {
pid,
port,
version: Some(version),
credentials: credentials.to_string(),
start,
})
}
async fn spawn(port: u16) -> Result<(u32, u64), String> {
let Some(state) = state_dir() else {
return Err("the server's working directory is unavailable".to_string());
};
let Some(files) = files_dir() else {
return Err("the server's files directory is unavailable".to_string());
};
let Some(log) = log_path() else {
return Err("the server's log path is unavailable".to_string());
};
for dir in [Some(state.clone()), Some(files.clone()), temp_dir()]
.into_iter()
.flatten()
{
tokio::fs::create_dir_all(&dir).await.map_err(|e| {
format!(
"the server's working directory could not be created ({})",
e.kind()
)
})?;
}
if let Some(dir) = log.parent() {
let _ = tokio::fs::create_dir_all(dir).await;
}
let stderr = match stderr_path().and_then(|path| {
std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(path)
.ok()
}) {
Some(file) => std::process::Stdio::from(file),
None => std::process::Stdio::null(),
};
let Some(root) = root() else {
return Err("the install directory is unavailable".to_string());
};
let Some(binary) = super::install::binary_path() else {
return Err("the install directory is unavailable".to_string());
};
let (api_id, api_hash) = super::credentials();
let mut command = tokio::process::Command::new(binary);
#[cfg(target_os = "windows")]
command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
command
.current_dir(&root)
.arg("--local")
.arg("--http-ip-address")
.arg("127.0.0.1")
.arg("--http-port")
.arg(port.to_string())
.arg("--dir")
.arg(&state)
.arg("--files-dir")
.arg(&files)
.arg("--temp-dir")
.arg(temp_dir().unwrap_or_else(|| state.clone()))
.arg("--log")
.arg(&log)
.arg("--log-max-file-size")
.arg(LOG_MAX_BYTES.to_string())
.arg("--verbosity")
.arg("1")
.env("TELEGRAM_API_ID", &api_id)
.env("TELEGRAM_API_HASH", &api_hash)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(stderr)
.kill_on_drop(false);
let child = command
.spawn()
.map_err(|e| format!("the server binary could not be started ({})", e.kind()))?;
let pid = child
.id()
.ok_or_else(|| "the server reported no process id".to_string())?;
*owned().lock().await = Some(Owned { child, pid });
let Some(start) = process_identity(pid) else {
let child = owned().lock().await.take();
if let Some(Owned { mut child, .. }) = child {
let _ = child.start_kill();
let _ = child.wait().await;
}
return Err("the server process could not be identified, so it was stopped".to_string());
};
Ok((pid, start))
}
fn startup_failure(reason: &str) -> String {
let tail = [stderr_path(), log_path()]
.into_iter()
.flatten()
.find_map(|path| {
let bytes = std::fs::read(path).ok()?;
let start = bytes.len().saturating_sub(2048);
let text = String::from_utf8_lossy(&bytes[start..]).trim().to_string();
let line = text.lines().next_back()?.trim().to_string();
(!line.is_empty()).then_some(line)
})
.unwrap_or_default();
if tail.is_empty() {
format!("the server did not become ready ({reason})")
} else {
format!("the server did not become ready ({reason}): {tail}")
}
}
async fn exit_status() -> Option<std::process::ExitStatus> {
let mut guard = owned().lock().await;
let owned = guard.as_mut()?;
owned.child.try_wait().unwrap_or_default()
}
fn verify(instance: &Instance) -> bool {
instance.start != 0 && process_identity(instance.pid) == Some(instance.start)
}
pub(super) async fn alive(instance: &Instance) -> bool {
verify(instance) && probe(instance.port).await
}
pub(super) async fn adopt_or_reap() {
let Some(instance) = read_record() else {
return;
};
if !verify(&instance) {
tracing::debug!(
pid = instance.pid,
"telegram-bot-api: the record names no process this product started"
);
clear_record();
return;
}
if probe(instance.port).await {
tracing::info!(
port = instance.port,
"telegram-bot-api: adopting the running server"
);
return;
}
tracing::info!("telegram-bot-api: stopping a server left by a previous run");
terminate_pid(instance.pid).await;
clear_record();
}
pub(super) async fn stop() {
stop_process().await;
super::clear_endpoint();
}
async fn stop_process() {
if let Some(Owned { child, pid }) = owned().lock().await.take() {
stop_child(child, pid).await;
clear_record();
return;
}
let Some(instance) = read_record() else {
return;
};
if !verify(&instance) {
tracing::debug!(
pid = instance.pid,
"telegram-bot-api: the record names no process this product started"
);
clear_record();
return;
}
terminate_pid(instance.pid).await;
if process_alive(instance.pid) {
tracing::warn!(
pid = instance.pid,
"telegram-bot-api: the server outlived its stop"
);
} else {
clear_record();
}
}
async fn stop_child(mut child: tokio::process::Child, pid: u32) {
if matches!(child.try_wait(), Ok(None)) {
signal_terminate(pid);
}
if tokio::time::timeout(STOP_GRACE, child.wait())
.await
.is_err()
{
tracing::warn!("telegram-bot-api: the server did not stop in time");
let _ = child.start_kill();
let _ = child.wait().await;
}
}
async fn terminate_pid(pid: u32) {
if !process_alive(pid) {
return;
}
signal_terminate(pid);
if wait_until_gone(pid, STOP_GRACE).await {
return;
}
signal_kill(pid);
if !wait_until_gone(pid, STOP_GRACE).await {
tracing::warn!(pid, "telegram-bot-api: the server survived its kill");
}
}
async fn wait_until_gone(pid: u32, bound: Duration) -> bool {
let deadline = tokio::time::Instant::now() + bound;
loop {
if !process_alive(pid) {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
fn process_identity(pid: u32) -> Option<u64> {
#[cfg(target_os = "linux")]
{
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let rest = stat.rsplit_once(')')?.1;
rest.split_whitespace().nth(19)?.parse().ok()
}
#[cfg(target_os = "macos")]
{
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_bsdinfo>()).ok()?;
let read = unsafe {
libc::proc_pidinfo(
libc::c_int::try_from(pid).ok()?,
libc::PROC_PIDTBSDINFO,
0,
std::ptr::addr_of_mut!(info).cast::<libc::c_void>(),
size,
)
};
if read != size {
return None;
}
Some(info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
}
#[cfg(target_os = "windows")]
{
use windows_sys::Win32::Foundation::{CloseHandle, FILETIME};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle == 0 {
return None;
}
let mut creation = FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
};
let mut exit = creation;
let mut kernel = creation;
let mut user = creation;
let asked = unsafe {
GetProcessTimes(
handle,
&raw mut creation,
&raw mut exit,
&raw mut kernel,
&raw mut user,
)
};
unsafe { CloseHandle(handle) };
let start = (u64::from(creation.dwHighDateTime) << 32) | u64::from(creation.dwLowDateTime);
(asked != 0).then_some(start)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = pid;
None
}
}
fn process_alive(pid: u32) -> bool {
if pid == 0 {
return false;
}
#[cfg(unix)]
{
let alive = unsafe { libc::kill(i32::try_from(pid).unwrap_or(i32::MAX), 0) } == 0;
alive || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(windows)]
{
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle == 0 {
return false;
}
unsafe { CloseHandle(handle) };
true
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
false
}
}
fn signal_terminate(pid: u32) {
#[cfg(unix)]
if let Ok(pid) = i32::try_from(pid) {
unsafe { libc::kill(pid, libc::SIGTERM) };
}
#[cfg(not(unix))]
signal_kill(pid);
}
fn signal_kill(pid: u32) {
#[cfg(unix)]
if let Ok(pid) = i32::try_from(pid) {
unsafe { libc::kill(pid, libc::SIGKILL) };
}
#[cfg(windows)]
{
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_TERMINATE, TerminateProcess,
};
let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
if handle != 0 {
unsafe {
TerminateProcess(handle, 1);
CloseHandle(handle);
}
}
}
}
pub(super) async fn remove_state() {
stop().await;
let Some(root) = root() else { return };
match tokio::fs::remove_dir_all(&root).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(error = %e, "telegram-bot-api: could not remove its state"),
}
}
pub(super) async fn sweep_state_files() {
for dir in payload_dirs() {
let mut pending = vec![dir];
while let Some(dir) = pending.pop() {
let Ok(mut entries) = tokio::fs::read_dir(&dir).await else {
continue;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let Ok(kind) = entry.file_type().await else {
continue;
};
if kind.is_dir() {
pending.push(entry.path());
continue;
}
let old = entry
.metadata()
.await
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age > STATE_FILE_RETENTION);
if old {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_record_proves_itself_only_with_the_start_time_of_its_process() {
let pid = std::process::id();
let start = process_identity(pid).expect("the current process has a start time");
let record = Instance {
pid,
port: 1,
version: None,
credentials: String::new(),
start,
};
assert!(verify(&record));
assert!(!verify(&Instance {
start: 0,
..record.clone()
}));
assert!(!verify(&Instance {
start: start + 1,
..record
}));
}
#[tokio::test]
async fn an_artifact_that_cannot_run_reports_no_version() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("telegram-bot-api");
tokio::fs::write(&path, b"not an executable")
.await
.expect("write");
assert_eq!(reported_version_at(&path).await, None);
}
}