Skip to content

Commit 51eb3d4

Browse files
alxhubalexeagle
authored andcommitted
fix(service-worker): properly handle invalid hashes in all scenarios (#21288)
When the SW fetches URLs listed in a manifest with hashes, it checks the content hash against the manifest to make sure it has the correct version of the URL. In the event of a mismatch, the SW is supposed to consider the manifest invalid, and avoid using it. There are 3 cases to consider by which this can happen. Case 1: during the initial SW installation, a manifest is activated without waiting for every URL to be fully loaded. In the background, every prefetch URL listed by the manifest is requested and cached. One such prefetch request could fail the hash test, and cause the manifest to be treated as invalid. In such a case, the SW should enter a state of EXISTING_CLIENTS_ONLY, as the latest manifest is invalid. This case works today. Case 2: during the initial SW installation, as in Case 1, a manifest is activated without waiting for each URL to fully load. However, it's possible that the application could request a URL with a bad hash before background initialization tries to load that URL. This happens if, for example, the application has a broken index.html. In this case, the SW should enter a state of EXISTING_CLIENTS_ONLY, and serve the request from the network instead. What happens today is that the internal error escapes the SW and is returned as a rejected Promise to respondWith(), causing a browser-level error that the site cannot be loaded, breaking the site. This change allows the SW to detect the error and enter the correct state, falling back on the network if needed. Case 3: during checkForUpdate(), the SW will try to fully cache the new update before making it the latest version. Failure here is complicated - if the page fails to load due to transient network conditions (timeouts, 500s, etc), then it makes sense to continue serving the existing cached version, and attempt to activate the update on the next cycle. If the page fails due to non-transient conditions though (400 error, hash mismatch, etc), then the SW should consider the updated manifest invalid, and enter a state of EXISTING_CLIENTS_ONLY. Currently, all errors are treated as transient. This change causes the SW to treat all errors during updates as non-transient, which can cause the SW to unnecessarily enter a safe mode. A future change can allow the SW to remain in normal mode if the error is provably transient. PR Close #21288
1 parent 48c1898 commit 51eb3d4

File tree

5 files changed

+100
-16
lines changed

5 files changed

+100
-16
lines changed

packages/service-worker/worker/src/assets.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {Adapter, Context} from './adapter';
1010
import {CacheState, UpdateCacheStatus, UpdateSource, UrlMetadata} from './api';
1111
import {Database, Table} from './database';
12+
import {SwCriticalError} from './error';
1213
import {IdleScheduler} from './idle';
1314
import {AssetGroupConfig} from './manifest';
1415
import {sha1Binary} from './sha1';
@@ -345,7 +346,7 @@ export abstract class AssetGroup {
345346
if ((res as any)['redirected'] && !!res.url) {
346347
// If the redirect limit is exhausted, fail with an error.
347348
if (redirectLimit === 0) {
348-
throw new Error(
349+
throw new SwCriticalError(
349350
`Response hit redirect limit (fetchFromNetwork): request redirected too many times, next is ${res.url}`);
350351
}
351352

@@ -409,7 +410,7 @@ export abstract class AssetGroup {
409410

410411
// If the response was unsuccessful, there's nothing more that can be done.
411412
if (!cacheBustedResult.ok) {
412-
throw new Error(
413+
throw new SwCriticalError(
413414
`Response not Ok (cacheBustedFetchFromNetwork): cache busted request for ${req.url} returned response ${cacheBustedResult.status} ${cacheBustedResult.statusText}`);
414415
}
415416

@@ -419,7 +420,7 @@ export abstract class AssetGroup {
419420
// If the cache-busted version doesn't match, then the manifest is not an accurate
420421
// representation of the server's current set of files, and the SW should give up.
421422
if (canonicalHash !== cacheBustedHash) {
422-
throw new Error(
423+
throw new SwCriticalError(
423424
`Hash mismatch (cacheBustedFetchFromNetwork): ${req.url}: expected ${canonicalHash}, got ${cacheBustedHash} (after cache busting)`);
424425
}
425426

packages/service-worker/worker/src/driver.ts

+32-11
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {CacheState, DebugIdleState, DebugState, DebugVersion, Debuggable, Update
1111
import {AppVersion} from './app-version';
1212
import {Database, Table} from './database';
1313
import {DebugHandler} from './debug';
14+
import {SwCriticalError} from './error';
1415
import {IdleScheduler} from './idle';
1516
import {Manifest, ManifestHash, hashManifest} from './manifest';
1617
import {MsgAny, isMsgActivateUpdate, isMsgCheckForUpdates} from './msg';
@@ -38,7 +39,7 @@ interface LatestEntry {
3839
latest: string;
3940
}
4041

41-
enum DriverReadyState {
42+
export enum DriverReadyState {
4243
// The SW is operating in a normal mode, responding to all traffic.
4344
NORMAL,
4445

@@ -57,7 +58,7 @@ export class Driver implements Debuggable, UpdateSource {
5758
* Tracks the current readiness condition under which the SW is operating. This controls
5859
* whether the SW attempts to respond to some or all requests.
5960
*/
60-
private state: DriverReadyState = DriverReadyState.NORMAL;
61+
state: DriverReadyState = DriverReadyState.NORMAL;
6162
private stateMessage: string = '(nominal)';
6263

6364
/**
@@ -351,8 +352,22 @@ export class Driver implements Debuggable, UpdateSource {
351352
return this.safeFetch(event.request);
352353
}
353354

354-
// Handle the request. First try the AppVersion. If that doesn't work, fall back on the network.
355-
const res = await appVersion.handleFetch(event.request, event);
355+
let res: Response|null = null;
356+
try {
357+
// Handle the request. First try the AppVersion. If that doesn't work, fall back on the
358+
// network.
359+
res = await appVersion.handleFetch(event.request, event);
360+
} catch (err) {
361+
if (err.isCritical) {
362+
// Something went wrong with the activation of this version.
363+
await this.versionFailed(appVersion, err, this.latestHash === appVersion.manifestHash);
364+
365+
event.waitUntil(this.idle.trigger());
366+
return this.safeFetch(event.request);
367+
}
368+
throw err;
369+
}
370+
356371

357372
// The AppVersion will only return null if the manifest doesn't specify what to do about this
358373
// request. In that case, just fall back on the network.
@@ -482,7 +497,7 @@ export class Driver implements Debuggable, UpdateSource {
482497
// Attempt to schedule or initialize this version. If this operation is
483498
// successful, then initialization either succeeded or was scheduled. If
484499
// it fails, then full initialization was attempted and failed.
485-
await this.scheduleInitialization(this.versions.get(hash) !);
500+
await this.scheduleInitialization(this.versions.get(hash) !, this.latestHash === hash);
486501
} catch (err) {
487502
this.debugger.log(err, `initialize: schedule init of ${hash}`);
488503
return false;
@@ -623,13 +638,13 @@ export class Driver implements Debuggable, UpdateSource {
623638
* when the SW is not busy and has connectivity. This returns a Promise which must be
624639
* awaited, as under some conditions the AppVersion might be initialized immediately.
625640
*/
626-
private async scheduleInitialization(appVersion: AppVersion): Promise<void> {
641+
private async scheduleInitialization(appVersion: AppVersion, latest: boolean): Promise<void> {
627642
const initialize = async() => {
628643
try {
629644
await appVersion.initializeFully();
630645
} catch (err) {
631646
this.debugger.log(err, `initializeFully for ${appVersion.manifestHash}`);
632-
await this.versionFailed(appVersion, err);
647+
await this.versionFailed(appVersion, err, latest);
633648
}
634649
};
635650
// TODO: better logic for detecting localhost.
@@ -639,7 +654,7 @@ export class Driver implements Debuggable, UpdateSource {
639654
this.idle.schedule(`initialization(${appVersion.manifestHash})`, initialize);
640655
}
641656

642-
private async versionFailed(appVersion: AppVersion, err: Error): Promise<void> {
657+
private async versionFailed(appVersion: AppVersion, err: Error, latest: boolean): Promise<void> {
643658
// This particular AppVersion is broken. First, find the manifest hash.
644659
const broken =
645660
Array.from(this.versions.entries()).find(([hash, version]) => version === appVersion);
@@ -653,7 +668,7 @@ export class Driver implements Debuggable, UpdateSource {
653668

654669
// The action taken depends on whether the broken manifest is the active (latest) or not.
655670
// If so, the SW cannot accept new clients, but can continue to service old ones.
656-
if (this.latestHash === brokenHash) {
671+
if (this.latestHash === brokenHash || latest) {
657672
// The latest manifest is broken. This means that new clients are at the mercy of the
658673
// network, but caches continue to be valid for previous versions. This is
659674
// unfortunate but unavoidable.
@@ -711,9 +726,10 @@ export class Driver implements Debuggable, UpdateSource {
711726
}
712727

713728
async checkForUpdate(): Promise<boolean> {
729+
let hash: string = '(unknown)';
714730
try {
715731
const manifest = await this.fetchLatestManifest();
716-
const hash = hashManifest(manifest);
732+
hash = hashManifest(manifest);
717733

718734
// Check whether this is really an update.
719735
if (this.versions.has(hash)) {
@@ -723,7 +739,12 @@ export class Driver implements Debuggable, UpdateSource {
723739
await this.setupUpdate(manifest, hash);
724740

725741
return true;
726-
} catch (_) {
742+
} catch (err) {
743+
this.debugger.log(err, `Error occurred while updating to manifest ${hash}`);
744+
745+
this.state = DriverReadyState.EXISTING_CLIENTS_ONLY;
746+
this.stateMessage = `Degraded due to failed initialization: ${errorToString(err)}`;
747+
727748
return false;
728749
}
729750
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
export class SwCriticalError extends Error { readonly isCritical: boolean = true; }

packages/service-worker/worker/test/happy_spec.ts

+50-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {CacheDatabase} from '../src/db-cache';
10-
import {Driver} from '../src/driver';
10+
import {Driver, DriverReadyState} from '../src/driver';
1111
import {Manifest} from '../src/manifest';
1212
import {sha1} from '../src/sha1';
1313
import {MockRequest} from '../testing/fetch';
@@ -36,6 +36,24 @@ const distUpdate =
3636
.addUnhashedFile('/unhashed/a.txt', 'this is unhashed v2', {'Cache-Control': 'max-age=10'})
3737
.build();
3838

39+
const brokenFs = new MockFileSystemBuilder().addFile('/foo.txt', 'this is foo').build();
40+
41+
const brokenManifest: Manifest = {
42+
configVersion: 1,
43+
index: '/foo.txt',
44+
assetGroups: [{
45+
name: 'assets',
46+
installMode: 'prefetch',
47+
updateMode: 'prefetch',
48+
urls: [
49+
'/foo.txt',
50+
],
51+
patterns: [],
52+
}],
53+
dataGroups: [],
54+
hashTable: tmpHashTableForFs(brokenFs, {'/foo.txt': true}),
55+
};
56+
3957
const manifest: Manifest = {
4058
configVersion: 1,
4159
appData: {
@@ -132,6 +150,9 @@ const serverUpdate =
132150
.withRedirect('/redirected.txt', '/redirect-target.txt', 'this was a redirect')
133151
.build();
134152

153+
const brokenServer =
154+
new MockServerStateBuilder().withStaticFiles(brokenFs).withManifest(brokenManifest).build();
155+
135156
const server404 = new MockServerStateBuilder().withStaticFiles(dist).build();
136157

137158
const scope = new SwTestHarnessBuilder().withServerState(server).build();
@@ -673,6 +694,34 @@ const manifestUpdateHash = sha1(JSON.stringify(manifestUpdate));
673694
server.assertSawRequestFor('/baz');
674695
});
675696
});
697+
698+
describe('bugs', () => {
699+
async_it('does not crash with bad index hash', async() => {
700+
scope = new SwTestHarnessBuilder().withServerState(brokenServer).build();
701+
(scope.registration as any).scope = 'http://site.com';
702+
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
703+
704+
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
705+
});
706+
707+
async_it('enters degraded mode when update has a bad index', async() => {
708+
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
709+
await driver.initialized;
710+
server.clearRequests();
711+
712+
scope = new SwTestHarnessBuilder()
713+
.withCacheState(scope.caches.dehydrate())
714+
.withServerState(brokenServer)
715+
.build();
716+
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
717+
await driver.checkForUpdate();
718+
719+
scope.advance(12000);
720+
await driver.idle.empty;
721+
722+
expect(driver.state).toEqual(DriverReadyState.EXISTING_CLIENTS_ONLY);
723+
});
724+
});
676725
});
677726
})();
678727

packages/service-worker/worker/testing/mock.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,16 @@ export function tmpManifestSingleAssetGroup(fs: MockFileSystem): Manifest {
194194
};
195195
}
196196

197-
export function tmpHashTableForFs(fs: MockFileSystem): {[url: string]: string} {
197+
export function tmpHashTableForFs(
198+
fs: MockFileSystem, breakHashes: {[url: string]: boolean} = {}): {[url: string]: string} {
198199
const table: {[url: string]: string} = {};
199200
fs.list().forEach(path => {
200201
const file = fs.lookup(path) !;
201202
if (file.hashThisFile) {
202203
table[path] = file.hash;
204+
if (breakHashes[path]) {
205+
table[path] = table[path].split('').reverse().join('');
206+
}
203207
}
204208
});
205209
return table;

0 commit comments

Comments
 (0)