Page MenuHomePhabricator

Argument 1 passed to MediaWiki\Extension\AbuseFilter\Parser\ParserStatus::__construct() must be of the type boolean, null given
Closed, ResolvedPublicPRODUCTION ERROR

Description

Error message
TypeError from line 18 of /srv/mediawiki/php-1.36.0-wmf.22/extensions/AbuseFilter/includes/Parser/ParserStatus.php: Argument 1 passed to MediaWiki\Extension\AbuseFilter\Parser\ParserStatus::__construct() must be of the type boolean, null given, called in /srv/mediawiki/php-1.36.0-wmf.22/extensions/AbuseFilter/includes/Parser/AbuseFilterParser.php on line 407
Stack Trace
	#0 /srv/mediawiki/php-1.36.0-wmf.22/extensions/AbuseFilter/includes/Parser/AbuseFilterParser.php(407): MediaWiki\Extension\AbuseFilter\Parser\ParserStatus->__construct(NULL, boolean, NULL)
#1 /srv/mediawiki/php-1.36.0-wmf.22/extensions/AbuseFilter/includes/Parser/AbuseFilterParser.php(277): MediaWiki\Extension\AbuseFilter\Parser\AbuseFilterParser->parseDetailed(string)
#2 /srv/mediawiki/php-1.36.0-wmf.22/extensions/AbuseFilter/includes/AbuseFilterRunner.php(421): MediaWiki\Extension\AbuseFilter\Parser\AbuseFilterParser->checkConditions(string, boolean, string)
#3 /srv/mediawiki/php-1.36.0-wmf.22/extensions/AbuseFilter/includes/AbuseFilterRunner.php(388): AbuseFilterRunner->checkFilter(MediaWiki\Extension\AbuseFilter\Filter\Filter)
#4 /srv/mediawiki/php-1.36.0-wmf.22/extensions/AbuseFilter/includes/AbuseFilterRunner.php(206): AbuseFilterRunner->checkAllFilters(boolean)
#5 /srv/mediawiki/php-1.36.0-wmf.22/extensions/AbuseFilter/includes/AbuseFilterHooks.php(215): AbuseFilterRunner->run()
#6 /srv/mediawiki/php-1.36.0-wmf.22/extensions/AbuseFilter/includes/AbuseFilterHooks.php(159): AbuseFilterHooks::filterEdit(DerivativeContext, User, WikitextContent, string, string)
#7 /srv/mediawiki/php-1.36.0-wmf.22/includes/HookContainer/HookContainer.php(333): AbuseFilterHooks::onEditFilterMergedContent(DerivativeContext, WikitextContent, Status, string, User, boolean)
#8 /srv/mediawiki/php-1.36.0-wmf.22/includes/HookContainer/HookContainer.php(140): MediaWiki\HookContainer\HookContainer->callLegacyHook(string, array, array, array)
#9 /srv/mediawiki/php-1.36.0-wmf.22/includes/HookContainer/HookRunner.php(1542): MediaWiki\HookContainer\HookContainer->run(string, array)
#10 /srv/mediawiki/php-1.36.0-wmf.22/includes/editpage/Constraint/EditFilterMergedContentHookConstraint.php(90): MediaWiki\HookContainer\HookRunner->onEditFilterMergedContent(DerivativeContext, WikitextContent, Status, string, User, boolean)
#11 /srv/mediawiki/php-1.36.0-wmf.22/includes/editpage/Constraint/EditConstraintRunner.php(88): MediaWiki\EditPage\Constraint\EditFilterMergedContentHookConstraint->checkConstraint()
#12 /srv/mediawiki/php-1.36.0-wmf.22/includes/EditPage.php(2266): MediaWiki\EditPage\Constraint\EditConstraintRunner->checkConstraints()
#13 /srv/mediawiki/php-1.36.0-wmf.22/includes/EditPage.php(1687): EditPage->internalAttemptSave(NULL, boolean)
#14 /srv/mediawiki/php-1.36.0-wmf.22/includes/api/ApiEditPage.php(454): EditPage->attemptSave(NULL)
#15 /srv/mediawiki/php-1.36.0-wmf.22/includes/api/ApiMain.php(1607): ApiEditPage->execute()
#16 /srv/mediawiki/php-1.36.0-wmf.22/includes/api/ApiMain.php(587): ApiMain->executeAction()
#17 /srv/mediawiki/php-1.36.0-wmf.22/includes/api/ApiMain.php(558): ApiMain->executeActionWithErrorHandling()
#18 /srv/mediawiki/php-1.36.0-wmf.22/api.php(90): ApiMain->execute()
#19 /srv/mediawiki/php-1.36.0-wmf.22/api.php(45): wfApiMain()
#20 /srv/mediawiki/w/api.php(3): require(string)
#21 {main}
Impact

The request fails.

Notes

This issue appears together with

the execution time limit of 200 seconds was exceeded

and

ErrorException from line 407 of /srv/mediawiki/php-1.36.0-wmf.22/extensions/AbuseFilter/includes/Parser/AbuseFilterParser.php: PHP Notice: Undefined variable: res

