Skip to content

Commit 204c4de

Browse files
authored
Fix stream invalidation when statement goes out of scope (GH#1443) (#1598)
* FIX: Freeing metadata entries and clearing the vector * Resolving comments * Fix stream invalidation when statement goes out of scope (GH#1443) When a stream is created from sqlsrv_get_field, the stream now holds a reference (GC_ADDREF) to the statement's zend_resource. This prevents PHP from destroying the statement while the stream is still alive. Previously, if $stmt went out of scope (e.g. returned from a function), the statement destructor would close the stream, making it invalid even though userland code still held a reference to it. Changes: - Added zend_resource* stmt_res to sqlsrv_stream struct to hold a reference to the parent statement resource - Added zend_resource* zend_res to sqlsrv_stmt struct so the statement knows its own resource handle - On stream creation, increment statement resource refcount via GC_ADDREF - On stream close, release the reference via zend_list_delete after clearing active_stream (prevents double-close in statement destructor) - Store zend_resource on statement after registration in both sqlsrv_prepare and sqlsrv_query paths Fixes: #1443 * Add cleanup validation tests for stream-outlives-stmt fix Test 4: Memory leak detection - runs the stream-outlives-stmt pattern 20 times and verifies memory usage stays stable, confirming both the stream and statement are properly freed when the stream is closed. Test 5: Connection health - verifies the connection remains fully functional after all streams/statements are cleaned up, proving no dangling ODBC handles remain. * Removed newly added lines from changelog
1 parent add33e6 commit 204c4de

5 files changed

Lines changed: 190 additions & 3 deletions

File tree

source/shared/core_sqlsrv.h

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,13 +1390,14 @@ struct sqlsrv_stream {
13901390
SQLUSMALLINT field_index;
13911391
SQLSMALLINT sql_type;
13921392
sqlsrv_stmt* stmt;
1393+
zend_resource* stmt_res; // prevent statement from being freed while stream is active
13931394

13941395
sqlsrv_stream( _In_opt_ zval* str_z, _In_ SQLSRV_ENCODING enc ) :
1395-
stream_z( str_z ), encoding( enc ), field_index( 0 ), sql_type( SQL_UNKNOWN_TYPE ), stmt( NULL )
1396+
stream_z( str_z ), encoding( enc ), field_index( 0 ), sql_type( SQL_UNKNOWN_TYPE ), stmt( NULL ), stmt_res( NULL )
13961397
{
13971398
}
13981399

1399-
sqlsrv_stream() : stream_z( NULL ), encoding( SQLSRV_ENCODING_INVALID ), field_index( 0 ), sql_type( SQL_UNKNOWN_TYPE ), stmt( NULL )
1400+
sqlsrv_stream() : stream_z( NULL ), encoding( SQLSRV_ENCODING_INVALID ), field_index( 0 ), sql_type( SQL_UNKNOWN_TYPE ), stmt( NULL ), stmt_res( NULL )
14001401
{
14011402
}
14021403
};
@@ -1724,6 +1725,7 @@ struct sqlsrv_stmt : public sqlsrv_context {
17241725
zval field_cache; // cache for a single row of fields, to allow multiple and out of order retrievals
17251726
zval col_cache; // Used by get_field_as_string not to call SQLColAttribute() after every fetch.
17261727
zval active_stream; // the currently active stream reading data from the database
1728+
zend_resource* zend_res; // the zend_resource for this statement (used by streams to prevent premature stmt destruction)
17271729

17281730
sqlsrv_params_container params_container; // holds all parameters and references used for SQLBindParameter
17291731

source/shared/core_stmt.cpp

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ sqlsrv_stmt::sqlsrv_stmt( _In_ sqlsrv_conn* c, _In_ SQLHANDLE handle, _In_ error
132132
decimal_places(NO_CHANGE_DECIMAL_PLACES), // the default is no formatting to resultset required
133133
data_classification(false),
134134
buffered_query_limit( sqlsrv_buffered_result_set::BUFFERED_QUERY_LIMIT_INVALID ),
135-
send_streams_at_exec( true )
135+
send_streams_at_exec( true ),
136+
zend_res( NULL )
136137
{
137138
ZVAL_UNDEF( &active_stream );
138139

@@ -1522,6 +1523,14 @@ void core_get_field_common( _Inout_ sqlsrv_stmt* stmt, _In_ SQLUSMALLINT field_i
15221523
ss->sql_type = static_cast<SQLUSMALLINT>( sql_type );
15231524
ss->encoding = static_cast<SQLSRV_ENCODING>( sqlsrv_php_type.typeinfo.encoding );
15241525

1526+
// If the statement has a zend_resource, hold a reference to it.
1527+
// This prevents the statement from being destroyed while the stream
1528+
// is still alive (e.g. when $stmt goes out of scope but $stream is returned).
1529+
if( stmt->zend_res != NULL ) {
1530+
ss->stmt_res = stmt->zend_res;
1531+
GC_ADDREF( ss->stmt_res );
1532+
}
1533+
15251534
zval_auto_ptr return_value_z;
15261535
return_value_z = ( zval * )sqlsrv_malloc( sizeof( zval ));
15271536
ZVAL_UNDEF( return_value_z );

source/shared/core_stream.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,18 @@ int sqlsrv_stream_close( _Inout_ php_stream* stream, int /*close_handle*/ )
3434
// UNDEF the stream zval and delete our reference count to it.
3535
ZVAL_UNDEF( &( ss->stmt->active_stream ) );
3636

37+
// Save stmt_res before freeing ss, then release the statement reference.
38+
// This may trigger the statement destructor if this was the last reference,
39+
// which is safe because active_stream was already set to UNDEF above.
40+
zend_resource* saved_stmt_res = ss->stmt_res;
41+
3742
sqlsrv_free( ss );
3843
stream->abstract = NULL;
3944

45+
if( saved_stmt_res != NULL ) {
46+
zend_list_delete( saved_stmt_res );
47+
}
48+
4049
return 0;
4150
}
4251

source/sqlsrv/conn.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,9 @@ PHP_FUNCTION( sqlsrv_prepare )
11311131
// register the statement with the PHP runtime
11321132
ss::zend_register_resource( stmt_z, stmt, ss_sqlsrv_stmt::descriptor, ss_sqlsrv_stmt::resource_name );
11331133

1134+
// store the zend_resource on the statement so streams can prevent premature stmt destruction
1135+
stmt->zend_res = Z_RES(stmt_z);
1136+
11341137
// store the resource id with the connection so the connection
11351138
// can release this statement when it closes.
11361139
zend_long next_index = zend_hash_next_free_element( conn->stmts );
@@ -1255,6 +1258,10 @@ PHP_FUNCTION( sqlsrv_query )
12551258

12561259
// register the statement with the PHP runtime
12571260
ss::zend_register_resource(stmt_z, stmt, ss_sqlsrv_stmt::descriptor, ss_sqlsrv_stmt::resource_name);
1261+
1262+
// store the zend_resource on the statement so streams can prevent premature stmt destruction
1263+
stmt->zend_res = Z_RES(stmt_z);
1264+
12581265
// store the resource id with the connection so the connection
12591266
// can release this statement when it closes.
12601267
zend_ulong next_index = zend_hash_next_free_element( conn->stmts );
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
--TEST--
2+
GitHub issue 1443 - stream remains valid after statement goes out of scope
3+
--DESCRIPTION--
4+
When a stream is returned from a function where the statement variable goes out
5+
of scope, the stream should remain valid as long as it has references. Previously
6+
the statement destructor would close the stream, making it invalid.
7+
Also validates that both the stream and statement are properly cleaned up after use,
8+
with no resource leaks detected via memory_get_usage over repeated iterations.
9+
--SKIPIF--
10+
<?php require('skipif.inc'); ?>
11+
--FILE--
12+
<?php
13+
require_once("MsCommon.inc");
14+
15+
// Test 1: Stream returned from a function where $stmt goes out of scope
16+
function getStream($conn)
17+
{
18+
$stmt = sqlsrv_query($conn, "SELECT CONVERT(VARBINARY(32), 0x48656C6C6F)");
19+
if ($stmt === false) {
20+
fatalError("Query failed in getStream.");
21+
}
22+
sqlsrv_fetch($stmt);
23+
// $stmt will go out of scope when this function returns
24+
return sqlsrv_get_field($stmt, 0, SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY));
25+
}
26+
27+
$conn = connect();
28+
if ($conn === false) {
29+
fatalError("Could not connect.");
30+
}
31+
32+
$stream = getStream($conn);
33+
if ($stream === false) {
34+
fatalError("Failed to get stream.");
35+
}
36+
37+
// The stream should still be valid even though $stmt went out of scope
38+
$data = fread($stream, 100);
39+
if ($data === false) {
40+
fatalError("fread failed on stream.");
41+
}
42+
43+
// CONVERT(VARBINARY(32), 0x48656C6C6F) should return the bytes "Hello"
44+
if ($data === "Hello") {
45+
echo "Test 1 passed: stream data read correctly after stmt out of scope.\n";
46+
} else {
47+
echo "Test 1 FAILED: expected 'Hello', got '" . bin2hex($data) . "'.\n";
48+
}
49+
50+
fclose($stream);
51+
52+
// Test 2: Stream from sqlsrv_prepare + sqlsrv_execute
53+
function getStreamPrepared($conn)
54+
{
55+
$stmt = sqlsrv_prepare($conn, "SELECT CONVERT(VARBINARY(32), 0x576F726C64)");
56+
if ($stmt === false) {
57+
fatalError("Prepare failed in getStreamPrepared.");
58+
}
59+
sqlsrv_execute($stmt);
60+
sqlsrv_fetch($stmt);
61+
return sqlsrv_get_field($stmt, 0, SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY));
62+
}
63+
64+
$stream2 = getStreamPrepared($conn);
65+
if ($stream2 === false) {
66+
fatalError("Failed to get stream from prepared stmt.");
67+
}
68+
69+
$data2 = fread($stream2, 100);
70+
if ($data2 === "World") {
71+
echo "Test 2 passed: stream from prepared stmt works after stmt out of scope.\n";
72+
} else {
73+
echo "Test 2 FAILED: expected 'World', got '" . bin2hex($data2) . "'.\n";
74+
}
75+
76+
fclose($stream2);
77+
78+
// Test 3: Multiple reads from the stream
79+
function getStreamLargeData($conn)
80+
{
81+
$stmt = sqlsrv_query($conn, "SELECT CONVERT(VARBINARY(MAX), REPLICATE(CONVERT(VARBINARY(MAX), 0x41), 1000))");
82+
if ($stmt === false) {
83+
fatalError("Query failed in getStreamLargeData.");
84+
}
85+
sqlsrv_fetch($stmt);
86+
return sqlsrv_get_field($stmt, 0, SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY));
87+
}
88+
89+
$stream3 = getStreamLargeData($conn);
90+
if ($stream3 === false) {
91+
fatalError("Failed to get stream for large data.");
92+
}
93+
94+
$allData = '';
95+
while (!feof($stream3)) {
96+
$chunk = fread($stream3, 100);
97+
if ($chunk === false) {
98+
break;
99+
}
100+
$allData .= $chunk;
101+
}
102+
103+
if (strlen($allData) === 1000 && $allData === str_repeat('A', 1000)) {
104+
echo "Test 3 passed: large stream data read correctly.\n";
105+
} else {
106+
echo "Test 3 FAILED: expected 1000 bytes of 'A', got " . strlen($allData) . " bytes.\n";
107+
}
108+
109+
fclose($stream3);
110+
111+
// Test 4: Verify cleanup - no resource leak over repeated iterations.
112+
// Run the stream-outlives-stmt pattern in a loop and check that memory
113+
// usage stabilizes, confirming the statement is freed when the stream closes.
114+
$leakDetected = false;
115+
for ($i = 0; $i < 20; $i++) {
116+
$s = getStream($conn);
117+
$d = fread($s, 100);
118+
fclose($s);
119+
unset($s);
120+
unset($d);
121+
122+
if ($i === 0) {
123+
$memBaseline = memory_get_usage();
124+
}
125+
}
126+
$memAfter = memory_get_usage();
127+
// Allow a small tolerance (32 KB) for PHP internal allocations.
128+
// A real leak would grow ~proportionally to iteration count.
129+
if (($memAfter - $memBaseline) < 32768) {
130+
echo "Test 4 passed: no resource leak detected over repeated iterations.\n";
131+
} else {
132+
echo "Test 4 FAILED: possible leak, memory grew by " . ($memAfter - $memBaseline) . " bytes.\n";
133+
}
134+
135+
// Test 5: After all streams are closed, the connection should still be
136+
// fully functional — proving statements were cleaned up properly.
137+
$stmt = sqlsrv_query($conn, "SELECT 1 AS alive");
138+
if ($stmt === false) {
139+
echo "Test 5 FAILED: connection unusable after cleanup.\n";
140+
} else {
141+
sqlsrv_fetch($stmt);
142+
$val = sqlsrv_get_field($stmt, 0);
143+
if ($val == 1) {
144+
echo "Test 5 passed: connection works after all streams and statements cleaned up.\n";
145+
} else {
146+
echo "Test 5 FAILED: unexpected value $val.\n";
147+
}
148+
sqlsrv_free_stmt($stmt);
149+
}
150+
151+
sqlsrv_close($conn);
152+
echo "Done.\n";
153+
?>
154+
--EXPECT--
155+
Test 1 passed: stream data read correctly after stmt out of scope.
156+
Test 2 passed: stream from prepared stmt works after stmt out of scope.
157+
Test 3 passed: large stream data read correctly.
158+
Test 4 passed: no resource leak detected over repeated iterations.
159+
Test 5 passed: connection works after all streams and statements cleaned up.
160+
Done.

0 commit comments

Comments
 (0)