Skip to content

Commit c229525

Browse files
ondrejmirtesclaude
andcommitted
Fix divergences between the native extension and the PHP implementations
Found by a line-by-line review of every shadowed pair against its PHP twin: - The parser dropped T_BAD_CHARACTER only below the grammar's symbol-map size, but its id (411 on PHP 8.5) sits exactly at that bound — a stray control byte threw RangeException instead of parsing with a collected error. The drop-token table has its own size; bound both checks by it. - String_::parseEscapeSequences() throwing (oversized \u{...} codepoints in interpolated strings and backticks) left a raw pending PhpParser\Error where doParse()'s catch block hands it to the error handler. The translation createNode already did is extracted into abortForPendingException() and the escape-sequence helper routes through it. - The node-class registry latched the first caller's useCtor flag, so isInstanceOf('Node\Arg') — reached by parsing exit(...)/die(...) — poisoned Node\Arg into the constructor path, whose attributes-last convention Arg's signature breaks: every later file with call arguments failed with a TypeError. The property-write plan is now derived lazily and the path chosen per call. - TrinaryLogic::lazyMaxMin([]) threw ShouldNotHappenException; the PHP twin returns Yes ( starts at YES). The class is @api, so third parties can hit the empty case. - ScopeOps::finishMerge()'s array_merge() renumbered integer-coerced expression keys (an expression printing as '5' is stored under int key 5), corrupting them — the native side preserves keys. The PHP twin now uses + (the key sets are disjoint by construction). - CombinationsHelper's eagerly materialized product could overflow zend_ulong and silently truncate; it now fails loudly at PRODUCT_LIMIT. Inner elements are dereferenced like the twin's by-value foreach. - NodeTraverser owns references to the current subnode across visitor hooks and to the visitors in its plan — userland writes to the parent property or the visitor list mid-traversal could otherwise free objects still in use. - ScopeOps::getTypeFromCache() writes the by-ref $key on hit and miss (the twin assigns it unconditionally), and treats a stored null as a miss exactly like the twin's ?? null. - The array_map-args suffix of an expression key appended full node keys (re-appending /*pos:...*/ and /*keepVoid*/ suffixes of the arguments) where the twin appends plain printExpr() output. - Pass 2 of matchConditionalExpressions() validates holder types like Pass 1 instead of reading property slots type-confused. - zv.h's Ref::assign()/ObjRef::propAtWrite() install the new value before destroying the old one (the engine's assignment idiom), so a __destruct re-reading the slot cannot observe a freed zval. Corpus fixtures cover the three parser bugs (malformed input and the first-class-callable exit ordering); smoke covers lazyMaxMin([]). Interleaved re-benchmark after the fixes: user CPU and Used memory unchanged (-17% vs PHPSTAN_TURBO=0, +5-6% memory), analysis output byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVQhbBxDjDVAVeJVvLpWYE
1 parent 0e60a94 commit c229525

16 files changed

Lines changed: 254 additions & 105 deletions

src/Analyser/ScopeOps.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
use function array_filter;
2626
use function array_key_exists;
2727
use function array_key_first;
28-
use function array_merge;
2928
use function count;
3029
use function get_class;
3130
use function in_array;
@@ -307,7 +306,11 @@ public static function finishMerge(
307306

308307
return [
309308
$mergedExpressionTypes,
310-
array_merge($mergedNativeExpressionTypes, array_filter(self::mergeVariableHolders($ourNativeExpressionTypes, $theirNativeExpressionTypes), $filter)),
309+
// + instead of array_merge: the key sets are disjoint (matching entries were
310+
// unset from both native maps above), and array_merge would renumber
311+
// integer-coerced expression keys (an expression printing as '5' is stored
312+
// under int key 5), corrupting them. The native implementation preserves keys.
313+
$mergedNativeExpressionTypes + array_filter(self::mergeVariableHolders($ourNativeExpressionTypes, $theirNativeExpressionTypes), $filter),
311314
];
312315
}
313316

turbo-ext/src/CombinationsHelper.cpp

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ class CombinationsHelper
1919
/* caps the result array's pre-allocation hint, not the product itself */
2020
static constexpr zend_ulong SIZE_HINT_LIMIT = 1048576;
2121

