Skip to content

Commit

Permalink
Fix bug #79375
Browse files Browse the repository at this point in the history
Make sure deadlock errors are properly propagated and reports in
a number of places in mysqli and PDO MySQL.

This also fixes a memory and a segfault that can occur under these
conditions.
  • Loading branch information
kamil-tekiela authored and nikic committed Oct 28, 2020
1 parent 9353f11 commit b03776a
Show file tree
Hide file tree
Showing 8 changed files with 309 additions and 7 deletions.
4 changes: 4 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ PHP NEWS
. Fixed bug #79983 (openssl_encrypt / openssl_decrypt fail with OCB mode).
(Nikita)

- MySQLi:
. Fixed bug #79375 (mysqli_store_result does not report error from lock wait
timeout). (Kamil Tekiela, Nikita)

29 Oct 2020, PHP 7.4.12

- Core:
Expand Down
3 changes: 2 additions & 1 deletion ext/mysqli/mysqli_api.c
Original file line number Diff line number Diff line change
Expand Up @@ -1123,7 +1123,8 @@ void mysqli_stmt_fetch_mysqlnd(INTERNAL_FUNCTION_PARAMETERS)
}
MYSQLI_FETCH_RESOURCE_STMT(stmt, mysql_stmt, MYSQLI_STATUS_VALID);

