From 3c1050ebc34aa6e5a7e025523efe9d10ba2a3f63 Mon Sep 17 00:00:00 2001 From: Adrian Jones Date: Wed, 21 Oct 2026 15:45:20 -0700 Subject: [PATCH 01/17] Remove Tracy's asset-response Expires/Pragma headers from TracyDebugger instead of patching core PHP's session cache limiter queues "Expires: Thu, 19 Nov 1981" and "Pragma: no-cache" before Tracy serves ?_tracy_bar= assets, which contradicted the Cache-Control Tracy sends with them. TracyDebugger now removes both headers itself just before Debugger::enable() on those requests, so the local patch in tracy-2.12.x DeferredContent.php is dropped (the bundle injection patch there remains for now). --- TracyDebugger.module.php | 6 ++++++ tracy-2.12.x/src/Tracy/Debugger/DeferredContent.php | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/TracyDebugger.module.php b/TracyDebugger.module.php index afb9ebe1..292a5597 100644 --- a/TracyDebugger.module.php +++ b/TracyDebugger.module.php @@ -2035,6 +2035,12 @@ function bsZIndex() { Debugger::setSessionStorage(new \Tracy\NativeSession); } } + // Tracy's asset responses (?_tracy_bar=...) send their own Cache-Control, but PHP's session + // cache limiter has already queued "Expires: 1981" and "Pragma: no-cache", which contradict it + if(isset($_GET['_tracy_bar']) && !headers_sent()) { + header_remove('Expires'); + header_remove('Pragma'); + } Debugger::enable($outputMode, $logFolder, $this->data['fromEmail'] != '' && $this->data['email'] != '' ? $this->data['email'] : null); // Tracy's production error page, with the CSP nonce on its '); @@ -1707,37 +1710,6 @@ function addLocalAlert() { }); $barJs .= ' - document.addEventListener("DOMContentLoaded", function() { - var showButton = document.createElement("div"); - showButton.id = "tracy-show-button"; - showButton.title = "Show Tracy"; - showButton.textContent = "\u21F1"; - document.body.appendChild(showButton); - }); - - function addHideLink() { - if(!document.getElementById("tracy-debug-bar")) { - window.requestAnimationFrame(addHideLink); - } else { - var debugBar = document.getElementById("tracy-debug-bar"); - var barul = debugBar.getElementsByTagName("ul")[0]; - var toggleli = document.createElement("li"); - toggleli.appendChild(document.createTextNode("⇲")); - toggleli.setAttribute("id", "hide-button"); - toggleli.setAttribute("title", "Hide Tracy"); - toggleli.addEventListener("click", function() { - document.body.classList.remove("has-tracy-debugbar"); - document.getElementById("tracy-debug").style.display = "none"; - document.getElementById("tracy-show-button").style.display = "block"; - document.cookie = "tracyHidden=1; path=/"; - document.cookie = "tracyShow=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/"; - }); - barul.replaceChild(toggleli, barul.lastChild.previousElementSibling); - window.Tracy.Debug.bar.restorePosition(); - } - } - addHideLink(); - function unhideBar() { document.getElementById("tracy-debug").style.display = "block"; document.getElementById("tracy-show-button").style.display = "none"; @@ -1746,41 +1718,44 @@ function unhideBar() { document.cookie = "tracyHidden=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/"; document.cookie = "tracyShow=1; path=/"; } - function attachUnhideBar() { - var btn = document.getElementById("tracy-show-button"); - if(!btn) { - window.requestAnimationFrame(attachUnhideBar); - } else { - btn.addEventListener("click", unhideBar); - } - } - attachUnhideBar(); - function modifyTracyLogo() { - if(!document.getElementById("tracy-debug-bar")) { - window.requestAnimationFrame(modifyTracyLogo); - } else { - var tracyLogo = document.getElementById("tracy-debug-logo"); - tracyLogo.setAttribute("title", tracyLogo.getAttribute("title") + "\n\nTracy Debugger for PW '.$this->getModuleInfo()['version'].'\nClick to scroll to page top\nDouble-click to visit module settings"); - tracyLogo.addEventListener("click", function() { - window.scrollTo(0,0); - }, false); - tracyLogo.addEventListener("dblclick", function() { - window.open("'.$this->wire('config')->urls->admin.'module/edit?name=TracyDebugger","_blank"); - }, false); - } - } - modifyTracyLogo(); + document.addEventListener("DOMContentLoaded", function() { + var showButton = document.createElement("div"); + showButton.id = "tracy-show-button"; + showButton.title = "Show Tracy"; + showButton.textContent = "\u21F1"; + showButton.addEventListener("click", unhideBar); + document.body.appendChild(showButton); + }); - function bsZIndex() { - if(!document.getElementById("tracy-bs")) { - window.requestAnimationFrame(bsZIndex); - } else { - if(document.getElementById("tracy-bs")) - document.getElementById("tracy-bs").style.zIndex = ' . $this->data['panelZindex'] .'; - } - } - bsZIndex(); + window.tracyDebuggerOnBar(function() { + var debugBar = document.getElementById("tracy-debug-bar"); + var barul = debugBar.getElementsByTagName("ul")[0]; + var toggleli = document.createElement("li"); + toggleli.appendChild(document.createTextNode("⇲")); + toggleli.setAttribute("id", "hide-button"); + toggleli.setAttribute("title", "Hide Tracy"); + toggleli.addEventListener("click", function() { + document.body.classList.remove("has-tracy-debugbar"); + document.getElementById("tracy-debug").style.display = "none"; + document.getElementById("tracy-show-button").style.display = "block"; + document.cookie = "tracyHidden=1; path=/"; + document.cookie = "tracyShow=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/"; + }); + barul.replaceChild(toggleli, barul.lastChild.previousElementSibling); + window.Tracy.Debug.bar.restorePosition(); + }); + + window.tracyDebuggerOnBar(function() { + var tracyLogo = document.getElementById("tracy-debug-logo"); + tracyLogo.setAttribute("title", tracyLogo.getAttribute("title") + "\n\nTracy Debugger for PW '.$this->getModuleInfo()['version'].'\nClick to scroll to page top\nDouble-click to visit module settings"); + tracyLogo.addEventListener("click", function() { + window.scrollTo(0,0); + }, false); + tracyLogo.addEventListener("dblclick", function() { + window.open("'.$this->wire('config')->urls->admin.'module/edit?name=TracyDebugger","_blank"); + }, false); + }); document.addEventListener("keydown", function(e) { if((e.keyCode==27||e.charCode==27)) { @@ -1825,14 +1800,11 @@ function bsZIndex() { )) . ';' . "\n"; $barJs .= ' - function removeDumpsRecorderPanelState() { - if(!document.getElementById("tracy-debug-bar")) { - window.requestAnimationFrame(removeDumpsRecorderPanelState); - } else if(!document.getElementById("tracy-debug-panel-ProcessWire-DumpsRecorderPanel")) { - localStorage.removeItem("tracy-debug-panel-ProcessWire-DumpsRecorderPanel"); + window.tracyDebuggerOnBar(function() { + if(!document.getElementById("tracy-debug-panel-ProcessWire-DumpsRecorderPanel")) { + localStorage.removeItem("tracy-debug-panel-ProcessWire-DumpsRecorderPanel"); } - } - removeDumpsRecorderPanelState(); + }); document.addEventListener("DOMContentLoaded", function() { if(!/(^|;\\s*)tracyHidden=[^;]/.test(document.cookie)) { diff --git a/scripts/dumps-polling.js b/scripts/dumps-polling.js index 9b0f993e..b766af11 100644 --- a/scripts/dumps-polling.js +++ b/scripts/dumps-polling.js @@ -188,10 +188,6 @@ } function init() { - if(!document.getElementById("tracy-debug-bar")) { - window.requestAnimationFrame(init); - return; - } // this script is in every bar bundle, so only poll when the Dumps Recorder panel is in this bar if(!document.getElementById(recorderPanelId)) return; // lastSeenId stays -1 until the first poll, which fully syncs the panel from @@ -199,7 +195,7 @@ pollTimer = setTimeout(poll, pollInterval); } - init(); + window.tracyDebuggerOnBar(init); function resetToMinAndPollSoon() { pollInterval = minPollInterval; diff --git a/scripts/file-editor.js b/scripts/file-editor.js index 5514d5fa..dd31ee08 100644 --- a/scripts/file-editor.js +++ b/scripts/file-editor.js @@ -21,7 +21,8 @@ if(!tracyFileEditorLoader) { initializeEditor: function() { if(!document.getElementById("tfe_recently_opened")) { - window.requestAnimationFrame(tracyFileEditorLoader.initializeEditor); + // the panel's content isn't in the page until Tracy initializes the panel + window.tracyDebuggerOnPanelInit("tracy-debug-panel-ProcessWire-FileEditorPanel", tracyFileEditorLoader.initializeEditor); } else { // populate recently opened files select, normalizing legacy entries with inconsistent diff --git a/scripts/main.js b/scripts/main.js index feb9241c..4a3014d9 100644 --- a/scripts/main.js +++ b/scripts/main.js @@ -70,6 +70,47 @@ } })(); +// Run code once Tracy has built the bar, or once a panel's content is in the page, rather than polling for +// their elements on every animation frame: a popup window or a page whose panel is never opened would +// otherwise poll for as long as it stays open +(function() { + var barCallbacks = []; + var panelCallbacks = {}; + + window.tracyDebuggerOnBar = function(fn) { + if(document.getElementById('tracy-debug-bar')) fn(); + else barCallbacks.push(fn); + }; + + window.tracyDebuggerOnPanelInit = function(panelId, fn) { + (panelCallbacks[panelId] = panelCallbacks[panelId] || []).push(fn); + }; + + if(!window.Tracy || !Tracy.Debug || !Tracy.DebugPanel) return; + + // callbacks run even when Tracy's init throws partway (e.g. on a corrupt panel position in localStorage) + var debugInit = Tracy.Debug.init; + Tracy.Debug.init = function() { + try { + return debugInit.apply(this, arguments); + } + finally { + if(document.getElementById('tracy-debug-bar')) barCallbacks.splice(0).forEach(function(fn) { fn(); }); + } + }; + + var panelInit = Tracy.DebugPanel.prototype.init; + Tracy.DebugPanel.prototype.init = function() { + try { + return panelInit.apply(this, arguments); + } + finally { + var callbacks = this.elem && panelCallbacks[this.elem.id]; + if(callbacks) callbacks.splice(0).forEach(function(fn) { fn(); }); + } + }; +})(); + // Tracy's reset.css clears color and background on every hovered element in the bar, panels and // BlueScreen ("tracy-div:not(a b) *:hover"), which beats many of our panel styles and the Ace // editor theme (its background turns transparent while hovered). Narrow it to the direct children From eea281732f5ad0af2dec8974bae8490132af7c56 Mon Sep 17 00:00:00 2001 From: Adrian Jones Date: Wed, 21 Oct 2026 20:15:38 -0700 Subject: [PATCH 15/17] Keep barJsFile() from deleting a variant in use, and handle a failed write The clean-up deleted any bar-*.js older than a day, including a variant still in use whose file was written long ago, so a concurrent bundle request could build without TracyDebugger's bar JS and browsers would cache that. A file in use is now written again at most hourly, which keeps its mtime recent, so only variants nothing has asked for in a day are removed. A failed write (unwritable cache dir, full disk) removes its .tmp file, logs an error and returns null, and the caller then adds no file rather than one Tracy can't read. --- TracyDebugger.module.php | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/TracyDebugger.module.php b/TracyDebugger.module.php index 9959d64d..c0cc495f 100644 --- a/TracyDebugger.module.php +++ b/TracyDebugger.module.php @@ -2940,20 +2940,25 @@ private static function barStylesJs($html): string { /** * Path of a cache file holding this bar JS, for Debugger::$customJsFiles (Tracy reads those - * files when it builds the bar bundle). Named by a hash of the content, so requests that build - * different JS never overwrite each other's file. Writing a new one (something that feeds the JS - * changed) also removes those more than a day old; a variant still in use is just written again. + * files when it builds the bar bundle), or null if it can't be written. Named by a hash of the + * content, so requests that build different JS never overwrite each other's file. A file in use + * is written again at most hourly, which keeps its mtime recent, so the clean-up of files more + * than a day old only removes variants nothing has asked for since. * */ private function barJsFile($js) { $file = $this->tracyCacheDir . 'bar-' . md5($js) . '.js'; - if(!is_file($file)) { - $tmpFile = $file . '.' . getmypid() . '.tmp'; - file_put_contents($tmpFile, $js); - rename($tmpFile, $file); - foreach(glob($this->tracyCacheDir . 'bar-*.js') ?: array() as $oldFile) { - if($oldFile !== $file && filemtime($oldFile) < time() - 86400) @unlink($oldFile); - } + if(is_file($file) && filemtime($file) > time() - 3600) return $file; + + $tmpFile = $file . '.' . getmypid() . '.tmp'; + if(@file_put_contents($tmpFile, $js) === false || !@rename($tmpFile, $file)) { + @unlink($tmpFile); + if(is_file($file)) return $file; + $this->wire('log')->error("TracyDebugger: unable to write the bar JS file $file"); + return null; + } + foreach(glob($this->tracyCacheDir . 'bar-*.js') ?: array() as $oldFile) { + if($oldFile !== $file && filemtime($oldFile) < time() - 86400) @unlink($oldFile); } return $file; } From 37ae9e775b64e9fc27bb15e8e815346a3709bd23 Mon Sep 17 00:00:00 2001 From: Adrian Jones Date: Wed, 21 Oct 2026 20:15:38 -0700 Subject: [PATCH 16/17] Only build the bar JS file on ?_tracy_bar= requests Tracy reads Debugger::$customJsFiles only when it serves the bar bundle, but every page view with the bar hashed TracyDebugger's bar JS and checked (sometimes wrote) its cache file. --- TracyDebugger.module.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/TracyDebugger.module.php b/TracyDebugger.module.php index c0cc495f..e6f0251c 100644 --- a/TracyDebugger.module.php +++ b/TracyDebugger.module.php @@ -1812,7 +1812,10 @@ function unhideBar() { } });'; - Debugger::$customJsFiles[] = $this->barJsFile($barJs); + // Tracy only reads $customJsFiles when it serves the bundle (?_tracy_bar=...) + if(isset($_GET['_tracy_bar']) && ($barJsFile = $this->barJsFile($barJs))) { + Debugger::$customJsFiles[] = $barJsFile; + } } From 83c5c6674525540940611e384621b99274026552 Mon Sep 17 00:00:00 2001 From: Adrian Jones Date: Wed, 21 Oct 2026 20:15:38 -0700 Subject: [PATCH 17/17] Console: report fatal errors raised under @, and show @-suppressed errors in scream mode tracyConsoleShutdownHandler still returned early on the pre-PHP-8 error_reporting() === 0 check, which on PHP 7 hid a fatal error raised inside an @ expression; a fatal error is now always reported. And the error handler's @ check now gives way to Tracy's scream mode (forceScream), whose purpose is to show those errors. --- includes/CodeProcessor.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/includes/CodeProcessor.php b/includes/CodeProcessor.php index d9c500f0..65d0b732 100644 --- a/includes/CodeProcessor.php +++ b/includes/CodeProcessor.php @@ -560,9 +560,10 @@ function ...") and, because the raw path never matches the compiled entry in the // error handler function function tracyConsoleErrorHandler($errno, $errstr, $errfile, $errline) { - // this prevents silenced(@) errors from being captured by this custom error handler. Since PHP 8, @ lowers - // error_reporting() to the fatal levels instead of 0, so test the error's own level against it - if(!(error_reporting() & $errno)) { + // this prevents silenced(@) errors from being captured by this custom error handler, unless Tracy's + // scream mode (forceScream) asks for them. Since PHP 8, @ lowers error_reporting() to the fatal + // levels instead of 0, so test the error's own level against it + if(!Debugger::$scream && !(error_reporting() & $errno)) { // continue script execution, skipping standard PHP error handler return true; } @@ -606,11 +607,6 @@ function tracyConsoleShutdownHandler() { /* always release the lock first, regardless of error state, so the panel stops reporting "Running..." the moment this request actually ends */ tracyConsoleReleasePidLock(); - // this prevents silenced(@) errors from being captured by this custom error handler - if(error_reporting() === 0) { - // continue script execution, skipping standard PHP error handler - return true; - } // when Tracy's shutdown handler is skipped, TracyDebugger's earlier shutdown function has taken the error $lasterror = TracyDebugger::$skipTracyShutdownHandler ? TracyDebugger::$consoleLastError : error_get_last();