Path: blob/master/src/applications/herald/engine/HeraldEngine.php
12256 views
<?php12final class HeraldEngine extends Phobject {34protected $rules = array();5protected $activeRule;6protected $transcript;78private $fieldCache = array();9private $fieldExceptions = array();10protected $object;11private $dryRun;1213private $forbiddenFields = array();14private $forbiddenActions = array();15private $skipEffects = array();1617private $profilerStack = array();18private $profilerFrames = array();1920private $ruleResults;21private $ruleStack;2223public function setDryRun($dry_run) {24$this->dryRun = $dry_run;25return $this;26}2728public function getDryRun() {29return $this->dryRun;30}3132public function getRule($phid) {33return idx($this->rules, $phid);34}3536public function loadRulesForAdapter(HeraldAdapter $adapter) {37return id(new HeraldRuleQuery())38->setViewer(PhabricatorUser::getOmnipotentUser())39->withDisabled(false)40->withContentTypes(array($adapter->getAdapterContentType()))41->needConditionsAndActions(true)42->needAppliedToPHIDs(array($adapter->getPHID()))43->needValidateAuthors(true)44->execute();45}4647public static function loadAndApplyRules(HeraldAdapter $adapter) {48$engine = new HeraldEngine();4950$rules = $engine->loadRulesForAdapter($adapter);51$effects = $engine->applyRules($rules, $adapter);52$engine->applyEffects($effects, $adapter, $rules);5354return $engine->getTranscript();55}5657/* -( Rule Stack )--------------------------------------------------------- */5859private function resetRuleStack() {60$this->ruleStack = array();61return $this;62}6364private function hasRuleOnStack(HeraldRule $rule) {65$phid = $rule->getPHID();66return isset($this->ruleStack[$phid]);67}6869private function pushRuleStack(HeraldRule $rule) {70$phid = $rule->getPHID();71$this->ruleStack[$phid] = $rule;72return $this;73}7475private function getRuleStack() {76return array_values($this->ruleStack);77}7879/* -( Rule Results )------------------------------------------------------- */8081private function resetRuleResults() {82$this->ruleResults = array();83return $this;84}8586private function setRuleResult(87HeraldRule $rule,88HeraldRuleResult $result) {8990$phid = $rule->getPHID();9192if ($this->hasRuleResult($rule)) {93throw new Exception(94pht(95'Herald rule "%s" already has an evaluation result.',96$phid));97}9899$this->ruleResults[$phid] = $result;100101$this->newRuleTranscript($rule)102->setRuleResult($result);103104return $this;105}106107private function hasRuleResult(HeraldRule $rule) {108$phid = $rule->getPHID();109return isset($this->ruleResults[$phid]);110}111112private function getRuleResult(HeraldRule $rule) {113$phid = $rule->getPHID();114115if (!$this->hasRuleResult($rule)) {116throw new Exception(117pht(118'Herald rule "%s" does not have an evaluation result.',119$phid));120}121122return $this->ruleResults[$phid];123}124125public function applyRules(array $rules, HeraldAdapter $object) {126assert_instances_of($rules, 'HeraldRule');127$t_start = microtime(true);128129// Rules execute in a well-defined order: sort them into execution order.130$rules = msort($rules, 'getRuleExecutionOrderSortKey');131$rules = mpull($rules, null, 'getPHID');132133$this->transcript = new HeraldTranscript();134$this->transcript->setObjectPHID((string)$object->getPHID());135$this->fieldCache = array();136$this->fieldExceptions = array();137$this->rules = $rules;138$this->object = $object;139140$this->resetRuleResults();141142$effects = array();143foreach ($rules as $phid => $rule) {144$this->resetRuleStack();145146$caught = null;147$result = null;148try {149$is_first_only = $rule->isRepeatFirst();150151if (!$this->getDryRun() &&152$is_first_only &&153$rule->getRuleApplied($object->getPHID())) {154155// This is not a dry run, and this rule is only supposed to be156// applied a single time, and it has already been applied.157// That means automatic failure.158159$result_code = HeraldRuleResult::RESULT_ALREADY_APPLIED;160$result = HeraldRuleResult::newFromResultCode($result_code);161} else if ($this->isForbidden($rule, $object)) {162$result_code = HeraldRuleResult::RESULT_OBJECT_STATE;163$result = HeraldRuleResult::newFromResultCode($result_code);164} else {165$result = $this->getRuleMatchResult($rule, $object);166}167} catch (HeraldRecursiveConditionsException $ex) {168$cycle_phids = array();169170$stack = $this->getRuleStack();171foreach ($stack as $stack_rule) {172$cycle_phids[] = $stack_rule->getPHID();173}174// Add the rule which actually cycled to the list to make the175// result more clear when we show it to the user.176$cycle_phids[] = $phid;177178foreach ($stack as $stack_rule) {179if ($this->hasRuleResult($stack_rule)) {180continue;181}182183$result_code = HeraldRuleResult::RESULT_RECURSION;184$result_data = array(185'cyclePHIDs' => $cycle_phids,186);187188$result = HeraldRuleResult::newFromResultCode($result_code)189->setResultData($result_data);190$this->setRuleResult($stack_rule, $result);191}192193$result = $this->getRuleResult($rule);194} catch (HeraldRuleEvaluationException $ex) {195// When we encounter an evaluation exception, the condition which196// failed to evaluate is responsible for logging the details of the197// error.198199$result_code = HeraldRuleResult::RESULT_EVALUATION_EXCEPTION;200$result = HeraldRuleResult::newFromResultCode($result_code);201} catch (Exception $ex) {202$caught = $ex;203} catch (Throwable $ex) {204$caught = $ex;205}206207if ($caught) {208// These exceptions are unexpected, and did not arise during rule209// evaluation, so we're responsible for handling the details.210211$result_code = HeraldRuleResult::RESULT_EXCEPTION;212213$result_data = array(214'exception.class' => get_class($caught),215'exception.message' => $ex->getMessage(),216);217218$result = HeraldRuleResult::newFromResultCode($result_code)219->setResultData($result_data);220}221222if (!$this->hasRuleResult($rule)) {223$this->setRuleResult($rule, $result);224}225$result = $this->getRuleResult($rule);226227if ($result->getShouldApplyActions()) {228foreach ($this->getRuleEffects($rule, $object) as $effect) {229$effects[] = $effect;230}231}232}233234$xaction_phids = null;235$xactions = $object->getAppliedTransactions();236if ($xactions !== null) {237$xaction_phids = mpull($xactions, 'getPHID');238}239240$object_transcript = id(new HeraldObjectTranscript())241->setPHID($object->getPHID())242->setName($object->getHeraldName())243->setType($object->getAdapterContentType())244->setFields($this->fieldCache)245->setAppliedTransactionPHIDs($xaction_phids)246->setProfile($this->getProfile());247248$this->transcript->setObjectTranscript($object_transcript);249250$t_end = microtime(true);251252$this->transcript->setDuration($t_end - $t_start);253254return $effects;255}256257public function applyEffects(258array $effects,259HeraldAdapter $adapter,260array $rules) {261assert_instances_of($effects, 'HeraldEffect');262assert_instances_of($rules, 'HeraldRule');263264$this->transcript->setDryRun((int)$this->getDryRun());265266if ($this->getDryRun()) {267$xscripts = array();268foreach ($effects as $effect) {269$xscripts[] = new HeraldApplyTranscript(270$effect,271false,272pht('This was a dry run, so no actions were actually taken.'));273}274} else {275$xscripts = $adapter->applyHeraldEffects($effects);276}277278assert_instances_of($xscripts, 'HeraldApplyTranscript');279foreach ($xscripts as $apply_xscript) {280$this->transcript->addApplyTranscript($apply_xscript);281}282283// For dry runs, don't mark the rule as having applied to the object.284if ($this->getDryRun()) {285return;286}287288// Update the "applied" state table. How this table works depends on the289// repetition policy for the rule.290//291// REPEAT_EVERY: We delete existing rows for the rule, then write nothing.292// This policy doesn't use any state.293//294// REPEAT_FIRST: We keep existing rows, then write additional rows for295// rules which fired. This policy accumulates state over the life of the296// object.297//298// REPEAT_CHANGE: We delete existing rows, then write all the rows which299// matched. This policy only uses the state from the previous run.300301$rules = mpull($rules, null, 'getID');302$rule_ids = mpull($xscripts, 'getRuleID');303304$delete_ids = array();305foreach ($rules as $rule_id => $rule) {306if ($rule->isRepeatFirst()) {307continue;308}309$delete_ids[] = $rule_id;310}311312$applied_ids = array();313foreach ($rule_ids as $rule_id) {314if (!$rule_id) {315// Some apply transcripts are purely informational and not associated316// with a rule, e.g. carryover emails from earlier revisions.317continue;318}319320$rule = idx($rules, $rule_id);321if (!$rule) {322continue;323}324325if ($rule->isRepeatFirst() || $rule->isRepeatOnChange()) {326$applied_ids[] = $rule_id;327}328}329330// Also include "only if this rule did not match the last time" rules331// which matched but were skipped in the "applied" list.332foreach ($this->skipEffects as $rule_id => $ignored) {333$applied_ids[] = $rule_id;334}335336if ($delete_ids || $applied_ids) {337$conn_w = id(new HeraldRule())->establishConnection('w');338339if ($delete_ids) {340queryfx(341$conn_w,342'DELETE FROM %T WHERE phid = %s AND ruleID IN (%Ld)',343HeraldRule::TABLE_RULE_APPLIED,344$adapter->getPHID(),345$delete_ids);346}347348if ($applied_ids) {349$sql = array();350foreach ($applied_ids as $id) {351$sql[] = qsprintf(352$conn_w,353'(%s, %d)',354$adapter->getPHID(),355$id);356}357queryfx(358$conn_w,359'INSERT IGNORE INTO %T (phid, ruleID) VALUES %LQ',360HeraldRule::TABLE_RULE_APPLIED,361$sql);362}363}364}365366public function getTranscript() {367$this->transcript->save();368return $this->transcript;369}370371public function doesRuleMatch(372HeraldRule $rule,373HeraldAdapter $object) {374$result = $this->getRuleMatchResult($rule, $object);375return $result->getShouldApplyActions();376}377378private function getRuleMatchResult(379HeraldRule $rule,380HeraldAdapter $object) {381382if ($this->hasRuleResult($rule)) {383// If we've already evaluated this rule because another rule depends384// on it, we don't need to reevaluate it.385return $this->getRuleResult($rule);386}387388if ($this->hasRuleOnStack($rule)) {389// We've recursed, fail all of the rules on the stack. This happens when390// there's a dependency cycle with "Rule conditions match for rule ..."391// conditions.392throw new HeraldRecursiveConditionsException();393}394$this->pushRuleStack($rule);395396$all = $rule->getMustMatchAll();397398$conditions = $rule->getConditions();399400$result_code = null;401$result_data = array();402403$local_version = id(new HeraldRule())->getConfigVersion();404if ($rule->getConfigVersion() > $local_version) {405$result_code = HeraldRuleResult::RESULT_VERSION;406} else if (!$conditions) {407$result_code = HeraldRuleResult::RESULT_EMPTY;408} else if (!$rule->hasValidAuthor()) {409$result_code = HeraldRuleResult::RESULT_OWNER;410} else if (!$this->canAuthorViewObject($rule, $object)) {411$result_code = HeraldRuleResult::RESULT_VIEW_POLICY;412} else if (!$this->canRuleApplyToObject($rule, $object)) {413$result_code = HeraldRuleResult::RESULT_OBJECT_RULE;414} else {415foreach ($conditions as $condition) {416$caught = null;417418try {419$match = $this->doesConditionMatch(420$rule,421$condition,422$object);423} catch (HeraldRuleEvaluationException $ex) {424throw $ex;425} catch (HeraldRecursiveConditionsException $ex) {426throw $ex;427} catch (Exception $ex) {428$caught = $ex;429} catch (Throwable $ex) {430$caught = $ex;431}432433if ($caught) {434throw new HeraldRuleEvaluationException();435}436437if (!$all && $match) {438$result_code = HeraldRuleResult::RESULT_ANY_MATCHED;439break;440}441442if ($all && !$match) {443$result_code = HeraldRuleResult::RESULT_ANY_FAILED;444break;445}446}447448if ($result_code === null) {449if ($all) {450$result_code = HeraldRuleResult::RESULT_ALL_MATCHED;451} else {452$result_code = HeraldRuleResult::RESULT_ALL_FAILED;453}454}455}456457// If this rule matched, and is set to run "if it did not match the last458// time", and we matched the last time, we're going to return a special459// result code which records a match but doesn't actually apply effects.460461// We need the rule to match so that storage gets updated properly. If we462// just pretend the rule didn't match it won't cause any effects (which463// is correct), but it also won't set the "it matched" flag in storage,464// so the next run after this one would incorrectly trigger again.465466$result = HeraldRuleResult::newFromResultCode($result_code)467->setResultData($result_data);468469$should_apply = $result->getShouldApplyActions();470471$is_dry_run = $this->getDryRun();472if ($should_apply && !$is_dry_run) {473$is_on_change = $rule->isRepeatOnChange();474if ($is_on_change) {475$did_apply = $rule->getRuleApplied($object->getPHID());476if ($did_apply) {477// Replace the result with our modified result.478$result_code = HeraldRuleResult::RESULT_LAST_MATCHED;479$result = HeraldRuleResult::newFromResultCode($result_code);480481$this->skipEffects[$rule->getID()] = true;482}483}484}485486$this->setRuleResult($rule, $result);487488return $result;489}490491private function doesConditionMatch(492HeraldRule $rule,493HeraldCondition $condition,494HeraldAdapter $adapter) {495496$transcript = $this->newConditionTranscript($rule, $condition);497498$caught = null;499$result_data = array();500501try {502$field_key = $condition->getFieldName();503504$field_value = $this->getProfiledObjectFieldValue(505$adapter,506$field_key);507508$is_match = $this->getProfiledConditionMatch(509$adapter,510$rule,511$condition,512$field_value);513if ($is_match) {514$result_code = HeraldConditionResult::RESULT_MATCHED;515} else {516$result_code = HeraldConditionResult::RESULT_FAILED;517}518} catch (HeraldRecursiveConditionsException $ex) {519$result_code = HeraldConditionResult::RESULT_RECURSION;520$caught = $ex;521} catch (HeraldInvalidConditionException $ex) {522$result_code = HeraldConditionResult::RESULT_INVALID;523$caught = $ex;524} catch (Exception $ex) {525$result_code = HeraldConditionResult::RESULT_EXCEPTION;526$caught = $ex;527} catch (Throwable $ex) {528$result_code = HeraldConditionResult::RESULT_EXCEPTION;529$caught = $ex;530}531532if ($caught) {533$result_data = array(534'exception.class' => get_class($caught),535'exception.message' => $ex->getMessage(),536);537}538539$result = HeraldConditionResult::newFromResultCode($result_code)540->setResultData($result_data);541542$transcript->setResult($result);543544if ($caught) {545throw $caught;546}547548return $result->getIsMatch();549}550551private function getProfiledConditionMatch(552HeraldAdapter $adapter,553HeraldRule $rule,554HeraldCondition $condition,555$field_value) {556557// Here, we're profiling the cost to match the condition value against558// whatever test is configured. Normally, this cost should be very559// small (<<1ms) since it amounts to a single comparison:560//561// [ Task author ][ is any of ][ alice ]562//563// However, it may be expensive in some cases, particularly if you564// write a rule with a very creative regular expression that backtracks565// explosively.566//567// At time of writing, the "Another Herald Rule" field is also568// evaluated inside the matching function. This may be arbitrarily569// expensive (it can prompt us to execute any finite number of other570// Herald rules), although we'll push the profiler stack appropriately571// so we don't count the evaluation time against this rule in the final572// profile.573574$this->pushProfilerRule($rule);575576$caught = null;577try {578$is_match = $adapter->doesConditionMatch(579$this,580$rule,581$condition,582$field_value);583} catch (Exception $ex) {584$caught = $ex;585} catch (Throwable $ex) {586$caught = $ex;587}588589$this->popProfilerRule($rule);590591if ($caught) {592throw $caught;593}594595return $is_match;596}597598private function getProfiledObjectFieldValue(599HeraldAdapter $adapter,600$field_key) {601602// Before engaging the profiler, make sure the field class is loaded.603604$adapter->willGetHeraldField($field_key);605606// The first time we read a field value, we'll actually generate it, which607// may be slow.608609// After it is generated for the first time, this will just read it from a610// cache, which should be very fast.611612// We still want to profile the request even if it goes to cache so we can613// get an accurate count of how many times we access the field value: when614// trying to improve the performance of Herald rules, it's helpful to know615// how many rules rely on the value of a field which is slow to generate.616617$caught = null;618619$this->pushProfilerField($field_key);620try {621$value = $this->getObjectFieldValue($field_key);622} catch (Exception $ex) {623$caught = $ex;624} catch (Throwable $ex) {625$caught = $ex;626}627$this->popProfilerField($field_key);628629if ($caught) {630throw $caught;631}632633return $value;634}635636private function getObjectFieldValue($field_key) {637if (array_key_exists($field_key, $this->fieldExceptions)) {638throw $this->fieldExceptions[$field_key];639}640641if (array_key_exists($field_key, $this->fieldCache)) {642return $this->fieldCache[$field_key];643}644645$adapter = $this->object;646647$caught = null;648try {649$value = $adapter->getHeraldField($field_key);650} catch (Exception $ex) {651$caught = $ex;652} catch (Throwable $ex) {653$caught = $ex;654}655656if ($caught) {657$this->fieldExceptions[$field_key] = $caught;658throw $caught;659}660661$this->fieldCache[$field_key] = $value;662663return $value;664}665666protected function getRuleEffects(667HeraldRule $rule,668HeraldAdapter $object) {669670$rule_id = $rule->getID();671if (isset($this->skipEffects[$rule_id])) {672return array();673}674675$effects = array();676foreach ($rule->getActions() as $action) {677$effect = id(new HeraldEffect())678->setObjectPHID($object->getPHID())679->setAction($action->getAction())680->setTarget($action->getTarget())681->setRule($rule);682683$name = $rule->getName();684$id = $rule->getID();685$effect->setReason(686pht(687'Conditions were met for %s',688"H{$id} {$name}"));689690$effects[] = $effect;691}692return $effects;693}694695private function canAuthorViewObject(696HeraldRule $rule,697HeraldAdapter $adapter) {698699// Authorship is irrelevant for global rules and object rules.700if ($rule->isGlobalRule() || $rule->isObjectRule()) {701return true;702}703704// The author must be able to create rules for the adapter's content type.705// In particular, this means that the application must be installed and706// accessible to the user. For example, if a user writes a Differential707// rule and then loses access to Differential, this disables the rule.708$enabled = HeraldAdapter::getEnabledAdapterMap($rule->getAuthor());709if (empty($enabled[$adapter->getAdapterContentType()])) {710return false;711}712713// Finally, the author must be able to see the object itself. You can't714// write a personal rule that CC's you on revisions you wouldn't otherwise715// be able to see, for example.716$object = $adapter->getObject();717return PhabricatorPolicyFilter::hasCapability(718$rule->getAuthor(),719$object,720PhabricatorPolicyCapability::CAN_VIEW);721}722723private function canRuleApplyToObject(724HeraldRule $rule,725HeraldAdapter $adapter) {726727// Rules which are not object rules can apply to anything.728if (!$rule->isObjectRule()) {729return true;730}731732$trigger_phid = $rule->getTriggerObjectPHID();733$object_phids = $adapter->getTriggerObjectPHIDs();734735if ($object_phids) {736if (in_array($trigger_phid, $object_phids)) {737return true;738}739}740741return false;742}743744private function newRuleTranscript(HeraldRule $rule) {745$xscript = id(new HeraldRuleTranscript())746->setRuleID($rule->getID())747->setRuleName($rule->getName())748->setRuleOwner($rule->getAuthorPHID());749750$this->transcript->addRuleTranscript($xscript);751752return $xscript;753}754755private function newConditionTranscript(756HeraldRule $rule,757HeraldCondition $condition) {758759$xscript = id(new HeraldConditionTranscript())760->setRuleID($rule->getID())761->setConditionID($condition->getID())762->setFieldName($condition->getFieldName())763->setCondition($condition->getFieldCondition())764->setTestValue($condition->getValue());765766$this->transcript->addConditionTranscript($xscript);767768return $xscript;769}770771private function newApplyTranscript(772HeraldAdapter $adapter,773HeraldRule $rule,774HeraldActionRecord $action) {775776$effect = id(new HeraldEffect())777->setObjectPHID($adapter->getPHID())778->setAction($action->getAction())779->setTarget($action->getTarget())780->setRule($rule);781782$xscript = new HeraldApplyTranscript($effect, false);783784$this->transcript->addApplyTranscript($xscript);785786return $xscript;787}788789private function isForbidden(790HeraldRule $rule,791HeraldAdapter $adapter) {792793$forbidden = $adapter->getForbiddenActions();794if (!$forbidden) {795return false;796}797798$forbidden = array_fuse($forbidden);799800$is_forbidden = false;801802foreach ($rule->getConditions() as $condition) {803$field_key = $condition->getFieldName();804805if (!isset($this->forbiddenFields[$field_key])) {806$reason = null;807808try {809$states = $adapter->getRequiredFieldStates($field_key);810} catch (Exception $ex) {811$states = array();812}813814foreach ($states as $state) {815if (!isset($forbidden[$state])) {816continue;817}818$reason = $adapter->getForbiddenReason($state);819break;820}821822$this->forbiddenFields[$field_key] = $reason;823}824825$forbidden_reason = $this->forbiddenFields[$field_key];826if ($forbidden_reason !== null) {827$result_code = HeraldConditionResult::RESULT_OBJECT_STATE;828$result_data = array(829'reason' => $forbidden_reason,830);831832$result = HeraldConditionResult::newFromResultCode($result_code)833->setResultData($result_data);834835$this->newConditionTranscript($rule, $condition)836->setResult($result);837838$is_forbidden = true;839}840}841842foreach ($rule->getActions() as $action_record) {843$action_key = $action_record->getAction();844845if (!isset($this->forbiddenActions[$action_key])) {846$reason = null;847848try {849$states = $adapter->getRequiredActionStates($action_key);850} catch (Exception $ex) {851$states = array();852}853854foreach ($states as $state) {855if (!isset($forbidden[$state])) {856continue;857}858$reason = $adapter->getForbiddenReason($state);859break;860}861862$this->forbiddenActions[$action_key] = $reason;863}864865$forbidden_reason = $this->forbiddenActions[$action_key];866if ($forbidden_reason !== null) {867$this->newApplyTranscript($adapter, $rule, $action_record)868->setAppliedReason(869array(870array(871'type' => HeraldAction::DO_STANDARD_FORBIDDEN,872'data' => $forbidden_reason,873),874));875876$is_forbidden = true;877}878}879880return $is_forbidden;881}882883/* -( Profiler )----------------------------------------------------------- */884885private function pushProfilerField($field_key) {886return $this->pushProfilerStack('field', $field_key);887}888889private function popProfilerField($field_key) {890return $this->popProfilerStack('field', $field_key);891}892893private function pushProfilerRule(HeraldRule $rule) {894return $this->pushProfilerStack('rule', $rule->getPHID());895}896897private function popProfilerRule(HeraldRule $rule) {898return $this->popProfilerStack('rule', $rule->getPHID());899}900901private function pushProfilerStack($type, $key) {902$this->profilerStack[] = array(903'type' => $type,904'key' => $key,905'start' => microtime(true),906);907908return $this;909}910911private function popProfilerStack($type, $key) {912if (!$this->profilerStack) {913throw new Exception(914pht(915'Unable to pop profiler stack: profiler stack is empty.'));916}917918$frame = last($this->profilerStack);919if (($frame['type'] !== $type) || ($frame['key'] !== $key)) {920throw new Exception(921pht(922'Unable to pop profiler stack: expected frame of type "%s" with '.923'key "%s", but found frame of type "%s" with key "%s".',924$type,925$key,926$frame['type'],927$frame['key']));928}929930// Accumulate the new timing information into the existing profile. If this931// is the first time we've seen this particular rule or field, we'll932// create a new empty frame first.933934$elapsed = microtime(true) - $frame['start'];935$frame_key = sprintf('%s/%s', $type, $key);936937if (!isset($this->profilerFrames[$frame_key])) {938$current = array(939'type' => $type,940'key' => $key,941'elapsed' => 0,942'count' => 0,943);944} else {945$current = $this->profilerFrames[$frame_key];946}947948$current['elapsed'] += $elapsed;949$current['count']++;950951$this->profilerFrames[$frame_key] = $current;952953array_pop($this->profilerStack);954}955956private function getProfile() {957if ($this->profilerStack) {958$frame = last($this->profilerStack);959$frame_type = $frame['type'];960$frame_key = $frame['key'];961$frame_count = count($this->profilerStack);962963throw new Exception(964pht(965'Unable to retrieve profile: profiler stack is not empty. The '.966'stack has %s frame(s); the final frame has type "%s" and key '.967'"%s".',968new PhutilNumber($frame_count),969$frame_type,970$frame_key));971}972973return array_values($this->profilerFrames);974}975976977}978979980