From e25ba0c9aabd5ae51e42544cb7660fd98ae1a968 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 22 Oct 2026 10:39:04 -0400 Subject: [PATCH 1/5] Allow configuring the SQLite busy timeout with SQLITE_BUSY_TIMEOUT --- packages/mysql-on-sqlite/README.md | 1 + .../src/sqlite/class-wp-mysql-on-sqlite.php | 3 ++ .../WP_MySQL_On_SQLite_Concurrency_Tests.php | 29 +++++++++++++++++++ .../WP_MySQL_On_SQLite_PDO_API_Tests.php | 28 ++++++++++++++++++ .../README.md | 16 ++++++++++ .../readme.txt | 11 +++++++ .../wp-includes/sqlite/class-wp-sqlite-db.php | 1 + .../wp-includes/sqlite/install-functions.php | 1 + 8 files changed, 90 insertions(+) diff --git a/packages/mysql-on-sqlite/README.md b/packages/mysql-on-sqlite/README.md index 2c08bec56..2f36fd642 100644 --- a/packages/mysql-on-sqlite/README.md +++ b/packages/mysql-on-sqlite/README.md @@ -76,6 +76,7 @@ version and configuring the SQLite connection: | --- | --- | --- | | `mysql_version` | MySQL version to emulate, represented as an integer | `80038` | | `sqlite_pdo` | Existing PDO SQLite connection | A new connection for `path` | +| `sqlite_timeout` | SQLite busy timeout in whole seconds | `10` | | `sqlite_journal_mode` | SQLite journal mode | `WAL` | | `sqlite_synchronous` | SQLite synchronous setting | `NORMAL` in WAL mode; otherwise the SQLite default | diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index 57d72f70c..701e0360a 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -795,6 +795,8 @@ class WP_MySQL_On_SQLite extends PDO { * * @type int $mysql_version Optional. MySQL version to emulate. Default 80038. * @type PDO|null $sqlite_pdo Optional. Existing PDO SQLite connection. + * @type int|null $sqlite_timeout Optional. SQLite busy timeout in whole seconds. + * Default null (10 seconds). * @type string|null $sqlite_journal_mode Optional. SQLite journal mode. Default 'WAL'. * @type string|int|null $sqlite_synchronous Optional. SQLite synchronous setting. * } @@ -876,6 +878,7 @@ function ( $key ) { } $connection_options = array( + 'timeout' => $options['sqlite_timeout'] ?? null, 'journal_mode' => $options['sqlite_journal_mode'] ?? null, 'synchronous' => $options['sqlite_synchronous'] ?? null, 'pdo_options' => $connection_pdo_options, diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php index b693fa1b5..c3f9f3e82 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php @@ -178,6 +178,35 @@ private function assertReadOnlyQuerySucceedsUnderWriteLock( string $query ): voi } } + public function testWriteQueryFailsImmediatelyWithZeroBusyTimeoutWhileAnotherConnectionHoldsWriteLock(): void { + // Connection A: set up the database and hold a write transaction. + $conn_a = new WP_SQLite_Connection( array( 'path' => $this->db_path ) ); + $driver_a = $this->create_driver( $conn_a ); + $driver_a->query( 'CREATE TABLE t (id INT, name VARCHAR(255))' ); + $driver_a->query( "INSERT INTO t VALUES (1, 'Alice')" ); + + // Simulate another PHP process holding a write transaction. + $conn_a->get_pdo()->exec( 'BEGIN IMMEDIATE' ); + + try { + // Connection B with a zero busy timeout — any lock conflict fails + // immediately instead of waiting for the default timeout. + $driver_b = new WP_MySQL_On_SQLite( + sprintf( 'mysql-on-sqlite:path=%s;dbname=wp', $this->db_path ), + null, + null, + array( 'sqlite_timeout' => 0 ) + ); + + $this->expectException( PDOException::class ); + $this->expectExceptionMessage( 'database is locked' ); + + $driver_b->query( "INSERT INTO t VALUES (2, 'Bob')" ); + } finally { + $conn_a->get_pdo()->exec( 'ROLLBACK' ); + } + } + private function create_in_memory_driver(): WP_MySQL_On_SQLite { $pdo_class = PHP_VERSION_ID >= 80400 ? Pdo\Sqlite::class : PDO::class; $pdo = new $pdo_class( 'sqlite::memory:' ); diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php index ceef0137c..bf7c8d7c3 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php @@ -379,6 +379,34 @@ public function test_journal_mode_and_synchronous_driver_options(): void { } } + public function test_timeout_driver_option_sets_sqlite_busy_timeout(): void { + $driver = new WP_MySQL_On_SQLite( + 'mysql-on-sqlite:path=:memory:;dbname=wp', + null, + null, + array( + 'sqlite_timeout' => 5, + ) + ); + + // The PDO SQLite driver does not support reading "PDO::ATTR_TIMEOUT" + // back with "getAttribute()", but the SQLite busy timeout reflects it + // in milliseconds. + $this->assertSame( + 5000, + (int) $driver->get_sqlite_pdo()->query( 'PRAGMA busy_timeout' )->fetchColumn() + ); + } + + public function test_timeout_defaults_to_the_connection_default(): void { + $driver = new WP_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp' ); + + $this->assertSame( + WP_SQLite_Connection::DEFAULT_SQLITE_TIMEOUT * 1000, + (int) $driver->get_sqlite_pdo()->query( 'PRAGMA busy_timeout' )->fetchColumn() + ); + } + public function test_query(): void { $result = $this->driver->query( "SELECT 1, 'abc'" ); $this->assertInstanceOf( WP_MySQL_On_SQLite_Statement::class, $result ); diff --git a/packages/plugin-sqlite-database-integration/README.md b/packages/plugin-sqlite-database-integration/README.md index 756ed167b..a875b6a8d 100644 --- a/packages/plugin-sqlite-database-integration/README.md +++ b/packages/plugin-sqlite-database-integration/README.md @@ -58,6 +58,22 @@ for an in-memory SQLite database. The database directory must be writable by PHP Integrations should always read `DB_PATH` after WordPress loads instead of assuming a fixed database path. +## Busy timeout + +SQLite allows only one writer at a time. When a write cannot start because another +connection holds the write lock, SQLite waits up to the busy timeout before failing +with a "database is locked" error. The default timeout is 10 seconds. + +To change the timeout, define the `SQLITE_BUSY_TIMEOUT` constant in `wp-config.php` +with a value in whole seconds: + +```php +// Wait up to 30 seconds for the write lock before failing. +define( 'SQLITE_BUSY_TIMEOUT', 30 ); +``` + +Set it to `0` to fail immediately instead of waiting. + ## Development Run these commands from the repository root: diff --git a/packages/plugin-sqlite-database-integration/readme.txt b/packages/plugin-sqlite-database-integration/readme.txt index ac220b9c4..9c8c5904a 100644 --- a/packages/plugin-sqlite-database-integration/readme.txt +++ b/packages/plugin-sqlite-database-integration/readme.txt @@ -73,6 +73,17 @@ To properly **secure the SQLite database**, define the `DB_PATH` constant to an The value of `DB_PATH` must be an absolute path to the SQLite database file, or `:memory:` for an in-memory SQLite database. The database directory must be writable by PHP. Changing `DB_PATH` selects a different database without moving an existing one. += How can I configure the SQLite busy timeout? = + +SQLite allows only one writer at a time. When a write query cannot start because another connection holds the write lock, SQLite waits up to the busy timeout before failing with a "database is locked" error. The default timeout is 10 seconds. + +To change the timeout, define the `SQLITE_BUSY_TIMEOUT` constant in `wp-config.php` with a value in whole seconds: + + // Wait up to 30 seconds for the write lock before failing. + define( 'SQLITE_BUSY_TIMEOUT', 30 ); + +Set it to `0` to fail immediately instead of waiting. + = What does the plugin require? = In addition to the WordPress and PHP versions listed above, the plugin requires the PDO SQLite PHP extension and SQLite 3.37.0 or newer. The setup screen also needs write access to the `wp-content` directory and will detect conflicting database drop-ins. diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php index adcbb5c6f..5317d10f8 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php @@ -453,6 +453,7 @@ public function db_connect( $allow_bail = true ) { try { $options = array( 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, + 'sqlite_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? (int) SQLITE_BUSY_TIMEOUT : null, ); $dbh = new WP_MySQL_On_SQLite( sprintf( diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php index 7e6610fad..de95f786b 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php @@ -30,6 +30,7 @@ function sqlite_make_db_sqlite() { null, array( 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, + 'sqlite_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? (int) SQLITE_BUSY_TIMEOUT : null, ) ); $translator->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO From 4d66fa259c8276dfd3efeb114d201d25fdcb294b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Tue, 27 Oct 2026 15:11:33 +0100 Subject: [PATCH 2/5] Remove the busy timeout docs from the plugin readmes SQLITE_BUSY_TIMEOUT is a small configuration detail. Like SQLITE_JOURNAL_MODE, it will be documented with the other configuration options later. --- .../plugin-sqlite-database-integration/README.md | 16 ---------------- .../readme.txt | 11 ----------- 2 files changed, 27 deletions(-) diff --git a/packages/plugin-sqlite-database-integration/README.md b/packages/plugin-sqlite-database-integration/README.md index a875b6a8d..756ed167b 100644 --- a/packages/plugin-sqlite-database-integration/README.md +++ b/packages/plugin-sqlite-database-integration/README.md @@ -58,22 +58,6 @@ for an in-memory SQLite database. The database directory must be writable by PHP Integrations should always read `DB_PATH` after WordPress loads instead of assuming a fixed database path. -## Busy timeout - -SQLite allows only one writer at a time. When a write cannot start because another -connection holds the write lock, SQLite waits up to the busy timeout before failing -with a "database is locked" error. The default timeout is 10 seconds. - -To change the timeout, define the `SQLITE_BUSY_TIMEOUT` constant in `wp-config.php` -with a value in whole seconds: - -```php -// Wait up to 30 seconds for the write lock before failing. -define( 'SQLITE_BUSY_TIMEOUT', 30 ); -``` - -Set it to `0` to fail immediately instead of waiting. - ## Development Run these commands from the repository root: diff --git a/packages/plugin-sqlite-database-integration/readme.txt b/packages/plugin-sqlite-database-integration/readme.txt index 9c8c5904a..ac220b9c4 100644 --- a/packages/plugin-sqlite-database-integration/readme.txt +++ b/packages/plugin-sqlite-database-integration/readme.txt @@ -73,17 +73,6 @@ To properly **secure the SQLite database**, define the `DB_PATH` constant to an The value of `DB_PATH` must be an absolute path to the SQLite database file, or `:memory:` for an in-memory SQLite database. The database directory must be writable by PHP. Changing `DB_PATH` selects a different database without moving an existing one. -= How can I configure the SQLite busy timeout? = - -SQLite allows only one writer at a time. When a write query cannot start because another connection holds the write lock, SQLite waits up to the busy timeout before failing with a "database is locked" error. The default timeout is 10 seconds. - -To change the timeout, define the `SQLITE_BUSY_TIMEOUT` constant in `wp-config.php` with a value in whole seconds: - - // Wait up to 30 seconds for the write lock before failing. - define( 'SQLITE_BUSY_TIMEOUT', 30 ); - -Set it to `0` to fail immediately instead of waiting. - = What does the plugin require? = In addition to the WordPress and PHP versions listed above, the plugin requires the PDO SQLite PHP extension and SQLite 3.37.0 or newer. The setup screen also needs write access to the `wp-content` directory and will detect conflicting database drop-ins. From 0e21f385ce14493e62cabe264cc2611e96cc2b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Tue, 27 Oct 2026 15:11:50 +0100 Subject: [PATCH 3/5] Rename the busy timeout option and use milliseconds Rename the sqlite_timeout driver option to sqlite_busy_timeout and the connection timeout option to busy_timeout. Both now take milliseconds, and the connection applies them with PRAGMA busy_timeout. SQLITE_BUSY_TIMEOUT uses milliseconds as well, and DEFAULT_SQLITE_TIMEOUT becomes DEFAULT_SQLITE_BUSY_TIMEOUT (10000). Driver options backed by an SQLite PRAGMA are named after it and take its values, like sqlite_journal_mode and sqlite_synchronous. PDO::ATTR_TIMEOUT only accepts whole seconds, and it overflows to no wait at all for values of 2147484 or more. --- packages/mysql-on-sqlite/README.md | 2 +- .../src/sqlite/class-wp-mysql-on-sqlite.php | 6 +++--- .../src/sqlite/class-wp-sqlite-connection.php | 18 +++++++++--------- .../WP_MySQL_On_SQLite_Concurrency_Tests.php | 10 +++++----- .../tests/WP_MySQL_On_SQLite_PDO_API_Tests.php | 13 +++++-------- .../wp-includes/sqlite/class-wp-sqlite-db.php | 2 +- .../wp-includes/sqlite/install-functions.php | 2 +- 7 files changed, 25 insertions(+), 28 deletions(-) diff --git a/packages/mysql-on-sqlite/README.md b/packages/mysql-on-sqlite/README.md index 2f36fd642..2ffa3cff1 100644 --- a/packages/mysql-on-sqlite/README.md +++ b/packages/mysql-on-sqlite/README.md @@ -76,7 +76,7 @@ version and configuring the SQLite connection: | --- | --- | --- | | `mysql_version` | MySQL version to emulate, represented as an integer | `80038` | | `sqlite_pdo` | Existing PDO SQLite connection | A new connection for `path` | -| `sqlite_timeout` | SQLite busy timeout in whole seconds | `10` | +| `sqlite_busy_timeout` | SQLite busy timeout in milliseconds | `10000` | | `sqlite_journal_mode` | SQLite journal mode | `WAL` | | `sqlite_synchronous` | SQLite synchronous setting | `NORMAL` in WAL mode; otherwise the SQLite default | diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index 701e0360a..a8aa29382 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -795,8 +795,8 @@ class WP_MySQL_On_SQLite extends PDO { * * @type int $mysql_version Optional. MySQL version to emulate. Default 80038. * @type PDO|null $sqlite_pdo Optional. Existing PDO SQLite connection. - * @type int|null $sqlite_timeout Optional. SQLite busy timeout in whole seconds. - * Default null (10 seconds). + * @type int|null $sqlite_busy_timeout Optional. SQLite busy timeout in milliseconds. + * Default 10000. * @type string|null $sqlite_journal_mode Optional. SQLite journal mode. Default 'WAL'. * @type string|int|null $sqlite_synchronous Optional. SQLite synchronous setting. * } @@ -878,7 +878,7 @@ function ( $key ) { } $connection_options = array( - 'timeout' => $options['sqlite_timeout'] ?? null, + 'busy_timeout' => $options['sqlite_busy_timeout'] ?? null, 'journal_mode' => $options['sqlite_journal_mode'] ?? null, 'synchronous' => $options['sqlite_synchronous'] ?? null, 'pdo_options' => $connection_pdo_options, diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php index 78461f1b1..0479ab5c0 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php @@ -17,9 +17,9 @@ */ class WP_SQLite_Connection { /** - * The default timeout in seconds for SQLite to wait for a writable lock. + * The default time in milliseconds for SQLite to wait for a lock. */ - const DEFAULT_SQLITE_TIMEOUT = 10; + const DEFAULT_SQLITE_BUSY_TIMEOUT = 10000; /** * The supported SQLite journal modes. @@ -76,8 +76,8 @@ class WP_SQLite_Connection { * Must be set when PDO instance is not provided. * @type PDO|null $pdo Optional. PDO instance with SQLite connection. * If not provided, a new PDO instance will be created. - * @type int|null $timeout Optional. SQLite timeout in seconds. - * The time to wait for a writable lock. + * @type int|null $busy_timeout Optional. SQLite busy timeout in milliseconds. + * The time to wait for a lock. Defaults to 10000. * @type string|null $journal_mode Optional. SQLite journal mode. Defaults to WAL. * @type string|int|null $synchronous Optional. SQLite synchronous setting. Defaults to * NORMAL when the effective journal mode is WAL. @@ -107,13 +107,13 @@ public function __construct( array $options ) { // Throw exceptions on error. $this->pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); - // Configure SQLite timeout. - if ( isset( $options['timeout'] ) && is_int( $options['timeout'] ) ) { - $timeout = $options['timeout']; + // Configure SQLite busy timeout. + if ( isset( $options['busy_timeout'] ) && is_int( $options['busy_timeout'] ) ) { + $busy_timeout = $options['busy_timeout']; } else { - $timeout = self::DEFAULT_SQLITE_TIMEOUT; + $busy_timeout = self::DEFAULT_SQLITE_BUSY_TIMEOUT; } - $this->pdo->setAttribute( PDO::ATTR_TIMEOUT, $timeout ); + $this->query( 'PRAGMA busy_timeout = ' . $busy_timeout ); // Configure SQLite journal mode. Default to WAL for best throughput. $effective_journal_mode = null; diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php index c3f9f3e82..9f4b637ac 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php @@ -133,8 +133,8 @@ public function testSetQuerySucceedsWhileAnotherConnectionHoldsWriteLock(): void // Connection B with zero timeout — any lock conflict fails immediately. $conn_b = new WP_SQLite_Connection( array( - 'path' => $this->db_path, - 'timeout' => 0, + 'path' => $this->db_path, + 'busy_timeout' => 0, ) ); $driver_b = $this->create_driver( $conn_b ); @@ -163,8 +163,8 @@ private function assertReadOnlyQuerySucceedsUnderWriteLock( string $query ): voi // Connection B with zero timeout — any lock conflict fails immediately. $conn_b = new WP_SQLite_Connection( array( - 'path' => $this->db_path, - 'timeout' => 0, + 'path' => $this->db_path, + 'busy_timeout' => 0, ) ); $driver_b = $this->create_driver( $conn_b ); @@ -195,7 +195,7 @@ public function testWriteQueryFailsImmediatelyWithZeroBusyTimeoutWhileAnotherCon sprintf( 'mysql-on-sqlite:path=%s;dbname=wp', $this->db_path ), null, null, - array( 'sqlite_timeout' => 0 ) + array( 'sqlite_busy_timeout' => 0 ) ); $this->expectException( PDOException::class ); diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php index bf7c8d7c3..4e4ee6119 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php @@ -379,30 +379,27 @@ public function test_journal_mode_and_synchronous_driver_options(): void { } } - public function test_timeout_driver_option_sets_sqlite_busy_timeout(): void { + public function test_busy_timeout_driver_option(): void { $driver = new WP_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp', null, null, array( - 'sqlite_timeout' => 5, + 'sqlite_busy_timeout' => 2500, ) ); - // The PDO SQLite driver does not support reading "PDO::ATTR_TIMEOUT" - // back with "getAttribute()", but the SQLite busy timeout reflects it - // in milliseconds. $this->assertSame( - 5000, + 2500, (int) $driver->get_sqlite_pdo()->query( 'PRAGMA busy_timeout' )->fetchColumn() ); } - public function test_timeout_defaults_to_the_connection_default(): void { + public function test_busy_timeout_defaults_to_the_connection_default(): void { $driver = new WP_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp' ); $this->assertSame( - WP_SQLite_Connection::DEFAULT_SQLITE_TIMEOUT * 1000, + WP_SQLite_Connection::DEFAULT_SQLITE_BUSY_TIMEOUT, (int) $driver->get_sqlite_pdo()->query( 'PRAGMA busy_timeout' )->fetchColumn() ); } diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php index 5317d10f8..969501089 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php @@ -453,7 +453,7 @@ public function db_connect( $allow_bail = true ) { try { $options = array( 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, - 'sqlite_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? (int) SQLITE_BUSY_TIMEOUT : null, + 'sqlite_busy_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? (int) SQLITE_BUSY_TIMEOUT : null, ); $dbh = new WP_MySQL_On_SQLite( sprintf( diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php index de95f786b..6f6ebcf88 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php @@ -30,7 +30,7 @@ function sqlite_make_db_sqlite() { null, array( 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, - 'sqlite_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? (int) SQLITE_BUSY_TIMEOUT : null, + 'sqlite_busy_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? (int) SQLITE_BUSY_TIMEOUT : null, ) ); $translator->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO From f73719aa5fab193afa154713ec0781586efdd6f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Tue, 27 Oct 2026 15:11:59 +0100 Subject: [PATCH 4/5] Validate the SQLite busy timeout Throw InvalidArgumentException when the busy timeout is not an integer from 0 to 2147483647, as with an invalid journal mode or synchronous setting. Before, a non-integer value silently fell back to the 10 second default. The plugin now passes SQLITE_BUSY_TIMEOUT to the driver as-is. The (int) cast turned values like false, null, or an empty string into 0, which disabled the wait instead of reporting the mistake. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 2 +- .../src/sqlite/class-wp-sqlite-connection.php | 11 ++-- .../WP_MySQL_On_SQLite_PDO_API_Tests.php | 12 ++++ .../tests/WP_SQLite_Connection_Tests.php | 57 +++++++++++++++++++ .../wp-includes/sqlite/class-wp-sqlite-db.php | 2 +- .../wp-includes/sqlite/install-functions.php | 2 +- 6 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index a8aa29382..f9027d73f 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -801,7 +801,7 @@ class WP_MySQL_On_SQLite extends PDO { * @type string|int|null $sqlite_synchronous Optional. SQLite synchronous setting. * } * - * @throws InvalidArgumentException When the MySQL version is invalid. + * @throws InvalidArgumentException When the MySQL version or an SQLite option is invalid. * @throws WP_MySQL_On_SQLite_Exception When the driver initialization fails. */ public function __construct( diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php index 0479ab5c0..59724f7d6 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php @@ -107,11 +107,12 @@ public function __construct( array $options ) { // Throw exceptions on error. $this->pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); - // Configure SQLite busy timeout. - if ( isset( $options['busy_timeout'] ) && is_int( $options['busy_timeout'] ) ) { - $busy_timeout = $options['busy_timeout']; - } else { - $busy_timeout = self::DEFAULT_SQLITE_BUSY_TIMEOUT; + // Configure SQLite busy timeout. SQLite stores it as a 32-bit signed integer. + $busy_timeout = $options['busy_timeout'] ?? self::DEFAULT_SQLITE_BUSY_TIMEOUT; + if ( ! is_int( $busy_timeout ) || $busy_timeout < 0 || $busy_timeout > 2147483647 ) { + throw new InvalidArgumentException( + 'Invalid SQLite busy timeout. It must be an integer from 0 to 2147483647.' + ); } $this->query( 'PRAGMA busy_timeout = ' . $busy_timeout ); diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php index 4e4ee6119..bfc0cefd5 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php @@ -404,6 +404,18 @@ public function test_busy_timeout_defaults_to_the_connection_default(): void { ); } + public function test_rejects_invalid_busy_timeout(): void { + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid SQLite busy timeout. It must be an integer from 0 to 2147483647.' ); + + new WP_MySQL_On_SQLite( + 'mysql-on-sqlite:path=:memory:;dbname=wp', + null, + null, + array( 'sqlite_busy_timeout' => '10000' ) + ); + } + public function test_query(): void { $result = $this->driver->query( "SELECT 1, 'abc'" ); $this->assertInstanceOf( WP_MySQL_On_SQLite_Statement::class, $result ); diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php index b65147a47..b6f98562d 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Connection_Tests.php @@ -189,6 +189,59 @@ public function testDriverKeepsConfiguredJournalMode(): void { $this->assertSame( 'delete', $this->get_journal_mode( $driver->get_connection() ) ); } + public function testDefaultBusyTimeout(): void { + $connection = new WP_SQLite_Connection( array( 'path' => $this->db_path ) ); + + $this->assertSame( '10000', $this->get_busy_timeout( $connection ) ); + } + + public function testBusyTimeoutAcceptsZero(): void { + $connection = new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'busy_timeout' => 0, + ) + ); + + $this->assertSame( '0', $this->get_busy_timeout( $connection ) ); + } + + public function testBusyTimeoutAcceptsMaximumValue(): void { + $connection = new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'busy_timeout' => 2147483647, + ) + ); + + $this->assertSame( '2147483647', $this->get_busy_timeout( $connection ) ); + } + + /** + * @dataProvider provideInvalidBusyTimeouts + */ + public function testInvalidBusyTimeoutThrows( $busy_timeout ): void { + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid SQLite busy timeout. It must be an integer from 0 to 2147483647.' ); + new WP_SQLite_Connection( + array( + 'path' => $this->db_path, + 'busy_timeout' => $busy_timeout, + ) + ); + } + + public function provideInvalidBusyTimeouts(): array { + return array( + 'string' => array( '10000' ), + 'float' => array( 10000.0 ), + 'boolean' => array( false ), + 'array' => array( array( 10000 ) ), + 'negative' => array( -1 ), + 'above maximum' => array( 2147483648 ), + ); + } + /** * Create the database file first, and then make its directory read-only, * so that the WAL sidecar files ("-wal", "-shm") cannot be created. @@ -216,4 +269,8 @@ private function get_journal_mode( WP_SQLite_Connection $connection ): string { private function get_synchronous( WP_SQLite_Connection $connection ): string { return (string) $connection->query( 'PRAGMA synchronous' )->fetchColumn(); } + + private function get_busy_timeout( WP_SQLite_Connection $connection ): string { + return (string) $connection->query( 'PRAGMA busy_timeout' )->fetchColumn(); + } } diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php index 969501089..15d5dc954 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php @@ -453,7 +453,7 @@ public function db_connect( $allow_bail = true ) { try { $options = array( 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, - 'sqlite_busy_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? (int) SQLITE_BUSY_TIMEOUT : null, + 'sqlite_busy_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? SQLITE_BUSY_TIMEOUT : null, ); $dbh = new WP_MySQL_On_SQLite( sprintf( diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php index 6f6ebcf88..59a9c9acd 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php @@ -30,7 +30,7 @@ function sqlite_make_db_sqlite() { null, array( 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, - 'sqlite_busy_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? (int) SQLITE_BUSY_TIMEOUT : null, + 'sqlite_busy_timeout' => defined( 'SQLITE_BUSY_TIMEOUT' ) ? SQLITE_BUSY_TIMEOUT : null, ) ); $translator->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO From c4d8490ba45d812d689a7587649eef2d53751d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Tue, 27 Oct 2026 15:12:03 +0100 Subject: [PATCH 5/5] Improve the busy timeout concurrency tests The zero timeout test only checked for the "database is locked" error, which also happens after the 10 second default. It passed even when the option was not forwarded to SQLite. Measure the time until the write fails instead, and test that a 200 ms timeout makes the write wait at least that long. The create_driver() helper built the driver on the connection's PDO without passing the busy timeout, so it reset to the default. Tests that needed a zero timeout had to set it again. Pass it on like the journal mode. Also keep the public test methods above the private helpers. --- .../WP_MySQL_On_SQLite_Concurrency_Tests.php | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php index 9f4b637ac..1b8d9137c 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php @@ -138,7 +138,6 @@ public function testSetQuerySucceedsWhileAnotherConnectionHoldsWriteLock(): void ) ); $driver_b = $this->create_driver( $conn_b ); - $conn_b->get_pdo()->setAttribute( PDO::ATTR_TIMEOUT, 0 ); // SET writes nothing, so it must not contend for the write lock. $result = $driver_b->query( "SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION'" ); @@ -149,6 +148,20 @@ public function testSetQuerySucceedsWhileAnotherConnectionHoldsWriteLock(): void } } + public function testWriteQueryFailsImmediatelyWithZeroBusyTimeoutWhileAnotherConnectionHoldsWriteLock(): void { + $elapsed = $this->measure_write_failure_under_write_lock( 0 ); + + // Far below the 10 second default. + $this->assertLessThan( 1, $elapsed ); + } + + public function testWriteQueryWaitsForBusyTimeoutWhileAnotherConnectionHoldsWriteLock(): void { + $elapsed = $this->measure_write_failure_under_write_lock( 200 ); + + $this->assertGreaterThanOrEqual( 0.2, $elapsed ); + $this->assertLessThan( 5, $elapsed ); + } + private function assertReadOnlyQuerySucceedsUnderWriteLock( string $query ): void { // Connection A: set up the database. $conn_a = new WP_SQLite_Connection( array( 'path' => $this->db_path ) ); @@ -168,7 +181,6 @@ private function assertReadOnlyQuerySucceedsUnderWriteLock( string $query ): voi ) ); $driver_b = $this->create_driver( $conn_b ); - $conn_b->get_pdo()->setAttribute( PDO::ATTR_TIMEOUT, 0 ); $result = $driver_b->query( $query ); @@ -178,7 +190,13 @@ private function assertReadOnlyQuerySucceedsUnderWriteLock( string $query ): voi } } - public function testWriteQueryFailsImmediatelyWithZeroBusyTimeoutWhileAnotherConnectionHoldsWriteLock(): void { + /** + * Run a write query while another connection holds the write lock. + * + * @param int $busy_timeout Busy timeout of the writing connection in milliseconds. + * @return float Time in seconds until the write query failed. + */ + private function measure_write_failure_under_write_lock( int $busy_timeout ): float { // Connection A: set up the database and hold a write transaction. $conn_a = new WP_SQLite_Connection( array( 'path' => $this->db_path ) ); $driver_a = $this->create_driver( $conn_a ); @@ -189,19 +207,21 @@ public function testWriteQueryFailsImmediatelyWithZeroBusyTimeoutWhileAnotherCon $conn_a->get_pdo()->exec( 'BEGIN IMMEDIATE' ); try { - // Connection B with a zero busy timeout — any lock conflict fails - // immediately instead of waiting for the default timeout. $driver_b = new WP_MySQL_On_SQLite( sprintf( 'mysql-on-sqlite:path=%s;dbname=wp', $this->db_path ), null, null, - array( 'sqlite_busy_timeout' => 0 ) + array( 'sqlite_busy_timeout' => $busy_timeout ) ); - $this->expectException( PDOException::class ); - $this->expectExceptionMessage( 'database is locked' ); - - $driver_b->query( "INSERT INTO t VALUES (2, 'Bob')" ); + $start = microtime( true ); + try { + $driver_b->query( "INSERT INTO t VALUES (2, 'Bob')" ); + } catch ( PDOException $e ) { + $this->assertStringContainsString( 'database is locked', $e->getMessage() ); + return microtime( true ) - $start; + } + $this->fail( 'The write query did not fail while another connection held the write lock.' ); } finally { $conn_a->get_pdo()->exec( 'ROLLBACK' ); } @@ -221,6 +241,7 @@ private function create_driver( WP_SQLite_Connection $connection ): WP_MySQL_On_ null, array( 'sqlite_pdo' => $connection->get_pdo(), + 'sqlite_busy_timeout' => (int) $connection->query( 'PRAGMA busy_timeout' )->fetchColumn(), 'sqlite_journal_mode' => $connection->query( 'PRAGMA journal_mode' )->fetchColumn(), ) );