Skip to content

Commit 1d3053f

Browse files
Adi1231234claude
andcommitted
fix: make inject() resilient to page navigation during initialization
Replace manual evaluate-based polling loops with waitForFunction, which natively survives execution context destruction caused by page navigation (e.g. Chrome's internal IndexedDB recovery after system sleep/resume). Also move the framenavigated listener registration before the initial inject() call, so navigation events during inject are handled by the existing listener. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 807efd5 commit 1d3053f

3 files changed

Lines changed: 468 additions & 38 deletions

File tree

src/Client.js

Lines changed: 22 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -112,27 +112,14 @@ class Client extends EventEmitter {
112112
* Private function
113113
*/
114114
async inject() {
115-
if (
116-
this.options.authTimeoutMs === undefined ||
117-
this.options.authTimeoutMs == 0
118-
) {
119-
this.options.authTimeoutMs = 30000;
120-
}
121-
let start = Date.now();
122-
let timeout = this.options.authTimeoutMs;
123-
let res = false;
124-
while (start > Date.now() - timeout) {
125-
res = await this.pupPage.evaluate(
126-
'window.Debug?.VERSION != undefined',
127-
);
128-
if (res) {
129-
break;
130-
}
131-
await new Promise((r) => setTimeout(r, 200));
132-
}
133-
if (!res) {
134-
throw 'auth timeout';
135-
}
115+
const authTimeout = this.options.authTimeoutMs || 30000;
116+
await this.pupPage
117+
.waitForFunction('window.Debug?.VERSION != undefined', {
118+
timeout: authTimeout,
119+
})
120+
.catch(() => {
121+
throw 'auth timeout';
122+
});
136123
await this.setDeviceName(
137124
this.options.deviceName,
138125
this.options.browserName,
@@ -320,27 +307,24 @@ class Client extends EventEmitter {
320307
webCacheOptions,
321308
);
322309

323-
await webCache.persist(this.currentIndexHtml, version);
310+
await webCache.persist(
311+
this.currentIndexHtml,
312+
version,
313+
);
324314
}
325315

326316
//Load util functions (serializers, helper functions)
327317
await this.pupPage.evaluate(LoadUtils);
328318

329-
let start = Date.now();
330-
let res = false;
331-
while (start > Date.now() - 30000) {
332-
// Check window.WWebJS Injection
333-
res = await this.pupPage.evaluate(
319+
// Check window.WWebJS Injection
320+
await this.pupPage
321+
.waitForFunction(
334322
'window.WWebJS != undefined',
335-
);
336-
if (res) {
337-
break;
338-
}
339-
await new Promise((r) => setTimeout(r, 200));
340-
}
341-
if (!res) {
342-
throw 'ready timeout';
343-
}
323+
{ timeout: 30000 },
324+
)
325+
.catch(() => {
326+
throw 'ready timeout';
327+
});
344328

345329
/**
346330
* Current connection information
@@ -490,8 +474,6 @@ class Client extends EventEmitter {
490474
referer: 'https://whatsapp.com/',
491475
});
492476

493-
await this.inject();
494-
495477
this.pupPage.on('framenavigated', async (frame) => {
496478
if (frame.url().includes('post_logout=1') || this.lastLoggedOut) {
497479
this.emit(Events.DISCONNECTED, 'LOGOUT');
@@ -502,6 +484,8 @@ class Client extends EventEmitter {
502484
}
503485
await this.inject();
504486
});
487+
488+
await this.inject();
505489
}
506490

507491
/**

tests/ab-comparison.js

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* A/B Comparison: Old inject vs New inject during navigation
3+
*
4+
* Reproduces the exact error: "Execution context was destroyed"
5+
*
6+
* Uses a local HTTP server to serve real pages with working scripts.
7+
* Navigation is triggered from Node.js (same effect as Chrome's internal navigation).
8+
*/
9+
10+
const http = require('http');
11+
const puppeteer = require('puppeteer');
12+
const chai = require('chai');
13+
const expect = chai.expect;
14+
15+
// Page that sets Debug.VERSION after a delay
16+
function makePage(delayMs) {
17+
return `<html><body><div id="app">WhatsApp Web</div>
18+
<script>
19+
setTimeout(function() {
20+
window.Debug = { VERSION: '2.3000.0' };
21+
}, ${delayMs});
22+
</script>
23+
</body></html>`;
24+
}
25+
26+
// Old inject: manual polling with page.evaluate (commit 6f909bc, lines 105-112)
27+
async function oldInjectPolling(page, timeout = 10000) {
28+
const start = Date.now();
29+
let res = false;
30+
while (start > (Date.now() - timeout)) {
31+
res = await page.evaluate('window.Debug?.VERSION != undefined');
32+
if (res) break;
33+
await new Promise(r => setTimeout(r, 200));
34+
}
35+
if (!res) throw new Error('auth timeout');
36+
return true;
37+
}
38+
39+
// New inject: waitForFunction (current fork main, line 98)
40+
async function newInjectPolling(page, timeout = 10000) {
41+
await page.waitForFunction('window.Debug?.VERSION != undefined', { timeout });
42+
return true;
43+
}
44+
45+
describe('A/B: Old vs New inject during navigation', function () {
46+
this.timeout(30000);
47+
let browser;
48+
let server;
49+
let serverUrl;
50+
51+
before(async function () {
52+
// Start local HTTP server
53+
server = http.createServer((req, res) => {
54+
res.writeHead(200, { 'Content-Type': 'text/html' });
55+
// Page sets Debug.VERSION after 800ms
56+
res.end(makePage(800));
57+
});
58+
await new Promise(resolve => {
59+
server.listen(0, '127.0.0.1', () => {
60+
serverUrl = `http://127.0.0.1:${server.address().port}`;
61+
resolve();
62+
});
63+
});
64+
65+
browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] });
66+
});
67+
68+
after(async function () {
69+
if (browser) await browser.close();
70+
if (server) await new Promise(resolve => server.close(resolve));
71+
});
72+
73+
// ──────────────────────────────────────────────────────────
74+
// A: page.evaluate FAILS when context is destroyed (deterministic proof)
75+
// ──────────────────────────────────────────────────────────
76+
it('A (PROOF): page.evaluate throws "context destroyed" during navigation', async function () {
77+
const page = await browser.newPage();
78+
try {
79+
await page.goto(serverUrl, { waitUntil: 'load' });
80+
81+
// Start a long-running evaluate (simulates an evaluate in-flight during navigation)
82+
const evalPromise = page.evaluate(async () => {
83+
await new Promise(r => setTimeout(r, 5000));
84+
return window.Debug?.VERSION;
85+
});
86+
87+
// Navigate while evaluate is running (like IndexedDB recovery)
88+
await new Promise(r => setTimeout(r, 300));
89+
await page.goto(serverUrl, { waitUntil: 'load' });
90+
91+
let error = null;
92+
try {
93+
await evalPromise;
94+
} catch (err) {
95+
error = err;
96+
}
97+
98+
// evaluate should have thrown with context-destroyed
99+
expect(error).to.not.be.null;
100+
expect(error.message.toLowerCase()).to.satisfy(msg =>
101+
msg.includes('context') ||
102+
msg.includes('navigat') ||
103+
msg.includes('detach') ||
104+
msg.includes('target')
105+
);
106+
console.log(' [A] page.evaluate threw:', error.message);
107+
} finally {
108+
await page.close();
109+
}
110+
});
111+
112+
// ──────────────────────────────────────────────────────────
113+
// B: NEW CODE - waitForFunction SURVIVES navigation
114+
// ──────────────────────────────────────────────────────────
115+
it('B (NEW CODE): waitForFunction SURVIVES navigation', async function () {
116+
const page = await browser.newPage();
117+
try {
118+
await page.goto(serverUrl, { waitUntil: 'load' });
119+
120+
// Start new-style polling
121+
const pollPromise = newInjectPolling(page, 15000);
122+
123+
// Same navigation trigger
124+
await new Promise(r => setTimeout(r, 300));
125+
page.evaluate(() => {
126+
window.location.reload();
127+
}).catch(() => {});
128+
129+
// Should survive and resolve
130+
const result = await pollPromise;
131+
expect(result).to.equal(true);
132+
133+
const version = await page.evaluate('window.Debug.VERSION');
134+
expect(version).to.equal('2.3000.0');
135+
console.log(' [B] Survived navigation! Got version:', version);
136+
} finally {
137+
await page.close();
138+
}
139+
});
140+
141+
// ──────────────────────────────────────────────────────────
142+
// C: FULL FIX - both mechanisms together
143+
// ──────────────────────────────────────────────────────────
144+
it('C (FULL FIX): framenavigated + waitForFunction', async function () {
145+
const page = await browser.newPage();
146+
try {
147+
let framenavigatedCount = 0;
148+
let injectViaListenerOk = false;
149+
150+
// Register BEFORE inject (our fix)
151+
page.on('framenavigated', async () => {
152+
framenavigatedCount++;
153+
try {
154+
await page.waitForFunction('window.Debug?.VERSION != undefined', { timeout: 10000 });
155+
await page.evaluate('window.Debug.VERSION');
156+
injectViaListenerOk = true;
157+
} catch (e) { /* ignore */ }
158+
});
159+
160+
await page.goto(serverUrl, { waitUntil: 'load' });
161+
162+
const pollPromise = newInjectPolling(page, 15000);
163+
164+
// Navigation mid-inject
165+
await new Promise(r => setTimeout(r, 300));
166+
page.evaluate(() => {
167+
window.location.reload();
168+
}).catch(() => {});
169+
170+
await pollPromise;
171+
await new Promise(r => setTimeout(r, 2000));
172+
173+
expect(framenavigatedCount).to.be.greaterThan(0);
174+
expect(injectViaListenerOk).to.equal(true);
175+
console.log(' [C] framenavigated:', framenavigatedCount, '| inject via listener:', injectViaListenerOk);
176+
} finally {
177+
await page.close();
178+
}
179+
});
180+
181+
// ──────────────────────────────────────────────────────────
182+
// D: SANITY - both work WITHOUT navigation
183+
// ──────────────────────────────────────────────────────────
184+
it('D (SANITY): both work when there is no navigation', async function () {
185+
const page1 = await browser.newPage();
186+
try {
187+
await page1.goto(serverUrl, { waitUntil: 'load' });
188+
await oldInjectPolling(page1, 10000);
189+
console.log(' [D] Old code works without navigation');
190+
} finally {
191+
await page1.close();
192+
}
193+
194+
const page2 = await browser.newPage();
195+
try {
196+
await page2.goto(serverUrl, { waitUntil: 'load' });
197+
await newInjectPolling(page2, 10000);
198+
console.log(' [D] New code works without navigation');
199+
} finally {
200+
await page2.close();
201+
}
202+
});
203+
});

0 commit comments

Comments
 (0)