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
2 changes: 1 addition & 1 deletion linux/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ The cancel token is checked between stages, and a stage that finishes after a ca

PostgreSQL, SQL Server and ClickHouse open every stream through `open()`. sqlx dials its own sockets, so the MySQL driver passes it the route instead: host and port, or the forward's socket path. Nothing relays bytes between the two. The socket options above apply only to streams `open()` makes, so a direct MySQL connection has no keepalive and finds a dead peer through the probe and its deadlines.

SSH runs the system OpenSSH client ([ADR 0013](docs/decisions/0013-openssh-client-transport.md)): one ControlMaster (`ssh -M -N -T`) per connection, with the user's `~/.ssh/config` applied, and one `ssh -O forward` Unix socket per server it reaches. The sockets live under `$XDG_RUNTIME_DIR/app/<app id>/ssh/` in directories only the user can open, with mode 0600. Every prompt (host key, password, passphrase, keyboard-interactive) reaches the app through the `tablepro-askpass` helper, and the 20 s handshake timeout pauses while a prompt is open. Under Flatpak, `ssh` comes from the GNOME runtime, and a `ProxyCommand` or `Match exec` that runs a host program, the host's `/etc/ssh` and FIDO keys kept in files do not work.
SSH runs the system OpenSSH client ([ADR 0013](docs/decisions/0013-openssh-client-transport.md)): one ControlMaster (`ssh -M -N -T`) per connection, with the user's `~/.ssh/config` applied, and one `ssh -O forward` Unix socket per server it reaches. The sockets live under `$XDG_RUNTIME_DIR/app/<app id>/ssh/` in directories only the user can open, with mode 0600. Only Flatpak creates `app/<app id>`, so a host install makes the missing levels with mode 0700, each opened without following a symlink and checked to be the user's; with `XDG_RUNTIME_DIR` unset, GLib puts the same layout under the cache directory. Every prompt (host key, password, passphrase, keyboard-interactive) reaches the app through the `tablepro-askpass` helper, and the 20 s handshake timeout pauses while a prompt is open. Under Flatpak, `ssh` comes from the GNOME runtime, and a `ProxyCommand` or `Match exec` that runs a host program, the host's `/etc/ssh` and FIDO keys kept in files do not work.

### Trust

Expand Down
1 change: 1 addition & 0 deletions linux/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions linux/crates/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ name = "logging"
path = "tests/logging.rs"
harness = false

# A panic hook is process-wide and the crash ends the process, so each
# case runs in a child of the binary.
[[test]]
name = "crash_report"
path = "tests/crash_report.rs"
harness = false

# These start the application, which GTK allows only on the process's
# main thread, so they run without the test harness.
[[test]]
Expand Down
62 changes: 62 additions & 0 deletions linux/crates/app/src/application/crash_notice.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use std::path::PathBuf;

use gtk4::glib;
use gtk4::prelude::*;
use gtk4::subclass::prelude::*;

use super::TableProApplication;
use crate::ui::SessionWindow;

impl TableProApplication {
/// Looks off the GTK thread for a report the last run left when it
/// crashed, and has a window say so.
pub(super) fn look_for_crash_report(&self) {
let Some(services) = self.services() else {
return;
};
let dir = services.storage.paths().crashes_dir();
let looking = services
.tasks
.spawn_blocking_task(move || crate::crash_report::take_unannounced(&dir));
let app = self.downgrade();
glib::spawn_future_local(async move {
let report = match looking.await {
Ok(Ok(Some(report))) => report,
Ok(Ok(None)) => return,
Ok(Err(error)) => {
tracing::warn!(%error, "could not look for a crash report");
return;
}
Err(failure) => {
tracing::warn!(%failure, "the crash report lookup failed");
return;
}
};
if let Some(app) = app.upgrade() {
app.announce_crash(report);
}
});
}

/// The window in front says it, or the first one to open when the
/// windows of a restored session are still on their way.
fn announce_crash(&self, report: PathBuf) {
let window = self.active_window().and_downcast::<SessionWindow>().or_else(|| {
self.windows()
.into_iter()
.find_map(|window| window.downcast::<SessionWindow>().ok())
});
match window {
Some(window) => window.announce_crash(report),
None => {
self.imp().crash_report.replace(Some(report));
}
}
}

pub(super) fn hand_over_crash_report(&self, window: &SessionWindow) {
if let Some(report) = self.imp().crash_report.take() {
window.announce_crash(report);
}
}
}
3 changes: 3 additions & 0 deletions linux/crates/app/src/application/imp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ pub struct TableProApplication {
/// Files handed over while the record is read, opened after the
/// windows it restores.
pub(super) pending_files: RefCell<Vec<gio::File>>,
/// A crash report found before any window opened.
pub(super) crash_report: RefCell<Option<std::path::PathBuf>>,
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -82,6 +84,7 @@ impl ApplicationImpl for TableProApplication {
install_application_accelerators(&*app);
if let Some(services) = self.services.get() {
services.start();
app.look_for_crash_report();
self.session_signals
.replace(Some(SessionSignals::start(&services.sessions)));
}
Expand Down
2 changes: 2 additions & 0 deletions linux/crates/app/src/application/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod accelerators;
mod app_actions;
mod app_services;
mod app_window_host;
mod crash_notice;
mod debug_info;
mod imp;
mod legal_section;
Expand Down Expand Up @@ -101,6 +102,7 @@ impl TableProApplication {
let host: Rc<dyn WindowHost> = Rc::new(AppWindowHost::new(self));
self.imp().windows.insert(window.id(), window.clone());
window.start(SessionWindowInit { services, host, launch });
self.hand_over_crash_report(&window);
window.present();
Some(window.upcast())
}
Expand Down
186 changes: 186 additions & 0 deletions linux/crates/app/src/crash_report.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//! What a crash leaves behind for the user to send, and how the next
//! launch finds it.

use std::backtrace::Backtrace;
use std::panic::PanicHookInfo;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

use tablepro_storage::StorageError;

use crate::config;

/// GTK calls the app back through C, which a panic cannot unwind, so a
/// panic on the GTK thread ends the process. A panic on a runtime thread
/// is caught there and shown as a failed task, so only these are
/// crashes.
const GTK_THREAD: &str = "main";

const REPORT_PREFIX: &str = "crash-";
const REPORT_SUFFIX: &str = ".txt";
/// Holds the name of the newest report the user was told about.
const ANNOUNCED: &str = "announced";
/// A launch that crashes every time leaves one report each, so the
/// oldest go once there are more than this.
const KEPT_REPORTS: usize = 20;

/// Keep the default hook, which writes the panic to stderr and so to
/// the journal, and also write it with a backtrace to a file of its own
/// in `dir`. A GTK callback that panics panics a second time when it
/// cannot unwind, and that goes in the same file.
pub fn install(dir: PathBuf) {
let report = OnceLock::new();
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
default_hook(info);
if std::thread::current().name() != Some(GTK_THREAD) {
return;
}
let path = report.get_or_init(|| dir.join(report_name(chrono::Utc::now(), std::process::id())));
let text = report_text(info, &Backtrace::force_capture(), chrono::Utc::now());
if let Err(error) = tablepro_storage::fs::append_private_blocking(path, text.as_bytes()) {
tracing::error!(%error, "could not save the crash report");
}
}));
}

/// Names sort by time, so the newest report is the last name.
fn report_name(now: chrono::DateTime<chrono::Utc>, pid: u32) -> String {
format!("{REPORT_PREFIX}{}-{pid}{REPORT_SUFFIX}", now.format("%Y%m%dT%H%M%SZ"))
}

fn report_text(info: &PanicHookInfo<'_>, backtrace: &Backtrace, now: chrono::DateTime<chrono::Utc>) -> String {
let message = info.payload_as_str().unwrap_or("(no message)");
let location = info
.location()
.map_or_else(|| "an unknown place".to_owned(), ToString::to_string);
let build = if cfg!(debug_assertions) { "debug" } else { "release" };
format!(
"TablePro {version}\n\
Application ID: {app_id}\n\
Profile: {profile}\n\
Build: {build}\n\
Time: {time}\n\
\n\
Panicked at {location}:\n\
{message}\n\
\n\
Backtrace:\n\
{backtrace}\n\n",
version = config::VERSION,
app_id = config::APP_ID,
profile = config::PROFILE,
time = now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
)
}

/// The newest report the user has not been told about, marked as told.
/// Runs at startup, before this run could have written one, and drops
/// the oldest reports past `KEPT_REPORTS`.
pub fn take_unannounced(dir: &Path) -> Result<Option<PathBuf>, StorageError> {
let mut reports = match std::fs::read_dir(dir) {
Ok(entries) => entries
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| name.starts_with(REPORT_PREFIX) && name.ends_with(REPORT_SUFFIX))
.collect::<Vec<_>>(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(StorageError::Io {
path: dir.to_owned(),
source,
});
}
};
reports.sort_unstable();
let surplus = reports.len().saturating_sub(KEPT_REPORTS);
for name in reports.drain(..surplus) {
if let Err(error) = std::fs::remove_file(dir.join(&name)) {
tracing::warn!(%error, "could not remove an old crash report");
}
}
let Some(newest) = reports.pop() else {
return Ok(None);
};
let marker = dir.join(ANNOUNCED);
let announced = std::fs::read_to_string(&marker).unwrap_or_default();
if announced.trim() >= newest.as_str() {
return Ok(None);
}
tablepro_storage::fs::write_private_blocking(&marker, newest.as_bytes())?;
Ok(Some(dir.join(newest)))
}

