Skip to content

Commit bb71907

Browse files
committed
fix: deep-audit findings — multi-attr alter, RefCell, close-after-save
Six audit findings from 5-agent post-batch review: - **PG bit(1) → Bool**: classify_type only matched bare "bit"; PG's format_type() returns "bit(1)" for length-1 BIT. Fixed cell editor rendering as text instead of checkbox. - **PG ALTER COLUMN multi-attribute loss**: build_alter_column used cascading early returns — type → nullable → default → first wins. A single AlterColumn op carrying multiple changed attributes lost all but the first. Now returns Vec<String>; structure_tracker's materialize flattens. +1 unit test asserting all 3 statements emit. - **PG missing ROLLBACK on COMMIT failure**: COMMIT failure left the pooled connection in an open transaction. Issue ROLLBACK before surfacing the failure. - **on_structure_save_failed leak**: did not clear close_after_save / close_window_after_save on failure. A future unrelated SaveCompleted on another tab would spuriously close the window. Mirrors the SaveFailedForTab handler. - **close_tabs_for_table leak**: Drop Table closed tabs through finish_close_workspace_tab without dropping their close_after_save entry. Same window-close-spuriously root cause. - **DuplicateRow filter-model index bug**: read source row from current_result.rows by row_position, but row_position is the FilterListModel index. After sort/search, wrong row's values cloned. Now reads through row_object_at + cells_clone. - **suppress_emit RefCell race**: clear_box can fire signals re-entrantly while a borrow_mut is live, panicking. Switched to Cell<bool> (no borrow guards, get/set only). - **Query timeout doesn't cancel underlying future**: timeout branch now also fires token.cancel() so any cancellation logic in the pool / connection-monitor sees the same signal as a manual Cancel. sqlx still has no future-drop hook for in-flight queries; long- running queries on the server may continue until network notices. - **Timeout icon**: appointment-soon-symbolic was a calendar icon; switched to dialog-warning-symbolic. Cleared (not bugs): F5/disconnect race, multi-result signal leak, single-instance lock timing, banner mutex flash, backslash injection, update_added_columns_ops predicate (retain_ops semantics correct).
1 parent b65d2da commit bb71907

7 files changed

Lines changed: 160 additions & 61 deletions

File tree