22+
/* combinations() refuses products beyond this instead of letting the
23+
* multiply overflow and silently truncate the result */
24+
static constexpr zend_ulong PRODUCT_LIMIT = 1ULL << 32;
25+
2226
/* UNDEF = pending exception */
2327
static zv::Val combinations(zv::ArrRef arrays)
2428
{
@@ -57,11 +61,29 @@ class CombinationsHelper
5761
vecs[i] = NULL;
5862
continue;
5963
}
64+
if (UNEXPECTED(total > PRODUCT_LIMIT / sizes[i])) {
65+
/* The PHP twin is a lazy generator and never materializes the
66+
* product, but every consumer iterates it fully, so a product
67+
* this size is unreachable in practice. Failing loudly beats
68+
* the silent truncation an overflowed multiply would cause. */
69+
for (uint32_t k = 0; k < i; k++) {
70+
if (vecs[k] != NULL) {
71+
efree(vecs[k]);
72+
}
73+
}
74+
efree(vecs);
75+
efree(sizes);
76+
efree(inner);
77+
pt_throw_should_not_happen();
78+
return zv::Val();
79+
}
6080
total *= sizes[i];
6181
vecs[i] = (zval **) emalloc(sizes[i] * sizeof(zval *));
6282
uint32_t j = 0;
6383
for (auto entry : innerArr) {
64-
vecs[i][j++] = entry.value().raw();
84+
/* deref like the twin's by-value foreach: a reference slot
85+
* must not propagate a shared reference into every combination */
86+
vecs[i][j++] = entry.value().deref().raw();
6587
}
6688
}
6789

turbo-ext/src/NodeTraverser.cpp

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,12 @@ class NodeTraverser
203203
~NodeTraverser()
204204
{
205205
if (plan != NULL) {
206+
/* the plan owns its visitor references: removeVisitor() (or any
207+
* userland write to $this->visitors) during traversal must not be
208+
* able to free a visitor the plan still dispatches to */
209+
for (uint32_t i = 0; i < nplanned; i++) {
210+
OBJ_RELEASE(plan[i].visitor);
211+
}
206212
efree(plan);
207213
}
208214
}
@@ -370,6 +376,11 @@ class NodeTraverser
370376
if (!value.instanceOf(nodeIface)) {
371377
continue;
372378
}
379+
/* Own a reference for the whole block: a visitor writing to the
380+
* parent's property from a hook can otherwise drop the node's
381+
* last reference while later hooks still run on it. The PHP twin
382+
* survives that by construction ($subNode owns a reference). */
383+
zv::Val subNodeOwned = zv::Val::copyOf(zv::Ref(value.raw()));
373384
zend_object *subNode = value.asObject();
374385

375386
bool traverseChildren = true;
@@ -401,6 +412,7 @@ class NodeTraverser
401412
if (UNEXPECTED(!writeSubnode(node, info->names[i], retRef))) {
402413
return;
403414
}
415+
subNodeOwned = zv::Val::copyOf(retRef);
404416
subNode = retRef.asObject();
405417
continue;
406418
}
@@ -465,6 +477,7 @@ class NodeTraverser
465477
if (UNEXPECTED(!writeSubnode(node, info->names[i], retRef))) {
466478
return;
467479
}
480+
subNodeOwned = zv::Val::copyOf(retRef);
468481
subNode = retRef.asObject();
469482
continue;
470483
}
@@ -748,6 +761,8 @@ class NodeTraverser
748761
}
749762
pt_visitor_plan *p = &plan[i];
750763
p->visitor = visitor.asObject();
764+
GC_ADDREF(p->visitor);
765+
nplanned = i + 1;
751766
p->ce = p->visitor->ce;
752767
p->enter_fn = findHook(p->ce, "enternode", sizeof("enternode") - 1);
753768
p->leave_fn = findHook(p->ce, "leavenode", sizeof("leavenode") - 1);
@@ -821,6 +836,8 @@ class NodeTraverser
821836
zend_object *self;
822837
pt_visitor_plan *plan = NULL;
823838
uint32_t nvisitors = 0;
839+
/* how many plan entries own a visitor reference (build can fail mid-way) */
840+
uint32_t nplanned = 0;
824841
bool stop = false;
825842
bool failed = false;
826843
};

turbo-ext/src/ScopeOps.cpp

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,11 @@ class ScopeOps
108108
}
109109