#[cfg(test)]
mod tests {
use chrono::TimeZone;

use super::*;

fn report(dir: &Path, name: &str) {
tablepro_storage::fs::append_private_blocking(&dir.join(name), b"a report").expect("a report");
}

#[test]
fn report_names_sort_by_time() {
let earlier = chrono::Utc
.with_ymd_and_hms(2026, 9, 30, 23, 59, 59)
.single()
.expect("a time");
let later = chrono::Utc
.with_ymd_and_hms(2026, 10, 1, 0, 0, 0)
.single()
.expect("a time");

assert_eq!(report_name(later, 42), "crash-20261001T000000Z-42.txt");
assert!(report_name(earlier, 99_999) < report_name(later, 1));
}

#[test]
fn the_newest_report_is_announced_once() {
let dir = tempfile::tempdir().expect("a directory");
report(dir.path(), "crash-20261001T000000Z-1.txt");
report(dir.path(), "crash-20261002T000000Z-2.txt");

let first = take_unannounced(dir.path()).expect("a listing");

assert_eq!(first, Some(dir.path().join("crash-20261002T000000Z-2.txt")));
assert_eq!(take_unannounced(dir.path()).expect("a listing"), None);

report(dir.path(), "crash-20261003T000000Z-3.txt");
assert_eq!(
take_unannounced(dir.path()).expect("a listing"),
Some(dir.path().join("crash-20261003T000000Z-3.txt"))
);
}

#[test]
fn no_directory_is_no_report() {
let dir = tempfile::tempdir().expect("a directory");

assert_eq!(take_unannounced(&dir.path().join("crashes")).expect("a listing"), None);
}

#[test]
fn only_the_newest_reports_are_kept() {
let dir = tempfile::tempdir().expect("a directory");
for day in 1..=KEPT_REPORTS + 3 {
report(dir.path(), &format!("crash-202610{day:02}T000000Z-1.txt"));
}

take_unannounced(dir.path()).expect("a listing");

let left = std::fs::read_dir(dir.path())
.expect("a listing")
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().starts_with(REPORT_PREFIX))
.count();
assert_eq!(left, KEPT_REPORTS);
assert!(!dir.path().join("crash-20261001T000000Z-1.txt").exists());
assert!(
dir.path()
.join(format!("crash-202610{:02}T000000Z-1.txt", KEPT_REPORTS + 3))
.exists()
);
}
}
5 changes: 4 additions & 1 deletion linux/crates/app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use tablepro_storage::{AppSettings, StoragePaths};

pub mod application;
pub mod config;
pub mod crash_report;
pub mod i18n;
pub mod logging;
mod persistence;
Expand Down Expand Up @@ -45,10 +46,12 @@ pub fn run() -> Result<glib::ExitCode, StartupError> {
tracing::warn!(%error, "translations unavailable; falling back to the source strings");
}

let paths = StoragePaths::from_glib(config::storage_dir_name(), config::APP_ID);
crash_report::install(paths.crashes_dir());

let runtime = runtime::AppRuntime::build().map_err(StartupError::Runtime)?;
gio::resources_register_include!("tablepro.gresource").map_err(StartupError::Resources)?;
let settings = Rc::new(AppSettings::open(config::APP_ID)?);
let paths = StoragePaths::from_glib(config::storage_dir_name(), config::APP_ID);
let drivers = build_registry();
tracing::info!(drivers = drivers.len(), "starting tablepro");

Expand Down
3 changes: 2 additions & 1 deletion linux/crates/app/src/ui/editor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ use self::imp::RunningScript;
use self::result_widgets::PageActions;
use self::run_log::{EntryKind, RunEvent, RunLog};
use self::run_scope::ScopedText;
use self::script_runner::{ScriptRun, plan_batches, run_in_tab};
use self::script_runner::{RUN_ROW_LIMIT, ScriptRun, plan_batches, run_in_tab};
use super::clipboard::{CopyRequest, InClauseRows};
use super::export::{ExportOffer, ExportOrigin, ExportQuery, ExportSource};
use super::grid::{GridMsg, GridSink, present_row_json};
Expand Down Expand Up @@ -383,6 +383,7 @@ impl SqlEditor {
policy,
statement_timeout: deps.settings.query_timeout(),
row_limit: RowLimit::EDITOR_DEFAULT,
run_row_limit: RUN_ROW_LIMIT,
};
let metadata = session.metadata().clone();
// Read again at the end from the run's own session, never the
Expand Down
Loading
Loading