resourceloader: Add timing metrics for key operations
[lhc/web/wiklou.git] / includes / resourceloader / ResourceLoader.php
index 5df2651..3d49d94 100644 (file)
  * @author Trevor Parscal
  */
 
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
 /**
  * Dynamic JavaScript and CSS resource loading system.
  *
  * Most of the documentation is on the MediaWiki documentation wiki starting at:
  *    https://www.mediawiki.org/wiki/ResourceLoader
  */
-class ResourceLoader {
+class ResourceLoader implements LoggerAwareInterface {
        /** @var int */
        protected static $filterCacheVersion = 7;
 
@@ -77,6 +81,11 @@ class ResourceLoader {
         */
        protected $blobStore;
 
+       /**
+        * @var LoggerInterface
+        */
+       private $logger;
+
        /**
         * Load information stored in the database about modules.
         *
@@ -169,74 +178,98 @@ class ResourceLoader {
         *
         * @param string $filter Name of filter to run
         * @param string $data Text to filter, such as JavaScript or CSS text
-        * @param string $cacheReport Whether to include the cache key report
+        * @param array $options For back-compat, can also be the boolean value for "cacheReport". Keys:
+        *  - (bool) cache: Whether to allow caching this data. Default: true.
+        *  - (bool) cacheReport: Whether to include the "cache key" report comment. Default: true.
         * @return string Filtered data, or a comment containing an error message
         */
-       public function filter( $filter, $data, $cacheReport = true ) {
+       public function filter( $filter, $data, $options = array() ) {
+               // Back-compat
+               if ( is_bool( $options ) ) {
+                       $options = array( 'cacheReport' => $options );
+               }
+               // Defaults
+               $options += array( 'cache' => true, 'cacheReport' => true );
 
-               // For empty/whitespace-only data or for unknown filters, don't perform
-               // any caching or processing
-               if ( trim( $data ) === '' || !in_array( $filter, array( 'minify-js', 'minify-css' ) ) ) {
+               // Don't filter empty content
+               if ( trim( $data ) === '' ) {
                        return $data;
                }
 
-               // Try for cache hit
-               // Use CACHE_ANYTHING since filtering is very slow compared to DB queries
-               $key = wfMemcKey( 'resourceloader', 'filter', $filter, self::$filterCacheVersion, md5( $data ) );
-               $cache = wfGetCache( CACHE_ANYTHING );
-               $cacheEntry = $cache->get( $key );
-               if ( is_string( $cacheEntry ) ) {
-                       wfIncrStats( "rl-$filter-cache-hits" );
-                       return $cacheEntry;
+               if ( !in_array( $filter, array( 'minify-js', 'minify-css' ) ) ) {
+                       $this->logger->warning( 'Invalid filter {filter}', array(
+                               'filter' => $filter
+                       ) );
+                       return $data;
                }
 
-               $result = '';
-               // Run the filter - we've already verified one of these will work
-               try {
-                       wfIncrStats( "rl-$filter-cache-misses" );
-                       switch ( $filter ) {
-                               case 'minify-js':
-                                       $result = JavaScriptMinifier::minify( $data,
-                                               $this->config->get( 'ResourceLoaderMinifierStatementsOnOwnLine' ),
-                                               $this->config->get( 'ResourceLoaderMinifierMaxLineLength' )
-                                       );
-                                       if ( $cacheReport ) {
-                                               $result .= "\n/* cache key: $key */";
-                                       }
-                                       break;
-                               case 'minify-css':
-                                       $result = CSSMin::minify( $data );
-                                       if ( $cacheReport ) {
-                                               $result .= "\n/* cache key: $key */";
-                                       }
-                                       break;
+               if ( !$options['cache'] ) {
+                       $result = $this->applyFilter( $filter, $data );
+               } else {
+                       $key = wfMemcKey( 'resourceloader', 'filter', $filter, self::$filterCacheVersion, md5( $data ) );
+                       $cache = wfGetCache( wfIsHHVM() ? CACHE_ACCEL : CACHE_ANYTHING );
+                       $cacheEntry = $cache->get( $key );
+                       if ( is_string( $cacheEntry ) ) {
+                               wfIncrStats( "resourceloader_cache.$filter.hit" );
+                               return $cacheEntry;
+                       }
+                       $result = '';
+                       try {
+                               $result = $this->applyFilter( $filter, $data );
+                               if ( $options['cacheReport'] ) {
+                                       $result .= "\n/* cache key: $key */";
+                               }
+                               $cache->set( $key, $result );
+                       } catch ( Exception $e ) {
+                               MWExceptionHandler::logException( $e );
+                               $this->logger->warning( 'Minification failed: {exception}', array(
+                                       'exception' => $e
+                               ) );
+                               $this->errors[] = self::formatExceptionNoComment( $e );
                        }
-
-                       // Save filtered text to Memcached
-                       $cache->set( $key, $result );
-               } catch ( Exception $e ) {
-                       MWExceptionHandler::logException( $e );
-                       wfDebugLog( 'resourceloader', __METHOD__ . ": minification failed: $e" );
-                       $this->errors[] = self::formatExceptionNoComment( $e );
                }
 
                return $result;
        }
 
+       private function applyFilter( $filter, $data ) {
+               $stats = RequestContext::getMain()->getStats();
+               $statStart = microtime( true );
+
+               switch ( $filter ) {
+                       case 'minify-js':
+                               $data = JavaScriptMinifier::minify( $data,
+                                       $this->config->get( 'ResourceLoaderMinifierStatementsOnOwnLine' ),
+                                       $this->config->get( 'ResourceLoaderMinifierMaxLineLength' )
+                               );
+                               break;
+                       case 'minify-css':
+                               $data = CSSMin::minify( $data );
+                               break;
+               }
+
+               $stats->timing( "resourceloader_cache.$filter.miss", microtime( true ) - $statStart );
+               return $data;
+       }
+
        /* Methods */
 
        /**
         * Register core modules and runs registration hooks.
         * @param Config|null $config
         */
-       public function __construct( Config $config = null ) {
+       public function __construct( Config $config = null, LoggerInterface $logger = null ) {
                global $IP;
 
-               if ( $config === null ) {
-                       wfDebug( __METHOD__ . ' was called without providing a Config instance' );
-                       $config = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
+               if ( !$logger ) {
+                       $logger = new NullLogger();
                }
+               $this->setLogger( $logger );
 
+               if ( !$config ) {
+                       $this->logger->debug( __METHOD__ . ' was called without providing a Config instance' );
+                       $config = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
+               }
                $this->config = $config;
 
                // Add 'local' source first
@@ -247,6 +280,7 @@ class ResourceLoader {
 
                // Register core modules
                $this->register( include "$IP/resources/Resources.php" );
+               $this->register( include "$IP/resources/ResourcesOOUI.php" );
                // Register extension modules
                Hooks::run( 'ResourceLoaderRegisterModules', array( &$this ) );
                $this->register( $config->get( 'ResourceModules' ) );
@@ -265,9 +299,21 @@ class ResourceLoader {
                return $this->config;
        }
 
+       public function setLogger( LoggerInterface $logger ) {
+               $this->logger = $logger;
+       }
+
+       /**
+        * @since 1.26
+        * @return MessageBlobStore
+        */
+       public function getMessageBlobStore() {
+               return $this->blobStore;
+       }
+
        /**
-        * @param MessageBlobStore $blobStore
         * @since 1.25
+        * @param MessageBlobStore $blobStore
         */
        public function setMessageBlobStore( MessageBlobStore $blobStore ) {
                $this->blobStore = $blobStore;
@@ -565,20 +611,45 @@ class ResourceLoader {
                return $this->sources[$source];
        }
 
+       /**
+        * @since 1.26
+        * @param string $value
+        * @return string Hash
+        */
+       public static function makeHash( $value ) {
+               // Use base64 to output more entropy in a more compact string (default hex is only base16).
+               // The first 8 chars of a base64 encoded digest represent the same binary as
+               // the first 12 chars of a hex encoded digest.
+               return substr( base64_encode( sha1( $value, true ) ), 0, 8 );
+       }
+
+       /**
+        * Helper method to get and combine versions of multiple modules.
+        *
+        * @since 1.26
+        * @param ResourceLoaderContext $context
+        * @param array $modules List of ResourceLoaderModule objects
+        * @return string Hash
+        */
+       public function getCombinedVersion( ResourceLoaderContext $context, Array $modules ) {
+               if ( !$modules ) {
+                       return '';
+               }
+               // Support: PHP 5.3 ("$this" for anonymous functions was added in PHP 5.4.0)
+               // http://php.net/functions.anonymous
+               $rl = $this;
+               $hashes = array_map( function ( $module ) use ( $rl, $context ) {
+                       return $rl->getModule( $module )->getVersionHash( $context );
+               }, $modules );
+               return self::makeHash( implode( $hashes ) );
+       }
+
        /**
         * Output a response to a load request, including the content-type header.
         *
         * @param ResourceLoaderContext $context Context in which a response should be formed
         */
        public function respond( ResourceLoaderContext $context ) {
-               // Use file cache if enabled and available...
-               if ( $this->config->get( 'UseFileCache' ) ) {
-                       $fileCache = ResourceFileCache::newFromContext( $context );
-                       if ( $this->tryRespondFromFileCache( $fileCache, $context ) ) {
-                               return; // output handled
-                       }
-               }
-
                // Buffer output to catch warnings. Normally we'd use ob_clean() on the
                // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
                // is used: ob_clean() will clear the GZIP header in that case and it won't come
@@ -597,7 +668,7 @@ class ResourceLoader {
                                // Do not allow private modules to be loaded from the web.
                                // This is a security issue, see bug 34907.
                                if ( $module->getGroup() === 'private' ) {
-                                       wfDebugLog( 'resourceloader', __METHOD__ . ": request for private module '$name' denied" );
+                                       $this->logger->debug( "Request for private module '$name' denied" );
                                        $this->errors[] = "Cannot show private module \"$name\"";
                                        continue;
                                }
@@ -607,37 +678,46 @@ class ResourceLoader {
                        }
                }
 
-               // Preload information needed to the mtime calculation below
                try {
+                       // Preload for getCombinedVersion()
                        $this->preloadModuleInfo( array_keys( $modules ), $context );
                } catch ( Exception $e ) {
                        MWExceptionHandler::logException( $e );
-                       wfDebugLog( 'resourceloader', __METHOD__ . ": preloading module info failed: $e" );
+                       $this->logger->warning( 'Preloading module info failed: {exception}', array(
+                               'exception' => $e
+                       ) );
                        $this->errors[] = self::formatExceptionNoComment( $e );
                }
 
-               // To send Last-Modified and support If-Modified-Since, we need to detect
-               // the last modified time
-               $mtime = wfTimestamp( TS_UNIX, $this->config->get( 'CacheEpoch' ) );
-               foreach ( $modules as $module ) {
-                       /**
-                        * @var $module ResourceLoaderModule
-                        */
-                       try {
-                               // Calculate maximum modified time
-                               $mtime = max( $mtime, $module->getModifiedTime( $context ) );
-                       } catch ( Exception $e ) {
-                               MWExceptionHandler::logException( $e );
-                               wfDebugLog( 'resourceloader', __METHOD__ . ": calculating maximum modified time failed: $e" );
-                               $this->errors[] = self::formatExceptionNoComment( $e );
-                       }
+               // Combine versions to propagate cache invalidation
+               $versionHash = '';
+               try {
+                       $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
+               } catch ( Exception $e ) {
+                       MWExceptionHandler::logException( $e );
+                       $this->logger->warning( 'Calculating version hash failed: {exception}', array(
+                               'exception' => $e
+                       ) );
+                       $this->errors[] = self::formatExceptionNoComment( $e );
                }
 
-               // If there's an If-Modified-Since header, respond with a 304 appropriately
-               if ( $this->tryRespondLastModified( $context, $mtime ) ) {
+               // See RFC 2616 ยง 3.11 Entity Tags
+               // http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
+               $etag = 'W/"' . $versionHash . '"';
+
+               // Try the client-side cache first
+               if ( $this->tryRespondNotModified( $context, $etag ) ) {
                        return; // output handled (buffers cleared)
                }
 
+               // Use file cache if enabled and available...
+               if ( $this->config->get( 'UseFileCache' ) ) {
+                       $fileCache = ResourceFileCache::newFromContext( $context );
+                       if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
+                               return; // output handled
+                       }
+               }
+
                // Generate a response
                $response = $this->makeModuleResponse( $context, $modules, $missing );
 
@@ -659,8 +739,7 @@ class ResourceLoader {
                        }
                }
 
-               // Send content type and cache related headers
-               $this->sendResponseHeaders( $context, $mtime, (bool)$this->errors );
+               $this->sendResponseHeaders( $context, $etag, (bool)$this->errors );
 
                // Remove the output buffer and output the response
                ob_end_clean();
@@ -687,13 +766,16 @@ class ResourceLoader {
        }
 
        /**
-        * Send content type and last modified headers to the client.
+        * Send main response headers to the client.
+        *
+        * Deals with Content-Type, CORS (for stylesheets), and caching.
+        *
         * @param ResourceLoaderContext $context
-        * @param string $mtime TS_MW timestamp to use for last-modified
+        * @param string $etag ETag header value
         * @param bool $errors Whether there are errors in the response
         * @return void
         */
-       protected function sendResponseHeaders( ResourceLoaderContext $context, $mtime, $errors ) {
+       protected function sendResponseHeaders( ResourceLoaderContext $context, $etag, $errors ) {
                $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
                // If a version wasn't specified we need a shorter expiry time for updates
                // to propagate to clients quickly
@@ -720,7 +802,9 @@ class ResourceLoader {
                } else {
                        header( 'Content-Type: text/javascript; charset=utf-8' );
                }
-               header( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $mtime ) );
+               // See RFC 2616 ยง 14.19 ETag
+               // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
+               header( 'ETag: ' . $etag );
                if ( $context->getDebug() ) {
                        // Do not cache debug responses
                        header( 'Cache-Control: private, no-cache, must-revalidate' );
@@ -733,42 +817,36 @@ class ResourceLoader {
        }
 
        /**
-        * Respond with 304 Last Modified if appropiate.
+        * Respond with HTTP 304 Not Modified if appropiate.
         *
-        * If there's an If-Modified-Since header, respond with a 304 appropriately
+        * If there's an If-None-Match header, respond with a 304 appropriately
         * and clear out the output buffer. If the client cache is too old then do nothing.
         *
         * @param ResourceLoaderContext $context
-        * @param string $mtime The TS_MW timestamp to check the header against
-        * @return bool True if 304 header sent and output handled
+        * @param string $etag ETag header value
+        * @return bool True if HTTP 304 was sent and output handled
         */
-       protected function tryRespondLastModified( ResourceLoaderContext $context, $mtime ) {
-               // If there's an If-Modified-Since header, respond with a 304 appropriately
-               // Some clients send "timestamp;length=123". Strip the part after the first ';'
-               // so we get a valid timestamp.
-               $ims = $context->getRequest()->getHeader( 'If-Modified-Since' );
+       protected function tryRespondNotModified( ResourceLoaderContext $context, $etag ) {
+               // See RFC 2616 ยง 14.26 If-None-Match
+               // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
+               $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
                // Never send 304s in debug mode
-               if ( $ims !== false && !$context->getDebug() ) {
-                       $imsTS = strtok( $ims, ';' );
-                       if ( $mtime <= wfTimestamp( TS_UNIX, $imsTS ) ) {
-                               // There's another bug in ob_gzhandler (see also the comment at
-                               // the top of this function) that causes it to gzip even empty
-                               // responses, meaning it's impossible to produce a truly empty
-                               // response (because the gzip header is always there). This is
-                               // a problem because 304 responses have to be completely empty
-                               // per the HTTP spec, and Firefox behaves buggily when they're not.
-                               // See also http://bugs.php.net/bug.php?id=51579
-                               // To work around this, we tear down all output buffering before
-                               // sending the 304.
-                               wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
-
-                               header( 'HTTP/1.0 304 Not Modified' );
-                               header( 'Status: 304 Not Modified' );
-
-                               // Send content type and cache headers
-                               $this->sendResponseHeaders( $context, $mtime, false );
-                               return true;
-                       }
+               if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
+                       // There's another bug in ob_gzhandler (see also the comment at
+                       // the top of this function) that causes it to gzip even empty
+                       // responses, meaning it's impossible to produce a truly empty
+                       // response (because the gzip header is always there). This is
+                       // a problem because 304 responses have to be completely empty
+                       // per the HTTP spec, and Firefox behaves buggily when they're not.
+                       // See also http://bugs.php.net/bug.php?id=51579
+                       // To work around this, we tear down all output buffering before
+                       // sending the 304.
+                       wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
+
+                       HttpStatus::header( 304 );
+
+                       $this->sendResponseHeaders( $context, $etag, false );
+                       return true;
                }
                return false;
        }
@@ -778,10 +856,13 @@ class ResourceLoader {
         *
         * @param ResourceFileCache $fileCache Cache object for this request URL
         * @param ResourceLoaderContext $context Context in which to generate a response
+        * @param string $etag ETag header value
         * @return bool If this found a cache file and handled the response
         */
        protected function tryRespondFromFileCache(
-               ResourceFileCache $fileCache, ResourceLoaderContext $context
+               ResourceFileCache $fileCache,
+               ResourceLoaderContext $context,
+               $etag
        ) {
                $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
                // Buffer output to catch warnings.
@@ -801,12 +882,8 @@ class ResourceLoader {
                }
                if ( $good ) {
                        $ts = $fileCache->cacheTimestamp();
-                       // If there's an If-Modified-Since header, respond with a 304 appropriately
-                       if ( $this->tryRespondLastModified( $context, $ts ) ) {
-                               return false; // output handled (buffers cleared)
-                       }
                        // Send content type and cache headers
-                       $this->sendResponseHeaders( $context, $ts, false );
+                       $this->sendResponseHeaders( $context, $etag, false );
                        $response = $fileCache->fetchText();
                        // Capture any PHP warnings from the output buffer and append them to the
                        // response in a comment if we're in debug mode.
@@ -899,17 +976,14 @@ MESSAGE;
                // Pre-fetch blobs
                if ( $context->shouldIncludeMessages() ) {
                        try {
-                               $blobs = $this->blobStore->get( $this, $modules, $context->getLanguage() );
+                               $this->blobStore->get( $this, $modules, $context->getLanguage() );
                        } catch ( Exception $e ) {
                                MWExceptionHandler::logException( $e );
-                               wfDebugLog(
-                                       'resourceloader',
-                                       __METHOD__ . ": pre-fetching blobs from MessageBlobStore failed: $e"
-                               );
+                               $this->logger->warning( 'Prefetching MessageBlobStore failed: {exception}', array(
+                                       'exception' => $e
+                               ) );
                                $this->errors[] = self::formatExceptionNoComment( $e );
                        }
-               } else {
-                       $blobs = array();
                }
 
                foreach ( $missing as $name ) {
@@ -919,79 +993,13 @@ MESSAGE;
                // Generate output
                $isRaw = false;
                foreach ( $modules as $name => $module ) {
-                       /**
-                        * @var $module ResourceLoaderModule
-                        */
-
                        try {
-                               $scripts = '';
-                               if ( $context->shouldIncludeScripts() ) {
-                                       // If we are in debug mode, we'll want to return an array of URLs if possible
-                                       // However, we can't do this if the module doesn't support it
-                                       // We also can't do this if there is an only= parameter, because we have to give
-                                       // the module a way to return a load.php URL without causing an infinite loop
-                                       if ( $context->getDebug() && !$context->getOnly() && $module->supportsURLLoading() ) {
-                                               $scripts = $module->getScriptURLsForDebug( $context );
-                                       } else {
-                                               $scripts = $module->getScript( $context );
-                                               // rtrim() because there are usually a few line breaks
-                                               // after the last ';'. A new line at EOF, a new line
-                                               // added by ResourceLoaderFileModule::readScriptFiles, etc.
-                                               if ( is_string( $scripts )
-                                                       && strlen( $scripts )
-                                                       && substr( rtrim( $scripts ), -1 ) !== ';'
-                                               ) {
-                                                       // Append semicolon to prevent weird bugs caused by files not
-                                                       // terminating their statements right (bug 27054)
-                                                       $scripts .= ";\n";
-                                               }
-                                       }
-                               }
-                               // Styles
-                               $styles = array();
-                               if ( $context->shouldIncludeStyles() ) {
-                                       // Don't create empty stylesheets like array( '' => '' ) for modules
-                                       // that don't *have* any stylesheets (bug 38024).
-                                       $stylePairs = $module->getStyles( $context );
-                                       if ( count( $stylePairs ) ) {
-                                               // If we are in debug mode without &only= set, we'll want to return an array of URLs
-                                               // See comment near shouldIncludeScripts() for more details
-                                               if ( $context->getDebug() && !$context->getOnly() && $module->supportsURLLoading() ) {
-                                                       $styles = array(
-                                                               'url' => $module->getStyleURLsForDebug( $context )
-                                                       );
-                                               } else {
-                                                       // Minify CSS before embedding in mw.loader.implement call
-                                                       // (unless in debug mode)
-                                                       if ( !$context->getDebug() ) {
-                                                               foreach ( $stylePairs as $media => $style ) {
-                                                                       // Can be either a string or an array of strings.
-                                                                       if ( is_array( $style ) ) {
-                                                                               $stylePairs[$media] = array();
-                                                                               foreach ( $style as $cssText ) {
-                                                                                       if ( is_string( $cssText ) ) {
-                                                                                               $stylePairs[$media][] = $this->filter( 'minify-css', $cssText );
-                                                                                       }
-                                                                               }
-                                                                       } elseif ( is_string( $style ) ) {
-                                                                               $stylePairs[$media] = $this->filter( 'minify-css', $style );
-                                                                       }
-                                                               }
-                                                       }
-                                                       // Wrap styles into @media groups as needed and flatten into a numerical array
-                                                       $styles = array(
-                                                               'css' => self::makeCombinedStyles( $stylePairs )
-                                                       );
-                                               }
-                                       }
-                               }
-
-                               // Messages
-                               $messagesBlob = isset( $blobs[$name] ) ? $blobs[$name] : '{}';
+                               $content = $module->getModuleContent( $context );
 
                                // Append output
                                switch ( $context->getOnly() ) {
                                        case 'scripts':
+                                               $scripts = $content['scripts'];
                                                if ( is_string( $scripts ) ) {
                                                        // Load scripts raw...
                                                        $out .= $scripts;
@@ -1001,6 +1009,7 @@ MESSAGE;
                                                }
                                                break;
                                        case 'styles':
+                                               $styles = $content['styles'];
                                                // We no longer seperate into media, they are all combined now with
                                                // custom media type groups into @media .. {} sections as part of the css string.
                                                // Module returns either an empty array or a numerical array with css strings.
@@ -1009,16 +1018,18 @@ MESSAGE;
                                        default:
                                                $out .= self::makeLoaderImplementScript(
                                                        $name,
-                                                       $scripts,
-                                                       $styles,
-                                                       new XmlJsCode( $messagesBlob ),
-                                                       $module->getTemplates()
+                                                       isset( $content['scripts'] ) ? $content['scripts'] : '',
+                                                       isset( $content['styles'] ) ? $content['styles'] : array(),
+                                                       isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : array(),
+                                                       isset( $content['templates'] ) ? $content['templates'] : array()
                                                );
                                                break;
                                }
                        } catch ( Exception $e ) {
                                MWExceptionHandler::logException( $e );
-                               wfDebugLog( 'resourceloader', __METHOD__ . ": generating module package failed: $e" );
+                               $this->logger->warning( 'Generating module package failed: {exception}', array(
+                                       'exception' => $e
+                               ) );
                                $this->errors[] = self::formatExceptionNoComment( $e );
 
                                // Respond to client with error-state instead of module implementation
@@ -1049,11 +1060,19 @@ MESSAGE;
                        }
                }
 
+               $enableFilterCache = true;
+               if ( count( $modules ) === 1 && reset( $modules ) instanceof ResourceLoaderUserTokensModule ) {
+                       // If we're building the embedded user.tokens, don't cache (T84960)
+                       $enableFilterCache = false;
+               }
+
                if ( !$context->getDebug() ) {
                        if ( $context->getOnly() === 'styles' ) {
                                $out = $this->filter( 'minify-css', $out );
                        } else {
-                               $out = $this->filter( 'minify-js', $out );
+                               $out = $this->filter( 'minify-js', $out, array(
+                                       'cache' => $enableFilterCache
+                               ) );
                        }
                }
 
@@ -1091,9 +1110,9 @@ MESSAGE;
                $module = array(
                        $name,
                        $scripts,
-                       (object) $styles,
-                       (object) $messages,
-                       (object) $templates,
+                       (object)$styles,
+                       (object)$messages,
+                       (object)$templates,
                );
                self::trimArray( $module );
 
@@ -1186,7 +1205,7 @@ MESSAGE;
         * and $group as supplied.
         *
         * @param string $name Module name
-        * @param int $version Module version number as a timestamp
+        * @param string $version Module version hash
         * @param array $dependencies List of module names on which this module depends
         * @param string $group Group which the module is in.
         * @param string $source Source of the module, or 'local' if not foreign.
@@ -1258,7 +1277,7 @@ MESSAGE;
         *        Registers modules with the given names and parameters.
         *
         * @param string $name Module name
-        * @param int $version Module version number as a timestamp
+        * @param string $version Module version hash
         * @param array $dependencies List of module names on which this module depends
         * @param string $group Group which the module is in
         * @param string $source Source of the module, or 'local' if not foreign
@@ -1450,7 +1469,7 @@ MESSAGE;
 
        /**
         * Build a load.php URL
-        * @deprecated since 1.24, use createLoaderURL instead
+        * @deprecated since 1.24 Use createLoaderURL() instead
         * @param array $modules Array of module names (strings)
         * @param string $lang Language code
         * @param string $skin Skin name