Stability hardening: crash.log + disk-full preflight (2.5)
Two independent improvements from Phase 2.5.
## Panic hook → crash.log
GUI users launched from a .desktop file have no stderr — today panics
vanish without trace. Install a panic::set_hook early in main() that
appends a structured entry to <library_root>/yt-offline.crash.log for
every panic from every thread (UI, axum workers, downloader spawns,
tray bg thread, thumbnail decode workers).
Each entry: ISO-8601 timestamp, thread name, file:line, panic message,
and a backtrace when RUST_BACKTRACE is set. The default panic hook
still runs after ours, so dev workflows keep getting the stderr trace.
Bounded: when the log passes 256 KB the next write rotates it to .1
(one previous generation kept). No external date library — small
civil-time formula in-tree.
## Disk-full preflight
Before yt-dlp ever launches, statvfs the target filesystem. If free
space is under 500 MB, push a synthetic Failed job classified as
DiskFull instead of starting yt-dlp. That way:
- Users see the actual problem ("only 230 MB free on /mnt/…") in the
Downloads modal with the same "free up space and retry" hint the
error-classifier emits for mid-download ENOSPC.
- No half-written file lands on disk.
- No 30-second yt-dlp warmup wasted on a doomed run.
statvfs returning None (non-Unix, missing path) skips the check —
better to let yt-dlp run than to refuse on no data.
5 new tests: 3 in crash (civil-time, log shape, real-panic capture),
2 in disk_space (statvfs plumbing + missing-path None). 74 pass total.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
55b90a22b6
commit
c1bd88d800
6 changed files with 400 additions and 5 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -5124,6 +5124,7 @@ dependencies = [
|
|||
"eframe",
|
||||
"image",
|
||||
"ksni",
|
||||
"libc",
|
||||
"notify-rust",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ rand = "0.8"
|
|||
# (zbus, no GTK/libdbus build dep), consistent with notify-rust above.
|
||||
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] }
|
||||
ksni = { version = "0.3.4", features = ["tokio"] }
|
||||
libc = "0.2.186"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
|
|
|
|||
232
src/crash.rs
Normal file
232
src/crash.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
//! Persistent panic logging so a crashed yt-offline leaves a paper trail.
|
||||
//!
|
||||
//! GUI users don't see stderr (the binary is launched without a terminal
|
||||
//! from a `.desktop` file or the IDE), so panics today vanish entirely.
|
||||
//! This module installs a `panic::set_hook` early in `main` that appends
|
||||
//! a structured entry to `<channels_root>/yt-offline.crash.log` for every
|
||||
//! panic from any thread.
|
||||
//!
|
||||
//! The default panic handler still runs after ours so dev runs keep
|
||||
//! getting the familiar stderr output.
|
||||
//!
|
||||
//! # Format
|
||||
//!
|
||||
//! Each entry is a small block — easy to grep, easy to attach to a bug
|
||||
//! report:
|
||||
//!
|
||||
//! ```text
|
||||
//! ── 2026-05-27T18:42:11Z thread: "tokio-runtime-worker" ──────────────
|
||||
//! panic at src/web.rs:482:9:
|
||||
//! called `Option::unwrap()` on a `None` value
|
||||
//! backtrace:
|
||||
//! …
|
||||
//! ```
|
||||
//!
|
||||
//! A backtrace is captured only when `RUST_BACKTRACE` is set; we don't
|
||||
//! force it because capture is slow and most users won't have symbols.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::panic;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Cap the log at ~256 KB. When the file grows past this, the next write
|
||||
/// rotates it to `*.1` (overwriting any previous rotation). Bounded both
|
||||
/// in disk use and in how many panics we keep around — the most recent
|
||||
/// dozen panics is what a bug-report attacher actually wants.
|
||||
const LOG_SIZE_CAP_BYTES: u64 = 256 * 1024;
|
||||
|
||||
/// Install a global panic hook that logs to `<dir>/yt-offline.crash.log`
|
||||
/// while preserving the default stderr behavior.
|
||||
///
|
||||
/// `dir` is the same directory the SQLite database lives in (typically
|
||||
/// `channels_root` in config). Pass the *absolute* path so the log
|
||||
/// remains discoverable regardless of where the user's `cwd` was when
|
||||
/// they launched the binary.
|
||||
///
|
||||
/// Safe to call once at process startup. Calling it more than once
|
||||
/// replaces the previous hook (which is fine: the new dir wins).
|
||||
pub fn install(dir: &Path) {
|
||||
let path = dir.join("yt-offline.crash.log");
|
||||
// Don't fail startup if the parent dir doesn't exist yet — log it,
|
||||
// continue. The DB-open path further down the chain will surface a
|
||||
// permission / missing-dir error in a more legible way.
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
|
||||
// Stash the default hook so we can chain to it: our hook does the
|
||||
// file write, then defers to the standard formatter that prints to
|
||||
// stderr (useful in dev / when run from a terminal).
|
||||
let default = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |info| {
|
||||
// Best-effort log. A panic inside the panic handler is a hard
|
||||
// abort — wrap the IO in a closure that swallows all errors.
|
||||
let _ = write_entry(&path, info);
|
||||
default(info);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Append a single panic entry to the log, rotating if the file is over
|
||||
/// the size cap. All IO errors are intentionally swallowed; this is a
|
||||
/// best-effort diagnostic, not a load-bearing data path.
|
||||
fn write_entry(path: &Path, info: &panic::PanicHookInfo<'_>) -> std::io::Result<()> {
|
||||
// Rotation: if the existing log is over the cap, move it aside.
|
||||
// A single rename is cheaper than parsing+truncating and gives the
|
||||
// user one previous generation for free.
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
if meta.len() >= LOG_SIZE_CAP_BYTES {
|
||||
let rotated = with_suffix(path, ".1");
|
||||
let _ = std::fs::rename(path, &rotated);
|
||||
}
|
||||
}
|
||||
|
||||
let mut f = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)?;
|
||||
|
||||
let ts = now_iso8601();
|
||||
let thread = std::thread::current();
|
||||
let thread_name = thread.name().unwrap_or("<unnamed>");
|
||||
let location = info
|
||||
.location()
|
||||
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
|
||||
.unwrap_or_else(|| "<unknown>".to_string());
|
||||
// PanicHookInfo carries the payload as `&dyn Any`; downcast the two
|
||||
// forms the stdlib produces (`&str` for `panic!("literal")` and
|
||||
// `String` for `panic!("{x}")`).
|
||||
let payload = info.payload();
|
||||
let msg = payload
|
||||
.downcast_ref::<&str>()
|
||||
.copied()
|
||||
.map(str::to_string)
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "<non-string panic payload>".to_string());
|
||||
|
||||
writeln!(f)?;
|
||||
writeln!(f, "── {ts} thread: {thread_name:?} ─────────────────────────")?;
|
||||
writeln!(f, "panic at {location}:")?;
|
||||
for line in msg.lines() {
|
||||
writeln!(f, " {line}")?;
|
||||
}
|
||||
// Capture a backtrace only when RUST_BACKTRACE is set (matches the
|
||||
// default panic-handler behavior; capturing is otherwise too slow on
|
||||
// hot paths and most users won't have symbols anyway).
|
||||
if std::env::var_os("RUST_BACKTRACE").is_some() {
|
||||
let bt = std::backtrace::Backtrace::force_capture();
|
||||
writeln!(f, "backtrace:")?;
|
||||
for line in bt.to_string().lines() {
|
||||
writeln!(f, " {line}")?;
|
||||
}
|
||||
}
|
||||
f.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build `<path>.<suffix>` next to the original — used to rotate logs.
|
||||
/// We append `.1` rather than `.<count>` because keeping one previous
|
||||
/// generation is enough; more would just bloat the install directory.
|
||||
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||
let mut s = path.as_os_str().to_owned();
|
||||
s.push(suffix);
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Minimal RFC3339-ish timestamp without pulling in `chrono`. Goes
|
||||
/// `2026-05-27T18:42:11Z` — accurate to the second, UTC. We compute it
|
||||
/// from `SystemTime` via the Howard Hinnant civil-time formula so we
|
||||
/// don't need a date library.
|
||||
fn now_iso8601() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0) as i64;
|
||||
let (y, mo, d, h, mi, s) = civil_from_unix(secs);
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
/// Convert UNIX seconds → (year, month, day, hour, minute, second) UTC.
|
||||
/// Adapted from Howard Hinnant's `civil_from_days`
|
||||
/// (<https://howardhinnant.github.io/date_algorithms.html#civil_from_days>).
|
||||
/// All branchless integer math; no leap-year edge cases.
|
||||
fn civil_from_unix(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let rem = secs.rem_euclid(86_400) as u32;
|
||||
let h = rem / 3600;
|
||||
let mi = (rem % 3600) / 60;
|
||||
let s = rem % 60;
|
||||
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u32;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe as i32 + (era * 400) as i32;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d, h, mi, s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn civil_round_trips_known_dates() {
|
||||
// 2024-01-01T00:00:00Z is 1704067200
|
||||
assert_eq!(civil_from_unix(1_704_067_200), (2024, 1, 1, 0, 0, 0));
|
||||
// 2026-05-22T18:42:11Z is 1779475331
|
||||
assert_eq!(civil_from_unix(1_779_475_331), (2026, 5, 22, 18, 42, 11));
|
||||
// Unix epoch
|
||||
assert_eq!(civil_from_unix(0), (1970, 1, 1, 0, 0, 0));
|
||||
// Leap day
|
||||
assert_eq!(civil_from_unix(1_582_934_400), (2020, 2, 29, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_entry_appends_panic_to_file() {
|
||||
// Build a real PanicHookInfo via std::panic::catch_unwind. We
|
||||
// can't construct one directly (the type's fields are private).
|
||||
// The simplest portable approach: install our hook against a
|
||||
// tempfile, then trigger a controlled panic in another thread
|
||||
// (`std::thread::spawn` so the panic doesn't unwind the test
|
||||
// runner), and assert the file contents afterward.
|
||||
let mut tmp = std::env::temp_dir();
|
||||
tmp.push(format!("yt-offline-crash-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let log = tmp.join("yt-offline.crash.log");
|
||||
|
||||
// Install the hook scoped to this test. We swap it back at the
|
||||
// end so other tests run on the default hook.
|
||||
let old = std::panic::take_hook();
|
||||
super::install(&tmp);
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("crash-test".into())
|
||||
.spawn(|| {
|
||||
panic!("deliberate test panic — please ignore");
|
||||
})
|
||||
.unwrap()
|
||||
.join();
|
||||
std::panic::set_hook(old);
|
||||
|
||||
let body = std::fs::read_to_string(&log).expect("crash log should exist");
|
||||
assert!(body.contains("deliberate test panic"));
|
||||
assert!(body.contains("crash-test"));
|
||||
assert!(body.contains("panic at "));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_format_has_expected_shape() {
|
||||
let s = now_iso8601();
|
||||
// YYYY-MM-DDTHH:MM:SSZ — 20 chars
|
||||
assert_eq!(s.len(), 20);
|
||||
assert!(s.ends_with('Z'));
|
||||
assert_eq!(s.as_bytes()[4], b'-');
|
||||
assert_eq!(s.as_bytes()[7], b'-');
|
||||
assert_eq!(s.as_bytes()[10], b'T');
|
||||
}
|
||||
}
|
||||
89
src/disk_space.rs
Normal file
89
src/disk_space.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//! Free-space query for the download preflight check.
|
||||
//!
|
||||
//! Rust stdlib doesn't have `available_space` on stable, so we call
|
||||
//! `statvfs(3)` via libc on Unix. On non-Unix the function returns
|
||||
//! `None`, which the caller treats as "skip the check" — we'd rather
|
||||
//! let yt-dlp run and surface a real ENOSPC error than refuse to start
|
||||
//! based on no data.
|
||||
|
||||
/// Return the bytes of free space available to the calling user on the
|
||||
/// filesystem holding `path`. `None` means the query failed (path missing,
|
||||
/// non-Unix host, libc error) and the caller should skip the preflight.
|
||||
///
|
||||
/// Uses `f_frsize * f_bavail` — `f_bavail` is the "available to
|
||||
/// unprivileged user" count which already excludes the reserved-root
|
||||
/// blocks; that's the number a non-root download cares about.
|
||||
#[cfg(unix)]
|
||||
pub fn available_bytes(path: &std::path::Path) -> Option<u64> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
|
||||
// SAFETY: libc::statvfs reads a valid C string and writes into a
|
||||
// zero-initialised libc::statvfs we own. Returning 0 on success is
|
||||
// documented; we check that before reading the fields. mem::zeroed()
|
||||
// is safe for plain-data libc structs.
|
||||
let mut buf: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut buf) };
|
||||
if rc != 0 {
|
||||
return None;
|
||||
}
|
||||
// f_frsize is the underlying block size (may differ from f_bsize on
|
||||
// some filesystems). f_bavail is in those blocks. Cast both to u64
|
||||
// since libc declares them as platform-dependent types.
|
||||
let frsize = buf.f_frsize as u64;
|
||||
let bavail = buf.f_bavail as u64;
|
||||
Some(frsize.saturating_mul(bavail))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn available_bytes(_path: &std::path::Path) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Floor below which we refuse to start a new download. Picked at the low
|
||||
/// end of a "typical 1080p YouTube video" range — most videos are smaller
|
||||
/// than this, but starting with less than 500 MB free is a near-certain
|
||||
/// path to an aborted job and a half-written file. Configurable later if
|
||||
/// a user complains; for now a fixed sane default is enough.
|
||||
pub const FREE_SPACE_FLOOR_BYTES: u64 = 500 * 1024 * 1024;
|
||||
|
||||
/// Format a byte count for human display. Duplicates a similar helper in
|
||||
/// app.rs because the modules can't easily share one (app.rs depends on
|
||||
/// eframe). 3 GB / 250 MB / 15 KB-style rounding.
|
||||
pub fn fmt_bytes(bytes: u64) -> String {
|
||||
if bytes >= 1_073_741_824 {
|
||||
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
|
||||
} else if bytes >= 1_048_576 {
|
||||
format!("{} MB", bytes / 1_048_576)
|
||||
} else if bytes >= 1024 {
|
||||
format!("{} KB", bytes / 1024)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn root_path_has_some_free_space() {
|
||||
// / always exists on Unix; if statvfs returns Some(_) at all,
|
||||
// the query plumbing works. We don't assert a specific number
|
||||
// because CI runners' disks vary.
|
||||
let bytes = available_bytes(std::path::Path::new("/"));
|
||||
assert!(bytes.is_some(), "statvfs(/) should succeed on Unix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn missing_path_returns_none() {
|
||||
// statvfs on a nonexistent path returns -1; we surface that as
|
||||
// None so the caller skips the preflight rather than refusing
|
||||
// every download.
|
||||
let bytes = available_bytes(std::path::Path::new("/this/path/should/not/exist/anywhere"));
|
||||
assert!(bytes.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -329,6 +329,34 @@ impl Downloader {
|
|||
// Per-platform download archive keeps cross-platform IDs from colliding
|
||||
// (TikTok IDs are numeric, YouTube IDs are 11-char base64, etc.).
|
||||
let _ = std::fs::create_dir_all(&platform_dir);
|
||||
|
||||
// Disk-full preflight. Refuse to spawn yt-dlp when the target
|
||||
// filesystem has less than the floor of free space — that's a
|
||||
// near-certain in-progress ENOSPC, which leaves a partial file
|
||||
// and a confusing failure. We surface it as a synthetic Failed
|
||||
// job classified as DiskFull so it appears in the Downloads
|
||||
// panel alongside other classified errors instead of vanishing.
|
||||
//
|
||||
// statvfs returning None (non-Unix host, missing path) skips the
|
||||
// check — better to let yt-dlp run and produce a real error
|
||||
// than refuse on missing data.
|
||||
if let Some(free) = crate::disk_space::available_bytes(&self.channels_root) {
|
||||
if free < crate::disk_space::FREE_SPACE_FLOOR_BYTES {
|
||||
self.push_synthetic_failure(
|
||||
url,
|
||||
platform_dir.display().to_string(),
|
||||
crate::error_class::ErrorClass::DiskFull,
|
||||
format!(
|
||||
"only {} free on {} (need at least {})",
|
||||
crate::disk_space::fmt_bytes(free),
|
||||
self.channels_root.display(),
|
||||
crate::disk_space::fmt_bytes(crate::disk_space::FREE_SPACE_FLOOR_BYTES),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let archive_path = platform_dir.join("archive.txt");
|
||||
let platform_label = info.platform.dir_name();
|
||||
|
||||
|
|
@ -619,6 +647,36 @@ impl Downloader {
|
|||
});
|
||||
}
|
||||
|
||||
/// Push a job that was never going to start — used for preflight
|
||||
/// failures (currently just disk-full). Constructs a closed channel
|
||||
/// so the immediate `Job::drain` sees no progress, and seeds the log
|
||||
/// with the human-readable reason so the UI's last_line + the error
|
||||
/// hint together explain what went wrong.
|
||||
fn push_synthetic_failure(
|
||||
&mut self,
|
||||
url: String,
|
||||
label: String,
|
||||
class: crate::error_class::ErrorClass,
|
||||
reason: String,
|
||||
) {
|
||||
let (_tx, rx) = std::sync::mpsc::channel::<Msg>();
|
||||
// _tx is dropped here, so any subsequent drain hits the
|
||||
// "channel closed" branch and stays still. We start `state` as
|
||||
// Failed directly rather than running it through the Finished
|
||||
// transition.
|
||||
let mut log = VecDeque::new();
|
||||
log.push_back(format!("[preflight] {reason}"));
|
||||
self.jobs.push(Job {
|
||||
url,
|
||||
label,
|
||||
state: JobState::Failed,
|
||||
progress: 0.0,
|
||||
log,
|
||||
failure_class: Some(class),
|
||||
rx,
|
||||
});
|
||||
}
|
||||
|
||||
/// Drain pending messages from all job threads and promote queued jobs.
|
||||
///
|
||||
/// Call this regularly from the UI event loop to pick up progress updates.
|
||||
|
|
|
|||
24
src/main.rs
24
src/main.rs
|
|
@ -15,7 +15,9 @@
|
|||
|
||||
mod app;
|
||||
mod config;
|
||||
mod crash;
|
||||
mod database;
|
||||
mod disk_space;
|
||||
mod download_options;
|
||||
mod downloader;
|
||||
mod error_class;
|
||||
|
|
@ -32,13 +34,25 @@ mod ytdlp_bin;
|
|||
fn main() -> eframe::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Load the config first — both modes share the channels_root path,
|
||||
// and the panic hook needs it before anything else runs so a crash
|
||||
// in the very early startup (DB open, tray init, ksni) still lands
|
||||
// in the crash log.
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let cfg_path = cwd.join("config.toml");
|
||||
let mut cfg = config::Config::load(&cfg_path)
|
||||
.unwrap_or_else(|_| config::Config::default_with_dir(cwd.join("channels")));
|
||||
|
||||
// Install the persistent panic logger. Logs go alongside the SQLite
|
||||
// database; the parent of channels_root is the same "library root"
|
||||
// the desktop UI shows.
|
||||
let crash_dir = cfg.backup.directory.parent()
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.unwrap_or_else(|| cfg.backup.directory.clone());
|
||||
crash::install(&crash_dir);
|
||||
|
||||
// --web [port] → run the web interface instead of the GUI
|
||||
if let Some(pos) = args.iter().position(|a| a == "--web") {
|
||||
let mut cfg = {
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
config::Config::load(&cwd.join("config.toml"))
|
||||
.unwrap_or_else(|_| config::Config::default_with_dir(cwd.join("channels")))
|
||||
};
|
||||
// Override port if provided after --web
|
||||
if let Some(port_str) = args.get(pos + 1) {
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue