From b73e4893c2020083cd27e6c05a02a58ff28627dd Mon Sep 17 00:00:00 2001 From: Rui Nelson <7100905+RuiNelson@users.noreply.github.com> Date: Sun, 31 May 2026 00:33:49 +0100 Subject: [PATCH 1/4] fix: close SQLite connections deterministically Add an explicit Connection.close() API that synchronously closes the underlying sqlite3 handle and only clears it after sqlite3_close returns SQLITE_OK. Previously Connection.deinit called sqlite3_close directly and ignored its result. If SQLite still had internal statements, BLOB handles, or backup objects alive, sqlite3_close could return SQLITE_BUSY and leave the database connection open. That made the Swift object appear destroyed while SQLite still held file descriptors, which could trigger iOS filesystem warnings when temporary database files were unlinked shortly afterwards. The deinitializer now delegates to close(), preserving the existing automatic cleanup path while avoiding silent handle leaks when explicit close succeeds. --- Sources/SQLite/Core/Connection.swift | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Sources/SQLite/Core/Connection.swift b/Sources/SQLite/Core/Connection.swift index 7529c678..b2a47050 100644 --- a/Sources/SQLite/Core/Connection.swift +++ b/Sources/SQLite/Core/Connection.swift @@ -161,7 +161,7 @@ public final class Connection { } deinit { - sqlite3_close(handle) + try? close() } // MARK: - @@ -186,6 +186,26 @@ public final class Connection { Int(sqlite3_total_changes(handle)) } + /// Closes the database connection immediately. + /// + /// Callers should release or exhaust prepared statements, BLOB handles, and backup operations before closing. If + /// SQLite reports that the connection is still busy, this method throws and keeps the connection handle open. + public func close() throws { + try sync { + guard let handle = _handle else { + return + } + + let resultCode = sqlite3_close(handle) + if resultCode == SQLITE_OK { + _handle = nil + return + } + + try check(resultCode) + } + } + /// Whether or not the database will return extended error codes when errors are handled. public var usesExtendedErrorCodes: Bool = false { didSet { From 69bd77f2e7eb9e6078d4c2705ba3ee885b553e2e Mon Sep 17 00:00:00 2001 From: Rui Nelson <7100905+RuiNelson@users.noreply.github.com> Date: Sun, 31 May 2026 00:45:15 +0100 Subject: [PATCH 2/4] test: cover deterministic SQLite connection close Add regression coverage for the explicit Connection.close() API. The new tests verify that close() is idempotent, reports SQLITE_BUSY while a prepared statement is still active, leaves the connection usable after a failed close attempt, and succeeds once the statement has been released. --- Tests/SQLiteTests/Core/ConnectionTests.swift | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Tests/SQLiteTests/Core/ConnectionTests.swift b/Tests/SQLiteTests/Core/ConnectionTests.swift index 2da096bf..4ef7c6a3 100644 --- a/Tests/SQLiteTests/Core/ConnectionTests.swift +++ b/Tests/SQLiteTests/Core/ConnectionTests.swift @@ -71,6 +71,33 @@ class ConnectionTests: SQLiteTestCase { XCTAssertTrue(db.readonly) } + func test_close_closesConnectionAndIsIdempotent() throws { + let db = try Connection() + + try db.close() + try db.close() + } + + func test_close_whenPreparedStatementIsActiveThrowsBusyAndCanBeRetried() throws { + let db = try Connection() + var statement: Statement? = try db.prepare("SELECT 1") + + try withExtendedLifetime(statement) { + XCTAssertThrowsError(try db.close()) { error in + if case SQLite.Result.error(_, let code, _) = error { + XCTAssertEqual(SQLITE_BUSY, code) + } else { + XCTFail("unexpected error: \(error)") + } + } + } + + XCTAssertEqual(1, try db.scalar("SELECT 1") as? Int64) + + statement = nil + try db.close() + } + func test_changes_returnsZeroOnNewConnections() { XCTAssertEqual(0, db.changes) } From 306f83a04d30352a0b389f3141b4358b5856f156 Mon Sep 17 00:00:00 2001 From: Rui Nelson <7100905+RuiNelson@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:09:01 +0100 Subject: [PATCH 3/4] docs: document Connection.close() in Index.md Co-authored-by: Cursor --- Documentation/Index.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Documentation/Index.md b/Documentation/Index.md index 18a41771..ca1332d7 100644 --- a/Documentation/Index.md +++ b/Documentation/Index.md @@ -16,6 +16,7 @@ - [In-Memory Databases](#in-memory-databases) - [URI parameters](#uri-parameters) - [Thread-Safety](#thread-safety) + - [Closing a Connection](#closing-a-connection) - [Building Type-Safe SQL](#building-type-safe-sql) - [Expressions](#expressions) - [Compound Expressions](#compound-expressions) @@ -490,6 +491,22 @@ db.busyHandler({ tries in > errors, you may be trying to access the same database simultaneously from > multiple connections. +#### Closing a Connection + +Call `close()` when you need the database connection closed before the +`Connection` goes out of scope—for example, before removing a temporary +database file from disk. + +```swift +try db.close() +``` + +Release prepared statements, BLOB handles, and [backup](#online-database-backup) +objects before closing; otherwise `close()` may throw. + +> _Note:_ On iOS, call `close()` (and handle any errors) before deleting temporary +> database files to avoid filesystem warnings. + ## Building Type-Safe SQL From b803acebb15cc641ae4a58ae123ea7a78915a0fe Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Fri, 26 Jun 2026 15:51:42 +0200 Subject: [PATCH 4/4] Add a precondition for an active handle --- Makefile | 2 +- Sources/SQLite/Core/Connection.swift | 7 +++++-- Tests/SQLiteTests/Core/ConnectionTests.swift | 22 +++++++++++--------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index 0bde22d5..807e42ab 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ IOS_VERSION = 16.4 SWIFTLINT_VERSION=0.63.1 SWIFTLINT=bin/swiftlint-$(SWIFTLINT_VERSION) SWIFTLINT_URL=https://github.com/realm/SwiftLint/releases/download/$(SWIFTLINT_VERSION)/portable_swiftlint.zip -XCBEAUTIFY_VERSION=3.1.2 +XCBEAUTIFY_VERSION=3.2.1 XCBEAUTIFY=bin/xcbeautify-$(XCBEAUTIFY_VERSION) ifeq ($(shell uname), Linux) XCBEAUTIFY_PLATFORM=x86_64-unknown-linux-gnu.tar.xz diff --git a/Sources/SQLite/Core/Connection.swift b/Sources/SQLite/Core/Connection.swift index b2a47050..a6199204 100644 --- a/Sources/SQLite/Core/Connection.swift +++ b/Sources/SQLite/Core/Connection.swift @@ -85,7 +85,10 @@ public final class Connection { } } - public var handle: OpaquePointer { _handle! } + public var handle: OpaquePointer { + precondition(_handle != nil, "connection already closed") + return _handle! + } fileprivate var _handle: OpaquePointer? @@ -187,7 +190,7 @@ public final class Connection { } /// Closes the database connection immediately. - /// + /// Normally not needed: the underlying connection is automatically closed when `Connection` goes out of scope. /// Callers should release or exhaust prepared statements, BLOB handles, and backup operations before closing. If /// SQLite reports that the connection is still busy, this method throws and keeps the connection handle open. public func close() throws { diff --git a/Tests/SQLiteTests/Core/ConnectionTests.swift b/Tests/SQLiteTests/Core/ConnectionTests.swift index 4ef7c6a3..9b3534fd 100644 --- a/Tests/SQLiteTests/Core/ConnectionTests.swift +++ b/Tests/SQLiteTests/Core/ConnectionTests.swift @@ -80,21 +80,23 @@ class ConnectionTests: SQLiteTestCase { func test_close_whenPreparedStatementIsActiveThrowsBusyAndCanBeRetried() throws { let db = try Connection() + // open statement var statement: Statement? = try db.prepare("SELECT 1") - try withExtendedLifetime(statement) { - XCTAssertThrowsError(try db.close()) { error in - if case SQLite.Result.error(_, let code, _) = error { - XCTAssertEqual(SQLITE_BUSY, code) - } else { - XCTFail("unexpected error: \(error)") - } + XCTAssertThrowsError(try db.close()) { error in + if case SQLite.Result.error(_, let code, _) = error { + // there's an open statement + XCTAssertEqual(SQLITE_BUSY, code) + } else { + XCTFail("unexpected error: \(error)") } } - + // db still operational XCTAssertEqual(1, try db.scalar("SELECT 1") as? Int64) - - statement = nil + // statement is closed, no more references + if statement != nil { + statement = nil + } try db.close() }