Skip to content

Commit 7bc498b

Browse files
Restore the error handler in finally, through one shared helper
Seven blocks installed initializeFromError() as the error handler and restored it only from catch (QueryPath\Exception), so any other throwable left the handler installed. Route them all through DOM::withErrorHandler(), which restores it in finally. Fixes #95 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cd0de71 commit 7bc498b

5 files changed

Lines changed: 112 additions & 64 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ QueryPath Changelog
2727
- Fix the `:text` pseudo-class so it matches jQuery: it selects `input` elements whose `type` attribute is absent or is `text` (case-insensitively). It never indicated, and still does not indicate, whether a node is a text node
2828
- `find('*')` now matches the nodes in the match set as well as their descendants, so that a selector can be tested against an element already in hand
2929
- `writeHTML5()` now returns the `DOMQuery` like the other `write*()` methods, and throws `QueryPath\IOException` instead of a `TypeError` when the file cannot be written (issue #80)
30+
- Fix QueryPath's error handler staying installed for the rest of the request when an exception other than a `QueryPath\Exception` (a `TypeError`, or one thrown by a stream wrapper, for example) escaped from parsing a document, inserting markup, or `writeXML()`/`writeHTML()` (issue #95)
3031

3132
# 4.1.0
3233

‎src/DOM.php‎

Lines changed: 35 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,7 @@ private function parseXMLString($string, $flags = 0)
177177
{
178178
$document = self::createDocument('1.0');
179179
$lead = strtolower(substr($string, 0, 5)); // <?xml
180-
try {
181-
set_error_handler([ParseException::class, 'initializeFromError'], $this->errTypes);
182-
180+
self::withErrorHandler(ParseException::class, function () use ($document, $string, $flags, $lead) {
183181
if (isset($this->options['convert_to_encoding'])) {
184182
// Is there another way to do this?
185183
@@ -217,12 +215,7 @@ private function parseXMLString($string, $flags = 0)
217215
else {
218216
self::loadHTMLString($document, $string);
219217
}
220-
} // Emulate 'finally' behavior.
221-
catch (Exception $e) {
222-
restore_error_handler();
223-
throw $e;
224-
}
225-
restore_error_handler();
218+
}, $this->errTypes);
226219

227220
if (empty($document)) {
228221
throw new ParseException('Unknown parser exception.');
@@ -453,15 +446,9 @@ protected function prepareInsert($item)
453446
}
454447

455448
$frag = $this->document->createDocumentFragment();
456-
try {
457-
set_error_handler([ParseException::class, 'initializeFromError'], $this->errTypes);
449+
self::withErrorHandler(ParseException::class, function () use ($frag, $item) {
458450
$frag->appendXML($item);
459-
} // Simulate a finally block.
460-
catch (Exception $e) {
461-
restore_error_handler();
462-
throw $e;
463-
}
464-
restore_error_handler();
451+
}, $this->errTypes);
465452

466453
return $frag;
467454
}
@@ -498,6 +485,28 @@ protected function prepareInsert($item)
498485
throw new Exception('Cannot prepare item of unsupported type: ' . gettype($item));
499486
}
500487

488+
/**
489+
* Run a callback with PHP errors converted into exceptions.
490+
*
491+
* @param string $exceptionClass
492+
* ParseException or a subclass, whose initializeFromError() handles the errors.
493+
* @param callable $callback
494+
* @param int $types
495+
* The error levels to convert.
496+
*
497+
* @return mixed
498+
* Whatever the callback returns.
499+
*/
500+
protected static function withErrorHandler($exceptionClass, callable $callback, $types = E_ALL)
501+
{
502+
set_error_handler([$exceptionClass, 'initializeFromError'], $types);
503+
try {
504+
return $callback();
505+
} finally {
506+
restore_error_handler();
507+
}
508+
}
509+
501510
/**
502511
* Convenience function for getNthMatch(0).
503512
*/
@@ -537,17 +546,9 @@ private function parseXMLFile($filename, $flags = 0, $context = null)
537546
// If a context is specified, we basically have to do the reading in
538547
// two steps:
539548
if (! empty($context)) {
540-
try {
541-
set_error_handler(['\QueryPath\ParseException', 'initializeFromError'], $this->errTypes);
542-
$contents = file_get_contents($filename, false, $context);
543-
}
544-
// Apparently there is no 'finally' in PHP, so we have to restore the error
545-
// handler this way:
546-
catch (Exception $e) {
547-
restore_error_handler();
548-
throw $e;
549-
}
550-
restore_error_handler();
549+
$contents = self::withErrorHandler(ParseException::class, function () use ($filename, $context) {
550+
return file_get_contents($filename, false, $context);
551+
}, $this->errTypes);
551552

552553
if ($contents == false) {
553554
throw new ParseException(sprintf(
@@ -576,27 +577,16 @@ private function parseXMLFile($filename, $flags = 0, $context = null)
576577

577578
$ext = $lastDot !== false ? strtolower(substr($filename, $lastDot)) : '';
578579

579-
try {
580-
set_error_handler([ParseException::class, 'initializeFromError'], $this->errTypes);
580+
// An explicit parser setting wins; otherwise go by the file extension, defaulting to XML.
581+
$asHtml = $useParser === 'html' || ($useParser !== 'xml' && isset($htmlExtensions[$ext]));
581582

582-
// If the parser is explicitly set to XML, use that parser.
583-
if ($useParser === 'xml') {
584-
$document->load($filename, $flags);
585-
} // Otherwise, see if it looks like HTML.
586-
elseif ($useParser === 'html' || isset($htmlExtensions[$ext])) {
587-
// Try parsing it as HTML.
583+
self::withErrorHandler(ParseException::class, function () use ($document, $filename, $flags, $asHtml) {
584+
if ($asHtml) {
588585
self::loadHTMLFile($document, $filename);
589-
} // Default to XML.
590-
else {
586+
} else {
591587
$document->load($filename, $flags);
592588
}
593-
594-
} // Emulate 'finally' behavior.
595-
catch (Exception $e) {
596-
restore_error_handler();
597-
throw $e;
598-
}
599-
restore_error_handler();
589+
}, $this->errTypes);
600590

601591
return $document;
602592
}

‎src/DOMQuery.php‎

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,14 +1338,9 @@ public function writeXML($path = null, $options = 0)
13381338
if ($path === null) {
13391339
print $this->document->saveXML(null, $options);
13401340
} else {
1341-
try {
1342-
set_error_handler([IOException::class, 'initializeFromError']);
1341+
self::withErrorHandler(IOException::class, function () use ($path, $options) {
13431342
$this->document->save($path, $options);
1344-
} catch (Exception $e) {
1345-
restore_error_handler();
1346-
throw $e;
1347-
}
1348-
restore_error_handler();
1343+
});
13491344
}
13501345

13511346
return $this;
@@ -1376,14 +1371,9 @@ public function writeHTML($path = null)
13761371
if ($path === null) {
13771372
print $this->saveDocumentHTML();
13781373
} else {
1379-
try {
1380-
set_error_handler(['\QueryPath\ParseException', 'initializeFromError']);
1374+
self::withErrorHandler(ParseException::class, function () use ($path) {
13811375
$this->saveDocumentHTML($path);
1382-
} catch (Exception $e) {
1383-
restore_error_handler();
1384-
throw $e;
1385-
}
1386-
restore_error_handler();
1376+
});
13871377
}
13881378

13891379
return $this;
@@ -1413,12 +1403,9 @@ public function writeHTML5($path = null)
14131403
// Print the document to stdout.
14141404
print $html5->saveHTML($this->document);
14151405
} else {
1416-
set_error_handler([IOException::class, 'initializeFromError']);
1417-
try {
1406+
self::withErrorHandler(IOException::class, function () use ($html5, $path) {
14181407
$html5->save($this->document, $path);
1419-
} finally {
1420-
restore_error_handler();
1421-
}
1408+
});
14221409
}
14231410

14241411
return $this;

‎tests/QueryPath/DOMQueryTest.php‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use QueryPath\ParseException;
1717
use QueryPath\QueryPath;
1818
use QueryPath\DOMQuery;
19+
use RuntimeException;
1920
use SplDoublyLinkedList;
2021
use stdClass;
2122

@@ -1504,6 +1505,55 @@ public function testFailWriteHTML5()
15041505
html5qp('<html><body>bar</body></html>')->writeHTML5('./test/no-writing.html');
15051506
}
15061507

1508+
public function throwingCallProvider(): array
1509+
{
1510+
$url = ThrowingStreamWrapper::PROTOCOL . '://out';
1511+
1512+
return [
1513+
'writeXML' => [function () use ($url) {
1514+
qp('<?xml version="1.0"?><foo/>')->writeXML($url);
1515+
}],
1516+
'writeHTML' => [function () use ($url) {
1517+
htmlqp('<p>bar</p>')->writeHTML($url);
1518+
}],
1519+
'writeHTML5' => [function () use ($url) {
1520+
html5qp('<p>bar</p>')->writeHTML5($url);
1521+
}],
1522+
'load with context' => [function () use ($url) {
1523+
qp($url . '.xml', null, ['context' => stream_context_create()]);
1524+
}],
1525+
];
1526+
}
1527+
1528+
/**
1529+
* A throwable QueryPath does not convert must not leave its error handler installed.
1530+
*
1531+
* @dataProvider throwingCallProvider
1532+
*
1533+
* @param callable $callback
1534+
*/
1535+
public function testErrorHandlerRestoredAfterForeignThrowable(callable $callback)
1536+
{
1537+
$sentinel = function () {
1538+
return false;
1539+
};
1540+
1541+
stream_wrapper_register(ThrowingStreamWrapper::PROTOCOL, ThrowingStreamWrapper::class);
1542+
set_error_handler($sentinel);
1543+
try {
1544+
$callback();
1545+
$this->fail('Expected a RuntimeException.');
1546+
} catch (RuntimeException $e) {
1547+
$current = set_error_handler(null);
1548+
restore_error_handler();
1549+
} finally {
1550+
restore_error_handler();
1551+
stream_wrapper_unregister(ThrowingStreamWrapper::PROTOCOL);
1552+
}
1553+
1554+
$this->assertSame($sentinel, $current);
1555+
}
1556+
15071557
public function testWriteHTML5()
15081558
{
15091559
$qp = html5qp('<html><body>bar</body></html>');
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace QueryPathTests;
4+
5+
use RuntimeException;
6+
7+
/**
8+
* A stream wrapper that throws a non-QueryPath exception when a stream is opened.
9+
*/
10+
class ThrowingStreamWrapper
11+
{
12+
public const PROTOCOL = 'qpthrow';
13+
14+
public $context;
15+
16+
public function stream_open($path, $mode, $options, &$opened_path) // phpcs:ignore PSR1.Methods.CamelCapsMethodName
17+
{
18+
throw new RuntimeException('Cannot open ' . $path);
19+
}
20+
}

0 commit comments

Comments
 (0)