drupal_static() is currently as follows (excluding some of the header comments). The code hasn't changed in a decade.

/**
 * @param $name
 *   Globally unique name for the variable. For a function with only one static,
 *   variable, the function name (e.g. via the PHP magic __FUNCTION__ constant)
 *   is recommended. For a function with multiple static variables add a
 *   distinguishing suffix to the function name for each one.
 * @param $default_value
 *   Optional default value.
 * @param $reset
 *   TRUE to reset one or all variables(s). This parameter is only used
 *   internally and should not be passed in; use drupal_static_reset() instead.
 *   (This function's return value should not be used when TRUE is passed in.)
 *
 * @return
 *   Returns a variable by reference.
 *
 * @see drupal_static_reset()
 */
function &drupal_static($name, $default_value = NULL, $reset = FALSE) {
  static $data = array(), $default = array();
  // First check if dealing with a previously defined static variable.
  if (isset($data[$name]) || array_key_exists($name, $data)) {
    // Non-NULL $name and both $data[$name] and $default[$name] statics exist.
    if ($reset) {
      // Reset pre-existing static variable to its default value.
      $data[$name] = $default[$name];
    }
    return $data[$name];
  }
  // Neither $data[$name] nor $default[$name] static variables exist.
  if (isset($name)) {
    if ($reset) {
      // Reset was called before a default is set and yet a variable must be
      // returned.
      return $data;
    }
    // First call with new non-NULL $name. Initialize a new static variable.
    $default[$name] = $data[$name] = $default_value;
    return $data[$name];
  }
  // Reset all: ($name == NULL). This needs to be done one at a time so that
  // references returned by earlier invocations of drupal_static() also get
  // reset.
  foreach ($default as $name => $value) {
    $data[$name] = $value;
  }
  // As the function returns a reference, the return should always be a
  // variable.
  return $data;
}

Right at the start, this immediately looks wrong to me:

  if (isset($data[$name]) || array_key_exists($name, $data)) {
    // Non-NULL $name and both $data[$name] and $default[$name] statics exist.
    ...
  }

(a) That logic is buggy, and certainly does not align with what the comments claim is happening.

(b) If we're going to fall back to testing array_key_exists($name, $data), then testing isset($data[$name]) beforehand is redundant.

(c) isset($data[$name]) can be TRUE when $name = NULL

(d) $default[$name] is not even looked at.

I can't actually tell for certain what the intended logic was, as there are multiple problems. That isset($data[$name]) is definitely wrong, though. We can get away without testing array_key_exists($name, $default) if we have established that array_key_exists($name, $data) (examining the rest of the code confirms that the one follows from the other), but we definitely need to check isset($name) as otherwise we will get this:

>>> drupal_static('', 'foo')
=> "foo"
>>> drupal_static(NULL)
=> "foo"

Which means Drupal can no longer perform a full reset of the static variables, because the interface for doing that is passing $name = NULL.

This happens because $data[NULL] = 'foo' actually gives us ['' => 'foo'] and also array_key_exists(NULL, $data) => TRUE.

I think this code should actually be:

  if (isset($name) && array_key_exists($name, $data)) {
    // Non-NULL $name, and both $data[$name] and (implicitly)
    // $default[$name] statics exist.
    ...
  }

However the following block is also conditional upon isset($name), so we should refactor it all to:

  if (isset($name)) {
    if (array_key_exists($name, $data)) {
      // Non-NULL $name, and both $data[$name] and (implicitly)
      // $default[$name] statics exist.
      ...
    }
    else {
      // Neither $data[$name] nor $default[$name] static variables exist.
      ...
    }
  }

and personally I am in favour of also putting the remainder of the function -- the "Reset all: ($name == NULL)" code -- into an else {...} clause for that initial if (isset($name)) statement.

I also think $default should be $defaults (plural) to match with $data (plural).

In all, I propose that the function be refactored like so:

