Skip to content

Commit b9ef8b8

Browse files
authored
Merge pull request #46 from reclaimprotocol/fix/viewer-boot-watchdog-main
fix: retry stalled viewer module
2 parents 4df751c + a4a9acf commit b9ef8b8

7 files changed

Lines changed: 276 additions & 32 deletions

File tree

images/minimal-vnc-desktop/Dockerfile

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -271,20 +271,19 @@ RUN set -eux; \
271271
--minify-whitespace --minify-syntax \
272272
--outfile=/usr/share/novnc/viewer.bundle.js; \
273273
rm -rf /tmp/esbuild.tgz /tmp/esbuild-src /tmp/esbuild.lock; \
274-
# Content-hash the bundle so novnc-proxy can serve it immutably (max-age=1y):
275-
# a content change yields a NEW url, so a reconnect/reopen skips re-downloading
276-
# it entirely — with zero staleness risk (the reason liveview.html itself stays
277-
# no-store, pointing at whatever hash is current). Deterministic: the hash is of
278-
# esbuild's deterministic output. Rewrite the <script src> to the hashed name.
274+
# Immutable primary bundle plus a no-cache fallback; generate SRI from its bytes.
279275
bundle_hash="$(sha256sum /usr/share/novnc/viewer.bundle.js | cut -c1-12)"; \
276+
bundle_sri="$(openssl dgst -sha384 -binary /usr/share/novnc/viewer.bundle.js | base64 | tr -d '\n')"; \
280277
mv /usr/share/novnc/viewer.bundle.js "/usr/share/novnc/viewer-${bundle_hash}.bundle.js"; \
281-
sed -i "s#src=\"viewer\.bundle\.js\"#src=\"viewer-${bundle_hash}.bundle.js\"#" \
278+
cp "/usr/share/novnc/viewer-${bundle_hash}.bundle.js" "/usr/share/novnc/viewer-fallback-${bundle_hash}.bundle.js"; \
279+
sed -i "s#viewer\.bundle\.js#viewer-${bundle_hash}.bundle.js#g; s#viewer-fallback\.bundle\.js#viewer-fallback-${bundle_hash}.bundle.js#g; s#sha384-__VIEWER_BUNDLE_SRI__#sha384-${bundle_sri}#g" \
282280
/usr/share/novnc/liveview.html; \
283281
# Precompress the (hashed) bundle: it's the cold-start bottleneck over the
284282
# tunnel and novnc-proxy serves the .gz via Accept-Encoding negotiation. -n
285283
# drops the name/mtime from the gzip header so the output is deterministic;
286284
# -k keeps the raw file for clients without gzip.
287-
gzip -9 -n -k -f "/usr/share/novnc/viewer-${bundle_hash}.bundle.js"
285+
gzip -9 -n -k -f "/usr/share/novnc/viewer-${bundle_hash}.bundle.js" \
286+
"/usr/share/novnc/viewer-fallback-${bundle_hash}.bundle.js"
288287

289288
COPY third-party/fortress /usr/share/doc/popcorn/third-party/fortress
290289
COPY --chown=kernel:kernel extensions/proxy /home/kernel/extensions/proxy

images/minimal-vnc-desktop/host/popcorn-host.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,14 @@
685685
emit('hello', d);
686686
reportLayout('hello');
687687
break;
688+
// The preflight ran; the full viewer handshake may still fail.
689+
case 'POPCORN_BOOT': emit('boot', d); break;
690+
case 'POPCORN_BOOT_ERROR': emit('booterror', d); break;
691+
// The watchdog gave up waiting for the module. attempt/retrying separate a
692+
// session that RECOVERED on reload from one that never booted at all —
693+
// without it both look identical downstream, which is what made this class
694+
// of stall invisible for five days.
695+
case 'POPCORN_BOOT_STALL': emit('bootstall', d); break;
688696
case 'POPCORN_VIEWPORT': emit('viewport', d); break;
689697
case 'POPCORN_CONNECT': emit('connect', d); break;
690698
case 'POPCORN_FRAME':
@@ -847,7 +855,7 @@
847855
}
848856