if (FAIL == mysqlnd_stmt_fetch(stmt->stmt, &fetched_anything)) {
if (FAIL == mysqlnd_stmt_fetch(stmt->stmt, &fetched_anything)) {
MYSQLI_REPORT_STMT_ERROR(stmt->stmt);
RETURN_BOOL(FALSE);
} else if (fetched_anything == TRUE) {
RETURN_BOOL(TRUE);
Expand Down
172 changes: 172 additions & 0 deletions ext/mysqli/tests/bug79375.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
--TEST--
Bug #79375: mysqli_store_result does not report error from lock wait timeout
--SKIPIF--
<?php
require_once('skipif.inc');
require_once('skipifconnectfailure.inc');
if (!defined('MYSQLI_STORE_RESULT_COPY_DATA')) die('skip requires mysqlnd');
?>
--FILE--
<?php

require_once("connect.inc");
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$mysqli = new my_mysqli($host, $user, $passwd, $db, $port, $socket);
$mysqli2 = new my_mysqli($host, $user, $passwd, $db, $port, $socket);

$mysqli->query('DROP TABLE IF EXISTS test');
$mysqli->query('CREATE TABLE test (first int) ENGINE = InnoDB');
$mysqli->query('INSERT INTO test VALUES (1),(2),(3),(4),(5),(6),(7),(8),(9)');

function testStmtStoreResult(mysqli $mysqli, string $name) {
$mysqli->query("SET innodb_lock_wait_timeout = 1");
$mysqli->query("START TRANSACTION");
$query = "SELECT first FROM test WHERE first = 1 FOR UPDATE";
echo "Running query on $name\n";
$stmt = $mysqli->prepare($query);
$stmt->execute();
try {
$stmt->store_result();
echo "Got {$stmt->num_rows} for $name\n";
} catch(mysqli_sql_exception $e) {
echo $e->getMessage()."\n";
}
}
function testStmtGetResult(mysqli $mysqli, string $name) {
$mysqli->query("SET innodb_lock_wait_timeout = 1");
$mysqli->query("START TRANSACTION");
$query = "SELECT first FROM test WHERE first = 1 FOR UPDATE";
echo "Running query on $name\n";
$stmt = $mysqli->prepare($query);
$stmt->execute();
try {
$res = $stmt->get_result();
echo "Got {$res->num_rows} for $name\n";
} catch(mysqli_sql_exception $e) {
echo $e->getMessage()."\n";
}
}
function testNormalQuery(mysqli $mysqli, string $name) {
$mysqli->query("SET innodb_lock_wait_timeout = 1");
$mysqli->query("START TRANSACTION");
$query = "SELECT first FROM test WHERE first = 1 FOR UPDATE";
echo "Running query on $name\n";
try {
$res = $mysqli->query($query);
echo "Got {$res->num_rows} for $name\n";
} catch(mysqli_sql_exception $e) {
echo $e->getMessage()."\n";
}
}
function testStmtUseResult(mysqli $mysqli, string $name) {
$mysqli->query("SET innodb_lock_wait_timeout = 1");
$mysqli->query("START TRANSACTION");
$query = "SELECT first FROM test WHERE first = 1 FOR UPDATE";
echo "Running query on $name\n";
$stmt = $mysqli->prepare($query);
$stmt->execute();
try {
$stmt->fetch(); // should throw an error
$stmt->fetch();
echo "Got {$stmt->num_rows} for $name\n";
} catch (mysqli_sql_exception $e) {
echo $e->getMessage()."\n";
}
}
function testResultFetchRow(mysqli $mysqli, string $name) {
$mysqli->query("SET innodb_lock_wait_timeout = 1");
$mysqli->query("START TRANSACTION");
$query = "SELECT first FROM test WHERE first = 1 FOR UPDATE";
echo "Running query on $name\n";
$res = $mysqli->query($query, MYSQLI_USE_RESULT);
try {
$res->fetch_row();
$res->fetch_row();
echo "Got {$res->num_rows} for $name\n";
} catch(mysqli_sql_exception $e) {
echo $e->getMessage()."\n";
}
}

testStmtStoreResult($mysqli, 'first connection');
testStmtStoreResult($mysqli2, 'second connection');

$mysqli->close();
$mysqli2->close();

echo "\n";
// try it again for get_result
$mysqli = new my_mysqli($host, $user, $passwd, $db, $port, $socket);
$mysqli2 = new my_mysqli($host, $user, $passwd, $db, $port, $socket);

testStmtGetResult($mysqli, 'first connection');
testStmtGetResult($mysqli2, 'second connection');

$mysqli->close();
$mysqli2->close();

echo "\n";
// try it again with unprepared query
$mysqli = new my_mysqli($host, $user, $passwd, $db, $port, $socket);
$mysqli2 = new my_mysqli($host, $user, $passwd, $db, $port, $socket);

testNormalQuery($mysqli, 'first connection');
testNormalQuery($mysqli2, 'second connection');

$mysqli->close();
$mysqli2->close();

echo "\n";
// try it again with unprepared query
$mysqli = new my_mysqli($host, $user, $passwd, $db, $port, $socket);
$mysqli2 = new my_mysqli($host, $user, $passwd, $db, $port, $socket);

testStmtUseResult($mysqli, 'first connection');
testStmtUseResult($mysqli2, 'second connection');

$mysqli->close();
$mysqli2->close();

echo "\n";
// try it again using fetch_row on a result object
$mysqli = new my_mysqli($host, $user, $passwd, $db, $port, $socket);
$mysqli2 = new my_mysqli($host, $user, $passwd, $db, $port, $socket);

testResultFetchRow($mysqli, 'first connection');
testResultFetchRow($mysqli2, 'second connection');

$mysqli->close();
$mysqli2->close();

?>
--CLEAN--
<?php
require_once("clean_table.inc");
?>
--EXPECTF--
Running query on first connection
Got %d for first connection
Running query on second connection
Lock wait timeout exceeded; try restarting transaction

Running query on first connection
Got %d for first connection
Running query on second connection
Lock wait timeout exceeded; try restarting transaction

Running query on first connection
Got %d for first connection
Running query on second connection
Lock wait timeout exceeded; try restarting transaction

Running query on first connection
Got %d for first connection
Running query on second connection
Lock wait timeout exceeded; try restarting transaction

Running query on first connection
Got 1 for first connection
Running query on second connection

Warning: mysqli_result::fetch_row(): Error while reading a row in %s on line %d
Got 0 for second connection
8 changes: 6 additions & 2 deletions ext/mysqlnd/mysqlnd_ps.c
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,11 @@ MYSQLND_METHOD(mysqlnd_stmt, store_result)(MYSQLND_STMT * const s)
stmt->state = MYSQLND_STMT_USE_OR_STORE_CALLED;
} else {
COPY_CLIENT_ERROR(conn->error_info, result->stored_data->error_info);
COPY_CLIENT_ERROR(stmt->error_info, result->stored_data->error_info);
stmt->result->m.free_result_contents(stmt->result);
stmt->result = NULL;
stmt->state = MYSQLND_STMT_PREPARED;
DBG_RETURN(NULL);
}

DBG_RETURN(result);
Expand Down Expand Up @@ -178,7 +180,7 @@ MYSQLND_METHOD(mysqlnd_stmt, get_result)(MYSQLND_STMT * const s)
break;
}

