Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [1.0.14] - 2026-04-15

### Fixed

- **Stale `cloudsync_table_settings` crash**: Reopening a database that had its base table and `<table>_cloudsync` meta-table dropped without calling `cloudsync_cleanup` crashed with a double-free on `sqlite3_close`. Two bugs were involved: (1) `cloudsync_dbversion_rebuild` returned `DBRES_NOMEM` when `cloudsync_dbversion_build_query` yielded a NULL SQL string (stale row in `cloudsync_table_settings` but no matching `*_cloudsync` table in `sqlite_master`), failing extension init; (2) on init failure `dbsync_register_functions` manually freed the context that SQLite already owned via the `cloudsync_version` destructor, causing a double-free when the connection was later closed. `cloudsync_dbversion_rebuild` now treats a NULL build query the same as `count == 0` (no prepared statement, db_version stays at the minimum and is rebuilt on the next `cloudsync_init`), and the manual free in the error path has been removed.

### Added

- Unit test `do_test_stale_table_settings_dropped_meta` (Stale Table Settings Dropped Meta) covering the drop-base-table + drop-meta-table + reopen scenario.

## [1.0.13] - 2026-04-14

### Fixed
Expand Down
35 changes: 22 additions & 13 deletions src/cloudsync.c
Original file line number Diff line number Diff line change
Expand Up @@ -363,11 +363,11 @@ void dbvm_reset (dbvm_t *stmt) {

// MARK: - Database Version -

char *cloudsync_dbversion_build_query (cloudsync_context *data) {
int cloudsync_dbversion_build_query (cloudsync_context *data, char **sql_out) {
// this function must be manually called each time tables changes
// because the query plan changes too and it must be re-prepared
// unfortunately there is no other way

// we need to execute a query like:
/*
SELECT max(version) as version FROM (
Expand All @@ -380,29 +380,38 @@ char *cloudsync_dbversion_build_query (cloudsync_context *data) {
SELECT value as version FROM cloudsync_settings WHERE key = 'pre_alter_dbversion'
)
*/

// the good news is that the query can be computed in SQLite without the need to do any extra computation from the host language

char *value = NULL;
int rc = database_select_text(data, SQL_DBVERSION_BUILD_QUERY, &value);
return (rc == DBRES_OK) ? value : NULL;

*sql_out = NULL;
return database_select_text(data, SQL_DBVERSION_BUILD_QUERY, sql_out);
}

int cloudsync_dbversion_rebuild (cloudsync_context *data) {
if (data->db_version_stmt) {
databasevm_finalize(data->db_version_stmt);
data->db_version_stmt = NULL;
}

int64_t count = dbutils_table_settings_count_tables(data);
if (count == 0) return DBRES_OK;
else if (count == -1) return cloudsync_set_dberror(data);

char *sql = cloudsync_dbversion_build_query(data);
if (!sql) return DBRES_NOMEM;

char *sql = NULL;
int rc = cloudsync_dbversion_build_query(data, &sql);
if (rc != DBRES_OK) return cloudsync_set_dberror(data);

// A NULL SQL with rc == OK means the generator produced a NULL row:
// sqlite_master has no *_cloudsync meta-tables (for example, the user
// dropped the base table and its meta-table without calling
// cloudsync_cleanup, leaving stale cloudsync_table_settings rows).
// Treat this the same as count == 0: no prepared statement, db_version
// stays at the minimum and will be rebuilt on the next cloudsync_init.
// Genuine errors from database_select_text are handled above.
if (!sql) return DBRES_OK;
DEBUG_SQL("db_version_stmt: %s", sql);
int rc = databasevm_prepare(data, sql, (void **)&data->db_version_stmt, DBFLAG_PERSISTENT);

rc = databasevm_prepare(data, sql, (void **)&data->db_version_stmt, DBFLAG_PERSISTENT);
DEBUG_STMT("db_version_stmt %p", data->db_version_stmt);
cloudsync_memory_free(sql);
return rc;
Expand Down
2 changes: 1 addition & 1 deletion src/cloudsync.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
extern "C" {
#endif

#define CLOUDSYNC_VERSION "1.0.13"
#define CLOUDSYNC_VERSION "1.0.14"
#define CLOUDSYNC_MAX_TABLENAME_LEN 512

#define CLOUDSYNC_VALUE_NOTSET -1
Expand Down
5 changes: 4 additions & 1 deletion src/sqlite/cloudsync_sqlite.c
Original file line number Diff line number Diff line change
Expand Up @@ -1470,7 +1470,10 @@ int dbsync_register_functions (sqlite3 *db, char **pzErrMsg) {
// load config, if exists
if (cloudsync_config_exists(data)) {
if (cloudsync_context_init(data) == NULL) {
cloudsync_context_free(data);
// Do not free ctx here: it is already owned by the cloudsync_version
// function (registered above with cloudsync_context_free as its
// destructor). SQLite will release it when the connection is closed.
// Freeing it manually would cause a double-free on sqlite3_close.
if (pzErrMsg) *pzErrMsg = sqlite3_mprintf("An error occurred while trying to initialize context");
return SQLITE_ERROR;
}
Expand Down
123 changes: 123 additions & 0 deletions test/postgresql/51_stale_table_settings_dropped_meta.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
-- 'Stale cloudsync_table_settings with dropped meta-table'
-- Mirrors the SQLite unit test: Stale Table Settings Dropped Meta
--
-- When a user drops a tracked table and its <table>_cloudsync meta-table
-- manually (without calling cloudsync_cleanup), cloudsync_table_settings is
-- left with stale rows while pg_tables no longer has any matching
-- *_cloudsync table. Before the fix in cloudsync_dbversion_rebuild, opening a
-- new backend and calling any cloudsync function caused
-- cloudsync_dbversion_build_query to produce a NULL SQL string (string_agg
-- over zero rows), which was misreported as DBRES_NOMEM, making
-- cloudsync_context_init fail and every cloudsync_* call ereport ERROR.

\set testid '51'
\ir helper_test_init.sql

\connect postgres
\ir helper_psql_conn_setup.sql

DROP DATABASE IF EXISTS cloudsync_test_51;
CREATE DATABASE cloudsync_test_51;

\connect cloudsync_test_51
\ir helper_psql_conn_setup.sql

CREATE EXTENSION IF NOT EXISTS cloudsync;

-- Phase 1: create a tracked table and initialize cloudsync on it.
DROP TABLE IF EXISTS stale_doc CASCADE;
CREATE TABLE stale_doc (id TEXT PRIMARY KEY NOT NULL, body TEXT);
SELECT cloudsync_init('stale_doc', 'CLS', 1) AS _init \gset

-- Sanity: the meta-table exists and cloudsync_table_settings has a row for it.
SELECT count(*) AS meta_exists FROM pg_tables WHERE tablename = 'stale_doc_cloudsync' \gset
SELECT (:meta_exists::int = 1) AS meta_exists_ok \gset
\if :meta_exists_ok
\echo [PASS] (:testid) stale_doc_cloudsync meta-table created
\else
\echo [FAIL] (:testid) expected stale_doc_cloudsync to exist
SELECT (:fail::int + 1) AS fail \gset
\endif

SELECT count(*) AS settings_rows FROM cloudsync_table_settings WHERE tbl_name = 'stale_doc' \gset
SELECT (:settings_rows::int > 0) AS settings_rows_ok \gset
\if :settings_rows_ok
\echo [PASS] (:testid) cloudsync_table_settings has row(s) for stale_doc
\else
\echo [FAIL] (:testid) expected cloudsync_table_settings row for stale_doc
SELECT (:fail::int + 1) AS fail \gset
\endif

-- Phase 2: drop BOTH the base table and the meta-table without calling
-- cloudsync_cleanup. cloudsync_table_settings still references stale_doc,
-- but pg_tables has no *_cloudsync tables at all now.
DROP TABLE stale_doc;
DROP TABLE stale_doc_cloudsync;

SELECT count(*) AS cloudsync_meta_tables FROM pg_tables WHERE tablename LIKE '%_cloudsync' \gset
SELECT (:cloudsync_meta_tables::int = 0) AS no_meta_ok \gset
\if :no_meta_ok
\echo [PASS] (:testid) no *_cloudsync meta-tables remain in pg_tables
\else
\echo [FAIL] (:testid) expected zero *_cloudsync tables, got :cloudsync_meta_tables
SELECT (:fail::int + 1) AS fail \gset
\endif

-- Phase 3: reconnect to force a fresh backend. pg_cloudsync_context is a
-- static per-process pointer, so a new backend means
-- cloudsync_pg_context_init runs again on the next cloudsync call — which
-- is exactly what used to fail under this bug.
\connect cloudsync_test_51
\ir helper_psql_conn_setup.sql

-- cloudsync_version is a pure function that does not touch the context, so
-- this call cannot fail even with the bug present. It's here only as a
-- trivial smoke check that the extension is still loadable.
SELECT cloudsync_version() IS NOT NULL AS version_ok \gset
\if :version_ok
\echo [PASS] (:testid) cloudsync_version() reachable after reopen
\else
\echo [FAIL] (:testid) cloudsync_version() failed after reopen
SELECT (:fail::int + 1) AS fail \gset
\endif

-- The real check: calling a function that goes through get_cloudsync_context()
-- must succeed. Before the fix, cloudsync_dbversion_rebuild returned
-- DBRES_NOMEM here because SQL_DBVERSION_BUILD_QUERY's string_agg over zero
-- rows produced a NULL SQL string, and the whole init path would ereport
-- ERROR — any cloudsync_* call below would abort the script.
CREATE TABLE stale_doc2 (id TEXT PRIMARY KEY NOT NULL, body TEXT);
SELECT cloudsync_init('stale_doc2', 'CLS', 1) AS _init2 \gset

-- The new table's meta-table exists. If cloudsync_init failed (pre-fix
-- behavior) this count will be 0, covering both the init-rc check and the
-- meta-table creation in a single assertion.
SELECT count(*) AS meta2_exists FROM pg_tables WHERE tablename = 'stale_doc2_cloudsync' \gset
SELECT (:meta2_exists::int = 1) AS meta2_exists_ok \gset
\if :meta2_exists_ok
\echo [PASS] (:testid) cloudsync_init succeeded and stale_doc2_cloudsync was created
\else
\echo [FAIL] (:testid) expected stale_doc2_cloudsync to exist after cloudsync_init
SELECT (:fail::int + 1) AS fail \gset
\endif

-- An insert via the new cloudsync_init'd table should produce a cloudsync
-- metadata entry — confirming the context is fully functional.
INSERT INTO stale_doc2 (id, body) VALUES ('a', 'hello');

SELECT count(*) AS meta_rows FROM stale_doc2_cloudsync \gset
SELECT (:meta_rows::int > 0) AS meta_rows_ok \gset
\if :meta_rows_ok
\echo [PASS] (:testid) stale_doc2_cloudsync has metadata after insert
\else
\echo [FAIL] (:testid) expected metadata rows in stale_doc2_cloudsync
SELECT (:fail::int + 1) AS fail \gset
\endif

-- Cleanup
\ir helper_test_cleanup.sql
\if :should_cleanup
DROP DATABASE IF EXISTS cloudsync_test_51;
\else
\echo [INFO] !!!!!
\endif
1 change: 1 addition & 0 deletions test/postgresql/full_test.sql
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
\ir 48_row_filter_multi_table.sql
\ir 49_row_filter_prefill.sql
\ir 50_block_lww_existing_data.sql
\ir 51_stale_table_settings_dropped_meta.sql

-- 'Test summary'
\echo '\nTest summary:'
Expand Down
Loading
Loading