849857
return {
850-
/** Subscribe to viewer events: hello|viewport|connect|frame|disconnect|error|kbdstate|inputdrift|interaction|health|scale|layout|rtt */
858+
/** Subscribe to viewer events: boot|booterror|bootstall|hello|viewport|connect|frame|disconnect|error|kbdstate|inputdrift|interaction|health|scale|layout|rtt */
851859
on: function (name, fn) {
852860
(listeners[name] || (listeners[name] = [])).push(fn);
853861
return this;

images/minimal-vnc-desktop/kbd/test/host-embed-layout.test.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,30 @@ test('the viewer\'s scale report is relayed UP to the embedder', () => {
293293
assert.ok(h.parentPosted.some((m) => m.type === 'POPCORN_SCALE'), 'and keeps it travelling up');
294294
});
295295

296+
test('boot diagnostics are emitted locally and relayed through a portal', () => {
297+
const h = makeHostWindow({ top: false, ...COMPLIANT });
298+
const host = h.PopcornHost.attach(h.iframe, { childOrigin: 'https://pod.test', relay: true });
299+
const boots = [];
300+
const errors = [];
301+
const stalls = [];
302+
host.on('boot', (d) => boots.push(d));
303+
host.on('booterror', (d) => errors.push(d));
304+
host.on('bootstall', (d) => stalls.push(d));
305+
306+
h.fromChild({ type: 'POPCORN_BOOT', unsupported: null, ua: 'viewer-ua' });
307+
h.fromChild({ type: 'POPCORN_BOOT_ERROR', msg: 'SyntaxError', src: 'liveview.html', line: 206 });
308+
h.fromChild({ type: 'POPCORN_BOOT_STALL', stage: 'module', attempt: 0, retrying: true });
309+
310+
assert.equal(boots.length, 1, 'the embedding page can observe that the viewer page ran');
311+
assert.equal(errors.length, 1, 'the embedding page can observe module evaluation failures');
312+
assert.equal(stalls.length, 1, 'the embedding page can observe a viewer that never booted');
313+
assert.equal(stalls[0].stage, 'module', 'startup stalls are distinct from RFB transport failures');
314+
assert.equal(stalls[0].retrying, true, 'the retry flag survives, so a recovery is distinguishable');
315+
assert.deepEqual(h.parentPosted.map((m) => m.type),
316+
['POPCORN_BOOT', 'POPCORN_BOOT_ERROR', 'POPCORN_BOOT_STALL'],
317+
'every diagnostic continues through a relay frame');
318+
});
319+
296320
test('keyboard health is relayed through a portal hop', () => {
297321
const h = makeHostWindow({ top: false, ...COMPLIANT });
298322
const host = h.PopcornHost.attach(h.iframe, { childOrigin: 'https://pod.test', relay: true });

images/minimal-vnc-desktop/liveview.html

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,14 +159,116 @@
159159
'This browser is too old to run the live viewer. Update your OS/browser, or open this link in the latest Safari or Chrome.'
160160
);
161161
}
162+
163+
// Report that the page ran before the viewer module completes its handshake.
164+
try {
165+
parent.postMessage({
166+
type: 'POPCORN_BOOT',
167+
unsupported: window.__viewerUnsupported || null,
168+
ua: navigator.userAgent
169+
}, '*');
170+
} catch (e) { /* no parent, or a parent that refuses '*' */ }
171+
172+
// Surface module evaluation failures to cross-origin embedders.
173+
if (window.addEventListener) {
174+
window.addEventListener('error', function (event) {
175+
// Runtime errors after startup belong to the viewer's own lifecycle,
176+
// not the boot-failure channel.
177+
if (window.__viewerBooted) return;
178+
try {
179+
parent.postMessage({
180+
type: 'POPCORN_BOOT_ERROR',
181+
msg: String((event && event.message) || ''),
182+
src: String((event && event.filename) || ''),
183+
line: (event && event.lineno) || 0
184+
}, '*');
185+
} catch (e) { /* see above */ }
186+
});
187+
}
188+
189+
// Retry the asset, not this document: the primary, a query-busted retry,
190+
// then a distinct no-cache fallback. Ten seconds avoids restarting slow boots.
191+
var BOOT_DEADLINE_MS = 10000;
192+
var bootAttempt = 0;
193+
var assetPaths = [
194+
'viewer.bundle.js',
195+
'viewer.bundle.js?__pcna=1',
196+
'viewer-fallback.bundle.js'
197+
];
198+
var assetScript;
199+
var assetSettled = false;
200+
201+
// Rewritten by the Docker build with hashed paths and SRI.
202+
var assetIntegrity = 'sha384-__VIEWER_BUNDLE_SRI__';
203+
204+
function reportStall(retrying) {
205+
try {
206+
parent.postMessage({
207+
type: 'POPCORN_BOOT_STALL', stage: 'asset', attempt: bootAttempt, retrying: retrying,
208+
path: assetPaths[bootAttempt]
209+
}, '*');
210+
} catch (e) { /* no parent, or a parent that refuses '*' */ }
211+
}
212+
213+
function terminalAssetFailure() {
214+
reportStall(false);
215+
window.__viewerOverlay(
216+
'Live view couldn\u2019t start',
217+
'The viewer didn\u2019t finish loading. Check your connection and try again.',
218+
function () { location.reload(); }
219+
);
220+
}
221+
222+
function loadViewerAsset() {
223+
if (window.__viewerBooted || assetSettled) return;
224+
if (bootAttempt >= assetPaths.length) {
225+
assetSettled = true;
226+
terminalAssetFailure();
227+
return;
228+
}
229+
if (assetScript && assetScript.parentNode) assetScript.parentNode.removeChild(assetScript);
230+
assetScript = document.createElement('script');
231+
assetScript.type = 'module';
232+
assetScript.src = assetPaths[bootAttempt];
233+
assetScript.integrity = assetIntegrity;
234+
assetScript.onerror = function () {
235+
if (window.__viewerBooted || assetSettled) return;
236+
try {
237+
parent.postMessage({
238+
type: 'POPCORN_BOOT_ERROR', msg: 'Viewer bundle request failed',
239+
src: assetPaths[bootAttempt], line: 0
240+
}, '*');
241+
} catch (e) { /* see above */ }
242+
if (bootAttempt + 1 >= assetPaths.length) {
243+
assetSettled = true;
244+
terminalAssetFailure();
245+
return;
246+
}
247+
reportStall(true);
248+
bootAttempt++;
249+
loadViewerAsset();
250+
};
251+
document.head.appendChild(assetScript);
252+
}
253+
254+
if (!window.__viewerUnsupported && window.setTimeout) {
255+
loadViewerAsset();
256+
setTimeout(function watchdog() {
257+
if (window.__viewerBooted || assetSettled) return;
258+
if (bootAttempt + 1 >= assetPaths.length) {
259+
assetSettled = true;
260+
terminalAssetFailure();
261+
return;
262+
}
263+
reportStall(true);
264+
bootAttempt++;
265+
loadViewerAsset();
266+
setTimeout(watchdog, BOOT_DEADLINE_MS);
267+
}, BOOT_DEADLINE_MS);
268+
}
162269
})();
163270
</script>
164271

165-
<!-- The entire viewer — noVNC core + the kbd/IME layer + this page's
166-
controller — is bundled by esbuild at build time into one file (see
167-
viewer.js + the Dockerfile bundler step), so it loads in a single request
168-
instead of a ~70-module ES-graph waterfall. Served no-store; a module
169-
script is deferred, so the classic preflight above runs first. -->
170-
<script type="module" src="viewer.bundle.js"></script>
272+
<!-- The asset loader above starts the content-addressed viewer module. -->
171273
</body>
172274
</html>

0 commit comments

Comments
 (0)