Skip to content

Commit d23b6a1

Browse files
committed
Avoid race condition in SW version request
Previously the SW could receive the wrong auth token (by reading before it was written by the main thread) and then loop attempting to use it unsuccessfully, indefinitely. Now, the SW reloads the auth token for each test, meaning that although initial requests may fail, it will recover as soon as the UI shares the required token. This also remvoes the 'force update' SW logic, added in the distance past to handle a mismatch between desktop versions. The desktop version that requires this is before the minimum version in our runtime config, so those users will already be prompted to upgrade, and have been for a very long time.
1 parent e067422 commit d23b6a1

File tree

2 files changed

+45
-107
lines changed

2 files changed

+45
-107
lines changed

src/services/server-api.ts

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,41 +14,41 @@ import { GraphQLApiClient } from './server-graphql-api';
1414
import { RestApiClient } from './server-rest-api';
1515
import { RequestDefinition, RequestOptions } from '../model/send/send-request-model';
1616

17-
const authTokenPromise = !RUNNING_IN_WORKER
18-
// Main UI gets given the auth token directly in its URL:
19-
? Promise.resolve(new URLSearchParams(window.location.search).get('authToken') ?? undefined)
20-
// For workers, the new (March 2020) UI shares the auth token with SW via IDB:
21-
: localForage.getItem<string>('latest-auth-token')
22-
.then((authToken) => {
23-
if (authToken) return authToken;
24-
25-
// Old UI (Jan-March 2020) shares auth token via SW query param:
26-
const workerParams = new URLSearchParams(
27-
(self as unknown as WorkerGlobalScope).location.search
28-
);
29-
return workerParams.get('authToken') ?? undefined;
30-
31-
// Pre-Jan 2020 UI doesn't share auth token - ok with old desktop, fails with 0.1.18+.
32-
});
17+
async function getAuthToken() {
18+
if (!RUNNING_IN_WORKER) {
19+
return Promise.resolve(
20+
new URLSearchParams(window.location.search).get('authToken') ?? undefined
21+
);
22+
}
23+
24+
// For workers, the UI shares the auth token with SW via IDB:
25+
const authToken = await localForage.getItem<string>('latest-auth-token')
26+
if (authToken) return authToken;
27+
}
3328

3429
const serverReady = getDeferred();
3530
export const announceServerReady = () => serverReady.resolve();
3631
export const waitUntilServerReady = () => serverReady.promise;
3732

38-
const apiClient: Promise<GraphQLApiClient | RestApiClient> = authTokenPromise.then(async (authToken) => {
39-
// Delay checking, just to avoid spamming requests that we know won't work. Doesn't work
40-
// in the SW, which doesn't get a server-ready ping, and is delayed quite a while anyway.
33+
const apiClient = (async (): Promise<GraphQLApiClient | RestApiClient> => {
34+
// Delay checking, just to avoid spamming requests that we know won't work. Doesn't work in
35+
// the update SW, which doesn't get a server-ready ping, so just wait a bit:
4136
if (!RUNNING_IN_WORKER) await waitUntilServerReady();
42-
43-
const restClient = new RestApiClient(authToken);
44-
const graphQLClient = new GraphQLApiClient(authToken);
37+
else await delay(5000);
4538

4639
// To work out which API is supported, we loop trying to get the version from
4740
// each one (may take a couple of tries as the server starts up), and then
4841
// check the resulting version to see what's supported.
4942

5043
let version: string | undefined;
5144
while (!version) {
45+
// Reload auth token each time. For main UI it'll never change (but load is instant),
46+
// while for SW there's a race so we need to reload just in case:
47+
const authToken = await getAuthToken();
48+
49+
const restClient = new RestApiClient(authToken);
50+
const graphQLClient = new GraphQLApiClient(authToken);
51+
5252
version = await restClient.getServerVersion().catch(() => {
5353
console.log("Couldn't get version from REST API");
5454

@@ -58,15 +58,20 @@ const apiClient: Promise<GraphQLApiClient | RestApiClient> = authTokenPromise.th
5858
});
5959
});
6060

61-
if (!version) await delay(100);
61+
if (version) {
62+
if (versionSatisfies(version, SERVER_REST_API_SUPPORTED)) {
63+
return restClient;
64+
} else {
65+
return graphQLClient;
66+
}
67+
} else {
68+
// Wait a little then try again:
69+
await delay(100);
70+
}
6271
}
6372

64-
if (versionSatisfies(version, SERVER_REST_API_SUPPORTED)) {
65-
return restClient;
66-
} else {
67-
return graphQLClient;
68-
}
69-
});
73+
throw new Error(`Unreachable error: got version ${version} but couldn't pick an API client`);
74+
})();
7075

