Skip to content

BlueScreen::renderToFile(): the markdown "agent" copy dumps stack-trace arguments unbounded and can exhaust memory #620

Description

@jkuchar

Tracy 2.12.0, PHP 8.5.7.

BlueScreen::renderToFile() writes two artifacts: the HTML page and a markdown "agent" copy (BlueScreen.php:162-165renderAgent()). The HTML dumper is configured from BlueScreen::$maxDepth/$maxLength/$maxItems (BlueScreen.php:444-455), but the agent dumper is not:

// BlueScreen.php:459-466
private function getAgentDumper(): \Closure
{
	return fn($var) => Dumper::toText($var, [Dumper::DEPTH => 3, ...]);
}

It hardcodes the depth and passes no TRUNCATE and no ITEMS, so those fall back to Describer's defaults. And agent.phtml:122 dumps each stack-frame argument without a key:

$agentDump($v)          // agent.phtml:122  → described at depth 0
$dump($v, (string) $argName)   // section-stack-callStack.phtml:131 → described at depth 1

That distinction matters because Describer skips its own caps at depth 0:

// Describer.php:139,149
} elseif ($depth && $this->maxItems && count($arr) > $this->maxItems) {
// Describer.php:109
Helpers::encodeString($s, $depth ? $this->maxLength : null)

So for the markdown copy a top-level array argument is enumerated in full and a top-level string is never truncated, no matter how BlueScreen is configured. A single large object graph in a trace argument is therefore dumped whole.

Measured

One stack frame carrying one array of small objects, memory_limit=-1, zend.exception_ignore_args=0 (which Debugger::enable() sets at Debugger.php:206), peak memory of the render:

objects in the argument render() (HTML) renderAgent() (.md)
20 000 +2.01 MB (148 kB output) +106.97 MB (2.79 MB output)
50 000 +3.89 MB (148 kB output) +266.81 MB

With a realistic memory_limit (128 MB here) the process dies inside the dump:

ErrorException: Allowed memory size of 134217728 bytes exhausted … Dumper/Describer.php:174

Turning BlueScreen::$maxDepth/$maxLength/$maxItems down to the floor does not prevent it — they never reach the agent dumper.

Why the memory reserve does not catch this

Debugger::$reservedMemorySize is released only in shutdownHandler() (Debugger.php:295) and exceptionHandler() (:313-314). A bluescreen rendered from application code — in our case a Monolog handler bridging into Tracy\Logger/BlueScreen during an ordinary request — never reaches that path, so there is no reserve to fall back on. And once the fatal has happened, the post-mortem path calls renderToFile() again with ~500 kB of headroom against a render that wanted ~107 MB.

Reproduce

$blueScreen = new Tracy\BlueScreen;
$objects = [];
for ($i = 0; $i < 20000; $i++) {
	$objects[] = new class($i) { public function __construct(public int $i) {} };
}
$fn = function (array $payload) { throw new RuntimeException('boom'); };
try { $fn($objects); } catch (Throwable $e) {}

$blueScreen->renderToFile($e, __DIR__ . '/bluescreen.html');  // writes .html and .md

Suggested direction

Any one of these would bound it; the first looks closest to the existing intent:

  1. pass the configured $maxDepth/$maxLength/$maxItems into getAgentDumper() as well, instead of hardcoding depth and dropping the other two;
  2. dump trace arguments in agent.phtml with a key, the way section-stack-callStack.phtml already does, so the value starts at depth 1 and the existing caps apply;
  3. apply maxItems/maxLength at depth 0 too — the $depth && guard means the very value a user asked to dump is the one value never capped.

Happy to send a PR for whichever direction you prefer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions