Skip to content

Commit 4778276

Browse files
committed
Make Rust AST cache GC-safe
1 parent 8b24b49 commit 4778276

2 files changed

Lines changed: 163 additions & 37 deletions

File tree

packages/mysql-on-sqlite/tests/mysql/native/WP_MySQL_Native_Parser_Node_Cycle_Tests.php

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,11 @@
55
/**
66
* Cycle-collection / memory-bound tests for the Rust-side identity cache.
77
*
8-
* The Rust extension stores cached wrappers in a HashMap that PHP's GC
9-
* cannot see by default. Each cached wrapper has a `$native_ast` property
10-
* pointing back at the AST, forming a cycle the cycle collector can't
11-
* walk into without help. These tests are the contract for the custom
12-
* `gc_handler` on `WP_MySQL_Native_Ast` that exposes the cached wrappers
13-
* to PHP's collector — they will fail until the handler is in place and
14-
* working correctly. They're written to break in every direction the
15-
* leak can manifest:
8+
* The Rust extension stores cached wrappers in a HashMap. Each cached
9+
* wrapper has a `$native_ast` property pointing back at the AST, forming
10+
* a cycle the cycle collector can only reclaim when the native AST's
11+
* `get_gc` handler exposes the cached wrappers to PHP's collector. These
12+
* tests break in every direction that cycle handling can regress:
1613
*
1714
* - Loops parsing many ASTs without explicit GC must not grow without
1815
* bound (ordinary mode of use).
@@ -34,17 +31,6 @@ protected function setUp(): void {
3431
if ( ! class_exists( 'WP_MySQL_Native_Parser', false ) ) {
3532
$this->markTestSkipped( 'Native MySQL parser extension is not loaded.' );
3633
}
37-
// The Rust-side identity cache forms a reference cycle (cache ->
38-
// wrapper -> $native_ast property -> WpMySqlNativeAst -> cache)
39-
// that PHP's GC cannot walk into without a custom gc_handler on
40-
// `zend_object_handlers->get_gc`. ext-php-rs 0.15 does not expose
41-
// the primitives needed to install one safely (see PR notes), so
42-
// these tests document the desired contract but stay incomplete
43-
// until the cycle collector can reach the Rust map. Skipping
44-
// avoids the false-positive memory failure on the CI runner.
45-
$this->markTestIncomplete(
46-
'Cycle collection across the Rust-side node_cache requires a custom get_gc handler that ext-php-rs 0.15 does not yet support; tracked as a follow-up.'
47-
);
4834
// Force a clean slate before each test — ASTs from earlier tests
4935
// must not pollute the memory measurements below.
5036
gc_collect_cycles();

packages/php-ext-wp-mysql-parser/src/lib.rs

Lines changed: 158 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33
use std::cell::RefCell;
44
use std::collections::{HashMap, HashSet};
55
use std::os::raw::c_char;
6+
use std::os::raw::c_int;
67
use std::ptr;
8+
use std::sync::atomic::{AtomicBool, Ordering};
79
use std::sync::Arc;
810

911
use ext_php_rs::boxed::ZBox;
1012

1113
use ext_php_rs::convert::{FromZval, IntoZval, IntoZvalDyn};
1214
use ext_php_rs::exception::{PhpException, PhpResult};
13-
use ext_php_rs::ffi::{zend_class_entry, zend_object, zval};
15+
use ext_php_rs::ffi::{
16+
ext_php_rs_executor_globals, zend_class_entry, zend_get_gc_buffer, zend_object,
17+
zend_object_handlers, zval, HashTable, IS_OBJECT_EX,
18+
};
1419
use ext_php_rs::flags::DataType;
1520
use ext_php_rs::prelude::*;
1621
use ext_php_rs::types::{ArrayKey, ZendCallable, ZendHashTable, ZendObject, Zval};
@@ -37,6 +42,13 @@ extern "C" {
3742
value: *mut zval,
3843
);
3944

45+
fn zend_get_gc_buffer_grow(gc_buffer: *mut zend_get_gc_buffer);
46+
47+
fn zend_std_get_gc(
48+
object: *mut zend_object,
49+
table: *mut *mut zval,
50+
n: *mut c_int,
51+
) -> *mut HashTable;
4052
}
4153

4254
#[derive(Clone)]
@@ -1223,19 +1235,132 @@ fn native_ast(native_ast: &Zval) -> PhpResult<&WpMySqlNativeAst> {
12231235
.ok_or_else(|| php_error("Missing native AST handle"))
12241236
}
12251237

1226-
/// Build a Zval that references an existing PHP object with refcount bumped.
1238+
/// Tracks whether the GC handler has been installed on `WP_MySQL_Native_Ast`'s
1239+
/// shared handlers struct. We patch in place rather than swapping the handlers
1240+
/// pointer because ext-php-rs uses that pointer as part of registered-class
1241+
/// identity checks.
1242+
static WP_MYSQL_NATIVE_AST_HANDLERS_PATCHED: AtomicBool = AtomicBool::new(false);
1243+
1244+
/// Custom `get_gc` handler that exposes `WpMySqlNativeAst.node_cache` to PHP's
1245+
/// cycle collector.
1246+
///
1247+
/// The Rust cache owns strong references to cached parser-node wrappers, and
1248+
/// each wrapper points back at the native AST through its `$native_ast`
1249+
/// property. Reporting the cached wrappers here gives PHP a complete view of
1250+
/// the otherwise opaque `AST -> cache -> wrapper -> AST` cycle.
1251+
unsafe extern "C" fn ast_get_gc(
1252+
object: *mut zend_object,
1253+
table: *mut *mut zval,
1254+
n: *mut c_int,
1255+
) -> *mut HashTable {
1256+
*table = ptr::null_mut();
1257+
*n = 0;
1258+
1259+
let mut standard_table = ptr::null_mut();
1260+
let mut standard_count = 0;
1261+
let standard_properties = zend_std_get_gc(object, &mut standard_table, &mut standard_count);
1262+
1263+
let Some(ast) = ext_php_rs::types::ZendClassObject::<WpMySqlNativeAst>::from_zend_obj(&*object)
1264+
.and_then(|z| z.obj.as_ref())
1265+
else {
1266+
*table = standard_table;
1267+
*n = standard_count;
1268+
return standard_properties;
1269+
};
1270+
1271+
let Ok(cache) = ast.node_cache.try_borrow() else {
1272+
*table = standard_table;
1273+
*n = standard_count;
1274+
return standard_properties;
1275+
};
1276+
1277+
let Some(gc_buffer) = gc_buffer_create() else {
1278+
*table = standard_table;
1279+
*n = standard_count;
1280+
return standard_properties;
1281+
};
1282+
1283+
if !standard_table.is_null() {
1284+
for offset in 0..standard_count {
1285+
gc_buffer_add_zval(gc_buffer, standard_table.add(offset as usize));
1286+
}
1287+
}
1288+
for boxed in cache.values() {
1289+
gc_buffer_add_obj(gc_buffer, ptr::from_ref(boxed.as_ref()).cast_mut());
1290+
}
1291+
gc_buffer_use(gc_buffer, table, n);
1292+
1293+
standard_properties
1294+
}
1295+
1296+
unsafe fn gc_buffer_create() -> Option<*mut zend_get_gc_buffer> {
1297+
let executor_globals = ext_php_rs_executor_globals();
1298+
if executor_globals.is_null() {
1299+
return None;
1300+
}
1301+
1302+
let gc_buffer = ptr::addr_of_mut!((*executor_globals).get_gc_buffer);
1303+
(*gc_buffer).cur = (*gc_buffer).start;
1304+
Some(gc_buffer)
1305+
}
1306+
1307+
unsafe fn gc_buffer_add_obj(gc_buffer: *mut zend_get_gc_buffer, obj: *mut zend_object) {
1308+
if (*gc_buffer).cur == (*gc_buffer).end {
1309+
zend_get_gc_buffer_grow(gc_buffer);
1310+
}
1311+
1312+
let slot = (*gc_buffer).cur;
1313+
(*slot).value.obj = obj;
1314+
(*slot).u1.type_info = IS_OBJECT_EX;
1315+
(*gc_buffer).cur = (*gc_buffer).cur.add(1);
1316+
}
1317+
1318+
unsafe fn gc_buffer_add_zval(gc_buffer: *mut zend_get_gc_buffer, value: *const zval) {
1319+
if value.is_null() {
1320+
return;
1321+
}
1322+
if (*gc_buffer).cur == (*gc_buffer).end {
1323+
zend_get_gc_buffer_grow(gc_buffer);
1324+
}
1325+
1326+
let slot = (*gc_buffer).cur;
1327+
ptr::copy_nonoverlapping(value, slot, 1);
1328+
(*gc_buffer).cur = (*gc_buffer).cur.add(1);
1329+
}
1330+
1331+
unsafe fn gc_buffer_use(gc_buffer: *mut zend_get_gc_buffer, table: *mut *mut zval, n: *mut c_int) {
1332+
*table = (*gc_buffer).start;
1333+
if (*gc_buffer).start.is_null() {
1334+
*n = 0;
1335+
} else {
1336+
*n = (*gc_buffer).cur.offset_from((*gc_buffer).start) as c_int;
1337+
}
1338+
}
1339+
1340+
/// Patch `WP_MySQL_Native_Ast`'s shared `zend_object_handlers` so PHP's cycle
1341+
/// collector can walk cached wrappers held by the Rust-side HashMap.
1342+
unsafe fn install_gc_handler_for(obj: *mut zend_object) {
1343+
if WP_MYSQL_NATIVE_AST_HANDLERS_PATCHED.load(Ordering::Acquire) {
1344+
return;
1345+
}
1346+
1347+
let handlers = (*obj).handlers;
1348+
if handlers.is_null() {
1349+
return;
1350+
}
1351+
1352+
let handlers_mut = handlers as *mut zend_object_handlers;
1353+
(*handlers_mut).get_gc = Some(ast_get_gc);
1354+
WP_MYSQL_NATIVE_AST_HANDLERS_PATCHED.store(true, Ordering::Release);
1355+
}
1356+
1357+
/// Build a Zval that references an existing PHP object.
12271358
///
12281359
/// Used on cache hits to hand a stored `ZBox<ZendObject>` back to PHP without
1229-
/// allocating a new wrapper. PHP convention for object zvals is that the
1230-
/// zval owns one strong reference, so we have to addref before publishing
1231-
/// the pointer; otherwise the object would be freed twice (once when this
1232-
/// zval drops, once when the cache drops).
1233-
fn zval_from_object_addref(obj: &mut ZendObject) -> Zval {
1234-
// PHP convention: an object zval owns one strong reference, so bump
1235-
// the embedded zend_refcounted_h refcount before publishing the
1236-
// pointer; otherwise the object would be freed twice (once when this
1237-
// zval drops, once when the cache drops).
1238-
obj.gc.refcount += 1;
1360+
/// allocating a new wrapper. `Zval::set_object()` bumps the object refcount
1361+
/// for the returned zval; the `ZBox` in `node_cache` owns the cache's
1362+
/// reference.
1363+
fn zval_from_cached_object(obj: &mut ZendObject) -> Zval {
12391364
let mut zv = Zval::new();
12401365
zv.set_object(obj);
12411366
zv
@@ -1266,12 +1391,14 @@ impl WpMySqlNativeAst {
12661391
index: usize,
12671392
classes: &PhpClasses,
12681393
) -> PhpResult<Zval> {
1269-
let mut cache = self.node_cache.borrow_mut();
1394+
{
1395+
let mut cache = self.node_cache.borrow_mut();
12701396

1271-
// Cache hit: skip allocation entirely and return a Zval pointing
1272-
// at the cached wrapper with refcount bumped.
1273-
if let Some(boxed) = cache.get_mut(&index) {
1274-
return Ok(zval_from_object_addref(boxed));
1397+
// Cache hit: skip allocation entirely and return a Zval pointing
1398+
// at the cached wrapper with refcount bumped by `set_object()`.
1399+
if let Some(boxed) = cache.get_mut(&index) {
1400+
return Ok(zval_from_cached_object(boxed));
1401+
}
12751402
}
12761403

12771404
// Cache miss: build the wrapper as `create_php_node_with_classes`
@@ -1312,11 +1439,15 @@ impl WpMySqlNativeAst {
13121439
idx_i64,
13131440
)?;
13141441

1442+
let mut cache = self.node_cache.borrow_mut();
1443+
if let Some(boxed) = cache.get_mut(&index) {
1444+
return Ok(zval_from_cached_object(boxed));
1445+
}
13151446
cache.insert(index, object);
13161447
let stored = cache
13171448
.get_mut(&index)
13181449
.expect("just-inserted node missing from cache");
1319-
Ok(zval_from_object_addref(stored))
1450+
Ok(zval_from_cached_object(stored))
13201451
}
13211452
}
13221453

@@ -1838,6 +1969,15 @@ impl WpMySqlNativeParser {
18381969
.into_zval(false)
18391970
.map_err(php_error)?;
18401971
let native_ast = native_ast(&native_ast_zval)?;
1972+
// Install the custom `get_gc` after ext-php-rs can resolve the
1973+
// object. PHP's cycle collector then sees wrappers held by the
1974+
// Rust-side cache.
1975+
unsafe {
1976+
let obj_ref = native_ast_zval
1977+
.object()
1978+
.ok_or_else(|| php_error("Native AST zval is not an object"))?;
1979+
install_gc_handler_for(ptr::from_ref(obj_ref).cast_mut());
1980+
}
18411981
native_ast.arena.create_php_ast(&native_ast_zval)
18421982
})
18431983
}

0 commit comments

Comments
 (0)