$worstCase), JSON_UNESCAPED_SLASHES); // +1 for the newline that follows the summary line. self::$summaryReserveBytesCache = strlen((string)$line) + 1; } return self::$summaryReserveBytesCache; } /** * Compose one labeled excerpt block from the newest of the given paths. * * Paths that do not exist are skipped: a journal that was never written is * a normal state, not a failure. That silence is only READABLE because the * support collector states, separately and unconditionally, which * directories and files it checked -- see * ABJ_404_Solution_DiagnosticCollectionManifest. Without that manifest an * empty return here is indistinguishable from a wrong directory, a wrong * node, or a regression in this reader, which is precisely how beta.1's * "the journal came back empty" ended up undiagnosable. Do NOT make this * method the place that explains itself: a reader is the last component * that can be trusted to describe its own failure, which is why the * manifest stats the files independently instead. * * @param array $paths Candidate files, any order; missing ones are ignored. * @param int $budgetBytes Hard ceiling for the returned string, header included. * @param string $header Section label, e.g. "Recent AJAX stage traces (JSONL):\n". * @param array $knownFailingIds Requests condemned in ANOTHER journal, * from failureIndex(). Without them this excerpt ranks only on what its own * files say, and the browser says it in one journal and not the other. * @param callable(array):array|null $lineTransform * Channel-specific normalization applied before evidence ranking. * @param array{paths: array, manifest: array}|null $fileSelection * A selection produced by DiagnosticJournalFileSelector. Passing it * keeps the excerpt and collection manifest on one decision. * @return string Empty string when nothing readable was found. */ public static function compose(array $paths, int $budgetBytes, string $header, array $knownFailingIds = array(), ?callable $lineTransform = null, ?array $fileSelection = null): string { try { $selection = $fileSelection ?? ABJ_404_Solution_DiagnosticJournalFileSelector::select($paths, $knownFailingIds); $files = isset($selection['paths']) && is_array($selection['paths']) ? $selection['paths'] : array(); if ($files === array()) { return ''; } $contentBudget = $budgetBytes - strlen($header) - self::summaryReserveBytes(); if ($contentBudget <= 0) { return ''; } $read = self::readLines($files); if ($read['lines'] === array()) { return ''; } $lines = $lineTransform === null ? $read['lines'] : $lineTransform($read['lines']); if (!is_array($lines) || $lines === array()) { return ''; } $selected = ABJ_404_Solution_DiagnosticEvidencePriority::select( $lines, $contentBudget, $knownFailingIds); if ($selected['lines'] === array()) { return ''; } // Keys come from COMPOSE_SUMMARY_KEYS via array_combine() rather // than a literal associative array, so a value added here without // a matching entry in the constant fails loudly instead of // silently widening the summary the reserve was derived from. $summary = array_merge($selected['summary'], array_combine(self::COMPOSE_SUMMARY_KEYS, array( $read['filesRead'], $read['filesSkipped'], $read['bytesUnread'], self::selectionCount($selection, 'dropped_files'), self::selectionCount($selection, 'known_failure_files'), self::selectionCount($selection, 'server_failure_files'), self::selectionCount($selection, 'classification_issue_files'), self::selectionCount($selection, 'pinned_files'), ))); return $header . self::summaryLine($summary) . "\n" . implode("\n", $selected['lines']); } catch (Throwable $e) { self::reportFailure('Diagnostic journal excerpt failed: ' . $e->getMessage()); return ''; } } /** * Every request id the browser condemned in one channel's journals. * * Read as its own pass, BEFORE any excerpt is composed, because the answer * has to be available to a journal that does not contain it: the browser's * verdicts live only in the checkpoint journal, and the stage trace has to * rank the same requests as failing or it spends a browser-lost request's * stage timings on budget as ordinary context. Callers build the index per * channel and pass the union into every compose() call -- one index, both * journals, so the two can never disagree about which requests failed. * * This narrow pass scans every candidate before file selection and retains * only verdict lines. Applying the excerpt's file cap first would erase the * IDs needed to pin the older evidence file. * * @param array $paths One channel's candidate files, as compose() takes them. * @return array Condemned request ids, keyed by id; empty when unreadable. */ public static function failureIndex(array $paths): array { try { return ABJ_404_Solution_DiagnosticClientVerdict::requestIdsIn( self::failureVerdictLines($paths)); } catch (Throwable $e) { self::reportFailure('Diagnostic failure index failed: ' . $e->getMessage()); return array(); } } /** * One channel's journals as a single oldest-first stream of whole lines. * * Whole-journal consumers share the same ordinary-file selection and byte * allowance as an excerpt with no externally known failures. * * @param array $paths One channel's candidate files, as compose() takes them. * @return array Empty when nothing readable was found. */ public static function readAllLines(array $paths): array { try { $selection = ABJ_404_Solution_DiagnosticJournalFileSelector::select($paths); $files = $selection['paths']; if ($files === array()) { return array(); } return self::readLines($files)['lines']; } catch (Throwable $e) { self::reportFailure('Diagnostic journal read failed: ' . $e->getMessage()); return array(); } } /** * Browser-verdict lines from every existing candidate, before file capping. * * This pass deliberately has no file-count or byte-budget decision: it * stores only the two event shapes that can condemn another request, so a * later file selection cannot erase the IDs needed to pin their evidence. * * @param array $paths * @return array */ private static function failureVerdictLines(array $paths): array { $lines = array(); foreach ($paths as $path) { $fileResult = ABJ_404_Solution_HostFilesystemCall::isFile($path); if ($fileResult['warning'] !== null) { self::reportFailure('Diagnostic failure index file check warning: ' . $path . ' (' . $fileResult['warning'] . ')'); continue; } if (!$fileResult['result']) { continue; } $openResult = ABJ_404_Solution_HostFilesystemCall::fopen($path, 'rb'); if ($openResult['warning'] !== null) { self::reportFailure('Diagnostic failure index open warning: ' . $path . ' (' . $openResult['warning'] . ')'); if (is_resource($openResult['result'])) { $closeResult = ABJ_404_Solution_HostFilesystemCall::fclose($openResult['result']); if ($closeResult['warning'] !== null) { self::reportFailure('Diagnostic failure index close warning: ' . $path . ' (' . $closeResult['warning'] . ')'); } } continue; } $handle = $openResult['result']; if ($handle === false) { self::reportFailure('Diagnostic failure index could not scan: ' . $path); continue; } $pathLines = array(); $pathHealthy = true; try { while (true) { $lineResult = ABJ_404_Solution_HostFilesystemCall::fgets($handle); if ($lineResult['warning'] !== null) { self::reportFailure('Diagnostic failure index read warning: ' . $path . ' (' . $lineResult['warning'] . ')'); $pathHealthy = false; break; } $line = $lineResult['result']; if ($line === false) { break; } if (strpos($line, ABJ_404_Solution_DiagnosticClientVerdict::PRIOR_ATTEMPT_EVENT) === false && strpos($line, ABJ_404_Solution_DiagnosticClientVerdict::BEACON_BRANCH_EVENT) === false) { continue; } $line = trim($line); if ($line !== '') { $pathLines[] = $line; } } } finally { $closeResult = ABJ_404_Solution_HostFilesystemCall::fclose($handle); if ($closeResult['warning'] !== null) { self::reportFailure('Diagnostic failure index close warning: ' . $path . ' (' . $closeResult['warning'] . ')'); $pathHealthy = false; } } if ($pathHealthy) { $lines = array_merge($lines, $pathLines); } } return $lines; } /** * @param array{manifest?: array} $selection */ private static function selectionCount(array $selection, string $field): int { $manifest = isset($selection['manifest']) && is_array($selection['manifest']) ? $selection['manifest'] : array(); return ABJ_404_Solution_ExactInteger::readOr($manifest[$field] ?? null, 0, 0); } /** * Every whole line from the given files as one oldest-first stream. * * The read allowance is consumed newest file first, so if it runs out the * bytes lost are the OLDEST -- but ranking then happens over everything * that was read, which is what stops a busy file from displacing the * failing requests in another one. * * @param array $files Oldest first. * @return array{lines: array, filesRead: int, filesSkipped: int, bytesUnread: int} */ private static function readLines(array $files): array { $allowance = self::MAX_TOTAL_READ_BYTES; $perFile = array(); $filesRead = 0; $filesSkipped = 0; $bytesUnread = 0; foreach (array_reverse($files) as $path) { $sizeResult = ABJ_404_Solution_HostFilesystemCall::filesize($path); if ($sizeResult['warning'] !== null) { self::reportFailure('Diagnostic journal size warning: ' . $path . ' (' . $sizeResult['warning'] . ')'); } $size = $sizeResult['warning'] === null ? $sizeResult['result'] : false; if (!is_int($size)) { self::reportFailure('Diagnostic journal size could not be read: ' . $path); $filesSkipped++; continue; } if ($allowance <= 0) { $filesSkipped++; $bytesUnread += $size; continue; } $contents = self::readFileTail($path, $allowance); if ($contents === '') { $filesSkipped++; $bytesUnread += $size; continue; } $filesRead++; $bytesUnread += max(0, $size - strlen($contents)); $allowance -= strlen($contents); $perFile[] = $contents; } $lines = array(); foreach (array_reverse($perFile) as $contents) { foreach (explode("\n", $contents) as $line) { $line = trim($line); if ($line !== '') { $lines[] = $line; } } } return array( 'lines' => $lines, 'filesRead' => $filesRead, 'filesSkipped' => $filesSkipped, 'bytesUnread' => $bytesUnread, ); } /** * Last $limit bytes of a file, trimmed forward to a line boundary so a * reader never gets half a JSON record. */ private static function readFileTail(string $path, int $limit): string { if ($limit <= 0) { return ''; } $fileResult = ABJ_404_Solution_HostFilesystemCall::isFile($path); if ($fileResult['warning'] !== null) { self::reportFailure('Diagnostic journal file check warning: ' . $path . ' (' . $fileResult['warning'] . ')'); return ''; } if (!$fileResult['result']) { return ''; } $sizeResult = ABJ_404_Solution_HostFilesystemCall::filesize($path); if ($sizeResult['warning'] !== null) { self::reportFailure('Diagnostic journal size warning: ' . $path . ' (' . $sizeResult['warning'] . ')'); } $size = $sizeResult['warning'] === null ? $sizeResult['result'] : false; if (!is_int($size)) { self::reportFailure('Diagnostic journal size could not be read: ' . $path); return ''; } $offset = max(0, $size - $limit); $readResult = ABJ_404_Solution_FileSystemService::captureFilesystemWarnings( static function () use ($path, $offset, $limit) { return file_get_contents($path, false, null, $offset, $limit); } ); if ($readResult['warning'] !== null) { self::reportFailure('Diagnostic journal read warning: ' . $path . ' (' . $readResult['warning'] . ')'); } $contents = $readResult['warning'] === null ? $readResult['result'] : false; if (!is_string($contents)) { self::reportFailure('Diagnostic journal could not be read: ' . $path); return ''; } if ($offset > 0) { $newline = strpos($contents, "\n"); $contents = $newline === false ? '' : substr($contents, $newline + 1); } return trim($contents); } /** * The accounting line. JSON, so the whole block below the human header * stays parseable as JSONL, and bounded, so it can never eat into the * evidence it is describing. * * @param array $summary */ private static function summaryLine(array $summary): string { $line = json_encode(array('abj404_excerpt_summary' => $summary), JSON_UNESCAPED_SLASHES); if (!is_string($line)) { self::reportFailure( 'Diagnostic excerpt summary could not be JSON-encoded: ' . json_last_error_msg()); return '{"abj404_excerpt_summary":{"encoding_failed":1}}'; } // +1 for the newline that follows it, which the reserve also covers. $reserve = self::summaryReserveBytes(); if (strlen($line) + 1 > $reserve) { // summaryReserveBytes() is a worst-case bound over every key this // summary can hold, so reaching this branch means the summary // grew a key the reserve was not derived from -- a real defect, // not the ordinary encoding failure above. self::reportFailure('Diagnostic excerpt summary exceeded its reserve: ' . (strlen($line) + 1) . ' bytes against a ' . $reserve . '-byte reserve.'); return '{"abj404_excerpt_summary":{"over_reserve":1}}'; } return $line; } private static function reportFailure(string $message): void { // Unconditional; see AjaxCheckpointLogger::reportFailure(). abj404_logPhpFallback('ajax-trace', $message); } }