7176
export async function getServerVersion(): Promise<string> {
7277
return (await apiClient).getServerVersion();

src/services/ui-update-worker.ts

Lines changed: 11 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { initSentry, logError } from '../errors';
22
initSentry(process.env.SENTRY_DSN);
33

4+
// Used elsewhere in server API requests later to get the auth token:
45
import * as localForage from 'localforage';
6+
localForage.config({ name: "httptoolkit", version: 1 });
57

68
import { registerRoute, NavigationRoute } from 'workbox-routing';
79
import { PrecacheController } from 'workbox-precaching'
@@ -11,38 +13,8 @@ import { StaleWhileRevalidate, NetworkOnly } from 'workbox-strategies';
1113
import packageMetadata from '../../package.json';
1214
import { getServerVersion } from './server-api';
1315
import { lastServerVersion, versionSatisfies } from './service-versions';
14-
import { delay } from '../util/promise';
1516

1617
const appVersion = process.env.UI_VERSION || "Unknown";
17-
localForage.config({ name: "httptoolkit", version: 1 });
18-
19-
// Check if the server is accessible, and that we've been given the relevant auth
20-
// details. If we haven't, that means the desktop has started the server with an
21-
// auth token, but we haven't received it. In general, that means the UI hasn't
22-
// passed it on, because it's outdated and doesn't understand it. To fix this,
23-
// we forcibly update the UI immediately. Should only ever happen once.
24-
25-
type ServerStatus = 'accessible' | 'auth-required' | 'inaccessible'
26-
const serverStatus: Promise<ServerStatus> =
27-
fetch("http://127.0.0.1:45457/", { method: 'POST' })
28-
.then((response) => {
29-
if (response.status === 403) return 'auth-required';
30-
else return 'accessible';
31-
})
32-
.catch(() => 'inaccessible' as ServerStatus)
33-
.then((status) => {
34-
console.log('Service worker server status:', status);
35-
return status;
36-
});
37-
38-
const forceUpdateRequired = serverStatus.then(async (status) => {
39-
// Update should be forced if we're using a server that requires auth,
40-
// the UI hasn't properly provided an auth token, and there is
41-
// currently an active service worker (i.e. a cached UI)
42-
return status === 'auth-required' &&
43-
!(await localForage.getItem<string>('latest-auth-token')) &&
44-
!!self.registration.active;
45-
});
4618

4719
type PrecacheEntry = {
4820
url: string;
@@ -67,13 +39,6 @@ const precacheController = getPrecacheController();
6739
const precacheName = precacheController.strategy.cacheName;
6840

6941
async function precacheNewVersionIfSupported(event: ExtendableEvent) {
70-
if (await forceUpdateRequired) {
71-
// Don't bother precaching: we want to take over & then force kill/refresh everything ASAP
72-
self.skipWaiting();
73-
logError("Force update required on newly installed SW");
74-
return;
75-
}
76-
7742
await checkServerVersion();
7843

7944
// Any required as the install return types haven't been updated for v4, so still use 'updatedEntries'
@@ -84,15 +49,6 @@ async function precacheNewVersionIfSupported(event: ExtendableEvent) {
8449
async function checkServerVersion() {
8550
const serverVersion = await getServerVersion().catch(async (e) => {
8651
console.log("Failed to get server version. Fallback back to last version anyway...");
87-
88-
console.log(
89-
"Version unavailable but not forcing update, why?",
90-
"status", await serverStatus,
91-
"got token", !!(await localForage.getItem<string>('latest-auth-token')),
92-
"SW registrations", self.registration,
93-
"active SW", !!self.registration?.active
94-
);
95-
9652
logError(e);
9753

9854
// This isn't perfect, but it's a pretty good approximation of when it's safe to update
@@ -102,7 +58,7 @@ async function checkServerVersion() {
10258

10359
// This should never happen: the serverStatus checks should guarantee that we can
10460
// talk to the server in almost all cases, or that we have cached data. Fail & report it.
105-
throw new Error("No server version available, even though server check passed");
61+
throw new Error("No server version available in UI update worker");
10662
});
10763

10864
console.log(`Connected httptoolkit-server version is ${serverVersion}.`);
@@ -150,40 +106,17 @@ self.addEventListener('install', (event: ExtendableEvent) => {
150106
});
151107

152108
self.addEventListener('activate', async (event) => {
109+
console.log('Update worker activating...');
153110
event.waitUntil((async () => {
154111
console.log(`SW activating for version ${appVersion}`);
155112

156-
if (await forceUpdateRequired) {
157-
logError("Force update required on newly activated SW");
158-
159-
resettingSw = true; // Pass through all requests
160-
161-
// Take over and refresh all client pages:
162-
await self.clients.claim();
163-
const clients = await self.clients.matchAll({ type: 'window' });
164-
clients.map((client) => {
165-
console.log(`Refreshing ${client.url}`);
166-
return (client as WindowClient).navigate(client.url)
167-
.catch(async (e) => {
168-
// On the first error, try once more, after a brief delay. Sometimes
169-
// claim() might not process fast enough, and this is necessary.
170-
await delay(100);
171-
return (client as WindowClient).navigate(client.url);
172-
});
173-
});
174-
175-
// Unregister, so that future registration loads the SW & caches from scratch
176-
self.registration.unregister();
177-
console.log("SW forcibly refreshed");
178-
} else {
179-
// This can be removed only once we know that _nobody_ is using SWs from before 2019-05-09
180-
deleteOldWorkboxCaches();
181-
182-
await precacheController.activate(event);
183-
184-
// Delete the old (now unused) SW event logs
185-
indexedDB.deleteDatabase('keyval-store');
186-
}
113+
// This can be removed only once we know that _nobody_ is using SWs from before 2019-05-09
114+
deleteOldWorkboxCaches();
115+
116+
await precacheController.activate(event);
117+
118+
// Delete the old (now unused) SW event logs
119+
indexedDB.deleteDatabase('keyval-store');
187120
})());
188121
});
189122

0 commit comments

Comments
 (0)