resourceloader: Replace timestamp system with version hashing
authorTimo Tijhof <krinklemail@gmail.com>
Wed, 29 Apr 2015 22:53:24 +0000 (23:53 +0100)
committerOri.livneh <ori@wikimedia.org>
Tue, 19 May 2015 22:28:17 +0000 (22:28 +0000)
Modules now track their version via getVersionHash() instead of getModifiedTime().

== Background ==

While some resources have observeable timestamps (e.g. files stored on disk),
many other resources do not. E.g. config variables, and module definitions.

For static file modules, one can e.g. revert one of more files in a module to a
previous version and not affect the max timestamp.

Wiki modules include pages only if they exist. The user module supports common.js
and skin.js. By default neither exists. If a user has both, and then the
less-recently modified one is deleted, the max-timestamp remains unchanged.

For client-side caching, batch requests use "Math.max" on the relevant timestamps.
Again, if a module changes but another module is more recent (e.g. out-of-order
deployment, or out-of-order discovery), the change would not result in a cache miss.

More scenarios can be found in the associated Phabricator tasks.

== Version hash ==

Previously we virtually mapped these variables to a timestamp by storing the current
time alongside a hash of the value in ObjectCache. Considering the number of
possible request contexts (wikis * modules * users * skins * languages) this doesn't
work well. It results in needless cache invalidation when the first time observation
is purged due to LRU algorithms. It also has other minor bugs leading to fewer
cache hits.

All modules automatically get the benefits of version hashing with this change.
The old getDefinitionMtime() and getHashMtime() have been replaced with dummies
that return 1. These functions are often called from getModifiedTime() in subclasses.

For backward-compatibility, their respective values (definition summary and hash)
are now included in getVersionHash directly.

As examples, the following modules have been updated to use getVersionHash directly.
Other modules still work fine and can be updated later.

* ResourceLoaderFileModule
* ResourceLoaderEditToolbarModule
* ResourceLoaderStartUpModule
* ResourceLoaderWikiModule

The presence of hashes in place of timestamps increases the startup module size on
a default MediaWiki install from 4.4k to 5.8k (after gzip and minification).

== ETag ==

Since timestamps are no longer tracked, we need a different way to implement caching
for cache proxies (e.g. Varnish) and web browsers. Previously we used the
Last-Modified header (in combination with Cache-Control and Expires).

Instead of Last-Modified (and If-Modified-Since), we use ETag (and If-None-Match).

Entity tags (new in HTTP/1.1) are much stricter than Last-Modified by default.
They instruct browsers to allow usage of partial Range requests. Since our responses
are dynamically generated, we need to use the Weak version of ETag.

While this sounds bad, it's no different than Last-Modified. As reassured by
RFC 2616 <http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.3> the
specified behaviour behind Last-Modified follows the same "Weak" caching logic as
Entity tags. It's just that entity tags are capable of a stricter mode (whereas
Last-Modified is inherently weak).

== File cache ==

If $wgUseFileCache is enabled, ResourceLoader uses ResourceFileCache to cache
load.php responses. While the blind TTL handling (during the allowed expiry period)
is still maxage/timestamp based, tryRespondNotModified() now requires the caller to
know the expected ETag.

For this to work, the FileCache handling had to be moved from the top of
ResoureLoader::respond() to after the expected ETag is computed.

This also allows us to remove the duplicate tryRespondNotModified() handling since
that's is already handled by ResourceLoader::respond() meanwhile.

== Misc ==

* Remove redundant modifiedTime cache in ResourceLoaderFileModule.

* Change bugzilla references to Phabricator.

* Centralised inclusion of wgCacheEpoch using getDefinitionSummary. Previously this
  logic was duplicated in each place the modified timestamp was used.

* It's easy to forget calling the parent class in getDefinitionSummary().
  Previously this method only tracked 'class' by default. As such, various
  extensions hardcoded that one value instead of calling the parent and extending
  the array. To better prevent this in the future, getVersionHash() now asserts
  that the '_cacheEpoch' property made it through.

* tests: Don't use getDefinitionSummary() as an API.
  Fix ResourceLoaderWikiModuleTest to call getPages properly.

* In tests, the default timestamp used to be 1388534400000 (which is the unix time
  of 20140101000000; the unit tests' CacheEpoch). The new version hash of these
  modules is "XyCC+PSK", which is the base64 encoded prefix of the SHA1 digest of:
  '{"_class":"ResourceLoaderTestModule","_cacheEpoch":"20140101000000"}'

* Add sha1.js library for client-side hash generation.
  Compared various different implementations for code size (after minfication/gzip),
  and speed (when used for short hexidecimal strings).
  https://jsperf.com/sha1-implementations
  - CryptoJS <https://code.google.com/p/crypto-js/#SHA-1> (min+gzip: 2.5k)
    http://crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/sha1.js
    Chrome: 45k, Firefox: 89k, Safari: 92k
  - jsSHA <https://github.com/Caligatio/jsSHA>
    https://github.com/Caligatio/jsSHA/blob/3c1d4f2e/src/sha1.js (min+gzip: 1.8k)
    Chrome: 65k, Firefox: 53k, Safari: 69k
  - phpjs-sha1 <https://github.com/kvz/phpjs> (RL min+gzip: 0.8k)
    https://github.com/kvz/phpjs/blob/1eaab15d/functions/strings/sha1.js
    Chrome: 200k, Firefox: 280k, Safari: 78k

  Modern browsers implement the HTML5 Crypto API. However, this API is asynchronous,
  only enabled when on HTTPS in Chromium, and is quite low-level. It requires boilerplate
  code to actually use with TextEncoder, ArrayBuffer and Uint32Array. Due this being
  needed in the module loader, we'd have to load the fallback regardless. Considering
  this is not used in a critical path for performance, it's not worth shipping two
  implementations for this optimisation.

May also resolve:
* T44094
* T90411
* T94810

Bug: T94074
Change-Id: Ibb292d2416839327d1807a66c78fd96dac0637d0

18 files changed:
includes/OutputPage.php
includes/cache/ResourceFileCache.php
includes/resourceloader/ResourceLoader.php
includes/resourceloader/ResourceLoaderContext.php
includes/resourceloader/ResourceLoaderEditToolbarModule.php
includes/resourceloader/ResourceLoaderFileModule.php
includes/resourceloader/ResourceLoaderModule.php
includes/resourceloader/ResourceLoaderStartUpModule.php
includes/resourceloader/ResourceLoaderWikiModule.php
resources/Resources.php
resources/lib/phpjs-sha1/LICENSE.txt [new file with mode: 0644]
resources/lib/phpjs-sha1/sha1.js [new file with mode: 0644]
resources/src/mediawiki/mediawiki.js
tests/phpunit/ResourceLoaderTestCase.php
tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php
tests/phpunit/structure/ResourcesTest.php

index db22166..770cf47 100644 (file)
@@ -2867,13 +2867,7 @@ class OutputPage extends ContextSource {
                                // and we shouldn't be putting timestamps in Squid-cached HTML
                                $version = null;
                                if ( $group === 'user' ) {
-                                       // Get the maximum timestamp
-                                       $timestamp = 1;
-                                       foreach ( $grpModules as $module ) {
-                                               $timestamp = max( $timestamp, $module->getModifiedTime( $context ) );
-                                       }
-                                       // Add a version parameter so cache will break when things change
-                                       $query['version'] = wfTimestamp( TS_ISO_8601_BASIC, $timestamp );
+                                       $query['version'] = $resourceLoader->getCombinedVersion( $context, array_keys( $grpModules ) );
                                }
 
                                $query['modules'] = ResourceLoader::makePackedModulesString( array_keys( $grpModules ) );
index 6d26a2d..e1186ef 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 /**
- * Resource loader request result caching in the file system.
+ * ResourceLoader request result caching in the file system.
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -22,7 +22,7 @@
  */
 
 /**
- * Resource loader request result caching in the file system.
+ * ResourceLoader request result caching in the file system.
  *
  * @ingroup Cache
  */
index 5df2651..b8a0acf 100644 (file)
@@ -565,20 +565,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
@@ -607,8 +632,8 @@ 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 );
@@ -616,28 +641,33 @@ class ResourceLoader {
                        $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 );
+                       wfDebugLog( 'resourceloader', __METHOD__ . ": calculating version hash failed: $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 +689,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 +716,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 +752,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 +767,37 @@ 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 );
+
+                       header( 'HTTP/1.0 304 Not Modified' );
+                       header( 'Status: 304 Not Modified' );
+
+                       $this->sendResponseHeaders( $context, $etag, false );
+                       return true;
                }
                return false;
        }
