Errors are typed enums, built with thiserror wherever they cross a crate boundary. No TablePro crate depends on anyhow: the caller matches on the enum, and the app decides what to say from it, never from the message.
| Crate | Error types | Notes |
|---|---|---|
tablepro-core |
DriverError, ConfigError, TransportError, SshFailure, CertificateFileProblem, SecretError |
The contract every driver, transport and keyring speaks. |
tablepro-net |
tls::TlsSetupError |
Wraps a ConfigError, a rustls error or a PEM encoding error while a TLS config is built; drivers turn it into DriverError. |
tablepro-ssh |
SshFailure, inside TransportError::Ssh |
Classified from OpenSSH's exit status and stderr. |
drivers/<engine> |
DriverError only |
The client library's error never leaves the crate. |
tablepro-storage |
StorageError, SettingsError, KeyringError, CredentialError |
KeyringError::Secret wraps core's SecretError. |
tablepro-session |
ConnectError, OperationError, ClaimError |
ConnectError::Driver and OperationError::Driver carry the driver's error unchanged. |
tablepro (the app) |
Small enums per module (StartupError, LoggingError, AcquireError, ...) |
User-facing text comes from crates/app/src/ui/error_text/. |
DriverError is #[non_exhaustive], Clone and PartialEq, so a result can be stored, compared in tests and sent across threads. Each variant has one meaning:
| Variant | Meaning |
|---|---|
Transport(TransportError) |
The route failed: name resolution, refused, unreachable, a connect or SSH timeout, or an SshFailure. |
Auth { diagnostics } |
The server refused the credentials, with its own explanation when it gave one. |
LoginRefused(ServerDiagnostics) |
The server turned the login away for a reason other than the password, such as PostgreSQL's pg_hba.conf rules. The app offers no new password for it. |
IntegratedAuth(String) |
A Kerberos login failed before the server saw it. |
Tls { failure, detail } |
The handshake failed; TlsFailure says why. |
Server(ServerDiagnostics) |
The server refused the statement. |
ReadOnly(ReadOnlyRefusal) |
A write on a read-only connection: ClientGuard (the driver refused before sending) or ServerRejected (the server refused). |
Cancelled |
The caller stopped it. |
Timeout { phase, server_cancelled } |
A bound passed. server_cancelled says the server confirmed it stopped the statement. |
ConnectionLost { during } |
The connection went away; LossPhase says while doing what. |
RolledBack, RowGuardFailed, CommitOutcomeUnknown, PartiallyApplied |
What a failed save left in the database (adding-drivers.md). |
GuardUnsupported |
The engine reports no row count, so a guarded write cannot run. |
FileNotFound, FileAccessDenied, NotADatabase, FileIo |
A file engine's file. |
Busy(BusyReason) |
Something was busy: NoFreeConnection (the server has no room for another connection, or the pool had no free slot in time) or FileLocked (another connection held the database file's lock past the lock timeout). |
EditorSessionBusy |
The tab's session is already running a statement. |
Config(ConfigError) |
The settings cannot be used: a bad host, port, path, TLS option, auth mode or missing database name. |
Decode { column, detail } |
The driver could not read a value. A bug on our side, not the server's. The detail is for the log. |
Protocol(String) |
The server or the client library said something the driver did not expect. The text is for the log. |
Unsupported { feature } |
The engine has no such feature. UnsupportedFeature names it, and the app writes the sentence. |
Rules:
- Add a variant only when the app has to act on it differently from every existing one. A new message is not a reason.
- Never wrap a
Box<dyn Error>or the client library's error. Keep what the app needs: a code, a phase, a path. - The
#[error]text is for logs. The UI writes its own sentence from the variant. - A variant carries no English meant for the user. What the app needs to say goes in as a value it can render: an
UnsupportedFeature, anio::ErrorKind, a line and column. A library message that is English only, such as sqlparser's or serde_json's, is not kept.
DriverError::category() (and TransportError::category(), SshFailure::category()) sorts every error into the kind of problem it is. The app branches on the category, so an error a driver adds later still lands in a branch that says what to do next.
| Category | Variants | What the app does |
|---|---|---|
Configuration |
Config, Unsupported; SSH setup failures |
Names the setting to change. The session stops retrying until the user changes something. |
Network |
Transport (resolution, refused, unreachable); an SSH forward that was rejected or could not open, or an SSH master that exited |
Names the host and port. A session retries on a backoff. |
Tls |
Tls |
One sentence per TlsFailure, with a remedy button where one exists. The session stops retrying. |
Authentication |
Auth, LoginRefused, IntegratedAuth; SSH auth and host-key failures, and a cancelled SSH prompt |
Says the login or the host key was refused, with the server's reason. Never echoes what was typed. The session stops retrying. |
ReadOnly |
ReadOnly |
Says who refused: this connection's setting, or the server. |
Server |
Server |
The server's own words, code, detail and hint. |
Interrupted |
Cancelled |
"Stopped." |
Timeout |
Timeout, TransportError::Timeout |
One sentence per TimeoutPhase. |
ConnectionLost |
ConnectionLost |
Says the connection closed. The session reconnects on a backoff. |
WriteOutcome |
RolledBack, RowGuardFailed, CommitOutcomeUnknown, PartiallyApplied |
Says what is in the database now. Only RolledBack and RowGuardFailed may say nothing changed. |
File |
the four file variants | Names the path. A connect that failed on the file is not retried. |
Busy |
Busy, EditorSessionBusy |
Says to try again shortly, that another program or tab holds the file's lock, or to wait for the tab's statement. |
Internal |
GuardUnsupported, Decode, Protocol; SshFailure::Protocol and SshFailure::Internal |
Says what the driver could not do: check a save, read a value, read the reply. |
A server error keeps the shape the server sent it in. ServerCode holds each engine's own numbering, because the number is what tells a missing password apart from a missing table:
ServerCode |
Engine | Shown as |
|---|---|---|
SqlState(String) |
PostgreSQL | SQLSTATE 23505 |
MySql { number, sqlstate } |
MySQL, MariaDB | MySQL error 1062 |
Tds { number, state, class } |
SQL Server | SQL Server error 2627 |
ClickHouse { code } |
ClickHouse | ClickHouse code 62 |
Sqlite { primary, extended } |
SQLite | SQLite error 2067 |
ServerCode is closed: an engine with its own numbering adds a variant, and its label in crates/app/src/ui/error_text/driver.rs.
ServerDiagnostics also carries severity, detail, hint, position, where_context, schema, table, column and constraint. PostgreSQL fills most of them; the other engines mostly leave them empty. position counts characters from the start of the statement the user wrote: a driver that wraps the statement corrects it (the PostgreSQL cursor flight subtracts its DECLARE prefix).
A driver maps the codes the app acts on to their own variants and passes everything else through as Server. Each engine page has its driver's table, and the places where it can only read the message, as SQL Server's TLS failures through tiberius: PostgreSQL, MySQL and MariaDB, SQLite, SQL Server, ClickHouse.
Timeout and TransportError::Timeout name the phase that ran out, because each phase has a different cause and a different fix:
| Phase | Bound | Meaning |
|---|---|---|
Connect |
LivenessPolicy::connect_timeout, 10 s |
No TCP connection, or the socket did not open. |
Login |
login_timeout, 15 s |
Connected, but the login or TLS handshake did not finish. |
SshHandshake |
ssh_handshake_timeout, 20 s, paused while a prompt is open |
OpenSSH did not authenticate in time. |
SshChannelOpen |
ssh_channel_open_timeout, 10 s |
ssh -O forward did not answer in time. |
LockWait |
WorkspaceSettings::lock_timeout, 10 s |
Another session holds a lock this statement needs. |
Statement |
The call's deadline, or the workspace statement timeout of 60 s | The statement ran too long. server_cancelled says whether the server confirmed the stop. |
Probe |
probe_timeout, 5 s |
The server did not answer a liveness check. |
Drivers classify a handshake failure with tablepro_net::tls::classify, which walks the error's sources to the rustls error. SQL Server reads rustls's message text instead, because tiberius keeps only the text. Where the driver can tell, a failure before the server agreed to TLS is ServerRefusedTls, never a certificate problem.
TlsFailure |
Cause | What the app offers |
|---|---|---|
UnknownIssuer |
The certificate's issuer is not in the trust set. | Choose CA Certificate…, which opens the connect dialog at the CA file row. |
NameMismatch |
The certificate is for another name. | Set TLS Server Name, which opens the connect dialog at the server name row, where the route shows that row; MySQL over a direct route checks the host it dials and offers only Edit Connection…. |
Expired, NotYetValid, Revoked |
The certificate itself is bad. | Nothing: the server's certificate has to be replaced. |
ServerRefusedTls |
The server does not speak TLS on this port. | The message; turn TLS off or use the TLS port. |
ServerRequiresTls |
TLS was off and the server insists on it. | The message; turn TLS on. |
HandshakeRejected |
The server ended the handshake, for example over the client certificate. | The server's reason. |
CaFileInvalid, ClientIdentityInvalid |
A file the user chose cannot be used. | The message names the file. |
Other |
Anything else. | The rustls message. |
The buttons are TlsRemedy in crates/app/src/ui/error_text/tls_remedy.rs: the toast button when the connect dialog's Test fails, and the Edit button on the window's connect failed page. Which certificates each engine trusts, and how to choose a CA file under Flatpak, is in ARCHITECTURE.md.
- Map by the engine's code, not by message text. Where the client library keeps nothing but the text, say so in a comment and pin the text with a test.
- Keep the server's diagnostics:
DriverError::reported(ServerDiagnostics), andDriverError::auth(Some(diagnostics))for a refused login. - An error the engine raised to acknowledge our stop is the stop:
Cancelled, orTimeout { server_cancelled: true }(adding-drivers.md has howrun_readandrun_writetell). - A
ConnectionLostgoes throughTransport::explain_closed_stream, so an SSH master that exited is reported as that. - A dead route is reported only once a check outside the pool confirms it (adding-drivers.md).
crates/app/src/ui/error_text/ turns errors into sentences: driver_message for a DriverError, connect_error_message and operation_error_message for the session's errors, and an SSH module for SshFailure. Every string goes through crate::i18n. Never show Debug or the #[error] text of an error, on its own or inside a sentence.
The only text shown as it came is what another party wrote, inside a sentence that says whose it is: a server's ServerDiagnostics, the Secret Service's reply (the service_message of SecretError::Backend), OpenSSH's stderr, and the Kerberos library's report. A core crate's own detail is a value the app words, never English text: SshFailure carries exit statuses, I/O error kinds and SshRuntimeDirProblem. The TLS library's text is logged, and the TlsFailure alone picks the sentence. The detail of an Internal error is logged at trace when it is shown, since it can quote what the server sent.
Logging goes through tracing. crates/app/src/logging.rs installs a tracing_subscriber fmt layer that writes to stderr, which the GNOME session journals for both Flatpak and system installs. Panics land in the same stream, so a panic and the lines around it stay in order. There is no journald writer.
- The filter comes from
RUST_LOG. Without it, a development build logs atdebugand an installed one atinfo. ARUST_LOGthat does not parse falls back to that default and logs a warning saying so. TABLEPRO_LOG_FORMAT=jsonwrites one JSON object per line;text, the default, writes plain lines.
RUST_LOG=debug meson devenv -C _build ./crates/app/tablepro
RUST_LOG=tablepro=trace,info TABLEPRO_LOG_FORMAT=json meson devenv -C _build ./crates/app/tablepro
journalctl --user -f -t tableproLevels: error! for what the user must see or a broken contract, warn! for recoverable but suspicious, info! for lifecycle events, debug! for internal flow, trace! for query text and wire detail.
Never log a password, a secret, a key or a bound value. sqlx's own statement logging is turned off in the MySQL and SQLite drivers, and a test in each crate (tests/statement_logging.rs) proves no statement text reaches the subscriber.
The libraries under the drivers log what a database was sent or answered: tokio-postgres the text of every statement at debug, through log, which tracing-subscriber forwards; sqlx every statement, and a slow one as a warning; tiberius rows at trace and server messages as errors; the clickhouse crate exceptions that quote the query; deadpool the errors of the connections it recycles; sqlparser, also through log, the text of every statement, filter and default it parses, and the literals in it, at debug. So the subscriber drops every event whose target starts with tokio_postgres, deadpool, tiberius, sqlx, clickhouse or sqlparser, at every level. That filter sits beside the RUST_LOG one, so no directive turns them back on, however specific. A driver logs what it needs from their errors itself. A new driver's client library, or any other library that logs SQL text, joins SILENCED_LIBRARIES in logging.rs.
A panic on the GTK thread ends the process: GTK calls the app back through C, which a panic cannot unwind, so it aborts. A panic on a runtime thread is caught there (session/src/runtime/catch_panic.rs, and tokio's own JoinError) and ends one task, which the UI shows as a failure.
crates/app/src/crash_report.rsinstalls a panic hook inrun(), right after logging. It runs the default hook first, so stderr and the journal keep the usual line.- For a panic on the GTK thread it then appends a report to
StoragePaths::crashes_dir()($XDG_STATE_HOME/<dir>/crashes): the version, application ID, profile, debug or release build, the time, the message and place, and a backtrace captured whateverRUST_BACKTRACEsays. The file is 0600 in a 0700 directory, written throughtablepro_storage::fs::append_private_blocking. One file per process, namedcrash-<UTC time>-<pid>.txt; the second panic Rust raises when the first cannot unwind goes in the same file. - At the next launch the primary instance looks for the newest report it has not announced, off the GTK thread, and records its name in
crashes/announced. The first window shows a toast, "TablePro quit unexpectedly last time", whose Show Report button opens the folder with the report selected. The toast moves with the window from page to page until it is dismissed or times out. Only the 20 newest reports are kept. - Nothing leaves the machine: the user decides whether to attach the report to an issue.
- Still open: pending grid and Structure edits live only in memory and are lost with the process. Editor text and the tab list are saved 500 ms after a change.
The workspace lints deny unwrap_used, expect_used, panic, todo and unimplemented. clippy.toml allows unwrap, expect and panic! in tests; todo! and unimplemented! are denied there too. Return a real error instead. The one accepted use outside tests is in a const, where the compiler proves the value, as RowLimit::EDITOR_DEFAULT does.
- A
StringorBox<dyn Error>as an error type in a public function. - A client library's error in a public signature of a driver crate.
match error { _ => "Something went wrong" }. Match the category, then the variant.- A retry loop around an error the category says retrying cannot fix:
Configuration,Authentication,File,WriteOutcome. - Treating
CommitOutcomeUnknownorPartiallyAppliedas a failure that changed nothing.