Skip to content

Commit 77ed541

Browse files
morethanwordsclaude
andcommitted
Service worker: the MTProto port goes only to a tab that answers, never to the PiP window a request comes from
A request made while the worker had no MTProto port handed the port to the window that made it. With the client moved into a Document Picture-in-Picture window, every video is requested from that window, which has no listener: the port went there, nobody read it, and since the worker counted that window as connected, the real tab's next hello got no port either - streaming stayed stuck. - A window is connected once it answers hello, as the startup check already required: only the app's own tabs do. A request with no port runs that same check again over every window, so in PiP the port goes to the tab the app runs in - A window is asked once while its answer is awaited, and one look at the window list runs at a time: a window that never answers costs one pending hello, not one per request - A hello that fails no longer connects its window - The test reuses the shared deferred helper and one startup helper, and covers the PiP window and the single pending hello Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7f1f61d commit 77ed541

2 files changed

Lines changed: 79 additions & 62 deletions

File tree

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

Lines changed: 31 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -188,37 +188,44 @@ const {
188188
onClosedWindows: onDownloadClosedWindows
189189
} = handleDownload(serviceMessagePort);
190190

191+
const helloAwaiting: Set<string> = new Set();
192+
let checkingWindows: Promise<void>;
193+
191194
// * service worker can be killed, so won't get 'hello' event
192-
async function startupCheck() {
195+
// * a window is connected once it answers: only the app's tabs do, never a window with no app in it (the
196+
// * Document PiP window the app moves into), which would take the MTProto port with nobody to read it
197+
async function helloWindows(from: string) {
193198
const windowClients = await getWindowClients();
194199
const length = windowClients.length;
195-
log(`got ${length} windows from the start`);
200+
log(`got ${length} windows from`, from);
196201
windowClients.forEach((windowClient) => {
197202
if(windowClient.frameType === 'none') {
198203
log.warn('skipping bugged Safari starting window', windowClient.id);
199204
return;
200205
}
201206

207+
if(helloAwaiting.has(windowClient.id)) {
208+
return;
209+
}
210+
202211
log('checking window', windowClient.id);
203-
try {
204-
const promise = serviceMessagePort.invoke(
205-
'hello',
206-
undefined,
207-
undefined,
208-
windowClient
209-
);
210-
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
213-
promise.finally(() => {
214-
onWindowConnected(windowClient, 'startup check');
215-
});
216-
} catch(err) {
212+
helloAwaiting.add(windowClient.id);
213+
// * a busy or frozen tab answers long after it is asked, and is no less alive for it:
214+
// * dropping a late answer left the worker with no MTProto port
215+
serviceMessagePort.invoke('hello', undefined, undefined, windowClient).then(() => {
216+
onWindowConnected(windowClient, from);
217+
}, (err) => {
217218
log.error('failed to send hello to window', windowClient.id, err);
218-
}
219+
}).finally(() => {
220+
helloAwaiting.delete(windowClient.id);
221+
});
219222
});
220223
}
221-
startupCheck();
224+
225+
const checkWindows = (from: string) => checkingWindows ??= helloWindows(from).finally(() => {
226+
checkingWindows = undefined;
227+
});
228+
checkWindows('startup check');
222229

223230
const connectedWindows: Map<string, WindowClient> = new Map();
224231
(self as any).connectedWindows = connectedWindows;
@@ -271,22 +278,6 @@ watchCacheStoragesLifetime({
271278

272279
watchMtprotoOnDev({connectedWindows, onWindowConnected});
273280

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-
290281
const onFetch = (event: FetchEvent): void => {
291282
// Web manifests must reach the network so installed PWA metadata can update.
292283
if(
@@ -302,7 +293,12 @@ const onFetch = (event: FetchEvent): void => {
302293
return;
303294
}
304295

305-
reconnectWindowIfNeeded(event.clientId);
296+
// * a tab the worker does not count as connected - its startup answer was lost, or it was taken for gone -
297+
// * still asks for files, and with no MTProto port they hang until the worker restarts. The request may come
298+
// * from the PiP window rather than the tab its app runs in, so every window is asked again
299+
if(!_mtprotoMessagePort) {
300+
checkWindows('fetch');
301+
}
306302

307303
try {
308304
// const [, url, scope, params] = /http[:s]+\/\/.*?(\/(.*?)(?:$|\/(.*)$))/.exec(event.request.url) || [];

‎src/tests/serviceWorkerMtprotoPort.test.ts‎

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
/*
22
* The service worker streams every file through a port of its own to the MTProto worker, handed over
33
* 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.
4+
* late, one it took for gone - must still get that port, or every video hangs until the worker restarts;
5+
* and a window with no app in it to answer, the Document PiP window, must never get it.
56
*/
67
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
8+
import deferred from '@/tests/helpers/deferred';
79

810
const mocks = vi.hoisted(() => ({
911
invoke: vi.fn(),
@@ -59,15 +61,25 @@ vi.mock('@lib/passcode/deferredIsUsingPasscode', () => ({default: {
5961
}}));
6062

6163
const tab = {id: 'tab-1', type: 'window', frameType: 'top-level'};
64+
// * the Document PiP window the app moves into: a window of the origin, with no app of its own to answer
65+
const pip = {id: 'pip-1', type: 'window', frameType: 'auxiliary'};
6266

63-
const startWorker = () => import('@lib/serviceWorker/index.service');
67+
const startWorker = async() => {
68+
await import('@lib/serviceWorker/index.service');
69+
await vi.advanceTimersByTimeAsync(0);
70+
};
6471

65-
const fetchFromTab = (clientId: string) => (self as any).onfetch({
72+
const fetchFrom = (client: {id: string}) => (self as any).onfetch({
6673
request: {url: 'https://web.telegram.org/k/stream/%7B%7D', mode: 'cors', headers: new Headers()},
67-
clientId,
74+
clientId: client.id,
6875
respondWith: () => {}
6976
});
7077

78+
const answerHelloFrom = (...windows: any[]) => mocks.invoke.mockImplementation((type: string, payload: any, withAck: any, target: any) => {
79+
return type === 'hello' && windows.includes(target) ? Promise.resolve() : new Promise(() => {});
80+
});
81+
82+
const hellosSentTo = (target: any) => mocks.invoke.mock.calls.filter(([type, , , source]) => type === 'hello' && source === target);
7183
const portsSentTo = (target: any) => mocks.invokeVoid.mock.calls.filter(([type, , source]) => type === 'port' && source === target);
7284

7385
describe('the service worker\'s MTProto port', () => {
@@ -77,40 +89,36 @@ describe('the service worker\'s MTProto port', () => {
7789
mocks.invoke.mockReset().mockReturnValue(new Promise(() => {}));
7890
mocks.invokeVoid.mockReset();
7991
mocks.getWindowClients.mockReset().mockResolvedValue([]);
80-
(self as any).clients = {get: vi.fn(async(id: string) => id === tab.id ? tab : undefined)};
8192
});
8293

8394
afterEach(() => {
8495
vi.useRealTimers();
85-
delete (self as any).clients;
8696
delete (self as any).onfetch;
8797
});
8898

8999
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(() => {}));
100+
const answer = deferred();
101+
mocks.invoke.mockImplementation((type: string) => type === 'hello' ? answer.promise : new Promise(() => {}));
94102
mocks.getWindowClients.mockResolvedValue([tab]);
95103

96104
await startWorker();
97-
await vi.advanceTimersByTimeAsync(0);
98-
expect(mocks.invoke).toHaveBeenCalledWith('hello', undefined, undefined, tab);
105+
expect(hellosSentTo(tab)).toHaveLength(1);
99106

100107
// * the tab is busy or frozen while the worker starts, and answers well after it
101108
await vi.advanceTimersByTimeAsync(30e3);
102109
expect(portsSentTo(tab)).toHaveLength(0);
103-
answer();
110+
answer.resolve();
104111
await vi.advanceTimersByTimeAsync(0);
105112

106113
expect(portsSentTo(tab), 'the late answer was dropped').toHaveLength(1);
107114
});
108115

109116
test('goes to a tab the worker does not count with its next request', async() => {
110117
await startWorker();
111-
await vi.advanceTimersByTimeAsync(0);
118+
answerHelloFrom(tab);
119+
mocks.getWindowClients.mockResolvedValue([tab]);
112120

113-
fetchFromTab(tab.id);
121+
fetchFrom(tab);
114122
await vi.advanceTimersByTimeAsync(0);
115123

116124
expect(portsSentTo(tab), 'the request hangs with no port').toHaveLength(1);
@@ -119,30 +127,43 @@ describe('the service worker\'s MTProto port', () => {
119127

120128
test('costs a connected tab\'s requests nothing and is not sent twice', async() => {
121129
await startWorker();
130+
answerHelloFrom(tab);
131+
mocks.getWindowClients.mockResolvedValue([tab]);
132+
fetchFrom(tab);
122133
await vi.advanceTimersByTimeAsync(0);
123-
fetchFromTab(tab.id);
124-
await vi.advanceTimersByTimeAsync(0);
125-
const getClient = (self as any).clients.get;
126-
getClient.mockClear();
134+
mocks.getWindowClients.mockClear();
127135

128-
fetchFromTab(tab.id);
129-
fetchFromTab(tab.id);
136+
fetchFrom(tab);
137+
fetchFrom(tab);
130138
await vi.advanceTimersByTimeAsync(0);
131139

132-
expect(getClient).not.toHaveBeenCalled();
140+
expect(mocks.getWindowClients).not.toHaveBeenCalled();
133141
expect(portsSentTo(tab)).toHaveLength(1);
134142
});
135143

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);
144+
test('goes to the tab whose app answers, not to the PiP window the request comes from', async() => {
139145
await startWorker();
146+
answerHelloFrom(tab);
147+
mocks.getWindowClients.mockResolvedValue([pip, tab]);
148+
149+
fetchFrom(pip);
140150
await vi.advanceTimersByTimeAsync(0);
141151

142-
fetchFromTab(worker.id);
143-
fetchFromTab('');
152+
expect(portsSentTo(pip), 'nobody reads the port there').toHaveLength(0);
153+
expect(portsSentTo(tab)).toHaveLength(1);
154+
});
155+
156+
test('asks a window once while its answer is awaited', async() => {
157+
await startWorker();
158+
mocks.getWindowClients.mockResolvedValue([pip]);
159+
160+
fetchFrom(pip);
161+
await vi.advanceTimersByTimeAsync(0);
162+
fetchFrom(pip);
163+
fetchFrom(pip);
144164
await vi.advanceTimersByTimeAsync(0);
145165

166+
expect(hellosSentTo(pip)).toHaveLength(1);
146167
expect(mocks.invokeVoid.mock.calls.filter(([type]) => type === 'port')).toHaveLength(0);
147168
});
148169
});

0 commit comments

Comments
 (0)