@@ -778,10 +807,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 +833,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.
@@ -1186,7 +1214,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 +1286,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 +1478,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
index 988bfa6..66b4ee2 100644 (file)
@@ -227,6 +227,8 @@ class ResourceLoaderContext {
        }
 
        /**
+        * @see ResourceLoaderModule::getVersionHash
+        * @see OutputPage::makeResourceLoaderLink
         * @return string|null
         */
        public function getVersion() {
index d79174c..d0273c2 100644 (file)
@@ -66,24 +66,14 @@ class ResourceLoaderEditToolbarModule extends ResourceLoaderFileModule {
 
        /**
         * @param ResourceLoaderContext $context
-        * @return int UNIX timestamp
-        */
-       public function getModifiedTime( ResourceLoaderContext $context ) {
-               return max(
-                       parent::getModifiedTime( $context ),
-                       $this->getHashMtime( $context )
-               );
-       }
-
-       /**
-        * @param ResourceLoaderContext $context
-        * @return string Hash
+        * @return array
         */
-       public function getModifiedHash( ResourceLoaderContext $context ) {
-               return md5(
-                       parent::getModifiedHash( $context ) .
-                       serialize( $this->getLessVars( $context ) )
+       public function getDefinitionSummary( ResourceLoaderContext $context ) {
+               $summary = parent::getDefinitionSummary( $context );
+               $summary[] = array(
+                       'lessVars' => $this->getLessVars( $context ),
                );
+               return $summary;
        }
 
        /**
index 671098e..3569bf3 100644 (file)
@@ -143,15 +143,6 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
         */
        protected $hasGeneratedStyles = false;
 
-       /**
-        * @var array Cache for mtime
-        * @par Usage:
-        * @code
-        * array( [hash] => [mtime], [hash] => [mtime], ... )
-        * @endcode
-        */
-       protected $modifiedTime = array();
-
        /**
         * @var array Place where readStyleFile() tracks file dependencies
         * @par Usage:
@@ -522,7 +513,7 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
        }
 
        /**
-        * Get the last modified timestamp of this module.
+        * Helper method to gather file mtimes for getDefinitionSummary.
         *
         * Last modified timestamps are calculated from the highest last modified
         * timestamp of this module's constituent files as well as the files it
@@ -530,16 +521,11 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
         * calculations on files relevant to the given language, skin and debug
         * mode.
         *
-        * @param ResourceLoaderContext $context Context in which to calculate
-        *     the modified time
-        * @return int UNIX timestamp
         * @see ResourceLoaderModule::getFileDependencies
+        * @param ResourceLoaderContext $context
+        * @return array
         */
-       public function getModifiedTime( ResourceLoaderContext $context ) {
-               if ( isset( $this->modifiedTime[$context->getHash()] ) ) {
-                       return $this->modifiedTime[$context->getHash()];
-               }
-
+       protected function getFileMtimes( ResourceLoaderContext $context ) {
                $files = array();
 
                // Flatten style files into $files
@@ -578,22 +564,13 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
                // entry point Less file we already know about.
                $files = array_values( array_unique( $files ) );
 
-               // If a module is nothing but a list of dependencies, we need to avoid
-               // giving max() an empty array
-               if ( count( $files ) === 0 ) {
-                       $this->modifiedTime[$context->getHash()] = 1;
-                       return $this->modifiedTime[$context->getHash()];
-               }
-
-               $filesMtime = max( array_map( array( __CLASS__, 'safeFilemtime' ), $files ) );
-
-               $this->modifiedTime[$context->getHash()] = max(
-                       $filesMtime,
-                       $this->getMsgBlobMtime( $context->getLanguage() ),
-                       $this->getDefinitionMtime( $context )
-               );
-
-               return $this->modifiedTime[$context->getHash()];
+               // Don't max() because older files are significant.
+               // While the associated file names are significant, that is already taken care of by the
+               // definition summary. Avoid creating an array keyed by file path here because those are
+               // absolute file paths. Including that would needlessly cause global cache invalidation
+               // when the MediaWiki installation path changes (which is quite common in cases like
+               // Wikimedia where the installation path reflects the MediaWiki branch name).
+               return array_map( array( __CLASS__, 'safeFilemtime' ), $files );
        }
 
        /**
@@ -604,6 +581,8 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
         */
        public function getDefinitionSummary( ResourceLoaderContext $context ) {
                $summary = parent::getDefinitionSummary( $context );
+
+               $options = array();
                foreach ( array(
                        'scripts',
                        'debugScripts',
@@ -619,18 +598,24 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
                        'group',
                        'position',
                        'skipFunction',
+                       // FIXME: localBasePath includes the MediaWiki installation path and
+                       // needlessly causes cache invalidation.
                        'localBasePath',
                        'remoteBasePath',
                        'debugRaw',
                        'raw',
                ) as $member ) {
-                       $summary[$member] = $this->{$member};
+                       $options[$member] = $this->{$member};
                };
+
+               $summary[] = array(
+                       'options' => $options,
+                       'fileMtimes' => $this->getFileMTimes( $context ),
+                       'msgBlobMtime' => $this->getMsgBlobMtime( $context->getLanguage() ),
+               );
                return $summary;
        }
 
-       /* Protected Methods */
-
        /**
         * @param string|ResourceLoaderFilePath $path
         * @return string
index c4041a4..117dce6 100644 (file)
@@ -62,6 +62,8 @@ abstract class ResourceLoaderModule {
        protected $fileDeps = array();
        // In-object cache for message blob mtime
        protected $msgBlobMtime = array();
+       // In-object cache for version hash
+       protected $versionHash = array();
 
        /**
         * @var Config
@@ -384,8 +386,7 @@ abstract class ResourceLoaderModule {
        }
 
        /**
-        * Get the last modification timestamp of the message blob for this
-        * module in a given language.
+        * Get the last modification timestamp of the messages in this module for a given language.
         * @param string $lang Language code
         * @return int UNIX timestamp
         */
@@ -421,71 +422,122 @@ abstract class ResourceLoaderModule {
                $this->msgBlobMtime[$lang] = $mtime;
        }
 
-       /* Abstract Methods */
-
        /**
-        * Get this module's last modification timestamp for a given
-        * combination of language, skin and debug mode flag. This is typically
-        * the highest of each of the relevant components' modification
-        * timestamps. Whenever anything happens that changes the module's
-        * contents for these parameters, the mtime should increase.
+        * Get a string identifying the current version of this module in a given context.
         *
-        * NOTE: The mtime of the module's messages is NOT automatically included.
-        * If you want this to happen, you'll need to call getMsgBlobMtime()
-        * yourself and take its result into consideration.
+        * Whenever anything happens that changes the module's response (e.g. scripts, styles, and
+        * messages) this value must change. This value is used to store module responses in cache.
+        * (Both client-side and server-side.)
         *
-        * NOTE: The mtime of the module's hash is NOT automatically included.
-        * If your module provides a getModifiedHash() method, you'll need to call getHashMtime()
-        * yourself and take its result into consideration.
+        * It is not recommended to override this directly. Use getDefinitionSummary() instead.
+        * If overridden, one must call the parent getVersionHash(), append data and re-hash.
         *
-        * @param ResourceLoaderContext $context Context object
-        * @return int UNIX timestamp
+        * This method should be quick because it is frequently run by ResourceLoaderStartUpModule to
+        * propagate changes to the client and effectively invalidate cache.
+        *
+        * For backward-compatibility, the following optional data providers are automatically included:
+        *
+        * - getModifiedTime()
+        * - getModifiedHash()
+        *
+        * @since 1.26
+        * @param ResourceLoaderContext $context
+        * @return string Hash (should use ResourceLoader::makeHash)
         */
-       public function getModifiedTime( ResourceLoaderContext $context ) {
-               return 1;
+       public function getVersionHash( ResourceLoaderContext $context ) {
+               // Cache this somewhat expensive operation. Especially because some classes
+               // (e.g. startup module) iterate more than once over all modules to get versions.
+               $contextHash = $context->getHash();
+               if ( !array_key_exists( $contextHash, $this->versionHash ) ) {
+
+                       $summary = $this->getDefinitionSummary( $context );
+                       if ( !isset( $summary['_cacheEpoch'] ) ) {
+                               throw new Exception( 'getDefinitionSummary must call parent method' );
+                       }
+                       $str = json_encode( $summary );
+
+                       $mtime = $this->getModifiedTime( $context );
+                       if ( $mtime !== null ) {
+                               // Support: MediaWiki 1.25 and earlier
+                               $str .= strval( $mtime );
+                       }
+
+                       $mhash = $this->getModifiedHash( $context );
+                       if ( $mhash !== null ) {
+                               // Support: MediaWiki 1.25 and earlier
+                               $str .= strval( $mhash );
+                       }
+
+                       $this->versionHash[ $contextHash ] = ResourceLoader::makeHash( $str );
+               }
+               return $this->versionHash[ $contextHash ];
        }
 
        /**
-        * Helper method for calculating when the module's hash (if it has one) changed.
+        * Get the definition summary for this module.
+        *
+        * This is the method subclasses are recommended to use to track values in their
+        * version hash. Call this in getVersionHash() and pass it to e.g. json_encode.
         *
+        * Subclasses must call the parent getDefinitionSummary() and build on that.
+        * It is recommended that each subclass appends its own new array. This prevents
+        * clashes or accidental overwrites of existing keys and gives each subclass
+        * its own scope for simple array keys.
+        *
+        * @code
+        *     $summary = parent::getDefinitionSummary( $context );
+        *     $summary[] = array(
+        *         'foo' => 123,
+        *         'bar' => 'quux',
+        *     );
+        *     return $summary;
+        * @endcode
+        *
+        * Return an array containing values from all significant properties of this
+        * module's definition.
+        *
+        * Be careful not to normalise too much. Especially preserve the order of things
+        * that carry significance in getScript and getStyles (T39812).
+        *
+        * Avoid including things that are insiginificant (e.g. order of message keys is
+        * insignificant and should be sorted to avoid unnecessary cache invalidation).
+        *
+        * This data structure must exclusively contain arrays and scalars as values (avoid
+        * object instances) to allow simple serialisation using json_encode.
+        *
+        * If modules have a hash or timestamp from another source, that may be incuded as-is.
+        *
+        * A number of utility methods are available to help you gather data. These are not
+        * called by default and must be included by the subclass' getDefinitionSummary().
+        *
+        * - getMsgBlobMtime()
+        *
+        * @since 1.23
         * @param ResourceLoaderContext $context
-        * @return int UNIX timestamp
+        * @return array|null
         */
-       public function getHashMtime( ResourceLoaderContext $context ) {
-               $hash = $this->getModifiedHash( $context );
-               if ( !is_string( $hash ) ) {
-                       return 1;
-               }
-
-               // Embed the hash itself in the cache key. This allows for a few nifty things:
-               // - During deployment, servers with old and new versions of the code communicating
-               //   with the same memcached will not override the same key repeatedly increasing
-               //   the timestamp.
-               // - In case of the definition changing and then changing back in a short period of time
-               //   (e.g. in case of a revert or a corrupt server) the old timestamp and client-side cache
-               //   url will be re-used.
-               // - If different context-combinations (e.g. same skin, same language or some combination
-               //   thereof) result in the same definition, they will use the same hash and timestamp.
-               $cache = wfGetCache( CACHE_ANYTHING );
-               $key = wfMemcKey( 'resourceloader', 'hashmtime', $this->getName(), $hash );
-
-               $data = $cache->get( $key );
-               if ( is_int( $data ) && $data > 0 ) {
-                       // We've seen this hash before, re-use the timestamp of when we first saw it.
-                       return $data;
-               }
-
-               $timestamp = time();
-               $cache->set( $key, $timestamp );
-               return $timestamp;
+       public function getDefinitionSummary( ResourceLoaderContext $context ) {
+               return array(
+                       '_class' => get_class( $this ),
+                       '_cacheEpoch' => $this->getConfig()->get( 'CacheEpoch' ),
+               );
        }
 
        /**
-        * Get the hash for whatever this module may contain.
+        * Get this module's last modification timestamp for a given context.
         *
-        * This is the method subclasses should implement if they want to make
-        * use of getHashMTime() inside getModifiedTime().
+        * @deprecated since 1.26 Use getDefinitionSummary() instead
+        * @param ResourceLoaderContext $context Context object
+        * @return int|null UNIX timestamp
+        */
+       public function getModifiedTime( ResourceLoaderContext $context ) {
+               return null;
+       }
+
+       /**
+        * Helper method for providing a version hash to getVersionHash().
         *
+        * @deprecated since 1.26 Use getDefinitionSummary() instead
         * @param ResourceLoaderContext $context
         * @return string|null Hash
         */
@@ -494,74 +546,38 @@ abstract class ResourceLoaderModule {
        }
 
        /**
-        * Helper method for calculating when this module's definition summary was last changed.
+        * Back-compat dummy for old subclass implementations of getModifiedTime().
         *
-        * @since 1.23
+        * This method used to use ObjectCache to track when a hash was first seen. That principle
+        * stems from a time that ResourceLoader could only identify module versions by timestamp.
+        * That is no longer the case. Use getDefinitionSummary() directly.
         *
+        * @deprecated since 1.26 Superseded by getVersionHash()
         * @param ResourceLoaderContext $context
         * @return int UNIX timestamp
         */
-       public function getDefinitionMtime( ResourceLoaderContext $context ) {
-               $summary = $this->getDefinitionSummary( $context );
-               if ( $summary === null ) {
+       public function getHashMtime( ResourceLoaderContext $context ) {
+               if ( !is_string( $this->getModifiedHash( $context ) ) ) {
                        return 1;
                }
-
-               $hash = md5( json_encode( $summary ) );
-               $cache = wfGetCache( CACHE_ANYTHING );
-               $key = wfMemcKey( 'resourceloader', 'moduledefinition', $this->getName(), $hash );
-
-               $data = $cache->get( $key );
-               if ( is_int( $data ) && $data > 0 ) {
-                       // We've seen this hash before, re-use the timestamp of when we first saw it.
-                       return $data;
-               }
-
-               wfDebugLog( 'resourceloader', __METHOD__ . ": New definition for module "
-                       . "{$this->getName()} in context \"{$context->getHash()}\"" );
-               // WMF logging for T94810
-               global $wgRequest;
-               if ( isset( $wgRequest ) && $context->getUser() ) {
-                       wfDebugLog( 'resourceloader', __METHOD__ . ": Request with user parameter in "
-                       . "context \"{$context->getHash()}\" from " . $wgRequest->getRequestURL() );
-               }
-
-               $timestamp = time();
-               $cache->set( $key, $timestamp );
-               return $timestamp;
+               // Dummy that is > 1
+               return 2;
        }
 
        /**
-        * Get the definition summary for this module.
-        *
-        * This is the method subclasses should implement if they want to make
-        * use of getDefinitionMTime() inside getModifiedTime().
-        *
-        * Return an array containing values from all significant properties of this
-        * module's definition. Be sure to include things that are explicitly ordered,
-        * in their actaul order (bug 37812).
-        *
-        * Avoid including things that are insiginificant (e.g. order of message
-        * keys is insignificant and should be sorted to avoid unnecessary cache
-        * invalidation).
-        *
-        * Avoid including things already considered by other methods inside your
-        * getModifiedTime(), such as file mtime timestamps.
-        *
-        * Serialisation is done using json_encode, which means object state is not
-        * taken into account when building the hash. This data structure must only
-        * contain arrays and scalars as values (avoid object instances) which means
-        * it requires abstraction.
+        * Back-compat dummy for old subclass implementations of getModifiedTime().
         *
         * @since 1.23
-        *
+        * @deprecated since 1.26 Superseded by getVersionHash()
         * @param ResourceLoaderContext $context
-        * @return array|null
+        * @return int UNIX timestamp
         */
-       public function getDefinitionSummary( ResourceLoaderContext $context ) {
-               return array(
-                       'class' => get_class( $this ),
-               );
+       public function getDefinitionMtime( ResourceLoaderContext $context ) {
+               if ( $this->getDefinitionSummary( $context ) === null ) {
+                       return 1;
+               }
+               // Dummy that is > 1
+               return 2;
        }
 
        /**
index 48b3576..74164df 100644 (file)
 
 class ResourceLoaderStartUpModule extends ResourceLoaderModule {
 
-       /* Protected Members */
-
-       protected $modifiedTime = array();
+       // Cache for getConfigSettings() as it's called by multiple methods
        protected $configVars = array();
        protected $targets = array( 'desktop', 'mobile' );
 
-       /* Protected Methods */
-
        /**
         * @param ResourceLoaderContext $context
         * @return array
@@ -158,7 +154,7 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule {
         * data send to the client.
         *
         * @param array &$registryData Modules keyed by name with properties:
-        *  - number 'version'
+        *  - string 'version'
         *  - array 'dependencies'
         *  - string|null 'group'
         *  - string 'source'
@@ -209,10 +205,12 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule {
                                continue;
                        }
 
-                       // Coerce module timestamp to UNIX timestamp.
-                       // getModifiedTime() is supposed to return a UNIX timestamp, but custom implementations
-                       // might forget. TODO: Maybe emit warning?
-                       $moduleMtime = wfTimestamp( TS_UNIX, $module->getModifiedTime( $context ) );
+                       $versionHash = $module->getVersionHash( $context );
+                       if ( strlen( $versionHash ) !== 8 ) {
+                               // Module implementation either broken or deviated from ResourceLoader::makeHash
+                               // Asserted by tests/phpunit/structure/ResourcesTest.
+                               $versionHash = ResourceLoader::makeHash( $versionHash );
+                       }
 
                        $skipFunction = $module->getSkipFunction();
                        if ( $skipFunction !== null && !ResourceLoader::inDebugMode() ) {
@@ -225,14 +223,8 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule {
                                );
                        }
 
-                       $mtime = max(
-                               $moduleMtime,
-                               wfTimestamp( TS_UNIX, $this->getConfig()->get( 'CacheEpoch' ) )
-                       );
-
                        $registryData[$name] = array(
-                               // Convert to numbers as wfTimestamp always returns a string, even for TS_UNIX
-                               'version' => (int) $mtime,
+                               'version' => $versionHash,
                                'dependencies' => $module->getDependencies(),
                                'group' => $module->getGroup(),
                                'source' => $module->getSource(),
@@ -262,7 +254,7 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule {
                                continue;
                        }
 
-                       // Call mw.loader.register(name, timestamp, dependencies, group, source, skip)
+                       // Call mw.loader.register(name, version, dependencies, group, source, skip)
                        $registrations[] = array(
                                $name,
                                $data['version'],
@@ -280,8 +272,6 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule {
                return $out;
        }
 
-       /* Methods */
-
        /**
         * @return bool
         */
@@ -308,24 +298,16 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule {
         * @return string
         */
        public static function getStartupModulesUrl( ResourceLoaderContext $context ) {
+               $rl = $context->getResourceLoader();
                $moduleNames = self::getStartupModules();
 
-               // Get the latest version
-               $loader = $context->getResourceLoader();
-               $version = 1;
-               foreach ( $moduleNames as $moduleName ) {
-                       $version = max( $version,
-                               $loader->getModule( $moduleName )->getModifiedTime( $context )
-                       );
-               }
-
                $query = array(
                        'modules' => ResourceLoader::makePackedModulesString( $moduleNames ),
                        'only' => 'scripts',
                        'lang' => $context->getLanguage(),
                        'skin' => $context->getSkin(),
                        'debug' => $context->getDebug() ? 'true' : 'false',
-                       'version' => wfTimestamp( TS_ISO_8601_BASIC, $version )
+                       'version' => $rl->getCombinedVersion( $context, $moduleNames ),
                );
                // Ensure uniform query order
                ksort( $query );
@@ -382,59 +364,48 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule {
        }
 
        /**
+        * Get the definition summary for this module.
+        *
         * @param ResourceLoaderContext $context
-        * @return array|mixed
+        * @return array
         */
-       public function getModifiedTime( ResourceLoaderContext $context ) {
+       public function getDefinitionSummary( ResourceLoaderContext $context ) {
                global $IP;
+               $summary = parent::getDefinitionSummary( $context );
+               $summary[] = array(
+                       // Detect changes to variables exposed in mw.config (T30899).
+                       'vars' => $this->getConfigSettings( $context ),
+                       // Changes how getScript() creates mw.Map for mw.config
+                       'wgLegacyJavaScriptGlobals' => $this->getConfig()->get( 'LegacyJavaScriptGlobals' ),
+                       // Detect changes to the module registrations
+                       'moduleHashes' => $this->getAllModuleHashes( $context ),
 
-               $hash = $context->getHash();
-               if ( isset( $this->modifiedTime[$hash] ) ) {
-                       return $this->modifiedTime[$hash];
-               }
-
-               // Call preloadModuleInfo() on ALL modules as we're about
-               // to call getModifiedTime() on all of them
-               $loader = $context->getResourceLoader();
-               $loader->preloadModuleInfo( $loader->getModuleNames(), $context );
-
-               $time = max(
-                       wfTimestamp( TS_UNIX, $this->getConfig()->get( 'CacheEpoch' ) ),
-                       filemtime( "$IP/resources/src/startup.js" ),
-                       $this->getHashMtime( $context )
+                       'fileMtimes' => array(
+                               filemtime( "$IP/resources/src/startup.js" ),
+                       ),
                );
-
-               // ATTENTION!: Because of the line below, this is not going to cause
-               // infinite recursion - think carefully before making changes to this
-               // code!
-               // Pre-populate modifiedTime with something because the loop over
-               // all modules below includes the startup module (this module).
-               $this->modifiedTime[$hash] = 1;
-
-               foreach ( $loader->getModuleNames() as $name ) {
-                       $module = $loader->getModule( $name );
-                       $time = max( $time, $module->getModifiedTime( $context ) );
-               }
-
-               $this->modifiedTime[$hash] = $time;
-               return $this->modifiedTime[$hash];
+               return $summary;
        }
 
        /**
-        * Hash of all dynamic data embedded in getScript().
-        *
-        * Detect changes to mw.config settings embedded in #getScript (bug 28899).
+        * Helper method for getDefinitionSummary().
         *
         * @param ResourceLoaderContext $context
-        * @return string Hash
+        * @return string SHA-1
         */
-       public function getModifiedHash( ResourceLoaderContext $context ) {
-               $data = array(
-                       'vars' => $this->getConfigSettings( $context ),
-                       'wgLegacyJavaScriptGlobals' => $this->getConfig()->get( 'LegacyJavaScriptGlobals' ),
-               );
-
-               return md5( serialize( $data ) );
+       protected function getAllModuleHashes( ResourceLoaderContext $context ) {
+               $rl = $context->getResourceLoader();
+               // Preload for getCombinedVersion()
+               $rl->preloadModuleInfo( $rl->getModuleNames(), $context );
+
+               // ATTENTION: Because of the line below, this is not going to cause infinite recursion.
+               // Think carefully before making changes to this code!
+               // Pre-populate versionHash with something because the loop over all modules below includes
+               // the startup module (this module).
+               // See ResourceLoaderModule::getVersionHash() for usage of this cache.
+               $this->versionHash[ $context->getHash() ] = null;
+
+               return $rl->getCombinedVersion( $context, $rl->getModuleNames() );
        }
 
        /**
index 7b44cc6..4d207f6 100644 (file)
@@ -227,16 +227,15 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule {
        }
 
        /**
-        * Get the definition summary for this module.
-        *
         * @param ResourceLoaderContext $context
         * @return array
         */
        public function getDefinitionSummary( ResourceLoaderContext $context ) {
-               return array(
-                       'class' => get_class( $this ),
+               $summary = parent::getDefinitionSummary( $context );
+               $summary[] = array(
                        'pages' => $this->getPages( $context ),
                );
+               return $summary;
        }
 
        /**
index 0273f78..237addc 100644 (file)
@@ -775,6 +775,7 @@ return array(
                'class' => 'ResourceLoaderRawFileModule',
                // Keep maintenance/jsduck/eg-iframe.html in sync
                'scripts' => array(
+                       'resources/lib/phpjs-sha1/sha1.js',
                        'resources/src/mediawiki/mediawiki.js',
                        'resources/src/mediawiki/mediawiki.errorLogger.js',
                        'resources/src/mediawiki/mediawiki.startUp.js',
diff --git a/resources/lib/phpjs-sha1/LICENSE.txt b/resources/lib/phpjs-sha1/LICENSE.txt
new file mode 100644 (file)
index 0000000..04caf53
--- /dev/null
@@ -0,0 +1,20 @@
+Copyright (c) 2013 Kevin van Zonneveld (http://kvz.io) 
+and Contributors (http://phpjs.org/authors)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/resources/lib/phpjs-sha1/sha1.js b/resources/lib/phpjs-sha1/sha1.js
new file mode 100644 (file)
index 0000000..93c533d
--- /dev/null
@@ -0,0 +1,147 @@
+function sha1(str) {
+  //  discuss at: http://phpjs.org/functions/sha1/
+  // original by: Webtoolkit.info (http://www.webtoolkit.info/)
+  // improved by: Michael White (http://getsprink.com)
+  // improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
+  //    input by: Brett Zamir (http://brett-zamir.me)
+  //   example 1: sha1('Kevin van Zonneveld');
+  //   returns 1: '54916d2e62f65b3afa6e192e6a601cdbe5cb5897'
+
+  var rotate_left = function (n, s) {
+    var t4 = (n << s) | (n >>> (32 - s));
+    return t4;
+  };
+
+  /*var lsb_hex = function (val) {
+   // Not in use; needed?
+    var str="";
+    var i;
+    var vh;
+    var vl;
+
+    for ( i=0; i<=6; i+=2 ) {
+      vh = (val>>>(i*4+4))&0x0f;
+      vl = (val>>>(i*4))&0x0f;
+      str += vh.toString(16) + vl.toString(16);
+    }
+    return str;
+  };*/
+
+  var cvt_hex = function (val) {
+    var str = '';
+    var i;
+    var v;
+
+    for (i = 7; i >= 0; i--) {
+      v = (val >>> (i * 4)) & 0x0f;
+      str += v.toString(16);
+    }
+    return str;
+  };
+
+  var blockstart;
+  var i, j;
+  var W = new Array(80);
+  var H0 = 0x67452301;
+  var H1 = 0xEFCDAB89;
+  var H2 = 0x98BADCFE;
+  var H3 = 0x10325476;
+  var H4 = 0xC3D2E1F0;
+  var A, B, C, D, E;
+  var temp;
+
+  // utf8_encode
+  str = unescape(encodeURIComponent(str));
+  var str_len = str.length;
+
+  var word_array = [];
+  for (i = 0; i < str_len - 3; i += 4) {
+    j = str.charCodeAt(i) << 24 | str.charCodeAt(i + 1) << 16 | str.charCodeAt(i + 2) << 8 | str.charCodeAt(i + 3);
+    word_array.push(j);
+  }
+
+  switch (str_len % 4) {
+  case 0:
+    i = 0x080000000;
+    break;
+  case 1:
+    i = str.charCodeAt(str_len - 1) << 24 | 0x0800000;
+    break;
+  case 2:
+    i = str.charCodeAt(str_len - 2) << 24 | str.charCodeAt(str_len - 1) << 16 | 0x08000;
+    break;
+  case 3:
+    i = str.charCodeAt(str_len - 3) << 24 | str.charCodeAt(str_len - 2) << 16 | str.charCodeAt(str_len - 1) <<
+      8 | 0x80;
+    break;
+  }
+
+  word_array.push(i);
+
+  while ((word_array.length % 16) != 14) {
+    word_array.push(0);
+  }
+
+  word_array.push(str_len >>> 29);
+  word_array.push((str_len << 3) & 0x0ffffffff);
+
+  for (blockstart = 0; blockstart < word_array.length; blockstart += 16) {
+    for (i = 0; i < 16; i++) {
+      W[i] = word_array[blockstart + i];
+    }
+    for (i = 16; i <= 79; i++) {
+      W[i] = rotate_left(W[i - 3] ^ W[i - 8] ^ W[i - 14] ^ W[i - 16], 1);
+    }
+
+    A = H0;
+    B = H1;
+    C = H2;
+    D = H3;
+    E = H4;
+
+    for (i = 0; i <= 19; i++) {
+      temp = (rotate_left(A, 5) + ((B & C) | (~B & D)) + E + W[i] + 0x5A827999) & 0x0ffffffff;
+      E = D;
+      D = C;
+      C = rotate_left(B, 30);
+      B = A;
+      A = temp;
+    }
+
+    for (i = 20; i <= 39; i++) {
+      temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0x6ED9EBA1) & 0x0ffffffff;
+      E = D;
+      D = C;
+      C = rotate_left(B, 30);
+      B = A;
+      A = temp;
+    }
+
+    for (i = 40; i <= 59; i++) {
+      temp = (rotate_left(A, 5) + ((B & C) | (B & D) | (C & D)) + E + W[i] + 0x8F1BBCDC) & 0x0ffffffff;
+      E = D;
+      D = C;
+      C = rotate_left(B, 30);
+      B = A;
+      A = temp;
+    }
+
+    for (i = 60; i <= 79; i++) {
+      temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0xCA62C1D6) & 0x0ffffffff;
+      E = D;
+      D = C;
+      C = rotate_left(B, 30);
+      B = A;
+      A = temp;
+    }
+
+    H0 = (H0 + A) & 0x0ffffffff;
+    H1 = (H1 + B) & 0x0ffffffff;
+    H2 = (H2 + C) & 0x0ffffffff;
+    H3 = (H3 + D) & 0x0ffffffff;
+    H4 = (H4 + E) & 0x0ffffffff;
+  }
+
+  temp = cvt_hex(H0) + cvt_hex(H1) + cvt_hex(H2) + cvt_hex(H3) + cvt_hex(H4);
+  return temp.toLowerCase();
+}
index e556ed8..f2b4b00 100644 (file)
@@ -7,6 +7,7 @@
  * @alternateClassName mediaWiki
  * @singleton
  */
+/*global sha1 */
 ( function ( $ ) {
        'use strict';
 
                         *     {
                         *         'moduleName': {
                         *             // From startup mdoule
-                        *             'version': ############## (unix timestamp)
+                        *             'version': '################' (Hash)
                         *             'dependencies': ['required.foo', 'bar.also', ...], (or) function () {}
                         *             'group': 'somegroup', (or) null
                         *             'source': 'local', (or) 'anotherwiki'
                        }
 
                        /**
-                        * Zero-pad three numbers.
-                        *
-                        * @private
-                        * @param {number} a
-                        * @param {number} b
-                        * @param {number} c
-                        * @return {string}
-                        */
-                       function pad( a, b, c ) {
-                               return (
-                                       ( a < 10 ? '0' : '' ) + a +
-                                       ( b < 10 ? '0' : '' ) + b +
-                                       ( c < 10 ? '0' : '' ) + c
-                               );
-                       }
-
-                       /**
-                        * Convert UNIX timestamp to ISO8601 format.
-                        *
-                        * @private
-                        * @param {number} timestamp UNIX timestamp
+                        * @since 1.26
+                        * @param {Object[]} modules List of module registry objects
+                        * @return {string} Hash of concatenated version hashes.
                         */
-                       function formatVersionNumber( timestamp ) {
-                               var     d = new Date();
-                               d.setTime( timestamp * 1000 );
-                               return [
-                                       pad( d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate() ),
-                                       'T',
-                                       pad( d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ),
-                                       'Z'
-                               ].join( '' );
+                       function getCombinedVersion( modules ) {
+                               var hashes = $.map( modules, function ( module ) {
+                                       return module.version;
+                               } );
+                               // Trim for consistency with server-side ResourceLoader::makeHash. It also helps
+                               // save precious space in the limited query string. Otherwise modules are more
+                               // likely to require multiple HTTP requests.
+                               return sha1( hashes.join( '' ) ).slice( 0, 12 );
                        }
 
                        /**
                                 */
                                work: function () {
                                        var     reqBase, splits, maxQueryLength, q, b, bSource, bGroup, bSourceGroup,
-                                               source, concatSource, origBatch, group, g, i, modules, maxVersion, sourceLoadScript,
+                                               source, concatSource, origBatch, group, i, modules, sourceLoadScript,
                                                currReqBase, currReqBaseLength, moduleMap, l,
                                                lastDotIndex, prefix, suffix, bytesAdded, async;
 
                                                        // modules for this group from this source.
                                                        modules = splits[source][group];
 
-                                                       // Calculate the highest timestamp
-                                                       maxVersion = 0;
-                                                       for ( g = 0; g < modules.length; g += 1 ) {
-                                                               if ( registry[modules[g]].version > maxVersion ) {
-                                                                       maxVersion = registry[modules[g]].version;
-                                                               }
-                                                       }
-
-                                                       currReqBase = $.extend( { version: formatVersionNumber( maxVersion ) }, reqBase );
+                                                       currReqBase = $.extend( {
+                                                               version: getCombinedVersion( modules )
+                                                       }, reqBase );
                                                        // For user modules append a user name to the request.
                                                        if ( group === 'user' && mw.config.get( 'wgUserName' ) !== null ) {
                                                                currReqBase.user = mw.config.get( 'wgUserName' );
                                },
 
                                /**
-                                * Register a module, letting the system know about it and its
-                                * properties. Startup modules contain calls to this function.
+                                * Register a module, letting the system know about it and its properties.
+                                *
+                                * The startup modules contain calls to this method.
                                 *
                                 * When using multiple module registration by passing an array, dependencies that
                                 * are specified as references to modules within the array will be resolved before
                                 *
                                 * @param {string|Array} module Module name or array of arrays, each containing
                                 *  a list of arguments compatible with this method
-                                * @param {number} version Module version number as a timestamp (falls backs to 0)
+                                * @param {string|number} version Module version hash (falls backs to empty string)
+                                *  Can also be a number (timestamp) for compatibility with MediaWiki 1.25 and earlier.
                                 * @param {string|Array|Function} dependencies One string or array of strings of module
                                 *  names on which this module depends, or a function that returns that array.
                                 * @param {string} [group=null] Group which the module is in
                                        }
                                        // List the module as registered
                                        registry[module] = {
-                                               version: version !== undefined ? parseInt( version, 10 ) : 0,
+                                               version: version !== undefined ? String( version ) : '',
                                                dependencies: [],
                                                group: typeof group === 'string' ? group : null,
                                                source: typeof source === 'string' ? source : 'local',
                                        if ( !hasOwn.call( registry, module ) || registry[module].version === undefined ) {
                                                return null;
                                        }
-                                       return formatVersionNumber( registry[module].version );
+                                       return registry[module].version;
                                },
 
                                /**
index deecb31..4d4e83f 100644 (file)
@@ -25,27 +25,35 @@ abstract class ResourceLoaderTestCase extends MediaWikiTestCase {
                return $ctx;
        }
 
-       protected function setUp() {
-               parent::setUp();
-
-               ResourceLoader::clearCache();
-
-               $this->setMwGlobals( array(
+       public static function getSettings() {
+               return array(
                        // For ResourceLoader::inDebugMode since it doesn't have context
-                       'wgResourceLoaderDebug' => true,
+                       'ResourceLoaderDebug' => true,
 
                        // Avoid influence from wgInvalidateCacheOnLocalSettingsChange
-                       'wgCacheEpoch' => '20140101000000',
+                       'CacheEpoch' => '20140101000000',
 
                        // For ResourceLoader::__construct()
-                       'wgResourceLoaderSources' => array(),
+                       'ResourceLoaderSources' => array(),
 
                        // For wfScript()
-                       'wgScriptPath' => '/w',
-                       'wgScriptExtension' => '.php',
-                       'wgScript' => '/w/index.php',
-                       'wgLoadScript' => '/w/load.php',
-               ) );
+                       'ScriptPath' => '/w',
+                       'ScriptExtension' => '.php',
+                       'Script' => '/w/index.php',
+                       'LoadScript' => '/w/load.php',
+               );
+       }
+
+       protected function setUp() {
+               parent::setUp();
+
+               ResourceLoader::clearCache();
+
+               $globals = array();
+               foreach ( self::getSettings() as $key => $value ) {
+                       $globals[ 'wg' . $key ] = $value;
+               }
+               $this->setMwGlobals( $globals );
        }
 }
 
index e1197df..358d2a1 100644 (file)
@@ -226,23 +226,4 @@ class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase {
 
                $this->assertEquals( $rl->getTemplates(), $expected );
        }
-
-       public static function providerGetModifiedTime() {
-               $modules = self::getModules();
-
-               return array(
-                       // Check the default value when no templates present in module is 1
-                       array( $modules['noTemplateModule'], 1 ),
-               );
-       }
-
-       /**
-        * @dataProvider providerGetModifiedTime
-        * @covers ResourceLoaderFileModule::getModifiedTime
-        */
-       public function testGetModifiedTime( $module, $expected ) {
-               $rl = new ResourceLoaderFileModule( $module );
-               $ts = $rl->getModifiedTime( $this->getResourceLoaderContext() );
-               $this->assertEquals( $ts, $expected );
-       }
 }
index 7f3506c..72a2d6a 100644 (file)
@@ -23,7 +23,7 @@ mw.loader.addSource( {
 } );mw.loader.register( [
     [
         "test.blank",
-        1388534400
+        "XyCC+PSK"
     ]
 ] );',
                        ) ),
@@ -40,17 +40,17 @@ mw.loader.addSource( {
 } );mw.loader.register( [
     [
         "test.blank",
-        1388534400
+        "XyCC+PSK"
     ],
     [
         "test.group.foo",
-        1388534400,
+        "XyCC+PSK",
         [],
         "x-foo"
     ],
     [
         "test.group.bar",
-        1388534400,
+        "XyCC+PSK",
         [],
         "x-bar"
     ]
@@ -68,7 +68,7 @@ mw.loader.addSource( {
 } );mw.loader.register( [
     [
         "test.blank",
-        1388534400
+        "XyCC+PSK"
     ]
 ] );'
                        ) ),
@@ -90,7 +90,7 @@ mw.loader.addSource( {
 } );mw.loader.register( [
     [
         "test.blank",
-        1388534400,
+        "XyCC+PSK",
         [],
         null,
         "example"
@@ -126,11 +126,11 @@ mw.loader.addSource( {
 } );mw.loader.register( [
     [
         "test.x.core",
-        1388534400
+        "XyCC+PSK"
     ],
     [
         "test.x.polyfill",
-        1388534400,
+        "XyCC+PSK",
         [],
         null,
         null,
@@ -138,7 +138,7 @@ mw.loader.addSource( {
     ],
     [
         "test.y.polyfill",
-        1388534400,
+        "XyCC+PSK",
         [],
         null,
         null,
@@ -146,7 +146,7 @@ mw.loader.addSource( {
     ],
     [
         "test.z.foo",
-        1388534400,
+        "XyCC+PSK",
         [
             0,
             1,
@@ -222,36 +222,36 @@ mw.loader.addSource( {
 } );mw.loader.register( [
     [
         "test.blank",
-        1388534400
+        "XyCC+PSK"
     ],
     [
         "test.x.core",
-        1388534400
+        "XyCC+PSK"
     ],
     [
         "test.x.util",
-        1388534400,
+        "XyCC+PSK",
         [
             1
         ]
     ],
     [
         "test.x.foo",
-        1388534400,
+        "XyCC+PSK",
         [
             1
         ]
     ],
     [
         "test.x.bar",
-        1388534400,
+        "XyCC+PSK",
         [
             2
         ]
     ],
     [
         "test.x.quux",
-        1388534400,
+        "XyCC+PSK",
         [
             3,
             4,
@@ -260,25 +260,25 @@ mw.loader.addSource( {
     ],
     [
         "test.group.foo.1",
-        1388534400,
+        "XyCC+PSK",
         [],
         "x-foo"
     ],
     [
         "test.group.foo.2",
-        1388534400,
+        "XyCC+PSK",
         [],
         "x-foo"
     ],
     [
         "test.group.bar.1",
-        1388534400,
+        "XyCC+PSK",
         [],
         "x-bar"
     ],
     [
         "test.group.bar.2",
-        1388534400,
+        "XyCC+PSK",
         [],
         "x-bar",
         "example"
@@ -344,8 +344,8 @@ mw.loader.addSource( {
                $this->assertEquals(
 'mw.loader.addSource({"local":"/w/load.php"});'
 . 'mw.loader.register(['
-. '["test.blank",1388534400],'
-. '["test.min",1388534400,[0],null,null,'
+. '["test.blank","XyCC+PSK"],'
+. '["test.min","XyCC+PSK",[0],null,null,'
 . '"return!!(window.JSON\u0026\u0026JSON.parse\u0026\u0026JSON.stringify);"'
 . ']]);',
                        $module->getModuleRegistrations( $context ),
@@ -367,11 +367,11 @@ mw.loader.addSource( {
 } );mw.loader.register( [
     [
         "test.blank",
-        1388534400
+        "XyCC+PSK"
     ],
     [
         "test.min",
-        1388534400,
+        "XyCC+PSK",
         [
             0
         ],
index 93a3ebb..7974ee9 100644 (file)
@@ -31,16 +31,15 @@ class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase {
                $module = new ResourceLoaderWikiModule( $params );
                $module->setConfig( $config );
 
-               // Use getDefinitionSummary because getPages is protected
-               $summary = $module->getDefinitionSummary( ResourceLoaderContext::newDummyContext() );
-               $this->assertEquals(
-                       $expected,
-                       $summary['pages']
-               );
+               // Because getPages is protected..
+               $getPages = new ReflectionMethod( $module, 'getPages' );
+               $getPages->setAccessible( true );
+               $out = $getPages->invoke( $module, ResourceLoaderContext::newDummyContext() );
+               $this->assertEquals( $expected, $out );
        }
 
        public static function provideGetPages() {
-               $settings = array(
+               $settings = self::getSettings() + array(
                        'UseSiteJs' => true,
                        'UseSiteCss' => true,
                );
index d2b699d..9178bdb 100644 (file)
@@ -36,6 +36,14 @@ class ResourcesTest extends MediaWikiTestCase {
                );
        }
 
+       public function testVersionHash() {
+               $data = self::getAllModules();
+               foreach ( $data['modules'] as $moduleName => $module ) {
+                       $version = $module->getVersionHash( $data['context'] );
+                       $this->assertEquals( 8, strlen( $version ), "$moduleName must use ResourceLoader::makeHash" );
+               }
+       }
+
        /**
         * Verify that nothing explicitly depends on the 'jquery' and 'mediawiki' modules.
         * They are always loaded, depending on them is unsupported and leads to unexpected behaviour.