From 623dc1a322a8d5ea3dc8bc99ac90864cb3dbc9b9 Mon Sep 17 00:00:00 2001 From: Bare7a Date: Sun, 1 Nov 2026 20:12:57 +0200 Subject: [PATCH 1/4] chore(BAR-29): Shrinks the release binary with fat LTO and panic abort --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- Cargo.toml | 6 +- RELEASING.md | 2 +- crates/barsql-db/src/mysql/mod.rs | 5 +- crates/barsql-db/src/mysql/schema.rs | 5 +- crates/barsql-db/src/postgres/mod.rs | 8 + crates/barsql-db/src/postgres/schema.rs | 214 ++++++++++++----------- crates/barsql-db/src/postgres/session.rs | 17 +- crates/barsql-db/src/result.rs | 5 + crates/barsql-db/src/script.rs | 3 - crates/barsql/src/main.rs | 20 +++ guide/app.md | 2 +- packaging/windows/installer.nsi | 1 + 13 files changed, 170 insertions(+), 120 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index feb2924..2770787 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -76,5 +76,5 @@ body: id: logs attributes: label: Error message or log - description: Paste it as text. For an update or AI assistant problem, add the end of `update.log` or `mcp.log` from the [data folder](https://github.com/Bare7a/BarSQL/blob/master/guide/app.md#-portable-by-design). Leave out passwords, connection strings and private data. + description: Paste it as text. If BarSQL closed by itself, add `crash.log` from the [data folder](https://github.com/Bare7a/BarSQL/blob/master/guide/app.md#-portable-by-design). For an update or AI assistant problem, add the end of `update.log` or `mcp.log`. Leave out passwords, connection strings and private data. render: text diff --git a/Cargo.toml b/Cargo.toml index 91944fb..ceb6ad4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,10 @@ zip = { version = "9", default-features = false, features = ["deflate"] } [profile.dev.package."*"] debug = false +# Fat LTO in one codegen unit. The binary comes out about 15% smaller, for about twice the build time. [profile.release] -lto = "thin" +lto = "fat" +codegen-units = 1 strip = true +# Leaves out the unwinding code and tables, a large share of the binary. main.rs logs the panic to crash.log first. +panic = "abort" diff --git a/RELEASING.md b/RELEASING.md index 1379853..fe9decc 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -18,7 +18,7 @@ Run the workflow by hand with the version left empty to build the packages witho | `packaging/linux/PKGBUILD` (release workflow, in an Arch Linux container) | | `BarSQL.pkg.tar.zst` | - Only the portable updater assets name both a platform and an architecture, so portable copies never pick another download. The AppImage and the packages update from their own asset, and the installer and the dmg from the `.zip`. -- Release builds turn on the `production` feature, which enables the update check at launch. The release profile uses thin LTO and strips symbols. +- Release builds turn on the `production` feature, which enables the update check at launch. The release profile uses fat LTO in one codegen unit, strips symbols and aborts on a panic, background tasks' included, after writing it to `crash.log` in the data folder. Fat LTO makes the binary about 15% smaller than thin LTO, and the build takes about twice as long. - **macOS:** a `lipo` universal `.app` for macOS 12 and later with an ad-hoc signature; the dmg adds an `Applications` link. `BARSQL_NATIVE_ONLY=1` builds this Mac's architecture alone. - **Windows:** `crates/barsql/build.rs` embeds the icon and the version block. `packaging/windows/installer.nsi` installs the app with the `SQLite Database` file class for `.db`, `.sqlite`, `.sqlite3`, `.s3db` and `.sl3`. - **Linux:** [`cargo-deb`](https://crates.io/crates/cargo-deb) and [`cargo-generate-rpm`](https://crates.io/crates/cargo-generate-rpm) build the deb and rpm from `crates/barsql/Cargo.toml`, and `makepkg` builds the Arch package from `packaging/linux/PKGBUILD`. Each installs `/usr/bin/BarSQL` and a desktop entry that opens SQLite files; the deb and rpm work out their library dependencies from the binary. The window's app id is `BarSQL`, so Wayland docks match it to `BarSQL.desktop`. diff --git a/crates/barsql-db/src/mysql/mod.rs b/crates/barsql-db/src/mysql/mod.rs index c80d142..191fbea 100644 --- a/crates/barsql-db/src/mysql/mod.rs +++ b/crates/barsql-db/src/mysql/mod.rs @@ -278,7 +278,10 @@ impl MySession { } async fn read_time_zone(&mut self) { - if let Ok(Some(name)) = self.lease.conn.query_first::("SELECT @@session.time_zone").await { + // A typed row panics on a NULL or non-text value, which a MySQL-compatible server may send. + if let Ok(Some(row)) = self.lease.conn.query_first::("SELECT @@session.time_zone").await + && let Some(name) = schema::text(&row, 0) + { self.time_zone = parse_time_zone(&name); } } diff --git a/crates/barsql-db/src/mysql/schema.rs b/crates/barsql-db/src/mysql/schema.rs index 576bc6d..ebfa096 100644 --- a/crates/barsql-db/src/mysql/schema.rs +++ b/crates/barsql-db/src/mysql/schema.rs @@ -18,7 +18,7 @@ use crate::dump::DumpColumn; const MY: DriverType = DriverType::MySql; const SYSTEM_SCHEMAS: &[&str] = &["information_schema", "performance_schema", "mysql", "sys"]; -fn text(row: &Row, i: usize) -> Option { +pub(super) fn text(row: &Row, i: usize) -> Option { match row.as_ref(i)? { MyValue::NULL => None, MyValue::Bytes(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()), @@ -468,7 +468,8 @@ impl MyEngine { .iter() .map(|r| { let extra = string(r, 4); - let lower = extra.to_lowercase(); + // ASCII only, so offsets found in `lower` slice `extra` at the same place. + let lower = extra.to_ascii_lowercase(); let leftover = ["auto_increment", "default_generated", "virtual generated", "stored generated"] .iter() .fold(lower.clone(), |text, flag| text.replace(flag, "")); diff --git a/crates/barsql-db/src/postgres/mod.rs b/crates/barsql-db/src/postgres/mod.rs index e9fd8a7..b941bd1 100644 --- a/crates/barsql-db/src/postgres/mod.rs +++ b/crates/barsql-db/src/postgres/mod.rs @@ -348,6 +348,14 @@ impl Drop for PgLease { } } +// A try_get error's text names only the column. Its source says what was wrong with the value. +pub(crate) fn decode_error(err: &tokio_postgres::Error) -> QueryError { + match std::error::Error::source(err) { + Some(cause) => QueryError::message(format!("{err}: {cause}")), + None => pg_error(err), + } +} + pub(crate) fn pg_error(err: &tokio_postgres::Error) -> QueryError { let Some(db) = err.as_db_error() else { return QueryError::message(err.to_string()); diff --git a/crates/barsql-db/src/postgres/schema.rs b/crates/barsql-db/src/postgres/schema.rs index 678a5d1..f932351 100644 --- a/crates/barsql-db/src/postgres/schema.rs +++ b/crates/barsql-db/src/postgres/schema.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use barsql_core::schema::primary_keys; @@ -15,9 +15,9 @@ use barsql_sql::ddl::{ use barsql_sql::structure::check_body; use barsql_sql::{enum_values, qualified_table, quote_ident, quote_literal_in}; use tokio_postgres::Row; -use tokio_postgres::types::ToSql; +use tokio_postgres::types::{FromSql, ToSql}; -use super::{PgEngine, pg_error}; +use super::{PgEngine, decode_error, pg_error}; use crate::dump::DumpColumn; const PG: DriverType = DriverType::Postgres; @@ -60,6 +60,11 @@ const FUNCTIONS_SQL: &str = "WITH hidden AS ( AND p.oid NOT IN (SELECT oid FROM hidden) ORDER BY 1, 2, 5"; +// A Postgres-compatible server can send other types or NULLs here, which fails the read rather than the app. +fn get<'a, T: FromSql<'a>>(row: &'a Row, ix: usize) -> Result { + row.try_get(ix).map_err(|err| decode_error(&err)) +} + // A name's overloads can differ in kind: rank is a window function and a hypothetical-set aggregate. fn overload_kind(a: FunctionKind, b: FunctionKind) -> FunctionKind { match (a, b) { @@ -82,9 +87,9 @@ impl PgEngine { let row = rows.first().ok_or_else(|| QueryError::message("no rows in result set"))?; Ok(ConnectionStatus { connected: true, - database: row.get::<_, Option>(0).unwrap_or_default(), - schema: row.get::<_, Option>(1).unwrap_or_default(), - user: row.get::<_, Option>(2).unwrap_or_default(), + database: get::>(row, 0)?.unwrap_or_default(), + schema: get::>(row, 1)?.unwrap_or_default(), + user: get::>(row, 2)?.unwrap_or_default(), host: self.options.host.clone(), }) } @@ -98,7 +103,7 @@ impl PgEngine { &[], ) .await?; - Ok(rows.iter().map(|r| r.get(0)).collect()) + rows.iter().map(|r| get(r, 0)).collect() } pub(crate) async fn dump_columns( @@ -120,16 +125,17 @@ impl PgEngine { &[&schema, &table], ) .await?; - Ok(rows - .iter() - .map(|r| DumpColumn { - name: r.get(0), - generated: r.get(1), - identity_always: r.get(2), - serial: r.get(3), - ..Default::default() + rows.iter() + .map(|r| { + Ok(DumpColumn { + name: get(r, 0)?, + generated: get(r, 1)?, + identity_always: get(r, 2)?, + serial: get(r, 3)?, + ..Default::default() + }) }) - .collect()) + .collect() } // Fully qualified to pg_catalog, so search_path is irrelevant here. @@ -143,7 +149,7 @@ impl PgEngine { &[], ) .await?; - Ok(rows.iter().map(|r| SchemaInfo { name: r.get(0) }).collect()) + rows.iter().map(|r| Ok(SchemaInfo { name: get(r, 0)? })).collect() } pub async fn list_tables(self: &Arc, schema: &str) -> Result, QueryError> { @@ -167,10 +173,9 @@ impl PgEngine { &[&schema], ) .await?; - Ok(rows - .iter() - .map(|r| TableInfo { schema: r.get(0), name: r.get(1), kind: r.get::<_, String>(2).to_lowercase() }) - .collect()) + rows.iter() + .map(|r| Ok(TableInfo { schema: get(r, 0)?, name: get(r, 1)?, kind: get::(r, 2)?.to_lowercase() })) + .collect() } pub async fn list_columns(self: &Arc, schema: &str, table: &str) -> Result, QueryError> { @@ -220,32 +225,31 @@ impl PgEngine { &[&schema, &table], ) .await?; - Ok(rows - .iter() + rows.iter() .map(|r| { - let name: String = r.get(0); - let foreign_table: String = r.get(5); - let labels: Vec = r.get(9); + let name: String = get(r, 0)?; + let foreign_table: String = get(r, 5)?; + let labels: Vec = get(r, 9)?; let enum_values = if labels.is_empty() { - enum_values::from_checks(&name, &r.get::<_, Vec>(10), &PG) + enum_values::from_checks(&name, &get::>(r, 10)?, &PG) } else { labels }; - ColumnInfo { + Ok(ColumnInfo { name, - data_type: r.get::<_, Option>(1).unwrap_or_default(), - is_nullable: r.get(2), - default_val: r.get(3), - is_primary: r.get(4), + data_type: get::>(r, 1)?.unwrap_or_default(), + is_nullable: get(r, 2)?, + default_val: get(r, 3)?, + is_primary: get(r, 4)?, is_foreign: !foreign_table.is_empty(), foreign_table, - foreign_column: r.get(6), - is_identity: r.get(7), - is_computed: r.get(8), + foreign_column: get(r, 6)?, + is_identity: get(r, 7)?, + is_computed: get(r, 8)?, enum_values, - } + }) }) - .collect()) + .collect() } pub async fn list_indexes(self: &Arc, schema: &str, table: &str) -> Result, QueryError> { @@ -267,19 +271,19 @@ impl PgEngine { .await?; let mut out: Vec = Vec::new(); for r in &rows { - let name: String = r.get(0); + let name: String = get(r, 0)?; if out.last().is_none_or(|idx| idx.name != name) && !out.iter().any(|idx| idx.name == name) { out.push(IndexInfo { name: name.clone(), schema: schema.clone(), table: table.to_string(), - is_unique: r.get(1), - is_primary: r.get(2), - method: r.get(3), + is_unique: get(r, 1)?, + is_primary: get(r, 2)?, + method: get(r, 3)?, columns: Vec::new(), }); } - let column: Option = r.get(4); + let column: Option = get(r, 4)?; if let (Some(column), Some(idx)) = (column, out.iter_mut().find(|idx| idx.name == name)) { idx.columns.push(column); } @@ -314,23 +318,23 @@ impl PgEngine { .await?; let mut out: Vec = Vec::new(); for r in &rows { - let name: String = r.get(0); + let name: String = get(r, 0)?; if !out.iter().any(|c| c.name == name) { out.push(ConstraintInfo { name: name.clone(), schema: schema.clone(), table: table.to_string(), - kind: constraint_type_name(&r.get::<_, String>(1)), - definition: r.get::<_, Option>(2).unwrap_or_default(), - ref_table: r.get(3), + kind: constraint_type_name(&get::(r, 1)?), + definition: get::>(r, 2)?.unwrap_or_default(), + ref_table: get(r, 3)?, ..Default::default() }); } let c = out.iter_mut().find(|c| c.name == name).expect("constraint just added"); - if let Some(column) = r.get::<_, Option>(4) { + if let Some(column) = get::>(r, 4)? { c.columns.push(column); } - if let Some(column) = r.get::<_, Option>(5) { + if let Some(column) = get::>(r, 5)? { c.ref_columns.push(column); } } @@ -351,13 +355,12 @@ impl PgEngine { &[&schema, &table], ) .await?; - Ok(rows - .iter() + rows.iter() .map(|r| { - let (timing, events) = decode_trigger_type(r.get(1)); - TriggerInfo { name: r.get(0), schema: schema.clone(), table: table.to_string(), timing, events } + let (timing, events) = decode_trigger_type(get(r, 1)?); + Ok(TriggerInfo { name: get(r, 0)?, schema: schema.clone(), table: table.to_string(), timing, events }) }) - .collect()) + .collect() } pub async fn list_routines(self: &Arc, schema: &str) -> Result, QueryError> { @@ -375,19 +378,18 @@ impl PgEngine { &[&schema], ) .await?; - Ok(rows - .iter() + rows.iter() .map(|r| { - let procedure = r.get::<_, String>(1) == "p"; - RoutineInfo { - name: r.get(0), + let procedure = get::(r, 1)? == "p"; + Ok(RoutineInfo { + name: get(r, 0)?, schema: schema.clone(), kind: if procedure { ObjectKind::Procedure } else { ObjectKind::Function }, - return_type: if procedure { String::new() } else { r.get(2) }, - args: r.get(3), - } + return_type: if procedure { String::new() } else { get(r, 2)? }, + args: get(r, 3)?, + }) }) - .collect()) + .collect() } // Overloads fold into one function per schema and name. @@ -395,15 +397,15 @@ impl PgEngine { let rows = self.rows(FUNCTIONS_SQL, &[]).await?; let mut functions: Vec = Vec::new(); for r in &rows { - let (schema, name): (String, String) = (r.get(0), r.get(1)); - let kind = match r.get::<_, String>(2).as_str() { + let (schema, name): (String, String) = (get(r, 0)?, get(r, 1)?); + let kind = match get::(r, 2)?.as_str() { "a" => FunctionKind::Aggregate, "w" => FunctionKind::Window, - _ if r.get::<_, bool>(3) => FunctionKind::Table, + _ if get(r, 3)? => FunctionKind::Table, _ => FunctionKind::Scalar, }; - let signature = FunctionSignature { args: r.get(4), returns: r.get(5) }; - let description: String = r.get(6); + let signature = FunctionSignature { args: get(r, 4)?, returns: get(r, 5)? }; + let description: String = get(r, 6)?; match functions.last_mut() { Some(last) if last.schema == schema && last.name == name => { last.kind = overload_kind(last.kind, kind); @@ -414,8 +416,8 @@ impl PgEngine { } _ => functions.push(FunctionInfo { builtin: schema == "pg_catalog", - qualified_only: !r.get::<_, bool>(8), - source: r.get(7), + qualified_only: !get::(r, 8)?, + source: get(r, 7)?, name, schema, kind, @@ -587,19 +589,18 @@ impl PgEngine { &[&schema, &table], ) .await?; - Ok(rows - .iter() + rows.iter() .map(|r| { - let data_type: String = r.get::<_, Option>(1).unwrap_or_default(); - let default_expr: String = r.get(3); - let identity: String = r.get::<_, Option>(4).unwrap_or_default(); - let generated: String = r.get::<_, Option>(5).unwrap_or_default(); - let is_serial: bool = r.get(7); + let data_type: String = get::>(r, 1)?.unwrap_or_default(); + let default_expr: String = get(r, 3)?; + let identity: String = get::>(r, 4)?.unwrap_or_default(); + let generated: String = get::>(r, 5)?.unwrap_or_default(); + let is_serial: bool = get(r, 7)?; let mut col = DdlColumn { - name: r.get(0), + name: get(r, 0)?, data_type: data_type.clone(), - not_null: r.get(2), - collation: r.get(6), + not_null: get(r, 2)?, + collation: get(r, 6)?, ..Default::default() }; match (generated.as_str(), identity.as_str()) { @@ -613,9 +614,9 @@ impl PgEngine { } _ => col.default = default_expr, } - col + Ok(col) }) - .collect()) + .collect() } pub async fn table_structure(self: &Arc, schema: &str, table: &str) -> Result { @@ -630,15 +631,14 @@ impl PgEngine { &[&schema, &table], ) .await?; - let comment = |name: &str| { - comments.iter().find(|r| r.get::<_, String>(0) == name).map(|r| r.get::<_, String>(1)).unwrap_or_default() - }; + let comments: HashMap = + comments.iter().map(|r| Ok((get(r, 0)?, get(r, 1)?))).collect::>()?; let columns: Vec = self .ddl_columns(&schema, table, false) .await? .into_iter() .map(|col| StructColumn { - comment: comment(&col.name), + comment: comments.get(&col.name).cloned().unwrap_or_default(), nullable: !col.not_null, identity: match col.identity.as_str() { "ALWAYS" => Identity::Always, @@ -678,9 +678,9 @@ impl PgEngine { key_columns("confkey", "confrelid"), ); let (mut primary_key, mut indexes, mut foreign_keys, mut checks) = (None, Vec::new(), Vec::new(), Vec::new()); - for r in self.rows(&sql, &[&schema, &table]).await? { - let (name, columns): (String, Vec) = (r.get(0), r.get(2)); - match r.get::<_, String>(1).as_str() { + for r in &self.rows(&sql, &[&schema, &table]).await? { + let (name, columns): (String, Vec) = (get(r, 0)?, get(r, 2)?); + match get::(r, 1)?.as_str() { "p" => primary_key = Some(PrimaryKey { name, columns }), "u" => { indexes.push(StructIndex { name, columns, unique: true, constraint: true, ..Default::default() }) @@ -688,13 +688,13 @@ impl PgEngine { "f" => foreign_keys.push(ForeignKey { name, columns, - ref_schema: r.get(4), - ref_table: r.get(5), - ref_columns: r.get(6), - on_update: RefAction::parse(&r.get::<_, String>(7)), - on_delete: RefAction::parse(&r.get::<_, String>(8)), + ref_schema: get(r, 4)?, + ref_table: get(r, 5)?, + ref_columns: get(r, 6)?, + on_update: RefAction::parse(&get::(r, 7)?), + on_delete: RefAction::parse(&get::(r, 8)?), }), - _ => checks.push(Check { name, expression: check_body(&r.get::<_, String>(3)) }), + _ => checks.push(Check { name, expression: check_body(&get::(r, 3)?) }), } } // Indexes no constraint stands for. An expression or partial one is kept as the server writes it. @@ -717,14 +717,16 @@ impl PgEngine { &[&schema, &table], ) .await?; - indexes.extend(listed.iter().map(|r| StructIndex { - name: r.get(0), - unique: r.get(1), - method: r.get(2), - columns: r.get(3), - definition: r.get(4), - constraint: false, - })); + for r in &listed { + indexes.push(StructIndex { + name: get(r, 0)?, + unique: get(r, 1)?, + method: get(r, 2)?, + columns: get(r, 3)?, + definition: get(r, 4)?, + constraint: false, + }); + } Ok(TableStructure { schema, name: table.to_string(), @@ -756,15 +758,15 @@ impl PgEngine { Ok(rows .iter() .map(|r| { - let column: String = r.get(0); + let column: String = get(r, 0)?; let target = if column.is_empty() { format!("TABLE {qualified}") } else { format!("COLUMN {qualified}.{}", quote_ident(&PG, &column)) }; - format!("COMMENT ON {target} IS {};", quote_literal_in(SqlDialect::Postgres, &r.get::<_, String>(1))) + Ok(format!("COMMENT ON {target} IS {};", quote_literal_in(SqlDialect::Postgres, &get::(r, 1)?))) }) - .collect::>() + .collect::, QueryError>>()? .join("\n")) } @@ -778,7 +780,7 @@ impl PgEngine { let rows = self.rows(sql, params).await?; match rows.first() { None => Err(QueryError::message(format!("{kind} {name} not found"))), - Some(row) => Ok(terminate_statement(&row.get::<_, Option>(0).unwrap_or_default())), + Some(row) => Ok(terminate_statement(&get::>(row, 0)?.unwrap_or_default())), } } diff --git a/crates/barsql-db/src/postgres/session.rs b/crates/barsql-db/src/postgres/session.rs index 23c3792..6d3cdbc 100644 --- a/crates/barsql-db/src/postgres/session.rs +++ b/crates/barsql-db/src/postgres/session.rs @@ -8,7 +8,7 @@ use futures_util::{StreamExt, pin_mut}; use tokio_postgres::SimpleQueryMessage; use tokio_postgres::types::{Format, IsNull, ToSql, Type, to_sql_checked}; -use super::{PgLease, READ_ONLY_SETUP, pg_error, type_name}; +use super::{PgLease, READ_ONLY_SETUP, decode_error, pg_error, type_name}; use crate::event::{ScriptEvent, Sink, StatementResult, emit}; use crate::script::{ self, Buffered, StatementRun, StatementRunner, summary_for_exec, summary_for_rows, touches_transaction, @@ -110,7 +110,9 @@ impl PgSession { async fn transaction_state(&self) -> TxState { match self.lease.client().simple_query("SELECT now() <> statement_timestamp()").await { Ok(messages) - if messages.iter().any(|m| matches!(m, SimpleQueryMessage::Row(row) if row.get(0) == Some("t"))) => + if messages.iter().any( + |m| matches!(m, SimpleQueryMessage::Row(row) if row.try_get(0).is_ok_and(|v| v == Some("t"))), + ) => { TxState::Open } @@ -261,8 +263,15 @@ impl StatementRunner for PgSession { } Some(Ok(SimpleQueryMessage::Row(row))) => { let Some((columns, decoders, builder, rows)) = set.as_mut() else { continue }; - for (i, decoder) in decoders.iter().enumerate() { - decoder.push(row.get(i), &local, builder); + // After a SET client_encoding, like a LATIN1 dump's, the server's text may not be UTF-8. + let decoded = decoders.iter().enumerate().try_for_each(|(i, decoder)| { + decoder.push(row.try_get(i)?, &local, builder); + Ok(()) + }); + if let Err(err) = decoded { + let summary = close_set(set.take(), first_index, sink, started).await; + self.send_notices(first_index, sink).await; + return fail(decode_error(&err), summary).await; } builder.end_row(); *rows += 1; diff --git a/crates/barsql-db/src/result.rs b/crates/barsql-db/src/result.rs index 12ccc6c..f709438 100644 --- a/crates/barsql-db/src/result.rs +++ b/crates/barsql-db/src/result.rs @@ -92,7 +92,11 @@ impl ResultChunk { self.columns } + // Out of range reads as NULL, like ResultSet::cell, for helpers that read cell (0, n) of a short result. pub fn cell(&self, row: usize, col: usize) -> Cell<'_> { + if row >= self.rows || col >= self.columns { + return Cell::Null; + } let ix = row * self.columns + col; let kind = CellKind::from_bits(self.kinds[ix / CELLS_PER_WORD] >> ((ix % CELLS_PER_WORD) * 2)); let start = if ix == 0 { 0 } else { self.ends[ix - 1] as usize }; @@ -262,6 +266,7 @@ mod tests { assert_eq!(set.cell(41, 0), Cell::Number("41")); assert_eq!(set.display(41, 1), Some("false")); assert_eq!(set.cell(42, 0), Cell::Null); + assert_eq!(ChunkBuilder::new(5, 1).finish().cell(0, 4), Cell::Null, "an empty chunk"); assert_eq!(Cell::Number("9").to_value(), Value::Int(9)); assert_eq!(Cell::Number("1.5").to_value(), Value::Float(1.5)); } diff --git a/crates/barsql-db/src/script.rs b/crates/barsql-db/src/script.rs index b870162..7ae074b 100644 --- a/crates/barsql-db/src/script.rs +++ b/crates/barsql-db/src/script.rs @@ -79,9 +79,6 @@ impl Buffered { } pub fn text(&self, row: usize, col: usize) -> Option<&str> { - if row >= self.chunk.rows() || col >= self.chunk.columns() { - return None; - } self.chunk.display(row, col) } diff --git a/crates/barsql/src/main.rs b/crates/barsql/src/main.rs index 1ac83c7..8d81746 100644 --- a/crates/barsql/src/main.rs +++ b/crates/barsql/src/main.rs @@ -2,6 +2,8 @@ mod single_instance; +use std::path::Path; + use anyhow::Context as _; use barsql_app::{BarApp, find_sqlite_arg}; use barsql_ui::LaunchOptions; @@ -14,6 +16,7 @@ fn main() -> anyhow::Result<()> { } let args: Vec = std::env::args().skip(1).collect(); let data_dir = barsql_core::paths::ensure_data_dir().context("data folder")?; + log_panics(&data_dir); // Started by an AI app: no window, and never handed to a running BarSQL. if args.first().is_some_and(|arg| arg == "--mcp") { let runtime = runtime()?; @@ -56,6 +59,23 @@ fn main() -> anyhow::Result<()> { Ok(()) } +// Release builds abort on a panic, background tasks' too, so it goes to crash.log in the data folder first. +fn log_panics(data_dir: &Path) { + let path = data_dir.join("crash.log"); + let default = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let backtrace = std::backtrace::Backtrace::force_capture(); + let thread = std::thread::current(); + let report = format!( + "BarSQL {}: thread '{}' {info}\n\n{backtrace:#}\n", + env!("CARGO_PKG_VERSION"), + thread.name().unwrap_or("") + ); + let _ = std::fs::write(&path, report); + default(info); + })); +} + fn runtime() -> anyhow::Result { let runtime = tokio::runtime::Builder::new_multi_thread() .thread_stack_size(barsql_app::WORKER_STACK) diff --git a/guide/app.md b/guide/app.md index 7e7ee9e..2f5e416 100644 --- a/guide/app.md +++ b/guide/app.md @@ -52,7 +52,7 @@ BarSQL-data/ settings.json ``` -`settings.json` keeps your UI preferences - theme, language, layout and keyboard shortcuts. `mcp.log` appears once an AI assistant has used BarSQL. +`settings.json` keeps your UI preferences - theme, language, layout and keyboard shortcuts. `mcp.log` appears once an AI assistant has used BarSQL, and `crash.log` if BarSQL ever closes on an error. When the app sits somewhere writable, that folder is created **right next to the executable** (beside the `.app` bundle on macOS) - move it to a USB stick, network drive, or another PC and it just works. diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index a07609d..3849ab9 100644 --- a/packaging/windows/installer.nsi +++ b/packaging/windows/installer.nsi @@ -1,4 +1,5 @@ Unicode true +SetCompressor /SOLID lzma # Keep the install folder, uninstall key and file classes as they are so upgrades install over the old copy. # The in-app updater also sets DisplayVersion under the uninstall key. From a352fd2fb78779eaadf35187f3caf7ad36afcfe9 Mon Sep 17 00:00:00 2001 From: Bare7a Date: Sun, 1 Nov 2026 23:52:50 +0200 Subject: [PATCH 2/4] Adds crash log logic --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- Cargo.toml | 4 +- RELEASING.md | 2 +- crates/barsql-app/src/backup.rs | 6 +- crates/barsql-app/src/events.rs | 1 + crates/barsql-app/src/import.rs | 24 ++++++-- crates/barsql-app/src/mcp.rs | 5 +- crates/barsql-app/src/runs.rs | 83 +++++++++++++++++++++------ crates/barsql-app/src/tabs.rs | 16 ++++-- crates/barsql-core/src/query.rs | 36 ++++++++++++ crates/barsql-db/src/cancel.rs | 25 ++++++++ crates/barsql-db/src/lib.rs | 2 +- crates/barsql-db/src/sqlite/mod.rs | 8 +-- crates/barsql-db/src/turso/mod.rs | 4 +- crates/barsql-ui/locales/bg.json | 3 +- crates/barsql-ui/locales/de.json | 3 +- crates/barsql-ui/locales/en.json | 3 +- crates/barsql-ui/locales/es.json | 1 + crates/barsql-ui/src/import_dialog.rs | 14 +++++ crates/barsql-ui/src/results.rs | 17 +++++- crates/barsql-ui/src/state.rs | 48 ++++++++++++++-- crates/barsql/src/main.rs | 46 ++++++++++++++- guide/app.md | 2 +- 23 files changed, 298 insertions(+), 57 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2770787..94c244d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -76,5 +76,5 @@ body: id: logs attributes: label: Error message or log - description: Paste it as text. If BarSQL closed by itself, add `crash.log` from the [data folder](https://github.com/Bare7a/BarSQL/blob/master/guide/app.md#-portable-by-design). For an update or AI assistant problem, add the end of `update.log` or `mcp.log`. Leave out passwords, connection strings and private data. + description: Paste it as text. If BarSQL closed by itself or said it hit a bug, add `crash.log` from the [data folder](https://github.com/Bare7a/BarSQL/blob/master/guide/app.md#-portable-by-design). For an update or AI assistant problem, add the end of `update.log` or `mcp.log`. Leave out passwords, connection strings and private data. render: text diff --git a/Cargo.toml b/Cargo.toml index ceb6ad4..7814251 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,10 +71,8 @@ zip = { version = "9", default-features = false, features = ["deflate"] } [profile.dev.package."*"] debug = false -# Fat LTO in one codegen unit. The binary comes out about 15% smaller, for about twice the build time. +# Fat LTO in one codegen unit. The binary comes out about 16% smaller, for about twice the build time. [profile.release] lto = "fat" codegen-units = 1 strip = true -# Leaves out the unwinding code and tables, a large share of the binary. main.rs logs the panic to crash.log first. -panic = "abort" diff --git a/RELEASING.md b/RELEASING.md index fe9decc..0a47212 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -18,7 +18,7 @@ Run the workflow by hand with the version left empty to build the packages witho | `packaging/linux/PKGBUILD` (release workflow, in an Arch Linux container) | | `BarSQL.pkg.tar.zst` | - Only the portable updater assets name both a platform and an architecture, so portable copies never pick another download. The AppImage and the packages update from their own asset, and the installer and the dmg from the `.zip`. -- Release builds turn on the `production` feature, which enables the update check at launch. The release profile uses fat LTO in one codegen unit, strips symbols and aborts on a panic, background tasks' included, after writing it to `crash.log` in the data folder. Fat LTO makes the binary about 15% smaller than thin LTO, and the build takes about twice as long. +- Release builds turn on the `production` feature, which enables the update check at launch. The release profile uses fat LTO in one codegen unit and strips symbols. Fat LTO makes the binary about 16% smaller than thin LTO, and the build takes about twice as long. - **macOS:** a `lipo` universal `.app` for macOS 12 and later with an ad-hoc signature; the dmg adds an `Applications` link. `BARSQL_NATIVE_ONLY=1` builds this Mac's architecture alone. - **Windows:** `crates/barsql/build.rs` embeds the icon and the version block. `packaging/windows/installer.nsi` installs the app with the `SQLite Database` file class for `.db`, `.sqlite`, `.sqlite3`, `.s3db` and `.sl3`. - **Linux:** [`cargo-deb`](https://crates.io/crates/cargo-deb) and [`cargo-generate-rpm`](https://crates.io/crates/cargo-generate-rpm) build the deb and rpm from `crates/barsql/Cargo.toml`, and `makepkg` builds the Arch package from `packaging/linux/PKGBUILD`. Each installs `/usr/bin/BarSQL` and a desktop entry that opens SQLite files; the deb and rpm work out their library dependencies from the binary. The window's app id is `BarSQL`, so Wayland docks match it to `BarSQL.desktop`. diff --git a/crates/barsql-app/src/backup.rs b/crates/barsql-app/src/backup.rs index 645a843..fb986bd 100644 --- a/crates/barsql-app/src/backup.rs +++ b/crates/barsql-app/src/backup.rs @@ -3,7 +3,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use barsql_core::QueryError; -use barsql_db::{Cancel, Cell, ScriptEvent}; +use barsql_db::{Cancel, Cell, ScriptEvent, join_error}; use barsql_sql::ddl::terminate_statement; use barsql_sql::table_ref; @@ -59,10 +59,6 @@ fn io_error(error: std::io::Error) -> QueryError { QueryError::message(error.to_string()) } -fn join_error(error: tokio::task::JoinError) -> QueryError { - QueryError::message(error.to_string()) -} - struct Inserts<'a> { head: &'a str, rows: usize, diff --git a/crates/barsql-app/src/events.rs b/crates/barsql-app/src/events.rs index 930b44a..33d84c5 100644 --- a/crates/barsql-app/src/events.rs +++ b/crates/barsql-app/src/events.rs @@ -50,6 +50,7 @@ pub struct ImportProgress { pub struct ImportDone { pub result: Option, pub error: String, + pub bug: bool, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/barsql-app/src/import.rs b/crates/barsql-app/src/import.rs index 833db87..4885176 100644 --- a/crates/barsql-app/src/import.rs +++ b/crates/barsql-app/src/import.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::fs::File; +use std::panic::AssertUnwindSafe; use std::path::Path; use std::time::{Duration, Instant}; @@ -12,6 +13,7 @@ use barsql_sql::dml::{ build_batch_insert_with, build_import_create_table, build_truncate, is_bit_column, }; use barsql_sql::{Dialect, READ_ONLY_ERROR, split_statement_texts}; +use futures_util::FutureExt as _; use serde::{Deserialize, Serialize}; use crate::BarApp; @@ -193,17 +195,18 @@ impl BarApp { self.inner.runtime.spawn(async move { let emitter = Emitter { events: tx.clone(), total_rows: 0, last: None }; let started = Instant::now(); - let outcome = work(app.clone(), emitter, cancel.clone()).await; + let work = AssertUnwindSafe(work(app.clone(), emitter, cancel.clone())); + let outcome = work.catch_unwind().await.unwrap_or_else(|panic| Err(QueryError::from_panic(panic))); let done = match outcome { Ok(mut result) => { result.duration_ms = started.elapsed().as_millis() as i64; - ImportDone { result: Some(result), error: String::new() } + ImportDone { result: Some(result), ..Default::default() } } Err(err) if cancel.is_cancelled() || err.cancelled => ImportDone { result: Some(ImportResult { cancelled: true, ..Default::default() }), - error: String::new(), + ..Default::default() }, - Err(err) => ImportDone { result: None, error: err.message }, + Err(err) => ImportDone { result: None, error: err.message, bug: err.bug }, }; app.inner.jobs.end(&key, job); let _ = tx.send(ImportEvent::Done(done)).await; @@ -733,6 +736,19 @@ fn mysql_datetime(v: &str) -> String { mod tests { use super::*; + async fn boom() -> T { + panic!("boom") + } + + #[tokio::test] + async fn an_import_that_panics_ends_with_a_bug() { + let (app, id, _dir) = crate::connections::tests::setup().await; + let handle = app.run_import("import", &id, |_, _, _| boom()); + let event = handle.events.recv().await; + assert!(matches!(&event, Ok(ImportEvent::Done(done)) if done.bug && done.result.is_none()), "{event:?}"); + assert!(!app.cancel_query("import"), "the import's job ended"); + } + #[test] fn sql_scripts_must_be_utf8_and_lose_their_bom() { let path = Path::new("dump.sql"); diff --git a/crates/barsql-app/src/mcp.rs b/crates/barsql-app/src/mcp.rs index 514ceda..8290bd7 100644 --- a/crates/barsql-app/src/mcp.rs +++ b/crates/barsql-app/src/mcp.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::collections::hash_map::Entry; use std::fs::{self, OpenOptions}; use std::io::{self, BufRead, Write}; +use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, mpsc}; @@ -11,6 +12,7 @@ use std::time::{Duration, Instant}; use barsql_core::{ConnectionConfig, DriverType, ObjectKind, ObjectRef, QueryError}; use barsql_db::{ColumnMeta, ResultChunk}; +use futures_util::FutureExt as _; use serde_json::{Value, json}; use tokio::sync::{oneshot, watch}; @@ -162,7 +164,8 @@ impl Server { let result = if *cancelled.borrow() { Err(CANCELLED.into()) } else { - self.run(tool, args, &tab, cancelled.clone()).await + let run = AssertUnwindSafe(self.run(tool, args, &tab, cancelled.clone())); + run.catch_unwind().await.unwrap_or_else(|panic| Err(failed(QueryError::from_panic(panic)))) }; // Gives the connection back at once: SQLite has only one. self.bar.cleanup_tab(&tab).await; diff --git a/crates/barsql-app/src/runs.rs b/crates/barsql-app/src/runs.rs index 158c326..fd4e6c1 100644 --- a/crates/barsql-app/src/runs.rs +++ b/crates/barsql-app/src/runs.rs @@ -1,3 +1,4 @@ +use std::panic::AssertUnwindSafe; use std::time::Instant; use barsql_core::clock::now_rfc3339; @@ -8,6 +9,7 @@ use barsql_sql::{ ExplainStrategy, QueryPlan, ServerVersion, assert_read_only, build_explain, controls_transactions, first_keyword, is_read_only, parse_plan, single_statement, split_statement_texts, strip_leading_comments, }; +use futures_util::FutureExt as _; use serde::Serialize; use crate::events::{RunEvent, RunHandle, RunResult}; @@ -95,19 +97,40 @@ impl BarApp { self.inner.jobs.end(tab_id, stream_id); return Err(error); } - let (tx, rx) = async_channel::bounded(RUN_CHANNEL); - let app = self.clone(); let (tab, connection) = (tab_id.to_string(), connection_id.to_string()); - self.inner.runtime.spawn(async move { - let done = match app.run_batch(&tab, slot, &connection, &statements, &tx, &cancel).await { + Ok(self.spawn_run(tab_id, stream_id, Some(slot.clone()), move |app, tx| async move { + match app.run_batch(&tab, slot, &connection, &statements, &tx, &cancel).await { Ok(result_count) => RunEvent::Done { result_count, error: None }, Err(error) => RunEvent::Done { result_count: 0, error: Some(error) }, + } + })) + } + + // Ends with Done even when `work` panics. A bug can leave the tab's session mid-run, so the session goes too. + fn spawn_run(&self, tab_id: &str, stream_id: u64, slot: Option, work: F) -> RunHandle + where + F: FnOnce(BarApp, async_channel::Sender) -> Fut + Send + 'static, + Fut: Future + Send, + { + let (tx, rx) = async_channel::bounded(RUN_CHANNEL); + let app = self.clone(); + let tab = tab_id.to_string(); + self.inner.runtime.spawn(async move { + let work = AssertUnwindSafe(work(app.clone(), tx.clone())); + let done = match work.catch_unwind().await { + Ok(done) => done, + Err(panic) => RunEvent::Done { result_count: 0, error: Some(QueryError::from_panic(panic)) }, }; + if let (RunEvent::Done { error: Some(error), .. }, Some(slot)) = (&done, slot) + && error.bug + { + app.end_bugged_session(&tab, slot).await; + } // Ended first, so a cancel after Done finds nothing running. app.inner.jobs.end(&tab, stream_id); let _ = tx.send(done).await; }); - Ok(RunHandle { stream_id, events: rx }) + RunHandle { stream_id, events: rx } } async fn run_batch( @@ -200,12 +223,10 @@ impl BarApp { req.limit = 100; } let (stream_id, cancel) = self.inner.jobs.start(tab_id, connection_id); - let (tx, rx) = async_channel::bounded(RUN_CHANNEL); - let app = self.clone(); - let (tab, connection) = (tab_id.to_string(), connection_id.to_string()); - self.inner.runtime.spawn(async move { + let connection = connection_id.to_string(); + Ok(self.spawn_run(tab_id, stream_id, None, move |app, tx| async move { // So Stop works while the connection is still opening. - let done = match app.engine_unless_cancelled(&connection, &cancel).await { + match app.engine_unless_cancelled(&connection, &cancel).await { Err(error) => RunEvent::Done { result_count: 0, error: Some(error) }, Ok(engine) => { if let Err(error) = app.stream_table_page(&engine, &req, &tx, &cancel).await { @@ -214,11 +235,8 @@ impl BarApp { } RunEvent::Done { result_count: 1, error: None } } - }; - app.inner.jobs.end(&tab, stream_id); - let _ = tx.send(done).await; - }); - Ok(RunHandle { stream_id, events: rx }) + } + })) } async fn stream_table_page( @@ -591,9 +609,42 @@ async fn plan_of( #[cfg(test)] mod tests { - use barsql_core::{DriverType, QueryError}; + use std::time::Duration; + + use barsql_core::{DriverType, QueryError, Value}; use super::left_applied; + use crate::connections::tests::setup; + use crate::events::{AppEvent, RunEvent, RunHandle}; + + async fn boom() -> T { + panic!("boom") + } + + async fn drain(run: RunHandle) -> Vec { + let mut events = Vec::new(); + while let Ok(event) = run.events.recv().await { + events.push(event); + } + events + } + + #[tokio::test] + async fn a_run_that_panics_ends_with_a_bug_and_rolls_back_its_tab() { + let (app, id, _dir) = setup().await; + let notices = app.events(); + app.begin_transaction(&id, "tab").await.unwrap(); + drain(app.execute_query_stream(&id, "tab", "CREATE TABLE t (a INTEGER)").await.unwrap()).await; + let (stream_id, _) = app.inner.jobs.start("tab", &id); + let events = drain(app.spawn_run("tab", stream_id, Some(app.inner.tabs.slot("tab")), |_, _| boom())).await; + assert!(matches!(events.as_slice(), [RunEvent::Done { error: Some(error), .. }] if error.bug), "{events:?}"); + assert!(!app.cancel_query("tab"), "the run's job ended"); + assert!(!app.transaction_status("tab")); + assert_eq!(notices.try_recv(), Ok(AppEvent::TransactionsEnded { tab_ids: vec!["tab".into()] })); + let count = app.execute_query(&id, "SELECT count(*) FROM sqlite_master WHERE name = 't'"); + let made = tokio::time::timeout(Duration::from_secs(5), count).await.expect("the tab kept the connection"); + assert_eq!(made.unwrap().rows, [[Value::Int(0)]], "the tab's transaction was rolled back"); + } fn left(driver: DriverType, statements: &[&str]) -> String { let statements: Vec = statements.iter().map(|stmt| stmt.to_string()).collect(); diff --git a/crates/barsql-app/src/tabs.rs b/crates/barsql-app/src/tabs.rs index 98e1b34..fa901f1 100644 --- a/crates/barsql-app/src/tabs.rs +++ b/crates/barsql-app/src/tabs.rs @@ -4,10 +4,11 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use barsql_core::QueryError; -use barsql_db::{Cancel, Engine, Session}; +use barsql_db::{Cancel, Engine, Session, join_error}; use futures_util::future::join_all; use tokio::sync::OwnedMutexGuard; +use crate::events::AppEvent; use crate::{BarApp, lock}; // Each open editor tab gets its own session so SET, temp tables and transactions stay with that tab. @@ -149,11 +150,7 @@ impl BarApp { let app = self.clone(); let id = connection_id.to_string(); let connect = self.inner.runtime.spawn(async move { app.engine(&id).await }); - unless_cancelled( - async { connect.await.unwrap_or_else(|err| Err(QueryError::message(err.to_string()))) }, - cancel, - ) - .await + unless_cancelled(async { connect.await.unwrap_or_else(|err| Err(join_error(err))) }, cancel).await } // Called when the tab's work is done with the slot. @@ -214,6 +211,13 @@ impl BarApp { self.inner.tabs.mark(tab_id, false); rolled_back } + + pub(crate) async fn end_bugged_session(&self, tab_id: &str, slot: Slot) { + let mut slot = slot.lock().await; + if self.end_session(tab_id, &mut slot).await { + self.emit(AppEvent::TransactionsEnded { tab_ids: vec![tab_id.to_string()] }); + } + } } pub(crate) async fn unless_cancelled( diff --git a/crates/barsql-core/src/query.rs b/crates/barsql-core/src/query.rs index c03422c..c3df3e6 100644 --- a/crates/barsql-core/src/query.rs +++ b/crates/barsql-core/src/query.rs @@ -1,8 +1,11 @@ +use std::any::Any; use std::collections::BTreeMap; use std::fmt; use serde::{Deserialize, Serialize}; +const BUG: &str = "BarSQL hit a bug. The details are in crash.log in the data folder."; + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QueryError { @@ -19,6 +22,8 @@ pub struct QueryError { pub severity: String, #[serde(default, skip_serializing_if = "is_false")] pub cancelled: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub bug: bool, // Kept apart from the message so the connection dialog can mark the file's row. #[serde(default, skip_serializing_if = "Option::is_none")] pub extension: Option, @@ -39,6 +44,15 @@ impl QueryError { Self { message: "query cancelled".into(), cancelled: true, ..Default::default() } } + // A panic caught where a task ends. The panic hook has already written it and its backtrace to crash.log. + pub fn from_panic(payload: Box) -> Self { + let detail = match payload.downcast::() { + Ok(text) => *text, + Err(payload) => payload.downcast_ref::<&str>().map(|text| text.to_string()).unwrap_or_default(), + }; + Self { message: BUG.into(), detail, bug: true, ..Default::default() } + } + pub fn extension(path: &str, reason: impl Into) -> Self { let reason = reason.into(); let (_, name) = crate::config::path_parts(path); @@ -251,3 +265,25 @@ fn is_zero(v: &u32) -> bool { fn is_false(v: &bool) -> bool { !*v } + +#[cfg(test)] +mod tests { + use std::any::Any; + use std::panic::UnwindSafe; + + use super::QueryError; + + fn payload(panics: impl FnOnce() + UnwindSafe) -> Box { + std::panic::catch_unwind(panics).unwrap_err() + } + + #[test] + fn a_panic_reads_as_a_bug_that_keeps_its_message() { + let error = QueryError::from_panic(payload(|| panic!("index out of bounds"))); + assert!(error.bug); + assert_eq!(error.detail, "index out of bounds"); + let formatted = QueryError::from_panic(payload(|| panic!("row {} of {}", 3, 2))); + assert_eq!(formatted.detail, "row 3 of 2", "a formatted message is a String payload"); + assert!(!QueryError::message("syntax error").bug); + } +} diff --git a/crates/barsql-db/src/cancel.rs b/crates/barsql-db/src/cancel.rs index ab7d618..01a6c73 100644 --- a/crates/barsql-db/src/cancel.rs +++ b/crates/barsql-db/src/cancel.rs @@ -1,7 +1,9 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use barsql_core::QueryError; use tokio::sync::Notify; +use tokio::task::JoinError; type ServerCancel = Box; @@ -72,11 +74,34 @@ impl Drop for ServerCancelGuard { } } +// A task that didn't finish either panicked or was aborted, as the runtime does when it shuts down. +pub fn join_error(error: JoinError) -> QueryError { + match error.try_into_panic() { + Ok(panic) => QueryError::from_panic(panic), + Err(_) => QueryError::cancelled(), + } +} + #[cfg(test)] mod tests { use super::*; use std::sync::atomic::AtomicUsize; + async fn boom() { + panic!("boom") + } + + #[tokio::test] + async fn a_panicked_task_reads_as_a_bug_and_an_aborted_one_as_cancelled() { + let panicked = join_error(tokio::spawn(boom()).await.unwrap_err()); + assert!(panicked.bug && !panicked.cancelled); + assert_eq!(panicked.detail, "boom"); + let pending = tokio::spawn(std::future::pending::<()>()); + pending.abort(); + let aborted = join_error(pending.await.unwrap_err()); + assert!(aborted.cancelled && !aborted.bug); + } + #[tokio::test] async fn cancel_wakes_waiters_and_reaches_the_server_once() { let cancel = Cancel::new(); diff --git a/crates/barsql-db/src/lib.rs b/crates/barsql-db/src/lib.rs index 4abb3b5..0be3142 100644 --- a/crates/barsql-db/src/lib.rs +++ b/crates/barsql-db/src/lib.rs @@ -23,7 +23,7 @@ pub mod tls; pub mod turso; pub use barsql_core::QueryError; -pub use cancel::Cancel; +pub use cancel::{Cancel, join_error}; pub use dump::DumpQuery; pub use engine::{Engine, Session, TableQuery}; pub use event::{BATCH_ROWS, MAX_MESSAGES, ScriptEvent, Sink, StatementResult}; diff --git a/crates/barsql-db/src/sqlite/mod.rs b/crates/barsql-db/src/sqlite/mod.rs index b84f322..4608bcb 100644 --- a/crates/barsql-db/src/sqlite/mod.rs +++ b/crates/barsql-db/src/sqlite/mod.rs @@ -15,7 +15,7 @@ use tokio::sync::{Mutex, OwnedMutexGuard}; use crate::event::{ScriptEvent, Sink, StatementResult, emit}; use crate::lite::{LiteRef, LiteRows, LiteSource, LiteValue, values}; use crate::script::{self, Buffered, StatementRun, StatementRunner, summary_for_exec, summary_for_rows}; -use crate::{Cancel, ChunkBuilder, ColumnMeta, ColumnOrigin}; +use crate::{Cancel, ChunkBuilder, ColumnMeta, ColumnOrigin, join_error}; pub use crate::lite::values::parse_time; @@ -66,7 +66,7 @@ impl SqliteEngine { Ok((conn, added)) }) .await - .map_err(|err| QueryError::message(err.to_string()))??; + .map_err(join_error)??; let interrupt = Arc::new(conn.get_interrupt_handle()); let extension_functions = added.into_iter().collect(); Ok(Arc::new(Self { options, conn: Arc::new(Mutex::new(conn)), interrupt, extension_functions })) @@ -89,7 +89,7 @@ impl SqliteEngine { work: impl FnOnce(&Connection) -> Result + Send + 'static, ) -> Result { let guard = self.conn.clone().lock_owned().await; - tokio::task::spawn_blocking(move || work(&guard)).await.map_err(|err| QueryError::message(err.to_string()))? + tokio::task::spawn_blocking(move || work(&guard)).await.map_err(join_error)? } fn server_cancel(&self) -> impl Fn() + Send + Sync + 'static { @@ -151,7 +151,7 @@ impl SqliteSession { (guard, out) }) .await - .map_err(|err| QueryError::message(err.to_string()))?; + .map_err(join_error)?; self.in_transaction = !guard.is_autocommit(); self.guard = Some(guard); Ok(out) diff --git a/crates/barsql-db/src/turso/mod.rs b/crates/barsql-db/src/turso/mod.rs index 996e736..cc382bd 100644 --- a/crates/barsql-db/src/turso/mod.rs +++ b/crates/barsql-db/src/turso/mod.rs @@ -27,7 +27,7 @@ use crate::lite::{LiteRows, LiteSource, LiteValue, catalog, values}; use crate::script::{self, Buffered, StatementRun, StatementRunner, summary_for_exec, summary_for_rows}; use crate::ssh::{SshOptions, TunnelSlot}; use crate::tls::{TlsMode, http_client_config}; -use crate::{Cancel, ChunkBuilder, ColumnMeta}; +use crate::{Cancel, ChunkBuilder, ColumnMeta, join_error}; use hrana::{ CursorBatch, CursorBody, CursorEntry, CursorHead, CursorStep, Endpoint, HError, PipelineBody, PipelineOk, PipelineRequest, PipelineResponse, PipelineResult, Stmt, StmtResult, request_error, @@ -172,7 +172,7 @@ impl TursoEngine { let handle = tokio::runtime::Handle::current(); tokio::task::spawn_blocking(move || work(&mut HranaSource { engine: &engine, handle })) .await - .map_err(|err| QueryError::message(err.to_string()))? + .map_err(join_error)? } pub async fn connection_info(self: &Arc) -> Result { diff --git a/crates/barsql-ui/locales/bg.json b/crates/barsql-ui/locales/bg.json index 6b07f24..0335fad 100644 --- a/crates/barsql-ui/locales/bg.json +++ b/crates/barsql-ui/locales/bg.json @@ -339,7 +339,8 @@ "historyNotSaved": "Историята на заявките не можа да бъде запазена: {{error}}. Новите заявки няма да се появят в „Скорошни“, докато запазването не проработи.", "code": "Код {{code}}", "useName": "Използвай {{name}}", - "didYouMean": "Имахте предвид {{name}}?" + "didYouMean": "Имахте предвид {{name}}?", + "bug": "В BarSQL възникна вътрешна грешка. Подробностите са в crash.log в папката с данни." }, "goToLine": { "title": "Отиди на ред", "label": "Ред или ред:колона", "go": "Отиди" }, "status": { diff --git a/crates/barsql-ui/locales/de.json b/crates/barsql-ui/locales/de.json index c5615ca..3dac959 100644 --- a/crates/barsql-ui/locales/de.json +++ b/crates/barsql-ui/locales/de.json @@ -339,7 +339,8 @@ "historyNotSaved": "Der Abfrageverlauf konnte nicht gespeichert werden: {{error}}. Neue Abfragen erscheinen erst wieder unter „Zuletzt“, wenn das Speichern gelingt.", "code": "Code {{code}}", "useName": "{{name}} verwenden", - "didYouMean": "Meinten Sie {{name}}?" + "didYouMean": "Meinten Sie {{name}}?", + "bug": "In BarSQL ist ein interner Fehler aufgetreten. Details stehen in crash.log im Datenordner." }, "goToLine": { "title": "Gehe zu Zeile", "label": "Zeile oder Zeile:Spalte", "go": "Gehe zu" }, "status": { diff --git a/crates/barsql-ui/locales/en.json b/crates/barsql-ui/locales/en.json index 78c953c..b072e16 100644 --- a/crates/barsql-ui/locales/en.json +++ b/crates/barsql-ui/locales/en.json @@ -339,7 +339,8 @@ "historyNotSaved": "Query history could not be saved: {{error}}. Recent won't list new queries until saving works again.", "code": "Code {{code}}", "useName": "Use {{name}}", - "didYouMean": "Did you mean {{name}}?" + "didYouMean": "Did you mean {{name}}?", + "bug": "BarSQL hit a bug. The details are in crash.log in the data folder." }, "goToLine": { "title": "Go to line", "label": "Line, or line:column", "go": "Go" }, "status": { diff --git a/crates/barsql-ui/locales/es.json b/crates/barsql-ui/locales/es.json index 975a71a..8cc6e26 100644 --- a/crates/barsql-ui/locales/es.json +++ b/crates/barsql-ui/locales/es.json @@ -339,6 +339,7 @@ "code": "Código {{code}}", "useName": "Usar {{name}}", "didYouMean": "¿Quisiste decir {{name}}?", + "bug": "BarSQL encontró un fallo interno. Los detalles están en crash.log, en la carpeta de datos.", "pathNotText": "No se puede usar {{path}}: su ruta no es UTF-8 válido. Renómbralo e inténtalo de nuevo." }, "goToLine": { diff --git a/crates/barsql-ui/src/import_dialog.rs b/crates/barsql-ui/src/import_dialog.rs index fd1b7f0..188ffcc 100644 --- a/crates/barsql-ui/src/import_dialog.rs +++ b/crates/barsql-ui/src/import_dialog.rs @@ -537,6 +537,7 @@ impl ImportDialog { fn apply(&mut self, event: ImportEvent, cx: &mut Context) { match event { ImportEvent::Progress(progress) => self.progress = Some(progress), + ImportEvent::Done(done) if done.bug => self.error = Some(t(cx, "errors.bug")), ImportEvent::Done(done) if !done.error.is_empty() => self.error = Some(done.error.into()), ImportEvent::Done(done) => { if let Some(result) = &done.result @@ -1080,13 +1081,26 @@ mod tests { use std::cell::Cell; use std::rc::Rc; + use barsql_app::{ImportDone, ImportEvent}; use barsql_core::Value; use gpui_kit::component::Root; use gpui_kit::{AppContext as _, Entity, TestAppContext, VisualTestContext}; use super::{ImportDialog, Kind, Target, fits, table_name_for}; + use crate::i18n::I18n; use crate::test_support::{Env, settle}; + #[gpui_kit::test] + fn an_import_that_hit_a_bug_says_so_in_the_ui_language(cx: &mut TestAppContext) { + let env = Env::new(cx); + cx.update(|cx| cx.set_global(I18n::new("de"))); + let (view, cx) = open(&env, cx, Rc::new(Cell::new(0))); + let done = ImportDone { error: "BarSQL hit a bug.".into(), bug: true, ..Default::default() }; + view.update(cx, |dialog, cx| dialog.apply(ImportEvent::Done(done), cx)); + let said = "In BarSQL ist ein interner Fehler aufgetreten. Details stehen in crash.log im Datenordner."; + assert_eq!(view.read_with(cx, |dialog, _| dialog.error.clone()).as_deref(), Some(said)); + } + fn open<'a>( env: &Env, cx: &'a mut TestAppContext, diff --git a/crates/barsql-ui/src/results.rs b/crates/barsql-ui/src/results.rs index 88d21ff..c4d5dc6 100644 --- a/crates/barsql-ui/src/results.rs +++ b/crates/barsql-ui/src/results.rs @@ -60,7 +60,8 @@ impl ShownError { if error.cancelled { return Self { message: t(cx, "dialog.queryCancelled"), info: None, fix: None, scroll }; } - Self { message: error.message.clone().into(), info: Some(error), fix: None, scroll } + let message = if error.bug { t(cx, "errors.bug") } else { error.message.clone().into() }; + Self { message, info: Some(error), fix: None, scroll } } // Centred in the pane. `action` sits next to the copy button, and `fix` next to the hint. @@ -1532,10 +1533,22 @@ mod editor_tests { use gpui_kit::assets::IconName as Lucide; use super::messages_tests::{apply, done, panel}; - use super::{ResultsEvent, ResultsPanel}; + use super::{ResultsEvent, ResultsPanel, ShownError}; use crate::grid::GridEvent; + use crate::i18n::I18n; use crate::test_support::{Env, settle}; + #[gpui_kit::test] + fn a_bug_reads_in_the_ui_language_and_keeps_the_panic_as_detail(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.set_global(I18n::new("de")); + let shown = ShownError::new(QueryError::from_panic(Box::new("boom")), cx); + let said = "In BarSQL ist ein interner Fehler aufgetreten. Details stehen in crash.log im Datenordner."; + assert_eq!(shown.message, said); + assert_eq!(shown.info.map(|info| info.detail), Some("boom".to_string())); + }); + } + // Result `index`: one row of (id, name). fn people(index: usize, statement: &str) -> Vec { let columns: Arc<[ColumnMeta]> = [("id", "int4"), ("name", "text")] diff --git a/crates/barsql-ui/src/state.rs b/crates/barsql-ui/src/state.rs index 2b4b0ce..15d4aab 100644 --- a/crates/barsql-ui/src/state.rs +++ b/crates/barsql-ui/src/state.rs @@ -1,9 +1,10 @@ use std::future::Future; use barsql_app::BarApp; -use gpui_kit::{App, AppContext as _, Global, Task}; +use gpui_kit::{App, Global, Task}; -use crate::i18n::I18n; +use crate::i18n::{I18n, t}; +use crate::toast; pub struct AppState { pub bar: BarApp, @@ -49,8 +50,47 @@ pub fn set_setting_json(cx: &App, key: &str, value: &T) { } } -// Network futures run on tokio, never on GPUI's executors. +// Network futures run on tokio, never on GPUI's executors. A panic there ends the task empty, with a toast. pub fn spawn(cx: &App, work: impl Future + Send + 'static) -> Task> { let handle = cx.global::().bar.runtime().spawn(work); - cx.background_spawn(async move { handle.await.ok() }) + cx.spawn(async move |cx| match handle.await { + Ok(value) => Some(value), + Err(error) => { + if error.is_panic() { + cx.update(|cx| toast::error(t(cx, "errors.bug"), cx)); + } + None + } + }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use gpui_kit::TestAppContext; + + use crate::test_support::Env; + use crate::toast::{self, ToastKind}; + + async fn boom() { + panic!("boom") + } + + #[gpui_kit::test] + fn a_task_that_panics_shows_an_error_toast(cx: &mut TestAppContext) { + let _env = Env::new(cx); + let _task = cx.update(|cx| super::spawn(cx, boom())); + let mut toasts = Vec::new(); + for _ in 0..1000 { + cx.run_until_parked(); + toasts = cx.update(|cx| toast::messages(cx)); + if !toasts.is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + let said = "BarSQL hit a bug. The details are in crash.log in the data folder."; + assert_eq!(toasts, [(ToastKind::Error, said.to_string())]); + } } diff --git a/crates/barsql/src/main.rs b/crates/barsql/src/main.rs index 8d81746..711e020 100644 --- a/crates/barsql/src/main.rs +++ b/crates/barsql/src/main.rs @@ -2,13 +2,19 @@ mod single_instance; +use std::fs::OpenOptions; +use std::io::Write as _; use std::path::Path; use anyhow::Context as _; use barsql_app::{BarApp, find_sqlite_arg}; +use barsql_core::clock::now_rfc3339; use barsql_ui::LaunchOptions; use single_instance::Instance; +// Past this the log starts over, so a run of panics can't fill the disk. +const CRASH_LOG_LIMIT: u64 = 1 << 20; + fn main() -> anyhow::Result<()> { barsql_app::install_default_provider(); if let Some(code) = barsql_app::update::helper_mode() { @@ -59,7 +65,8 @@ fn main() -> anyhow::Result<()> { Ok(()) } -// Release builds abort on a panic, background tasks' too, so it goes to crash.log in the data folder first. +// A window launch has nowhere to print a panic, so it goes to crash.log in the data folder. Appended: a panic in an +// OS callback panics again on its way out ("cannot unwind"), and that report mustn't replace the first. fn log_panics(data_dir: &Path) { let path = data_dir.join("crash.log"); let default = std::panic::take_hook(); @@ -67,15 +74,23 @@ fn log_panics(data_dir: &Path) { let backtrace = std::backtrace::Backtrace::force_capture(); let thread = std::thread::current(); let report = format!( - "BarSQL {}: thread '{}' {info}\n\n{backtrace:#}\n", + "{} BarSQL {}: thread '{}' {info}\n\n{backtrace:#}\n", + now_rfc3339(), env!("CARGO_PKG_VERSION"), thread.name().unwrap_or("") ); - let _ = std::fs::write(&path, report); + let _ = record(&path, &report); default(info); })); } +fn record(path: &Path, report: &str) -> std::io::Result<()> { + if std::fs::metadata(path).is_ok_and(|meta| meta.len() > CRASH_LOG_LIMIT) { + return std::fs::write(path, report); + } + OpenOptions::new().create(true).append(true).open(path)?.write_all(report.as_bytes()) +} + fn runtime() -> anyhow::Result { let runtime = tokio::runtime::Builder::new_multi_thread() .thread_stack_size(barsql_app::WORKER_STACK) @@ -84,3 +99,28 @@ fn runtime() -> anyhow::Result { .context("tokio runtime")?; Ok(runtime) } + +#[cfg(test)] +mod tests { + use super::{CRASH_LOG_LIMIT, log_panics, record}; + + #[test] + fn crash_log_starts_over_once_it_passes_the_limit() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("crash.log"); + record(&path, &"x".repeat(CRASH_LOG_LIMIT as usize + 1)).unwrap(); + record(&path, "latest\n").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "latest\n"); + } + + #[test] + fn a_second_panic_adds_to_crash_log_instead_of_replacing_the_first() { + let dir = tempfile::tempdir().unwrap(); + log_panics(dir.path()); + let _ = std::panic::catch_unwind(|| panic!("the real panic")); + let _ = std::panic::catch_unwind(|| panic!("panic in a function that cannot unwind")); + let log = std::fs::read_to_string(dir.path().join("crash.log")).unwrap(); + let (first, second) = (log.find("the real panic"), log.find("cannot unwind")); + assert!(first.is_some() && first < second, "{log}"); + } +} diff --git a/guide/app.md b/guide/app.md index 2f5e416..004e82c 100644 --- a/guide/app.md +++ b/guide/app.md @@ -52,7 +52,7 @@ BarSQL-data/ settings.json ``` -`settings.json` keeps your UI preferences - theme, language, layout and keyboard shortcuts. `mcp.log` appears once an AI assistant has used BarSQL, and `crash.log` if BarSQL ever closes on an error. +`settings.json` keeps your UI preferences - theme, language, layout and keyboard shortcuts. `mcp.log` appears once an AI assistant has used BarSQL, and `crash.log` if BarSQL ever hits a bug, whether it closes or says so in an error. When the app sits somewhere writable, that folder is created **right next to the executable** (beside the `.app` bundle on macOS) - move it to a USB stick, network drive, or another PC and it just works. From 945367c37741563c7cad5c778fdee07eadd22f73 Mon Sep 17 00:00:00 2001 From: Bare7a Date: Sun, 1 Nov 2026 23:58:18 +0200 Subject: [PATCH 3/4] Bumped version to 1.2.3 --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b2f71d..ab8f80f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -626,7 +626,7 @@ dependencies = [ [[package]] name = "barsql" -version = "1.2.2" +version = "1.2.3" dependencies = [ "anyhow", "barsql-app", @@ -641,7 +641,7 @@ dependencies = [ [[package]] name = "barsql-app" -version = "1.2.2" +version = "1.2.3" dependencies = [ "async-channel", "barsql-core", @@ -669,7 +669,7 @@ dependencies = [ [[package]] name = "barsql-core" -version = "1.2.2" +version = "1.2.3" dependencies = [ "dirs 7.0.0", "hex", @@ -682,7 +682,7 @@ dependencies = [ [[package]] name = "barsql-db" -version = "1.2.2" +version = "1.2.3" dependencies = [ "anyhow", "async-channel", @@ -719,7 +719,7 @@ dependencies = [ [[package]] name = "barsql-io" -version = "1.2.2" +version = "1.2.3" dependencies = [ "barsql-core", "barsql-db", @@ -734,7 +734,7 @@ dependencies = [ [[package]] name = "barsql-sql" -version = "1.2.2" +version = "1.2.3" dependencies = [ "barsql-core", "libc", @@ -746,7 +746,7 @@ dependencies = [ [[package]] name = "barsql-storage" -version = "1.2.2" +version = "1.2.3" dependencies = [ "barsql-core", "serde", @@ -757,7 +757,7 @@ dependencies = [ [[package]] name = "barsql-ui" -version = "1.2.2" +version = "1.2.3" dependencies = [ "anyhow", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index 7814251..8f8ffa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "xtask"] [workspace.package] -version = "1.2.2" +version = "1.2.3" edition = "2024" license = "GPL-3.0-only" publish = false From bf3b2e658e8c708acb75063e72cbf60dff16c617 Mon Sep 17 00:00:00 2001 From: Bare7a Date: Mon, 2 Nov 2026 00:08:45 +0200 Subject: [PATCH 4/4] Updates back to THIN --- Cargo.toml | 4 +--- RELEASING.md | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8f8ffa2..1c061ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,8 +71,6 @@ zip = { version = "9", default-features = false, features = ["deflate"] } [profile.dev.package."*"] debug = false -# Fat LTO in one codegen unit. The binary comes out about 16% smaller, for about twice the build time. [profile.release] -lto = "fat" -codegen-units = 1 +lto = "thin" strip = true diff --git a/RELEASING.md b/RELEASING.md index 0a47212..1379853 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -18,7 +18,7 @@ Run the workflow by hand with the version left empty to build the packages witho | `packaging/linux/PKGBUILD` (release workflow, in an Arch Linux container) | | `BarSQL.pkg.tar.zst` | - Only the portable updater assets name both a platform and an architecture, so portable copies never pick another download. The AppImage and the packages update from their own asset, and the installer and the dmg from the `.zip`. -- Release builds turn on the `production` feature, which enables the update check at launch. The release profile uses fat LTO in one codegen unit and strips symbols. Fat LTO makes the binary about 16% smaller than thin LTO, and the build takes about twice as long. +- Release builds turn on the `production` feature, which enables the update check at launch. The release profile uses thin LTO and strips symbols. - **macOS:** a `lipo` universal `.app` for macOS 12 and later with an ad-hoc signature; the dmg adds an `Applications` link. `BARSQL_NATIVE_ONLY=1` builds this Mac's architecture alone. - **Windows:** `crates/barsql/build.rs` embeds the icon and the version block. `packaging/windows/installer.nsi` installs the app with the `SQLite Database` file class for `.db`, `.sqlite`, `.sqlite3`, `.s3db` and `.sl3`. - **Linux:** [`cargo-deb`](https://crates.io/crates/cargo-deb) and [`cargo-generate-rpm`](https://crates.io/crates/cargo-generate-rpm) build the deb and rpm from `crates/barsql/Cargo.toml`, and `makepkg` builds the Arch package from `packaging/linux/PKGBUILD`. Each installs `/usr/bin/BarSQL` and a desktop entry that opens SQLite files; the deb and rpm work out their library dependencies from the binary. The window's app id is `BarSQL`, so Wayland docks match it to `BarSQL.desktop`.