Tracy 2.12.0, PHP 8.5.7.
BlueScreen::renderToFile() writes two artifacts: the HTML page and a markdown "agent" copy (BlueScreen.php:162-165 → renderAgent()). 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:
- pass the configured
$maxDepth/$maxLength/$maxItems into getAgentDumper() as well, instead of hardcoding depth and dropping the other two;
- 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;
- 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.
Tracy 2.12.0, PHP 8.5.7.
BlueScreen::renderToFile()writes two artifacts: the HTML page and a markdown "agent" copy (BlueScreen.php:162-165→renderAgent()). The HTML dumper is configured fromBlueScreen::$maxDepth/$maxLength/$maxItems(BlueScreen.php:444-455), but the agent dumper is not:It hardcodes the depth and passes no
TRUNCATEand noITEMS, so those fall back toDescriber's defaults. Andagent.phtml:122dumps each stack-frame argument without a key:That distinction matters because
Describerskips its own caps at depth 0: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
BlueScreenis 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(whichDebugger::enable()sets atDebugger.php:206), peak memory of the render:render()(HTML)renderAgent()(.md)With a realistic
memory_limit(128 MB here) the process dies inside the dump:Turning
BlueScreen::$maxDepth/$maxLength/$maxItemsdown to the floor does not prevent it — they never reach the agent dumper.Why the memory reserve does not catch this
Debugger::$reservedMemorySizeis released only inshutdownHandler()(Debugger.php:295) andexceptionHandler()(:313-314). A bluescreen rendered from application code — in our case a Monolog handler bridging intoTracy\Logger/BlueScreenduring 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 callsrenderToFile()again with ~500 kB of headroom against a render that wanted ~107 MB.Reproduce
Suggested direction
Any one of these would bound it; the first looks closest to the existing intent:
$maxDepth/$maxLength/$maxItemsintogetAgentDumper()as well, instead of hardcoding depth and dropping the other two;agent.phtmlwith a key, the waysection-stack-callStack.phtmlalready does, so the value starts at depth 1 and the existing caps apply;maxItems/maxLengthat 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.