Problem/Motivation

ConfigurableLanguageManager::getLanguageSwitchLinks() temporarily overrides $this->negotiatedLanguages[TYPE_CONTENT] and [TYPE_INTERFACE] for each language while checking $url->access() inside an array_filter() callback. The original values are restored after the filter completes.

In Drupal 11.3, Renderer::executeInRenderContext() was changed to use PHP Fibers (#3383449: Add Fibers support to Drupal\Core\Render\Renderer). When a render context callable suspends inside a Fiber, control returns to the parent replacePlaceholders() Fiber loop, which can then start rendering other placeholders while the first one is suspended. If the breadcrumb block (or any other language-sensitive block) renders during this interleaving, it sees the temporarily-wrong content language and builds/caches its output with the wrong translation.

Why this regressed in 11.3

The buggy code pattern (global state mutation during access checks) was introduced in Drupal 9.4.12 by the SA-CORE-2023-003 fix (#2499357: Language switcher block does not adequately check content access when displaying links). However, it was latent and unexposed until Drupal 11.3 due to a key change in Renderer::executeInRenderContext().

In Drupal 11.2.8executeInRenderContext() is synchronous:

public function executeInRenderContext(RenderContext $context, callable $callable) {
  $previous_context = $this->getCurrentRenderContext();
  $this->setCurrentRenderContext($context);
  $result = $callable();   // ← runs to completion, no suspension
  $this->setCurrentRenderContext($previous_context);
  return $result;
}

In Drupal 11.3.3executeInRenderContext() uses Fibers:

public function executeInRenderContext(RenderContext $context, callable $callable) {
  $previous_context = $this->getCurrentRenderContext();
  $this->setCurrentRenderContext($context);

  $fiber = new \Fiber(static fn () => $callable());
  $fiber->start();
  while (!$fiber->isTerminated()) {
    if ($fiber->isSuspended()) {
      if (\Fiber::getCurrent() !== NULL) {
        $this->setCurrentRenderContext($previous_context);
        \Fiber::suspend();    // ← suspends UP to parent Fiber loop!
        $this->setCurrentRenderContext($context);
      }
      $fiber->resume();
    }
  }
  // ...
}

The comment in the 11.3 code explicitly states: "Doing so allows other placeholders to be rendered before returning here."

This Fiber-based suspension is exactly what causes the bug. While replacePlaceholders() iterates placeholders using Fibers (this was already present in 11.2), in 11.2 any executeInRenderContext() calls within those Fibers ran synchronously to completion. In 11.3, they can now suspend and yield control back to the placeholder loop, allowing other placeholder Fibers to interleave.

Both LanguageBlock and SystemBreadcrumbBlock became placeholders in 11.2.2 (#3533588: Core blocks now use placeholders, making it impossible for the theme to determine if the region is empty) via createPlaceholder(): TRUE. But in 11.2.x, placeholder Fibers couldn't interleave during executeInRenderContext() calls, so the language state mutation during $url->access() was invisible to other placeholders.

The interleaving sequence

replacePlaceholders() Fiber loop:
  → Start language switcher Fiber
    → LanguageBlock->build()
      → getLanguageSwitchLinks()
        → array_filter sets TYPE_CONTENT = 'cy' (Welsh)
        → $url->access()
          → executeInRenderContext() wraps in child Fiber
            → access check triggers something that suspends
              → child Fiber suspends → executeInRenderContext suspends UP
                → replacePlaceholders() loop resumes next Fiber
                  → Start/resume breadcrumb Fiber
                    → SystemBreadcrumbBlock->build()
                      → BreadcrumbManager->build()
                        → getCurrentLanguage(TYPE_CONTENT) returns 'cy' ← WRONG
                        → breadcrumb cached with Welsh translation
                  → breadcrumb Fiber completes (or suspends)
                → language switcher Fiber resumes
              → access check completes
        → negotiatedLanguages restored ← too late

Why it only appears after cache rebuild

Under normal operation, placeholder results are cached by dynamic_page_cache. The bug only manifests when placeholders are rendered fresh (cache miss) — typically after drush cr, container rebuild, or when a module is installed/uninstalled. This makes it intermittent and hard to diagnose.

Once the breadcrumb is cached with the wrong translation, it persists until the next cache clear. Depending on Fiber interleaving after the next cache clear, it may or may not re-trigger.

Steps to reproduce

Core-only reproduction

  1. Install Drupal with two languages (e.g. English + Welsh), using URL prefix detection
  2. Create a translatable content type, create a node with translations in both languages
  3. Place a Language switcher block and a Breadcrumb block in the same page layout
  4. Clear all caches: drush cr
  5. Visit the English version of the node
  6. Inspect the breadcrumb — if Fiber interleaving causes the breadcrumb to resolve during the language switcher's getLanguageSwitchLinks() filter, it will show the Welsh translation

Note: This is timing/interleaving-dependent. The bug requires the breadcrumb Fiber to execute during the language switcher's $url->access() call. This depends on when/whether the access check Fiber suspends.

Reliable reproduction (with contrib)

With the context_active_trail module (which provides a breadcrumb builder based on context-driven menu active trails) - make sure you use the 2.x dev branch as there are currently no releases compatible with 11:

  1. Set up a multilingual site (e.g. English + Welsh) with path prefix negotiation
  2. Place a Language switcher block and Breadcrumb block
  3. Configure a context-driven breadcrumb for a page (e.g. /sitemap) with a menu link that has translations
  4. Clear all caches: drush cr
  5. Visit the English version of the page
  6. The breadcrumb shows the Welsh translation ("Map o'r wefan") instead of the English one ("Site map")

The bug persists on all subsequent requests because the breadcrumb is cached with the wrong translation.

Proving the global state leak

The mutation happens inside the array_filter callback. You can observe it by adding a hook_node_access() implementation that checks the content language:

function mymodule_node_access(\Drupal\node\NodeInterface $node, $operation, \Drupal\Core\Session\AccountInterface $account) {
  $content_lang = \Drupal::languageManager()
    ->getCurrentLanguage(\Drupal\Core\Language\LanguageInterface::TYPE_CONTENT)
    ->getId();
  $url_lang = \Drupal::languageManager()
    ->getCurrentLanguage(\Drupal\Core\Language\LanguageInterface::TYPE_URL)
    ->getId();
  if ($content_lang !== $url_lang) {
    \Drupal::logger('language_bug')->warning(
      'Content language (@content) does not match URL language (@url) during access check for @title',
      ['@content' => $content_lang, '@url' => $url_lang, '@title' => $node->getTitle()]
    );
  }
  return \Drupal\Core\Access\AccessResult::neutral();
}

The buggy code

In ConfigurableLanguageManager::getLanguageSwitchLinks() (~line 433):

$original_languages = $this->negotiatedLanguages;
// ...
$result = array_filter($result, function (array $link): bool {
  $url = $link['url'] ?? NULL;
  $language = $link['language'] ?? NULL;
  if ($language instanceof LanguageInterface) {
    // BUG: This mutates global state visible to ALL code that runs
    // during $url->access() — and in 11.3, Fiber interleaving means
    // OTHER placeholder Fibers can execute during this window
    $this->negotiatedLanguages[LanguageInterface::TYPE_CONTENT] = $language;
    $this->negotiatedLanguages[LanguageInterface::TYPE_INTERFACE] = $language;
  }
  try {
    return $url instanceof Url && $url->access();
  }
  catch (\Exception) {
    return FALSE;
  }
});
// Restore happens here — but any Fiber-interleaved rendering already
// used the wrong language and cached its results
$this->negotiatedLanguages = $original_languages;

Proposed resolution

Option A: Restore after each access check (minimal fix)

Restore negotiatedLanguages after each individual $url->access() call, not just after the entire filter:

$result = array_filter($result, function (array $link): bool {
  $url = $link['url'] ?? NULL;
  $language = $link['language'] ?? NULL;
  $original = $this->negotiatedLanguages;
  if ($language instanceof LanguageInterface) {
    $this->negotiatedLanguages[LanguageInterface::TYPE_CONTENT] = $language;
    $this->negotiatedLanguages[LanguageInterface::TYPE_INTERFACE] = $language;
  }
  try {
    return $url instanceof Url && $url->access();
  }
  catch (\Exception) {
    return FALSE;
  }
  finally {
    $this->negotiatedLanguages = $original;
  }
});

This minimises the window where global state is mutated. The language is only wrong during $url->access() itself, and restored immediately after — before the Fiber can suspend and allow other placeholders to interleave.

Note: This is the approach @alexpott suggested in [#3362713] comment #54 for a related issue.

Important caveat: Even with this fix, the language state is still wrong during $url->access(). If the access check itself triggers rendering (e.g. via entity loading), that rendering will still see the wrong language. The try/finally only protects against inter-placeholder interleaving, not intra-access-check side effects.

Option B: Avoid global state mutation entirely

Pass the language context directly to the access check rather than temporarily changing the negotiated languages. This is a more architectural change but eliminates the side-effect class of bugs entirely.

Related issues

Comments

nicrodgers created an issue. See original summary.

nicrodgers’s picture

Issue summary: View changes
anybody’s picture

Status: Active » Closed (duplicate)

Closing this as duplicate of #3573391: getLanguageSwitchLinks() leaks temporary content language into placeholder rendering via Fiber interleaving (wrong translations in breadcrumbs, blocks) because that one has more activity and a MR, while this contains a lot of very helpful technical information!

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.