110110
/*
111-
* Mirrors ScopeOps::getTypeFromCache(). On a hit *keyOut stays NULL and
112-
* the resolved Type is returned; on a miss null is returned and *keyOut
113-
* receives the computed key (owned by the caller) so the PHP slow path
114-
* can proceed without recomputing it.
111+
* Mirrors ScopeOps::getTypeFromCache(). *keyOut receives the computed key
112+
* (owned by the caller) on both hit and miss, like the twin's
113+
* unconditional `$key = ...` assignment; it stays NULL only when an
114+
* exception is pending. A stored null counts as a miss — the twin's
115+
* `?? null` cannot distinguish it from an absent entry either.
115116
*/
116117
static zv::Val getTypeFromCache(zval *scope, zend_object *node, zend_string **keyOut)
117118
{
@@ -137,8 +138,10 @@ class ScopeOps
137138
}
138139
if (EXPECTED(Z_TYPE_P(table) == IS_ARRAY)) {
139140
zval *found = zend_symtable_find(Z_ARRVAL_P(table), key.get());
140-
if (found != NULL) {
141-
return zv::Val::copyOf(zv::Ref(found));
141+
if (found != NULL && Z_TYPE_P(found) != IS_NULL) {
142+
zv::Val result = zv::Val::copyOf(zv::Ref(found));
143+
*keyOut = key.take();
144+
return result;
142145
}
143146
}
144147

@@ -1121,6 +1124,12 @@ class ScopeOps
11211124
}
11221125
zv::Ref conditionHolder = conditionEntry.value().deref();
11231126
zv::Ref specifiedHolder = zv::Ref(specifiedSlot).deref();
1127+
/* Pass 1 validates only the entries it reaches before
1128+
* its first mismatch, so these can be unchecked here;
1129+
* the twin raises a catchable Error on wrong types */
1130+
if (UNEXPECTED(!pt_check_holder(conditionHolder.raw()) || !pt_check_holder(specifiedHolder.raw()))) {
1131+
return zv::Val();
1132+
}
11241133
if (pt_holder_certainty_value(conditionHolder.asObject()) != pt_holder_certainty_value(specifiedHolder.asObject())) {
11251134
all = false;
11261135
break;
@@ -2088,7 +2097,7 @@ void pt_register_scope_ops()
20882097
RETURN_THROWS();
20892098
}
20902099
if (key != NULL) {
2091-
/* cache miss: hand the computed key to the by-ref parameter */
2100+
/* hand the computed key to the by-ref parameter (hit and miss) */
20922101
if (Z_ISREF_P(key_out)) {
20932102
ZEND_TRY_ASSIGN_REF_STR(key_out, key);
20942103
} else {

turbo-ext/src/TrinaryLogic.cpp

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,9 @@ static void pt_trinary_lazy(INTERNAL_FUNCTION_PARAMETERS, LazyEvaluation::Mode m
293293
Z_PARAM_FUNC(fci, fcc)
294294
ZEND_PARSE_PARAMETERS_END();
295295

296-
if (mode == LazyEvaluation::MAX_MIN && UNEXPECTED(zend_hash_num_elements(objects) == 0)) {
297-
pt_throw_should_not_happen();
298-
RETURN_THROWS();
299-
}
296+
/* no empty-array check for MAX_MIN: unlike extremeIdentity()/maxMin(), the
297+
* PHP twin's lazyMaxMin([]) returns Yes ($min starts at YES), and run()'s
298+
* accumulator reproduces that */
300299

301300
zval objectsZv;
302301
ZVAL_ARR(&objectsZv, objects);

turbo-ext/src/parser/ParserEngine.h

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ struct Tables
7777
int *ruleToLength;
7878
zend_string **symbolToName; /* borrowed persistent copies (interned dup) */
7979
int symbolToNameSize;
80-
bool *dropTokens; /* indexed by php token id, size phpTokenToSymbolSize */
80+
bool *dropTokens; /* indexed by php token id */
81+
int dropTokensSize; /* ≥ phpTokenToSymbolSize: T_BAD_CHARACTER sits above the grammar's symbol map */
8182
};
8283

8384
/* Per-node-class construction plan, cached per process in the class registry. */
@@ -89,7 +90,13 @@ struct NodeClassInfo
8990
int propSlots[16];
9091
uint32_t attrsSlot; /* slot of NodeAbstract::$attributes */
9192
int numProps;
92-
bool useCtor; /* subNodes-style or otherwise non-trivial ctor */
93+
/* The slot-write plan is derived lazily, on the first resolve that wants
94+
* it — the registry entry must not depend on which caller saw the class
95+
* first (isInstanceOf() resolves with useCtor=true; letting that poison
96+
* the cache once made every later Node\Arg construction take the ctor
97+
* path, whose attributes-last convention Arg's signature breaks). */
98+
enum PlanState : uint8_t { PLAN_NONE = 0, PLAN_OK, PLAN_FAILED };
99+
PlanState planState;
93100
};
94101

95102
/*
@@ -229,6 +236,9 @@ class ParserEngine
229236
void emitError(zend_string *msg, zv::Val attributes); /* msg borrowed */
230237
/* `throw new Error(...)`: records the abort; the caller must return */
231238
void fatalError(const char *msg, zv::Val attributes);
239+
/* doParse()'s catch (Error $e) for a pending exception thrown by PHP
240+
* code the engine called (node ctors, String_::parseEscapeSequences) */
241+
void abortForPendingException();
232242

233243
/* ===== class resolution + node creation (scoped-phar safe) ===== */
234244

0 commit comments

Comments
 (0)