linux/crates/app/src/services/structure_tracker.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -349,10 +349,15 @@ impl StructureChangeTracker {
349349
for key in order {
350350
if let Some(col) = latest.get(&key) {
351351
match build_alter_column(driver_id, key.0.as_deref(), &key.1, col) {
352-
Ok(sql) => out.push(sql),
353-
// NoChange means the post-edit state matches the
354-
// original — no statement to emit. Real errors
355-
// still propagate.
352+
// MySQL emits exactly one MODIFY COLUMN; the
353+
// Vec is always length 1. Postgres returns
354+
// up to three statements when type +
355+
// nullable + default all changed in the same
356+
// op; flatten them.
357+
Ok(stmts) => out.extend(stmts),
358+
// NoChange means the post-edit state matches
359+
// the original — no statements to emit. Real
360+
// errors still propagate.
356361
Err(BuildDdlError::NoChange) => {}
357362
Err(e) => return Err(e),
358363
}
@@ -362,7 +367,7 @@ impl StructureChangeTracker {
362367
for op in &self.ops {
363368
if let StructureOp::AlterColumn { schema, table, column } = op {
364369
match build_alter_column(driver_id, schema.as_deref(), table, column) {
365-
Ok(sql) => out.push(sql),
370+
Ok(stmts) => out.extend(stmts),
366371
Err(BuildDdlError::NoChange) => {}
367372
Err(e) => return Err(e),
368373
}

linux/crates/app/src/ui/app/structure.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,12 @@ impl App {
326326
}
327327
}
328328
if wrap_tx && let Err(e) = conn.execute("COMMIT").await {
329+
// COMMIT failure leaves the connection in an
330+
// open transaction. Fire ROLLBACK so the
331+
// pooled connection is reusable; ignore its
332+
// result because the user already has the
333+
// commit-failure to surface.
334+
let _ = conn.execute("ROLLBACK").await;
329335
sender_for_cmd.input(AppMsg::StructureSaveFailed(tab_id, format!("{e}")));
330336
return;
331337
}
@@ -427,6 +433,14 @@ impl App {
427433
self.in_flight_saves.set(self.in_flight_saves.get() - 1);
428434
}
429435
self.structure_saves_in_flight.borrow_mut().remove(&tab_id);
436+
// Match the Browse-side `SaveFailedForTab` handler: a save
437+
// that started from the close-with-pending dialog left this
438+
// tab in `close_after_save`, and the window-close-after-save
439+
// intent in `close_window_after_save`. Both must be cleared
440+
// on failure or the next unrelated SaveCompleted on another
441+
// tab will spuriously close the window.
442+
self.close_after_save.borrow_mut().remove(&tab_id);
443+
self.close_window_after_save.set(false);
430444
if let Some(controller) = self
431445
.workspace_tabs
432446
.borrow()

linux/crates/app/src/ui/app/workspace_tabs.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,13 @@ impl App {
702702
let Some(removed) = removed else {
703703
return;
704704
};
705+
// Drop any close-after-save / window-close-after-save intent
706+
// pinned to this tab. `close_tabs_for_table` (Drop Table)
707+
// calls into here directly without going through the per-tab
708+
// close prompt, so a stale entry would otherwise live on
709+
// forever and cause a future unrelated SaveCompleted to
710+
// spuriously close the window.
711+
self.close_after_save.borrow_mut().remove(&id);
705712
match &removed {
706713
WorkspaceTab::Editor(slot) => {
707714
let _ = slot.controller.sender().send(SqlEditorInput::Cancel);

linux/crates/app/src/ui/browse_tab.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1567,12 +1567,17 @@ impl SimpleComponent for BrowseTab {
15671567
if self.current_columns.is_empty() {
15681568
return;
15691569
}
1570-
let Some(snapshot) = self.current_result.as_ref() else {
1571-
return;
1572-
};
1573-
let Some(source_row) = snapshot.rows.get(row_position as usize) else {
1570+
// Read the source row through the live RowObject at
1571+
// `row_position` in the FilterListModel — NOT by
1572+
// indexing `current_result.rows` directly. The grid's
1573+
// row_position reflects the user's current sort + any
1574+
// active search filter; raw `rows` is fetch order. A
1575+
// sort or filter would otherwise hand us the wrong
1576+
// row's cells.
1577+
let Some(source) = self.row_object_at(row_position) else {
15741578
return;
15751579
};
1580+
let source_cells = source.cells_clone();
15761581
// Clone source values; blank columns whose value is
15771582
// owned by the database (PK, identity / serial,
15781583
// generated). The duplicate is meant to be a *new*
@@ -1586,7 +1591,7 @@ impl SimpleComponent for BrowseTab {
15861591
if col.primary_key || col.is_auto_increment || col.is_generated {
15871592
Value::Null
15881593
} else {
1589-
source_row.get(i).cloned().unwrap_or(Value::Null)
1594+
source_cells.get(i).cloned().unwrap_or(Value::Null)
15901595
}
15911596
})
15921597
.collect();
@@ -2124,7 +2129,12 @@ enum TypeKind {
21242129
/// before bare `timestamp`, and `tinyint(1)` (MySQL bool) must be
21252130
/// matched before generic `tinyint` / `int` patterns.
21262131
fn classify_type(dt: &str) -> TypeKind {
2127-
if matches!(dt, "bool" | "boolean" | "bit" | "tinyint(1)") {
2132+
// Postgres `format_type()` returns "bit(1)" for length-1 BIT
2133+
// columns (not the bare "bit" the original guard expected).
2134+
// Both forms classify as Bool so the cell renders as a checkbox
2135+
// rather than a text editor that rejects "true"/"false" with
2136+
// "Invalid integer".
2137+
if matches!(dt, "bool" | "boolean" | "bit" | "bit(1)" | "tinyint(1)") {
21282138
return TypeKind::Bool;
21292139
}
21302140
if dt.contains("uuid") {

linux/crates/app/src/ui/editor.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,10 +333,29 @@ impl SimpleComponent for SqlEditor {
333333
} else {
334334
Box::pin(std::future::pending::<()>())
335335
};
336+
// The cancel token is the editor's own
337+
// signal channel — the driver does not
338+
// subscribe to it (sqlx has no future-
339+
// drop cancellation hook for Postgres /
340+
// MySQL). When the timeout wins, we
341+
// *also* fire `token.cancel()` so any
342+
// outer logic (pool shutdown, connection
343+
// monitor) sees the same "this query is
344+
// abandoned" signal as a manual Cancel,
345+
// and the future drops on the next poll.
346+
// Even with this, the underlying driver
347+
// call may keep running on the server
348+
// until the network layer notices the
349+
// dropped read; users on long timeouts
350+
// should restart the connection.
351+
let token_for_timeout = token.clone();
336352
let msg = tokio::select! {
337353
biased;
338354
_ = token.cancelled() => SqlEditorInput::ShowCancelled,
339-
_ = timeout => SqlEditorInput::ShowTimedOut(timeout_secs),
355+
_ = timeout => {
356+
token_for_timeout.cancel();
357+
SqlEditorInput::ShowTimedOut(timeout_secs)
358+
}
340359
outcomes = run_statements(conn, statements) => {
341360
let total_ms: u128 = outcomes.iter().map(|o| o.elapsed_ms).sum();
342361
let n_ok = outcomes
@@ -447,7 +466,7 @@ impl SimpleComponent for SqlEditor {
447466
let page = adw::StatusPage::builder()
448467
.title(crate::tr!("Query timed out"))
449468
.description(&reason)
450-
.icon_name("appointment-soon-symbolic")
469+
.icon_name("dialog-warning-symbolic")
451470
.vexpand(true)
452471
.build();
453472
self.results_holder.append(&page);

linux/crates/app/src/ui/structure_tab.rs

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
//! sets the SourceView buffer text. The Save button reads
2222
//! `materialize` again and dispatches `ExecuteTransaction` to App.
2323
24-
use std::cell::RefCell;
24+
use std::cell::{Cell, RefCell};
2525
use std::rc::Rc;
2626

2727
use relm4::adw::prelude::*;
@@ -175,8 +175,11 @@ pub struct StructureTab {
175175
last_dirty: Rc<RefCell<bool>>,
176176
/// Suppress reentrant rebuilds: setting Entry text triggers
177177
/// changed-signal echoes that would push duplicate RenameTable
178-
/// ops onto the tracker.
179-
suppress_emit: Rc<RefCell<bool>>,
178+
/// ops onto the tracker. `Cell` (not `RefCell`) because GTK can
179+
/// fire those signals re-entrantly during `clear_box` while
180+
/// another `borrow_mut` is still live on the stack — a
181+
/// `RefCell::borrow` racing it would panic.
182+
suppress_emit: Rc<Cell<bool>>,
180183
/// Monotonic counter for the "Add Column" placeholder name. Using
181184
/// `vec.len() + 1` produced duplicate `column_1` after the user
182185
/// removed a column; a forever-incrementing counter avoids the
@@ -287,7 +290,7 @@ impl StructureTab {
287290
// mode reload would land 7 columns × ~3 fields ≈ 19 spurious
288291
// pending changes. We re-enable emit on the next idle tick so
289292
// legitimate user input afterwards flows through.
290-
*self.suppress_emit.borrow_mut() = true;
293+
self.suppress_emit.set(true);
291294
clear_box(&self.columns_box);
292295
let driver_id = self.driver_id.clone();
293296
let list = boxed_list();
@@ -308,7 +311,7 @@ impl StructureTab {
308311
self.columns_box.append(&wrap_button_in_row(add_button));
309312
let suppress = self.suppress_emit.clone();
310313
relm4::gtk::glib::idle_add_local_once(move || {
311-
*suppress.borrow_mut() = false;
314+
suppress.set(false);
312315
});
313316
}
314317

@@ -435,7 +438,7 @@ fn build_column_row(
435438
col: &DraftColumn,
436439
driver_id: &str,
437440
sender: ComponentSender<StructureTab>,
438-
suppress_emit: Rc<RefCell<bool>>,
441+
suppress_emit: Rc<Cell<bool>>,
439442
) -> gtk::Widget {
440443
let row = gtk::Box::builder()
441444
.orientation(gtk::Orientation::Horizontal)
@@ -459,7 +462,7 @@ fn build_column_row(
459462
let sender_for_name = sender.clone();
460463
let suppress_for_name = suppress_emit.clone();
461464
name_entry.connect_changed(move |e| {
462-
if *suppress_for_name.borrow() {
465+
if suppress_for_name.get() {
463466
return;
464467
}
465468
sender_for_name.input(StructureTabInput::ColumnEdited {
@@ -481,7 +484,7 @@ fn build_column_row(
481484
let sender_for_type = sender.clone();
482485
let suppress_for_type = suppress_emit.clone();
483486
entry.connect_changed(move |e| {
484-
if *suppress_for_type.borrow() {
487+
if suppress_for_type.get() {
485488
return;
486489
}
487490
sender_for_type.input(StructureTabInput::ColumnEdited {
@@ -509,7 +512,7 @@ fn build_column_row(
509512
let sender_for_null = sender.clone();
510513
let suppress_for_null = suppress_emit.clone();
511514
nullable_check.connect_toggled(move |c| {
512-
if *suppress_for_null.borrow() {
515+
if suppress_for_null.get() {
513516
return;
514517
}
515518
sender_for_null.input(StructureTabInput::ColumnEdited {
@@ -532,7 +535,7 @@ fn build_column_row(
532535
let sender_for_default = sender.clone();
533536
let suppress_for_default = suppress_emit.clone();
534537
default_entry.connect_changed(move |e| {
535-
if *suppress_for_default.borrow() {
538+
if suppress_for_default.get() {
536539
return;
537540
}
538541
let text = e.text().to_string();
@@ -553,7 +556,7 @@ fn build_column_row(
553556
let sender_for_pk = sender.clone();
554557
let suppress_for_pk = suppress_emit.clone();
555558
pk_check.connect_toggled(move |c| {
556-
if *suppress_for_pk.borrow() {
559+
if suppress_for_pk.get() {
557560
return;
558561
}
559562
sender_for_pk.input(StructureTabInput::ColumnEdited {
@@ -572,7 +575,7 @@ fn build_column_row(
572575
let sender_for_auto = sender.clone();
573576
let suppress_for_auto = suppress_emit;
574577
auto_check.connect_toggled(move |c| {
575-
if *suppress_for_auto.borrow() {
578+
if suppress_for_auto.get() {
576579
return;
577580
}
578581
sender_for_auto.input(StructureTabInput::ColumnEdited {
@@ -1193,11 +1196,11 @@ impl SimpleComponent for StructureTab {
11931196

11941197
// Wire the table-name entry to push RenameTable / propagate to
11951198
// the model.
1196-
let suppress_emit = Rc::new(RefCell::new(false));
1199+
let suppress_emit = Rc::new(Cell::new(false));
11971200
let sender_for_name = sender.clone();
11981201
let suppress_for_name = suppress_emit.clone();
11991202
name_entry.connect_changed(move |e| {
1200-
if *suppress_for_name.borrow() {
1203+
if suppress_for_name.get() {
12011204
return;
12021205
}
12031206
sender_for_name.input(StructureTabInput::TableNameEdited(e.text().to_string()));
@@ -1648,9 +1651,9 @@ impl SimpleComponent for StructureTab {
16481651
if let Some(name) = new_table_name {
16491652
*self.mode.borrow_mut() = StructureMode::Edit;
16501653
*self.table_name.borrow_mut() = name.clone();
1651-
*self.suppress_emit.borrow_mut() = true;
1654+
self.suppress_emit.set(true);
16521655
self.name_entry.set_text(&name);
1653-
*self.suppress_emit.borrow_mut() = false;
1656+
self.suppress_emit.set(false);
16541657
self.drop_button.set_visible(true);
16551658
// New mode → Edit mode: the prominent name row at
16561659
// the top of the editor was a New-mode-only

0 commit comments

Comments
 (0)