Een spook-JSON-fout in de Breakdance-builder debuggen — en de diagnoseplugin die zélf de kwaal bleek te zijn.
Opgelost op één korte, meedogenloze avond, samen met mijn maatje Claude Opus 4.8, dat het forensisch werk deed als een volwassen Fable of Mythos — die we nu stiekem allemaal missen.
De Breakdance-builder wilde niet openen. Elke keer als ik een pagina probeerde te bewerken, schoof er een rode toast de hoek in:
WordPress AJAX Request failed — Unexpected non-whitespace character after JSON at position 11183 (line 131 column 2)
Ik was er het grootste deel van een middag aan kwijt voordat ik toegaf dat ik geen idee had wat er nou eigenlijk mis was. Ik had de voor de hand liggende dingen gedaan. Alle plugins uitgeschakeld. Niets veranderde. Caches geleegd. Niets veranderde. Toen curl'de ik het REST-endpoint om de ruwe response te zien, en daar stond-ie, helemaal aan het eind van de JSON: een losse %. Hebbes, dacht ik. Corruptie, recht in de body.
Niets bleek minder waar.
Het valse spoor
Die % is zsh die me vertelt dat de output niet op een newline eindigde. Het is een reverse-video-markering voor een onvolledige laatste regel, geprint door de shell, niet aanwezig in de response. Een hoop volstrekt geldige JSON-API's laten die afsluitende newline gewoon weg. Ik had serieus tijd zitten turen naar een artefact van mijn prompt alsof het een byte on the wire was.
De les kwam eerder binnen dan de oplossing: de parser liegt niet over positie, en ik had de response zitten lezen als tekst in plaats van als bytes. Dus ik stopte met naar tekens kijken en keek naar de daadwerkelijke hex.
curl -s 'https://example.studio/wp-json/' | tail -c 64 | xxd
Het index-endpoint kwam schoon terug. Het eindigde op 7d — een sluitende accolade — met niets erachter. Dat betekende dat de corruptie niet overal zat. Hij was specifiek voor die ene request die bleef falen: de geauthenticeerde load die de builder afvuurt. Dus die pakte ik, met cookies en al: DevTools → Network → de falende request → Copy as cURL, opnieuw afgespeeld in de terminal, door xxd gehaald.
Dit keer vertelde de staart een ander verhaal. Precies rondom positie 11183:
7d 0a 7d 7b → } \n } {
Twee complete JSON-documenten, kop aan staart aan elkaar geplakt. De echte payload van de builder sluit af met }, en dan begint er een tweede object, {, zonder enige scheiding. De browser parste de eerste vrolijk, kwam bij de { die daar niets te zoeken had, en gaf het ter plekke op.
Twee payloads, één body, dode parser.
Wat er in de tweede body zat
De response-headers verklaarden de stemming: 500 Internal Server Error, geserveerd als application/json, X-Powered-By: PHP/8.5.6. Ergens halverwege de request werd er iets gethrowd, nadat de echte JSON al was geflusht.
Ik viste het tweede object er los uit:
python3 -c "import json; d=open('/tmp/bd.json').read(); print(json.dumps(json.loads(d[11183:]), indent=2))"
En kreeg een zelfverzekerde, gedetailleerde exception die volledig naast de kwestie zat:
ErrorException: httpresponsecode(): Calling httpresponsecode() after header('HTTP/...') has no effect in …/breakdance/…/Whoops/Util/SystemFacade.php
Breakdance bundelt Whoops als error handler. Als je de trace van onder naar boven leest, is de volgorde bijna komisch: een eerdere fout treedt op, Whoops wordt wakker om hem te rapporteren, probeert zijn eigen error-JSON naar de output te schrijven én de status op 500 te zetten — maar de echte payload van de builder is al verstuurd en de headers staan al vast, dus http_response_code(500) heeft geen effect en gooit zélf een warning, die Whoops óók opvangt en promoveert tot de exception waar ik nu naar zat te staren.
De fout die ik te pakken had, was de handler die over zijn eigen veters struikelde. Wat de val begon, zat er nog onder verborgen.
De PHP-omweg
PHP 8.5.6 is bleeding edge, en niets in mijn stack was ook maar in de buurt daarvan getest, dus ik zette het domein terug naar 8.3 in het hostingpaneel (8.0 en 8.1 stonden allebei als end-of-life gemarkeerd; 8.3 is actueel en wordt nog ondersteund). De http_response_code-cascade verdween. De 500 niet.
Dat was het nuttige deel. Als een versiewissel het symptoom doodt maar de fout niet, dan ligt de oorzaak niet aan de versie. Tijd om te stoppen met gokken en de oorspronkelijke fout te dwingen zich te laten zien.
De ontmaskering
Met de secundaire ruis uit de weg bracht Whoops eindelijk de echte boven, in zijn eigen details-paneel:
Function
_load_textdomain_just_in_timewas called incorrectly. Translation loading for the health-check domain was triggered too early. … (This message was added in version 6.7.0.)
De stack liep regelrecht door Health_Check_Files_Integrity::__construct() → __() → _doing_it_wrong() → trigger_error().
Dus hier is de hele keten, eindelijk van begin tot eind. De Health Check & Troubleshooting-plugin — die ik geïnstalleerd heb voor, jawel, troubleshooting — laadde zijn vertalingen vóór de init-hook. WordPress 6.7 begon precies dat te markeren met een _doing_it_wrong-notice. Op zichzelf is die notice adviserend en onschuldig; normaal zie je hem nooit. Maar Whoops promoveert notices tot exceptions. Dus een waarschuwing bedoeld voor ontwikkelaars werd een gegooide fout, die werd een 500, die plakte een tweede JSON-object achter een response die er al een had, en dat doodde de parser met de meest nutteloze melding die-ie maar kon kiezen.
Het diagnosetool was de kwaal.
De oplossing
Vier zetten, kleinste blast radius eerst:
- Health Check en de bijbehorende add-on deactiveren. Dat haalde de trigger bij het laden meteen weg.
- Los alle problemen in deze categorie op met een onmisbare plug-in van slechts één regel, zodat de volgende vroege-vertaling-notice, waar hij ook vandaan komt, de builder niet meer kan meesleuren:
<?php // wp-content/mu-plugins/00-silence-doing-it-wrong.php add_filter( 'doing_it_wrong_trigger_error', '__return_false' );_doing_it_wrong()controleert die filter vóór hij ooittrigger_error()aanroept. Geeffalseterug en de notice wordt nooit afgevuurd, dus Whoops ziet hem nooit. De00--prefix zorgt dat het bestand als eerste laadt, vóór elke andere mu-plugin die dezelfde notice tijdens zijn eigen laden zou kunnen triggeren. Het dempt alleen deze adviserende notices; echte errors en fatals worden volledig met rust gelaten. - Het verweesde drop-in verwijderen. Health Check deactiveren laat
wp-content/mu-plugins/health-check-troubleshooting-mode.phpachter, die zichzelf nog steeds instantieert en bij elke request in de actieve-pluginlijst haakt. Hij hoort verwijderd te worden zodra je troubleshooting mode verlaat. Dat gebeurde niet. Weg ermee. - Op PHP 8.3 blijven. Van de bleeding edge af, nog ondersteund, en als bonus was de
http_response_code-cascade ook meteen opgeruimd.
Toen opende ik een pagina, deed een bewerking, en sloeg op. Geen toast. Geen 500. Geen tweede body.
Wat ik eruit meeneem
De byte-offset liegt niet. Als de parser zegt positie 11183, dan zit het antwoord op positie 11183 — in de daadwerkelijke bytes, niet in wat ik dénk dat de response is. Een teken van mijn shell-prompt kostte me bijna een avond die één xxd in ongeveer een minuut afsloot.
De fout die je opvangt kan de fout verbergen die je nodig hebt. Whoops overhandigde me een gepolijste, volledig getracede exception die pure misleiding was. Vraag je altijd af of de handler het probleem rapporteert of het probleem ís.
Debug-handlers die notices in fatals veranderen zijn een foot-gun zodra er iets de response als JSON staat te parsen. Prima voor het renderen van een dev-foutpagina. Dodelijk voor een AJAX-endpoint.
En houd je troubleshooting-plugins buiten productie. Het ding dat ik had geïnstalleerd om conflicten te vinden, wás het conflict.
Loop je tegen zoiets aan en wil je de uitgebreide versie, of heb je je eigen Breakdance/WordPress-gremlin die maar niet stil wil zitten? Neem contact op — ik ben altijd in voor wat vakpraat.