Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions lib/JIT/IncludeHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
use PHPLLVM\BasicBlock;
use PHPLLVM\Value\Function_;
use PHPCompiler\Block;
use PHPCompiler\Compiler\CompileFatal;
use PHPCompiler\JIT;
use PHPCompiler\JIT\Context;
use PHPCompiler\JIT\OperandName;
use PHPCompiler\JIT\Variable;
use PHPCompiler\OpCode;
use PHPCompiler\Runtime;
use PHPCompiler\VM\VmInclude;
use PHPCompiler\ext\standard\IncludeBindingJitHelper;
use PHPCompiler\ext\standard\IncludeJitHelper;
use PHPCompiler\Web\DeployRoot;
Expand Down Expand Up @@ -95,9 +98,33 @@ private static function compileIncludedFile(

$context->recordJitIncludedFile($path);

$included = $context->runtime->parseAndCompileFile($path, true);
try {
$included = $context->runtime->parseAndCompileFile($path, true);
} catch (\Throwable $e) {
if (VmInclude::isCatchableSyntaxParseThrowable($e)) {
self::emitIncludeParseError($jit, $path, $e);

return;
}
throw $e;
}
if (null === $included) {
$diag = $context->runtime->compiler->getCompileAbortDetail();
$diag = $context->runtime->compiler->getCompileAbortDetail()
?? Runtime::getLastParseFailure();
$normalized = VmInclude::normalizeSyntaxParseMessage((string) $diag);
if (
null !== $diag && '' !== $diag
&& (CompileFatal::isSyntaxParseErrorMessage($normalized)
|| (bool) preg_match('/syntax error\\b/i', $normalized))
) {
self::emitIncludeParseError(
$jit,
$path,
new \ParseError($normalized)
);

return;
}
$suffix = null !== $diag && '' !== $diag ? ' — '.$diag : ' — (no compiler abort detail; parser/CFG returned null)';
throw new \LogicException('failed to compile include: '.$path.$suffix);
}
Expand All @@ -107,6 +134,31 @@ private static function compileIncludedFile(
self::compileInlinedBlock($jit, $func, $callerBlock, $included, $resultOperand, false, 'c:include:'.$path);
}

/**
* Emit catchable ParseError at the include site (php-src ZEND_INCLUDE_OR_EVAL, #32154).
*
* Literal includes are inlined at JIT compile time; a syntax-error target must not abort
* compilation of the caller — seed Error→CompileError→ParseError then throw into user catch.
*/
private static function emitIncludeParseError(JIT $jit, string $path, \Throwable $error): void
{
$message = VmInclude::syntaxParseMessage($error);
$line = VmInclude::syntaxParseLine($error);
$object = $jit->context->type->object;
$object->lookup('Error');
$object->lookup('CompileError');
$object->lookup('ParseError');
TryCatchHelper::emitCatchableClassError(
$jit->context,
'ParseError',
$message,
$jit,
$path,
$line
);
BasicBlockHelper::ensureOpenInsertBlock($jit->context, 'include_parse_error_cont');
}

/**
* Inline a compiled block in caller scope (include/require or eval, issue #4652).
*/
Expand Down
8 changes: 8 additions & 0 deletions lib/Runtime.php
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,14 @@ public function parseAndCompileFile(string $filename, bool $forIncludeTarget = f

return $block;
} catch (\Throwable $e) {
// include/require syntax errors are catchable ParseError in the caller (#32154);
// do not leak parseAndCompile / PhpParser / bundle_snippet on stderr.
if ($forIncludeTarget && VM\VmInclude::isCatchableSyntaxParseThrowable($e)) {
$detail = $this->compiler->getCompileAbortDetail();
$primary = null !== $detail && '' !== $detail ? $detail : $e->getMessage();
$this->recordLastParseFailure(sprintf('%s: %s', $filename, $primary));
throw $e;
}
$this->emitParseCompileFailureStderr($filename, $e, isset($code) ? $code : null);
throw $e;
} finally {
Expand Down
56 changes: 55 additions & 1 deletion lib/VM.php
Original file line number Diff line number Diff line change
Expand Up @@ -9970,7 +9970,36 @@ function () use ($frame, $op, $arg1, $strict, $arraySpec, $vmContext): void {
}
$this->context->recordIncludedFile($resolved);
$this->context->scriptStack->push($resolved);
$parsed = $this->context->runtime->parseAndCompileFile($resolved, true);
try {
$parsed = $this->context->runtime->parseAndCompileFile($resolved, true);
} catch (\Throwable $e) {
$this->context->scriptStack->pop();
if (VM\VmInclude::isCatchableSyntaxParseThrowable($e)) {
$catchFrame = $this->dispatchIncludeParseError($e, $resolved, $frame);
if (null !== $catchFrame) {
$frame = $catchFrame;
goto restart;
}
break;
}
throw $e;
}
if (null === $parsed) {
$this->context->scriptStack->pop();
$detail = $this->context->runtime->formatParseAndCompileNullDetail(null)
?? Runtime::getLastParseFailure()
?? 'syntax error';
$catchFrame = $this->dispatchIncludeParseError(
new \ParseError(VM\VmInclude::normalizeSyntaxParseMessage($detail)),
$resolved,
$frame
);
if (null !== $catchFrame) {
$frame = $catchFrame;
goto restart;
}
break;
}
$new = $parsed->getFrame($this->context, $frame);
$new->ephemeral = true;
// ZEND_INCLUDE_OR_EVAL copies EX(This) into the included op_array (#31903).
Expand Down Expand Up @@ -13491,6 +13520,31 @@ private function dispatchVmParseError(\ParseError $error, Frame $frame): ?Frame
return $this->dispatchBuiltinThrowable($frame, $thrown);
}

/**
* include/require syntax failure — catchable ParseError in the included file (#32154).
*
* php-src: Zend/zend_execute.c ZEND_INCLUDE_OR_EVAL; zend_compile_file parse failures.
*/
private function dispatchIncludeParseError(\Throwable $error, string $includedFile, Frame $frame): ?Frame
{
$message = VM\VmInclude::syntaxParseMessage($error);
$line = VM\VmInclude::syntaxParseLine($error);
$this->context->errors->recordLastError(
VM\ErrorReporter::E_PARSE,
$message,
$includedFile,
$line
);
$thrown = VM\BuiltinExceptionSupport::materializeParseError(
$this->context,
$message,
$includedFile,
$line
);

return $this->dispatchBuiltinThrowable($frame, $thrown);
}

/** Bridge native ReflectionException from reflection builtins into user catch handlers (#7344). */
private function dispatchVmReflectionException(\ReflectionException $error, Frame $frame): ?Frame
{
Expand Down
71 changes: 71 additions & 0 deletions lib/VM/VmInclude.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

namespace PHPCompiler\VM;

use PHPCompiler\Compiler\CompileFatal;
use PHPCompiler\OpCode;

/**
* Include/require semantics SSOT for VM and compiled JIT/AOT (#10063, php-in-PHP).
*
* php-src: Zend/zend_execute.c — ZEND_INCLUDE_OR_EVAL, once-guard, return value
* php-src: main/fopen_wrappers.c — missing-file stream + Failed opening diagnostics (#30029)
* php-src: Zend/zend_compile.c — include syntax failures become catchable ParseError (#32154)
*/
final class VmInclude
{
Expand Down Expand Up @@ -59,6 +61,75 @@ public static function failedOpeningForInclusionMessage(
);
}

/**
* True when include/require compile failed with a Zend parser syntax error (#32154).
*
* php-src: zend_compile_file / ZEND_INCLUDE_OR_EVAL — syntax rejects throw catchable
* ParseError into the caller; they must not abort the process as parseAndCompile failure.
*/
public static function isCatchableSyntaxParseThrowable(\Throwable $e): bool
{
if ($e instanceof \ParseError || $e instanceof \PhpParser\Error) {
return true;
}

return CompileFatal::isSyntaxParseErrorMessage(self::stripParserLineSuffix($e->getMessage()));
}

/**
* php-parser / CompileFatal text toward Zend "syntax error, …" (zend_language_parser.y).
*/
public static function syntaxParseMessage(\Throwable $e): string
{
return self::normalizeSyntaxParseMessage($e->getMessage());
}

public static function normalizeSyntaxParseMessage(string $detail): string
{
$message = trim($detail);
if (str_starts_with(strtolower($message), 'parse error:')) {
$message = trim(substr($message, strlen('Parse error:')));
}
$message = self::stripParserLineSuffix($message);
if (str_starts_with($message, 'Syntax error,')) {
return 'syntax error,'.substr($message, strlen('Syntax error,'));
}

return $message;
}

public static function syntaxParseLine(\Throwable $e): int
{
if ($e instanceof \PhpParser\Error) {
$line = $e->getStartLine();
if ($line > 0) {
return $line;
}
}
if ($e instanceof CompileFatal && $e->sourceLine > 0) {
return $e->sourceLine;
}
if (preg_match('/\bon line (\d+)\b/', $e->getMessage(), $m)) {
return max(1, (int) $m[1]);
}
if ($e->getCode() > 0) {
return $e->getCode();
}

return 1;
}

/** php-parser appends " on line N"; Zend ParseError messages do not. */
public static function stripParserLineSuffix(string $message): string
{
$message = trim($message);
if (1 === preg_match('/^(.*) on line \d+$/', $message, $m)) {
return trim($m[1]);
}

return $message;
}

/**
* Fatal Error message for require/require_once after the stream Warning (zend_execute.c).
*/
Expand Down
2 changes: 2 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
<file>./test/compliance/IncludeInheritsThisJITTest.php</file>
<file>./test/compliance/IncludeInheritsClassScopeVMTest.php</file>
<file>./test/compliance/IncludeInheritsClassScopeJITTest.php</file>
<file>./test/compliance/IncludeParseError32154VMTest.php</file>
<file>./test/compliance/IncludeParseError32154JITTest.php</file>
<file>./test/compliance/EvalInheritsThisVMTest.php</file>
<file>./test/compliance/EvalInheritsThisJITTest.php</file>
<file>./test/compliance/EvalEmptyReturnsFalse31914VMTest.php</file>
Expand Down
31 changes: 31 additions & 0 deletions test/compliance/IncludeParseError32154JITTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace PHPCompiler;

require_once __DIR__.'/../BaseTest.php';

/**
* JIT: include() of a syntax-error file is catchable ParseError (#32154).
*
* Dedicated provider — full JITTest discovery is heavy, and path-slash data-set
* names break --filter.
*/
final class IncludeParseError32154JITTest extends BaseTest
{
protected static string $DIR = __DIR__;

public static function providePHPTests(): \Generator
{
yield 'include_parse_error.phpt' => self::parsePHPT(
__DIR__.'/cases/language/include_parse_error.phpt',
'include_parse_error.phpt'
);
}

public function setUp(): void
{
$this->BIN = realpath(__DIR__.'/../../bin/jit.php');
}
}
31 changes: 31 additions & 0 deletions test/compliance/IncludeParseError32154VMTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace PHPCompiler;

require_once __DIR__.'/../BaseTest.php';

/**
* VM: include() of a syntax-error file is catchable ParseError (#32154).
*
* Dedicated provider — full VMTest discovery is heavy, and path-slash data-set
* names break --filter.
*/
final class IncludeParseError32154VMTest extends BaseTest
{
protected static string $DIR = __DIR__;

public static function providePHPTests(): \Generator
{
yield 'include_parse_error.phpt' => self::parsePHPT(
__DIR__.'/cases/language/include_parse_error.phpt',
'include_parse_error.phpt'
);
}

public function setUp(): void
{
$this->BIN = realpath(__DIR__.'/../../bin/vm.php');
}
}
6 changes: 6 additions & 0 deletions test/compliance/cases/language/include_parse_error.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
--TEST--
language include() of syntax-error file throws catchable ParseError (#32154, Zend/zend_execute.c)
--RUNFILE--
include_parse_error/entry.php
--EXPECT--
ParseError:syntax error, unexpected T_LNUMBER, expecting ';'
1 change: 1 addition & 0 deletions test/compliance/cases/language/include_parse_error/bad.php
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<?php echo 1 2;
7 changes: 7 additions & 0 deletions test/compliance/cases/language/include_parse_error/entry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php
try {
include __DIR__ . '/bad.php';
echo "after";
} catch (Throwable $e) {
echo get_class($e), ':', $e->getMessage();
}
11 changes: 11 additions & 0 deletions test/repro/maintainer_gap_include_parse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
/**
* #32154 — include() of a syntax-error file is a catchable ParseError (php-src ZEND_INCLUDE_OR_EVAL).
* Must not abort the process with parseAndCompile failure / PhpParser dump.
*/
try {
include __DIR__ . '/maintainer_gap_include_parse_bad.php';
echo "after";
} catch (Throwable $e) {
echo get_class($e), ':', $e->getMessage();
}
1 change: 1 addition & 0 deletions test/repro/maintainer_gap_include_parse_bad.php
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<?php echo 1 2;
3 changes: 3 additions & 0 deletions test/unit/IncludeHelperShrinkTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public function testIncludeHelperDelegatesToIncludeJitHelper(): void
$this->assertStringContainsString('IncludeJitHelper::', $source);
$this->assertStringContainsString('IncludeBindingJitHelper::', $source);
$this->assertStringContainsString('IncludeBindingEmitHelper::', $source);
$this->assertStringContainsString('VmInclude::isCatchableSyntaxParseThrowable', $source);
$this->assertStringContainsString('emitCatchableClassError', $source);
$this->assertStringContainsString("'ParseError'", $source);
$this->assertStringNotContainsString('function shouldSkipSelfHostSpineCliInclude', $source);
$this->assertStringNotContainsString('function shouldStubM3SidecarHostNonLiteralInclude', $source);
$this->assertStringNotContainsString('function resolveLiteralPath', $source);
Expand Down
13 changes: 13 additions & 0 deletions test/unit/VmIncludeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ public function testMissingIncludeDiagnosticsMatchZend(): void
);
}

public function testIncludeSyntaxParseHelpersMatchZendChannel(): void
{
$parser = new \PhpParser\Error('Syntax error, unexpected T_LNUMBER, expecting \';\'', ['startLine' => 1]);
self::assertTrue(VmInclude::isCatchableSyntaxParseThrowable($parser));
self::assertSame(
'syntax error, unexpected T_LNUMBER, expecting \';\'',
VmInclude::syntaxParseMessage($parser)
);
self::assertSame(1, VmInclude::syntaxParseLine($parser));
self::assertTrue(VmInclude::isCatchableSyntaxParseThrowable(new \ParseError('syntax error, unexpected integer "2"')));
self::assertFalse(VmInclude::isCatchableSyntaxParseThrowable(new \RuntimeException('failed to open stream')));
}

public function testShouldSkipSelfHostSpineCliIncludeWhenSelfHostAot(): void
{
$prev = getenv('PHP_COMPILER_SELFHOST_AOT');
Expand Down
Loading