function &drupal_static($name, $default_value = NULL, $reset = FALSE) {
  static $data = array(), $defaults = array();
  if (isset($name)) {
    // Check if we're dealing with a previously defined static variable.
    if (array_key_exists($name, $data)) {
      // Both $data[$name] and (implicitly) $defaults[$name] statics exist.
      if ($reset) {
        // Reset pre-existing static variable to its default value.
        $data[$name] = $defaults[$name];
      }
      return $data[$name];
    }
    else {
      // Neither $data[$name] nor $defaults[$name] static variables exist.
      if ($reset) {
        // Reset was called before any value for $name was set, so we should
        // not set anything ($default_value is not reliable in this case). As
        // the function returns a reference, we must still return a variable.
        // (Code using $reset does not use the return value).
        return $data;
      }
      // First call with new non-NULL $name. Initialize a new static variable.
      $defaults[$name] = $data[$name] = $default_value;
      return $data[$name];
    }
  }
  else {
    // Reset all: ($name == NULL). This needs to be done one at a time so that
    // references returned by earlier invocations of drupal_static() also get
    // reset.
    foreach ($defaults as $name => $value) {
      $data[$name] = $value;
    }
    // As the function returns a reference, we must still return a variable.
    return $data;
  }
}

Comments

jweowu created an issue. See original summary.

jweowu’s picture

Status: Active » Needs review
StatusFileSize
new2.85 KB
jweowu’s picture

Title: drupal_static() code is buggy and inconsistently commented » drupal_static() edge case bug and inconsistent comments
jweowu’s picture

Version: 7.x-dev » 9.1.x-dev
Issue tags: +Needs backport to D7
StatusFileSize
new2.83 KB

This is virtually identical in 9.1.x.

jweowu’s picture

Version: 9.1.x-dev » 9.2.x-dev

Drupal 9.1.0-alpha1 will be released the week of October 19, 2020, which means new developments and disruptive changes should now be targeted for the 9.2.x-dev branch. For more information see the Drupal 9 minor version schedule and the Allowed changes during the Drupal 9 release cycle.

Version: 9.2.x-dev » 9.3.x-dev

Drupal 9.2.0-alpha1 will be released the week of May 3, 2021, which means new developments and disruptive changes should now be targeted for the 9.3.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.3.x-dev » 9.4.x-dev

Drupal 9.3.0-rc1 was released on November 26, 2021, which means new developments and disruptive changes should now be targeted for the 9.4.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.4.x-dev » 9.5.x-dev

Drupal 9.4.0-alpha1 was released on May 6, 2022, which means new developments and disruptive changes should now be targeted for the 9.5.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.5.x-dev » 10.1.x-dev

Drupal 9.5.0-beta2 and Drupal 10.0.0-beta2 were released on September 29, 2022, which means new developments and disruptive changes should now be targeted for the 10.1.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

smustgrave’s picture

Status: Needs review » Needs work

Will this need a test case for D10?

jweowu’s picture

Sure, here's a 10.1.x re-roll with tests.

smustgrave’s picture

Status: Needs review » Needs work

Wow thanks for the quick turnaround!

Only nit picky thing I see (and found thanks to phpstorm)

return $data[$name];
Can we move this out of the if/else and just have it outside both. Since both cases return the same thing, would be one less line of code.

Other then that everything looks good!

Great job with the test!

jweowu’s picture

Status: Needs work » Needs review
StatusFileSize
new4.43 KB
new771 bytes

True. I suspect I did that on purpose so that the code for each of those scenarios was very self-contained, but I really can't decide now which way around I prefer it, so here's a revised patch with that change. I think both ways are fine.

smustgrave’s picture

Status: Needs review » Reviewed & tested by the community

It was nit picky thing fully admit. But figured I'd ask now so no one else did down the line. The rest looks good to me.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 15: drupal-10.1.x-core-drupal_static-3136041-15.patch, failed testing. View results

smustgrave’s picture

Status: Needs work » Reviewed & tested by the community

Random ckeditor5

jweowu’s picture

Thank you for reviewing.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 15: drupal-10.1.x-core-drupal_static-3136041-15.patch, failed testing. View results

jweowu’s picture

Status: Needs work » Reviewed & tested by the community

Just the same irrelevant ckeditor test failure.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 15: drupal-10.1.x-core-drupal_static-3136041-15.patch, failed testing. View results

jweowu’s picture

Status: Needs work » Reviewed & tested by the community

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 15: drupal-10.1.x-core-drupal_static-3136041-15.patch, failed testing. View results

jweowu’s picture

jweowu’s picture

Status: Needs work » Reviewed & tested by the community
catch’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/includes/bootstrap.inc
@@ -429,36 +429,41 @@ function drupal_maintenance_theme() {
-      return $data;
+    // Check if we're dealing with a previously defined static variable.
+    if (array_key_exists($name, $data)) {
+      // Both $data[$name] and (implicitly) $defaults[$name] statics exist.

The isset() is dropped, but having isset() here was intentional to avoid calling array_key_exists() when the cache is warm, which should be the majority of the times that it's called given it's for static caching. It's not redundant.

jweowu’s picture

You're referring to the old code:

if (isset($data[$name]) || array_key_exists($name, $data)) { ... }

Functionally that isset() is redundant; but are you saying that PHP's implementation of isset($data[$name]) is faster than its implementation of array_key_exists($name, $data)? If so, that's fine (albeit unexpected) -- but it's visually very weird, so it will need a comment.

Moreover, can you confirm whether this performance problem with array_key_exists() is still true of PHP 8.1+? This code was written a long time ago, after all, so if they've fixed that in the meantime in PHP, then we definitely don't want to add the isset() back again.

jweowu’s picture

https://bugs.php.net/bug.php?id=71239 suggests that this performance discrepancy is no longer a going concern:

array_key_exists() has an optimized opcode implementation since PHP 7.4.

jweowu’s picture

Status: Needs work » Needs review
jweowu’s picture

Status: Needs review » Needs work
jweowu’s picture

Well... in fact bootstrap.inc isn't a namespaced file, so that was actually still fine -- but perhaps it's sensible to use \array_key_exists() as standard regardless, just as best-practice.

jweowu’s picture

Status: Needs work » Needs review
StatusFileSize
new4.43 KB
new545 bytes
smustgrave’s picture

Status: Needs review » Reviewed & tested by the community
Issue tags: +Needs Review Queue Initiative

Since only change was \array_key_exists() and no failures think this is good.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 34: drupal-10.1.x-core-drupal_static-3136041-34.patch, failed testing. View results

smustgrave’s picture

Status: Needs work » Reviewed & tested by the community

Another random failure.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 34: drupal-10.1.x-core-drupal_static-3136041-34.patch, failed testing. View results

smustgrave’s picture

Status: Needs work » Reviewed & tested by the community

Random failure

avpaderno’s picture

+    // Reset all: ($name == NULL). This needs to be done one at a time so that
+    // references returned by earlier invocations of drupal_static() also get
+    // reset.

Should not one at a time be once at a time?

jweowu’s picture

I wouldn't think so. "One at a time" seems appropriate here, as we're doing something to each array item individually.

larowlan’s picture

Priority: Normal » Minor

  • catch committed d070ec1d on 10.1.x
    Issue #3136041 by jweowu: drupal_static() edge case bug and inconsistent...
catch’s picture

Status: Reviewed & tested by the community » Fixed

array_key_exists() isn't the only change here, it's moving the bulk of the logic of the function after an isset($name). The array_key_exists() change is unnecessary, but it's consistent with #3346645: Eliminate anti-pattern isset($arr[$key]) || array_key_exists($key, $arr) now that PHP has optimized array_key_exists(), and hopefully we won't touch this logic again until deprecating per #1577902: [META] Remove all usages of drupal_static() & drupal_static_reset() (and hopefully sooner than another ten years).

diff --git a/core/tests/Drupal/KernelTests/Core/Bootstrap/ResettableStaticTest.php b/core/tests/Drupal/KernelTests/Core/Bootstrap/ResettableStaticTest.php
index 4e55b1d96f..7ad4b0d126 100644
--- a/core/tests/Drupal/KernelTests/Core/Bootstrap/ResettableStaticTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Bootstrap/ResettableStaticTest.php
@@ -37,7 +37,7 @@ public function testDrupalStatic() {
     drupal_static_reset();
     $this->assertEquals('foo', $var, 'Variable was reset after second invocation of global reset.');
 
-    // Test the empty string naming edge case for issue #3136041.
+    // Test calling drupal_static() with no arguments (empty string).
     $name1 = __CLASS__ . '_' . __METHOD__ . '1';
     $name2 = '';
     $var1 = &drupal_static($name1, 'initial1');

Made this change on commit since we usually rely on git blame for links back to issues. Committed/pushed to 10.1.x, thanks!

jweowu’s picture

Thanks all. (And thanks smustgrave for keeping on top of the false-positive status updates.)

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.