From d65c5185d8127eed0ec42426385c3befd96f98d8 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 23 Apr 2026 13:45:45 +0200 Subject: [PATCH] bug-fixes --- .../article_comments_controller.js | 60 ++++++++++++------- src/Command/PrewarmCommand.php | 23 +++++++ src/Entity/FeaturedAuthor.php | 2 +- src/Service/NostrClient.php | 45 +++++++++++++- 4 files changed, 104 insertions(+), 26 deletions(-) diff --git a/assets/controllers/article_comments_controller.js b/assets/controllers/article_comments_controller.js index c9000c0..cb1dc5a 100644 --- a/assets/controllers/article_comments_controller.js +++ b/assets/controllers/article_comments_controller.js @@ -18,14 +18,9 @@ export default class extends Controller { return; } if (this.preloadedValue) { - const run = () => { - void this.load(); - }; - if (typeof requestIdleCallback !== 'undefined') { - requestIdleCallback(run, { timeout: 8_000 }); - } else { - setTimeout(run, 800); - } + // Article SSR already included comments. Do not re-fetch: a slow or dropped + // request would replace working HTML with a generic error. Re-fetch on auth + // only (reply UI may need fresh permission state). return; } void this.load(); @@ -44,22 +39,41 @@ export default class extends Controller { async load() { const t0 = performance.now(); - try { - const res = await fetch(this.urlValue, { - headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' }, - }); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); + const perAttemptMs = 45_000; + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), perAttemptMs); + try { + const res = await fetch(this.urlValue, { + signal: controller.signal, + headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' }, + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const html = await res.text(); + this.containerTarget.innerHTML = html; + const ms = Math.round(performance.now() - t0); + if (attempt > 1) { + console.info(`[article-comments] fragment OK in ${ms}ms (after ${attempt} attempts)`, this.urlValue); + } else { + console.info(`[article-comments] fragment OK in ${ms}ms`, this.urlValue); + } + window.clearTimeout(timer); + return; + } catch (err) { + window.clearTimeout(timer); + if (attempt < maxAttempts) { + const delay = 1_200 * 2 ** (attempt - 1); + await new Promise((r) => setTimeout(r, delay)); + continue; + } + const ms = Math.round(performance.now() - t0); + console.warn(`[article-comments] fragment failed after ${ms}ms`, this.urlValue, err); + this.containerTarget.innerHTML = + '

Comments could not be loaded.

'; } - const html = await res.text(); - this.containerTarget.innerHTML = html; - const ms = Math.round(performance.now() - t0); - console.info(`[article-comments] fragment OK in ${ms}ms`, this.urlValue); - } catch (err) { - const ms = Math.round(performance.now() - t0); - console.warn(`[article-comments] fragment failed after ${ms}ms`, this.urlValue, err); - this.containerTarget.innerHTML = - '

Comments could not be loaded.

'; } } } diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php index 30b10a1..f868531 100644 --- a/src/Command/PrewarmCommand.php +++ b/src/Command/PrewarmCommand.php @@ -17,11 +17,13 @@ use Psr\Log\LoggerInterface; use swentel\nostr\Key\Key; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Helper; use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Console\Terminal; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; /** @@ -363,6 +365,27 @@ final class PrewarmCommand extends Command $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%'."\n".' %message% %elapsed:6s% '); $bar->setMessage($message); + // Long %message% lines (e.g. category slugs) wider than the terminal make Symfony’s ProgressBar + // shrink/expand the bar on every redraw; truncate so each line fits and the bar stays stable + // and can use the full width to the right. + $tw = (new Terminal())->getWidth(); + if ($tw < 40) { + $tw = 80; + } + $messageMaxWidth = max(12, $tw - 18); + $bar->setPlaceholderFormatter('message', function (ProgressBar $b) use ($messageMaxWidth): string { + $m = (string) ($b->getMessage() ?? ''); + if ($m === '') { + return ''; + } + if (Helper::width($m) > $messageMaxWidth) { + return Helper::substr($m, 0, max(1, $messageMaxWidth - 1)).'…'; + } + + return $m; + }); + $bar->setBarWidth(max(20, $tw - 32)); + return $bar; } diff --git a/src/Entity/FeaturedAuthor.php b/src/Entity/FeaturedAuthor.php index b183d8d..7b4a1d4 100644 --- a/src/Entity/FeaturedAuthor.php +++ b/src/Entity/FeaturedAuthor.php @@ -17,7 +17,7 @@ use Doctrine\ORM\Mapping as ORM; class FeaturedAuthor { #[ORM\Id] - #[ORM\GeneratedValue] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] #[ORM\Column] private ?int $id = null; diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 4467d52..229b366 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -33,6 +33,18 @@ class NostrClient /** Extra wall time for {@see bin/nostr_relay_request_worker.php} process vs. WebSocket timeout. */ private const DISCUSSION_WORKER_GRACE_SEC = 5.0; + /** + * Hard cap on unique relay URLs for article discussion. More relays do not help much (indexers duplicate) + * but blow up wall time when we fall back to sequential in-process {@see Request::send()}. + */ + private const MAX_DISCUSSION_RELAY_URLS = 10; + + /** + * {@see sendArticleDiscussionToRelaysSequential} visits relays one after another (~RELAY_REQUEST_TIMEOUT_SEC + * each). Keep this low so HTTP /fragment/comments and browsers do not hit 60–90s proxy cuts. + */ + private const MAX_SEQUENTIAL_RELAY_URLS = 3; + /** When a logged-in user lists this relay, also use {@see self::AGGR_NOSTR_LAND} for comment + profile reads. */ private const NOSTR_LAND = 'wss://nostr.land'; @@ -972,6 +984,12 @@ class NostrClient array_merge($baseForDiscussion, $authorRelays) ); $plannedRelayUrls = array_values(array_unique($mergedForDiscussion, \SORT_REGULAR)); + if (\count($plannedRelayUrls) > self::MAX_DISCUSSION_RELAY_URLS) { + $plannedRelayUrls = \array_slice($plannedRelayUrls, 0, self::MAX_DISCUSSION_RELAY_URLS); + $this->logger->notice('nostr.article_discussion.relay_list_capped', [ + 'max' => self::MAX_DISCUSSION_RELAY_URLS, + ]); + } $filters = $this->createArticleDiscussionFilters($coordinate, $rootEventHexId); $subscription = new Subscription(); @@ -990,7 +1008,8 @@ class NostrClient $tSend = microtime(true); $workerPath = $this->projectDir.'/bin/nostr_relay_request_worker.php'; if (!\is_file($workerPath) || \count($plannedRelayUrls) <= 1) { - $response = $this->sendArticleDiscussionToRelaysSequential($plannedRelayUrls, $requestMessage); + $forSeq = $this->capRelayUrlsForSequentialPath($plannedRelayUrls); + $response = $this->sendArticleDiscussionToRelaysSequential($forSeq, $requestMessage); } else { try { $response = $this->sendArticleDiscussionToRelaysParallel($plannedRelayUrls, $requestMessage); @@ -999,7 +1018,11 @@ class NostrClient 'message' => $e->getMessage(), 'exception_class' => \get_class($e), ]); - $response = $this->sendArticleDiscussionToRelaysSequential($plannedRelayUrls, $requestMessage); + $forSeq = $this->capRelayUrlsForSequentialPath($plannedRelayUrls); + $this->logger->warning('nostr.article_discussion.sequential_fallback', [ + 'relays' => $forSeq, + ]); + $response = $this->sendArticleDiscussionToRelaysSequential($forSeq, $requestMessage); } } $sendMs = (int) round((microtime(true) - $tSend) * 1000); @@ -1084,6 +1107,24 @@ class NostrClient return ['thread' => $thread, 'quotes' => $quotes]; } + /** + * @param list $relayUrls + * + * @return list + */ + private function capRelayUrlsForSequentialPath(array $relayUrls): array + { + if (\count($relayUrls) <= self::MAX_SEQUENTIAL_RELAY_URLS) { + return $relayUrls; + } + $this->logger->notice('nostr.article_discussion.sequential_relay_cap', [ + 'used' => self::MAX_SEQUENTIAL_RELAY_URLS, + 'had' => \count($relayUrls), + ]); + + return \array_values(\array_slice($relayUrls, 0, self::MAX_SEQUENTIAL_RELAY_URLS)); + } + /** * One {@see Request} over all relays (library visits each wss:// in series). *