Skip to content

Commit 7f1f61d

Browse files
morethanwordsclaude
andcommitted
Merge pull request #753 from morethanwords/fix/sw-mtproto-port-handshake
Service worker: a tab it lost the handshake with gets the MTProto port back, and streamed videos stop hanging Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents a6333b5 + e465c66 commit 7f1f61d

2 files changed

Lines changed: 169 additions & 6 deletions

File tree

‎src/lib/serviceWorker/index.service.ts‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -208,13 +208,10 @@ async function startupCheck() {
208208
windowClient
209209
);
210210

211-
let timedOut = false;
212-
const timeout = setTimeout(() => timedOut = true, 5000);
211+
// * a busy or frozen tab answers long after the worker has started, and is no less alive for it:
212+
// * nothing asks it again, so dropping a late answer left the worker with no MTProto port
213213
promise.finally(() => {
214-
if(!timedOut) {
215-
clearTimeout(timeout);
216-
onWindowConnected(windowClient, 'startup check');
217-
}
214+
onWindowConnected(windowClient, 'startup check');
218215
});
219216
} catch(err) {
220217
log.error('failed to send hello to window', windowClient.id, err);
@@ -274,6 +271,22 @@ watchCacheStoragesLifetime({
274271

275272
watchMtprotoOnDev({connectedWindows, onWindowConnected});
276273

274+
/**
275+
* A tab the worker does not count as connected - its startup answer was lost, or it was taken for gone -
276+
* still asks for files, and with no MTProto port they hang until the worker restarts. Asking says it is
277+
* alive, so take it back. Costs nothing while the port is there.
278+
*/
279+
const reconnectWindowIfNeeded = async(clientId: string) => {
280+
if(_mtprotoMessagePort || !clientId) {
281+
return;
282+
}
283+
284+
const client = await ctx.clients.get(clientId);
285+
if(client?.type === 'window' && !_mtprotoMessagePort) {
286+
onWindowConnected(client as WindowClient, 'fetch');
287+
}
288+
};
289+
277290
const onFetch = (event: FetchEvent): void => {
278291
// Web manifests must reach the network so installed PWA metadata can update.
279292
if(
@@ -289,6 +302,8 @@ const onFetch = (event: FetchEvent): void => {
289302
return;
290303
}
291304

305+
reconnectWindowIfNeeded(event.clientId);
306+
292307
try {
293308
// const [, url, scope, params] = /http[:s]+\/\/.*?(\/(.*?)(?:$|\/(.*)$))/.exec(event.request.url) || [];
294309
const [scope, _params] = event.request.url.split('/').slice(-2);
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* The service worker streams every file through a port of its own to the MTProto worker, handed over
3+
* by the first tab it counts as connected. A tab it does not count - one that answered the startup check
4+
* late, one it took for gone - must still get that port, or every video hangs until the worker restarts.
5+
*/
6+
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
7+
8+
const mocks = vi.hoisted(() => ({
9+
invoke: vi.fn(),
10+
invokeVoid: vi.fn(),
11+
getWindowClients: vi.fn()
12+
}));
13+
14+
vi.mock('@lib/serviceWorker/serviceMessagePort', () => ({
15+
default: class {
16+
addMultipleEventsListeners() {}
17+
attachPort() {}
18+
detachPort() {}
19+
invoke(...args: any[]) {
20+
return mocks.invoke(...args);
21+
}
22+
23+
invokeVoid(...args: any[]) {
24+
return mocks.invokeVoid(...args);
25+
}
26+
}
27+
}));
28+
vi.mock('@helpers/context', async(importOriginal) => ({
29+
...await importOriginal<typeof import('@helpers/context')>(),
30+
getWindowClients: mocks.getWindowClients
31+
}));
32+
vi.mock('@helpers/listenMessagePort', () => ({default: () => {}}));
33+
vi.mock('@lib/crypto/cryptoMessagePort', () => ({default: {attachPort: () => {}, detachPort: () => {}}}));
34+
vi.mock('@lib/serviceWorker/stream', () => ({default: () => {}, toggleStreamInUse: () => {}}));
35+
vi.mock('@lib/serviceWorker/cache', () => ({CACHE_ASSETS_NAME: 'assets', requestCache: () => {}}));
36+
vi.mock('@lib/serviceWorker/push', () => ({
37+
closeAllNotifications: () => {},
38+
fillPushObject: () => {},
39+
onPing: () => {},
40+
onShownNotification: () => {},
41+
resetPushAccounts: () => {}
42+
}));
43+
vi.mock('@lib/files/cacheStorage', () => ({default: class {}}));
44+
vi.mock('@lib/serviceWorker/clientAccounts', () => ({deleteClientAccount: () => {}, setClientAccount: () => {}}));
45+
vi.mock('@lib/serviceWorker/download', () => ({default: () => ({onDownloadFetch: () => {}, onClosedWindows: () => {}})}));
46+
vi.mock('@lib/serviceWorker/share', () => ({default: () => {}, checkWindowClientForDeferredShare: () => {}}));
47+
vi.mock('@lib/serviceWorker/rtmp', () => ({onRtmpFetch: () => {}, onRtmpLeftCall: () => {}}));
48+
vi.mock('@lib/hls/onHlsQualityFileFetch', () => ({onHlsQualityFileFetch: () => {}}));
49+
vi.mock('@lib/hls/onHlsStreamFetch', () => ({onHlsStreamFetch: () => {}}));
50+
vi.mock('@lib/hls/onHlsPlaylistFetch', () => ({onHlsPlaylistFetch: () => {}}));
51+
vi.mock('@lib/serviceWorker/backgrounds', () => ({onBackgroundsFetch: () => {}}));
52+
vi.mock('@lib/serviceWorker/watchMtprotoOnDev', () => ({watchMtprotoOnDev: () => {}}));
53+
vi.mock('@lib/serviceWorker/clearOldCache', () => ({watchCacheStoragesLifetime: () => {}}));
54+
vi.mock('@lib/passcode/keyStore', () => ({default: {save: () => {}, resetDeferred: () => {}}}));
55+
vi.mock('@lib/passcode/deferredIsUsingPasscode', () => ({default: {
56+
resolveDeferred: () => {},
57+
resetDeferred: () => {},
58+
isUsingPasscodeUndeferred: () => false
59+
}}));
60+
61+
const tab = {id: 'tab-1', type: 'window', frameType: 'top-level'};
62+
63+
const startWorker = () => import('@lib/serviceWorker/index.service');
64+
65+
const fetchFromTab = (clientId: string) => (self as any).onfetch({
66+
request: {url: 'https://web.telegram.org/k/stream/%7B%7D', mode: 'cors', headers: new Headers()},
67+
clientId,
68+
respondWith: () => {}
69+
});
70+
71+
const portsSentTo = (target: any) => mocks.invokeVoid.mock.calls.filter(([type, , source]) => type === 'port' && source === target);
72+
73+
describe('the service worker\'s MTProto port', () => {
74+
beforeEach(() => {
75+
vi.resetModules();
76+
vi.useFakeTimers();
77+
mocks.invoke.mockReset().mockReturnValue(new Promise(() => {}));
78+
mocks.invokeVoid.mockReset();
79+
mocks.getWindowClients.mockReset().mockResolvedValue([]);
80+
(self as any).clients = {get: vi.fn(async(id: string) => id === tab.id ? tab : undefined)};
81+
});
82+
83+
afterEach(() => {
84+
vi.useRealTimers();
85+
delete (self as any).clients;
86+
delete (self as any).onfetch;
87+
});
88+
89+
test('goes to a tab that answers the startup check late', async() => {
90+
let answer: () => void;
91+
mocks.invoke.mockImplementation((type: string) => type === 'hello' ?
92+
new Promise<void>((resolve) => answer = resolve) :
93+
new Promise(() => {}));
94+
mocks.getWindowClients.mockResolvedValue([tab]);
95+
96+
await startWorker();
97+
await vi.advanceTimersByTimeAsync(0);
98+
expect(mocks.invoke).toHaveBeenCalledWith('hello', undefined, undefined, tab);
99+
100+
// * the tab is busy or frozen while the worker starts, and answers well after it
101+
await vi.advanceTimersByTimeAsync(30e3);
102+
expect(portsSentTo(tab)).toHaveLength(0);
103+
answer();
104+
await vi.advanceTimersByTimeAsync(0);
105+
106+
expect(portsSentTo(tab), 'the late answer was dropped').toHaveLength(1);
107+
});
108+
109+
test('goes to a tab the worker does not count with its next request', async() => {
110+
await startWorker();
111+
await vi.advanceTimersByTimeAsync(0);
112+
113+
fetchFromTab(tab.id);
114+
await vi.advanceTimersByTimeAsync(0);
115+
116+
expect(portsSentTo(tab), 'the request hangs with no port').toHaveLength(1);
117+
expect(mocks.invokeVoid).toHaveBeenCalledWith('hello', undefined, tab);
118+
});
119+
120+
test('costs a connected tab\'s requests nothing and is not sent twice', async() => {
121+
await startWorker();
122+
await vi.advanceTimersByTimeAsync(0);
123+
fetchFromTab(tab.id);
124+
await vi.advanceTimersByTimeAsync(0);
125+
const getClient = (self as any).clients.get;
126+
getClient.mockClear();
127+
128+
fetchFromTab(tab.id);
129+
fetchFromTab(tab.id);
130+
await vi.advanceTimersByTimeAsync(0);
131+
132+
expect(getClient).not.toHaveBeenCalled();
133+
expect(portsSentTo(tab)).toHaveLength(1);
134+
});
135+
136+
test('is not handed to anything but a window', async() => {
137+
const worker = {id: 'worker-1', type: 'sharedworker'};
138+
(self as any).clients.get = vi.fn(async() => worker);
139+
await startWorker();
140+
await vi.advanceTimersByTimeAsync(0);
141+
142+
fetchFromTab(worker.id);
143+
fetchFromTab('');
144+
await vi.advanceTimersByTimeAsync(0);
145+
146+
expect(mocks.invokeVoid.mock.calls.filter(([type]) => type === 'port')).toHaveLength(0);
147+
});
148+
});

0 commit comments

Comments
 (0)