Details

Request ID
X9wkrwpAAEwAAIFdym4AAABB
Request URL
/w/api.php (POSTed)

Event Timeline

I am not very sure, but I think the only way for this error to happen is that the request times out during the parse() call, then the 'finally' block is executed, and $res is unset. I cannot reproduce this with a simple test case, so there might be something specific going on with error handlers installed by MW, or something related to the version of PHP, or something like that.

I'm going to send a patch to remove the 'finally' altogether, which should resolve the issue.

Change 650565 had a related patch set uploaded (by Daimona Eaytoy; owner: Daimona Eaytoy):
[mediawiki/extensions/AbuseFilter@master] Avoid 'finally' clause in AbuseFilterParser::parseDetailed

https://gerrit.wikimedia.org/r/650565

I am not very sure, but I think the only way for this error to happen is that the request times out during the parse() call, then the 'finally' block is executed, and $res is unset.

If that's the case, then I don't think removing finally clause will fix it, since you'll still use the unset $res to call ParserStatus::__construct()

The root cause of the issue, I believe, is that AbuseFilterParser::parse() can return bool or null, but this is not documented.

/**
 * @param string $code
 * @return bool
 */
public function parse( $code ) {
    return $this->intEval( $code )->toBool();
}

It's documented to return bool only, but that's not quite accurate.

If we follow through AFPData::toBool(), we can see that it's ultimately returning a prop which is documented to be almost everything

/**
 * @var mixed|null|AFPData[] The actual data contained in this object
 * @private Use $this->getData()
 */
public $data;

Then we have this ( called from the parse() call above)

public function intEval( $code ) {
...
 $result = new AFPData( AFPData::DEMPTY, ); // <-- Creating AFPData without second argument will initialize  $data to null
 $this->doLevelEntry( $result );
 if ( $result->getType() === AFPData::DUNDEFINED ) {
	$result = new AFPData( AFPData::DBOOL, false ); 
 $this->statsd->timing( 'abusefilter_oldparser_full', microtime( true ) - $startTime );
 return $result;
 }

I am not very sure, but I think the only way for this error to happen is that the request times out during the parse() call, then the 'finally' block is executed, and $res is unset.

If that's the case, then I don't think removing finally clause will fix it, since you'll still use the unset $res to call ParserStatus::__construct()

This is wrong. 'finally' blocks are guaranteed to be executed out of the ordinary flow, and the 'finally' block here is executed even if the time limit is reached while parsing. If we remove the 'finally' block, and the time limit is reached while parsing, the exception will bubble up without reaching the return line. Also, the 'finally' is not necessary right now, because every "acceptable" exception is covered by the 'catch' clause, and any unwanted exception should bubble up.

I can now actually come up with a working example:

function throwRuntime() {
    throw new \RuntimeException;
}

function main() {
    try {
        $res = throwRuntime();
    } catch ( \LogicException $e ) {
        $res = false;
    } finally {
        return $res;
    }
}

a();

What you'll get is

Warning: Undefined variable $res in /in/qbmaF on line 13

And this is probably what's happening here, except that the exception being thrown is WMFTimeoutException (or whatever it's called), and the catch clause looks for AFPException. Removing the finally block will cause the exception to bubble up as expected.

The root cause of the issue, I believe, is that AbuseFilterParser::parse() can return bool or null, but this is not documented.

/**
 * @param string $code
 * @return bool
 */
public function parse( $code ) {
    return $this->intEval( $code )->toBool();
}

This is also incorrect, toBool() casts the internal type to bool, as the name suggests.

If we follow through AFPData::toBool(), we can see that it's ultimately returning a prop which is documented to be almost everything

/**
 * @var mixed|null|AFPData[] The actual data contained in this object
 * @private Use $this->getData()
 */
public $data;

That property can hold any data type because AFPData are generic value objects used to represent a value within the AF tokenizer/parser/evaluator. If you carefully analyze what AFPData::castTypes does, you'll note that if the target type is bool, then it will return a boolean (same for other types).

Then we have this ( called from the parse() call above)

public function intEval( $code ) {
...
 $result = new AFPData( AFPData::DEMPTY, ); // <-- Creating AFPData without second argument will initialize  $data to null
 $this->doLevelEntry( $result );
 if ( $result->getType() === AFPData::DUNDEFINED ) {
	$result = new AFPData( AFPData::DBOOL, false ); 
 $this->statsd->timing( 'abusefilter_oldparser_full', microtime( true ) - $startTime );
 return $result;
 }

Correct, but it doesn't prove your point. That AFPData is passed through the parser levels, which will replace the $result with the actual result. Even if this would result in a DNULL/DEMPTY being returned, the toBool call will cast it to a boolean.

Change 650565 merged by jenkins-bot:
[mediawiki/extensions/AbuseFilter@master] Avoid 'finally' clause in AbuseFilterParser::parseDetailed

https://gerrit.wikimedia.org/r/650565