if ((result = result->m.store_result(result, conn, MYSQLND_STORE_PS | MYSQLND_STORE_NO_COPY))) {
if (result->m.store_result(result, conn, MYSQLND_STORE_PS | MYSQLND_STORE_NO_COPY)) {
UPSERT_STATUS_SET_AFFECTED_ROWS(stmt->upsert_status, result->stored_data->row_count);
stmt->state = MYSQLND_STMT_PREPARED;
result->type = MYSQLND_RES_PS_BUF;
Expand Down Expand Up @@ -881,7 +883,9 @@ mysqlnd_stmt_fetch_row_unbuffered(MYSQLND_RES * result, void * param, const unsi
} else if (ret == FAIL) {
if (row_packet->error_info.error_no) {
COPY_CLIENT_ERROR(conn->error_info, row_packet->error_info);
COPY_CLIENT_ERROR(stmt->error_info, row_packet->error_info);
if (stmt) {
COPY_CLIENT_ERROR(stmt->error_info, row_packet->error_info);
}
}
SET_CONNECTION_STATE(&conn->state, CONN_READY);
result->unbuf->eof_reached = TRUE; /* so next time we won't get an error */
Expand Down
2 changes: 1 addition & 1 deletion ext/mysqlnd/mysqlnd_result.c
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@ MYSQLND_METHOD(mysqlnd_result_unbuffered, fetch_row)(MYSQLND_RES * result, void
result->memory_pool->checkpoint = checkpoint;

DBG_INF_FMT("ret=%s fetched=%u", ret == PASS? "PASS":"FAIL", *fetched_anything);
DBG_RETURN(PASS);
DBG_RETURN(ret);
}
/* }}} */

Expand Down
10 changes: 8 additions & 2 deletions ext/pdo_mysql/mysql_statement.c
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,10 @@ static int pdo_mysql_stmt_execute_prepared_libmysql(pdo_stmt_t *stmt) /* {{{ */

/* if buffered, pre-fetch all the data */
if (H->buffered) {
mysql_stmt_store_result(S->stmt);
if (mysql_stmt_store_result(S->stmt)) {
pdo_mysql_error_stmt(stmt);
PDO_DBG_RETURN(0);
}
}
}
}
Expand Down Expand Up @@ -300,6 +303,7 @@ static int pdo_mysql_stmt_execute_prepared_mysqlnd(pdo_stmt_t *stmt) /* {{{ */
/* if buffered, pre-fetch all the data */
if (H->buffered) {
if (mysql_stmt_store_result(S->stmt)) {
pdo_mysql_error_stmt(stmt);
PDO_DBG_RETURN(0);
}
}
Expand Down Expand Up @@ -388,7 +392,8 @@ static int pdo_mysql_stmt_next_rowset(pdo_stmt_t *stmt) /* {{{ */
/* if buffered, pre-fetch all the data */
if (H->buffered) {
if (mysql_stmt_store_result(S->stmt)) {
PDO_DBG_RETURN(1);
pdo_mysql_error_stmt(stmt);
PDO_DBG_RETURN(0);
}
}
}
Expand Down Expand Up @@ -623,6 +628,7 @@ static int pdo_mysql_stmt_fetch(pdo_stmt_t *stmt, enum pdo_fetch_orientation ori
PDO_DBG_INF_FMT("stmt=%p", S->stmt);
if (S->stmt) {
if (FAIL == mysqlnd_stmt_fetch(S->stmt, &fetched_anything) || fetched_anything == FALSE) {
pdo_mysql_error_stmt(stmt);
PDO_DBG_RETURN(0);
}

Expand Down
113 changes: 113 additions & 0 deletions ext/pdo_mysql/tests/bug79375.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
--TEST--
Bug #79375: mysqli_store_result does not report error from lock wait timeout
--SKIPIF--
<?php
if (!extension_loaded('pdo') || !extension_loaded('pdo_mysql')) die('skip not loaded');
require_once(__DIR__ . DIRECTORY_SEPARATOR . 'skipif.inc');
require_once(__DIR__ . DIRECTORY_SEPARATOR . 'mysql_pdo_test.inc');
MySQLPDOTest::skip();
?>
--FILE--
<?php
require_once(__DIR__ . DIRECTORY_SEPARATOR . 'mysql_pdo_test.inc');

function createDB(): PDO {
$db = MySQLPDOTest::factory();
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
return $db;
}

$db = createDB();
$db2 = createDB();
$db->query('DROP TABLE IF EXISTS test');
$db->query('CREATE TABLE test (first int) ENGINE = InnoDB');
$db->query('INSERT INTO test VALUES (1),(2),(3),(4),(5),(6),(7),(8),(9)');

function testNormalQuery(PDO $db, string $name) {
$db->exec("SET innodb_lock_wait_timeout = 1");
$db->exec("START TRANSACTION");
$query = "SELECT first FROM test WHERE first = 1 FOR UPDATE";
echo "Running query on $name\n";
try {
$stmt = $db->query($query);
echo "Got {$stmt->rowCount()} for $name\n";
} catch (PDOException $e) {
echo $e->getMessage()."\n";
}
}

function testPrepareExecute(PDO $db, string $name) {
$db->exec("SET innodb_lock_wait_timeout = 1");
$db->exec("START TRANSACTION");
$query = "SELECT first FROM test WHERE first = 1 FOR UPDATE";
echo "Running query on $name\n";
$stmt = $db->prepare($query);
try {
$stmt->execute();
echo "Got {$stmt->rowCount()} for $name\n";
} catch (PDOException $e) {
echo $e->getMessage()."\n";
}
}

function testUnbuffered(PDO $db, string $name) {
$db->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$db->exec("SET innodb_lock_wait_timeout = 1");
$db->exec("START TRANSACTION");
$query = "SELECT first FROM test WHERE first = 1 FOR UPDATE";
echo "Running query on $name\n";
$stmt = $db->prepare($query);
$stmt->execute();
try {
$rows = $stmt->fetchAll();
$count = count($rows);
echo "Got $count for $name\n";
} catch (PDOException $e) {
echo $e->getMessage()."\n";
}
}

testNormalQuery($db, 'first connection');
testNormalQuery($db2, 'second connection');
unset($db);
unset($db2);
echo "\n";

$db = createDB();
$db2 = createDB();
testPrepareExecute($db, 'first connection');
testPrepareExecute($db2, 'second connection');
unset($db);
unset($db2);
echo "\n";

$db = createDB();
$db2 = createDB();
testUnbuffered($db, 'first connection');
testUnbuffered($db2, 'second connection');
unset($db);
unset($db2);
echo "\n";

?>
--CLEAN--
<?php
require __DIR__ . '/mysql_pdo_test.inc';
MySQLPDOTest::dropTestTable();
?>
--EXPECT--
Running query on first connection
Got 1 for first connection
Running query on second connection
SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction

Running query on first connection
Got 1 for first connection
Running query on second connection
SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction

Running query on first connection
Got 1 for first connection
Running query on second connection
SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction
4 changes: 3 additions & 1 deletion ext/pdo_mysql/tests/bug_74376.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,7 @@ $stmt = $db->query("select (select 1 union select 2)");

print "ok";
?>
--EXPECT--
--EXPECTF--

Warning: PDO::query(): SQLSTATE[21000]: Cardinality violation: 1242 Subquery returns more than 1 row in %s on line %d
ok

0 comments on commit b03776a

Please sign in to comment.