Skip to content

Commit a4a9acf

Browse files
committed
fix: recover viewer startup asset failures
1 parent 77e1e26 commit a4a9acf

5 files changed

Lines changed: 199 additions & 69 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/liveview.html

Lines changed: 71 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -186,62 +186,89 @@
186186
});
187187
}
188188

189-
// Reload a stalled viewer a bounded number of times; unsupported browsers skip it.
190-
// 10s, not something tighter. Worst realistic cold boot is ~114KB gzip over a bad
191-
// 3G link (~2.5s) plus parsing ~412KB of JS on a slow phone (~1-2s). A deadline
192-
// near that budget would reload devices that were merely SLOW — and since each
193-
// reload restarts the download, that makes those sessions strictly worse, not
194-
// better. Three attempts at 10s still gives up ~22s before the ~52s stream
195-
// timeout that this replaces.
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.
196191
var BOOT_DEADLINE_MS = 10000;
197-
var BOOT_MAX_RETRIES = 2;
198-
var pcnrMatch = /[?&]__pcnr=(\d+)/.exec(location.search);
199-
var bootAttempt = pcnrMatch ? (parseInt(pcnrMatch[1], 10) || 0) : 0;
200-
201-
// Preserve viewer parameters while updating the retry counter.
202-
function bootUrlForAttempt(n) {
203-
var base = location.href.split('#')[0].split('?')[0];
204-
var qs = location.search.replace(/^\?/, '');
205-
var keep = [];
206-
if (qs) {
207-
var parts = qs.split('&');
208-
for (var i = 0; i < parts.length; i++) {
209-
if (parts[i] && parts[i].indexOf('__pcnr=') !== 0) keep.push(parts[i]);
210-
}
211-
}
212-
if (n > 0) keep.push('__pcnr=' + n);
213-
return base + (keep.length ? '?' + keep.join('&') : '') + location.hash;
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 '*' */ }
214211
}
215212

216-
if (!window.__viewerUnsupported && window.setTimeout) {
217-
setTimeout(function () {
218-
if (window.__viewerBooted) return;
219-
var willRetry = bootAttempt < BOOT_MAX_RETRIES;
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;
220236
try {
221237
parent.postMessage({
222-
type: 'POPCORN_BOOT_STALL', stage: 'module', attempt: bootAttempt, retrying: willRetry
238+
type: 'POPCORN_BOOT_ERROR', msg: 'Viewer bundle request failed',
239+
src: assetPaths[bootAttempt], line: 0
223240
}, '*');
224-
} catch (e) { /* no parent, or a parent that refuses '*' */ }
225-
if (!willRetry) {
226-
window.__viewerOverlay(
227-
'Live view couldn\u2019t start',
228-
'The viewer didn\u2019t finish loading. Check your connection and try again.',
229-
function () { location.replace(bootUrlForAttempt(0)); }
230-
);
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();
231261
return;
232262
}
233-
// replace(), not assign(): a failed attempt must not become a history entry.
234-
location.replace(bootUrlForAttempt(bootAttempt + 1));
263+
reportStall(true);
264+
bootAttempt++;
265+
loadViewerAsset();
266+
setTimeout(watchdog, BOOT_DEADLINE_MS);
235267
}, BOOT_DEADLINE_MS);
236268
}
237269
})();
238270
</script>
239271

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

images/minimal-vnc-desktop/proxy/main.go

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@ import (
2323
"strconv"
2424
"strings"
2525
"sync"
26+
"sync/atomic"
2627
"time"
2728
)
2829

2930
const websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
3031

32+
var websocketBridgeSequence atomic.Uint64
33+
3134
func main() {
3235
listen := flag.String("listen", envDefault("NOVNC_LISTEN", ":6080"), "HTTP listen address")
3336
vnc := flag.String("vnc", envDefault("VNC_ADDR", "127.0.0.1:5900"), "upstream VNC address")
@@ -345,6 +348,9 @@ func staticHandler(root string, ready readyGate) http.HandlerFunc {
345348
// carries the current bundle hash, so it has to be re-read every load.
346349
base := path.Base(clean)
347350
switch {
351+
case strings.HasPrefix(base, "viewer-fallback-") && strings.HasSuffix(base, ".bundle.js"):
352+
// The recovery path must not inherit the primary's immutable cache.
353+
w.Header().Set("Cache-Control", "no-store, max-age=0, must-revalidate")
348354
case strings.HasPrefix(base, "viewer-") && strings.HasSuffix(base, ".bundle.js"):
349355
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
350356
case strings.HasSuffix(clean, "/liveview.html") || strings.HasSuffix(clean, "/kbd-autofocus.js") ||
@@ -987,32 +993,48 @@ func websocketKey() (string, error) {
987993
}
988994

989995
func proxyWebsocket(w http.ResponseWriter, r *http.Request, upstream string) {
996+
sid := websocketDiagSID(r)
990997
if !isWebsocketRequest(r) {
998+
if sid != "" {
999+
log.Printf("[websockify sid=%s] rejected: websocket upgrade required", sid)
1000+
}
9911001
http.Error(w, "websocket upgrade required", http.StatusBadRequest)
9921002
return
9931003
}
9941004

9951005
key := strings.TrimSpace(r.Header.Get("Sec-WebSocket-Key"))
9961006
if key == "" {
1007+
if sid != "" {
1008+
log.Printf("[websockify sid=%s] rejected: missing Sec-WebSocket-Key", sid)
1009+
}
9971010
http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest)
9981011
return
9991012
}
10001013

10011014
vncConn, err := net.DialTimeout("tcp", upstream, 5*time.Second)
10021015
if err != nil {
1016+
if sid != "" {
1017+
log.Printf("[websockify sid=%s] VNC dial %s failed: %v", sid, upstream, err)
1018+
}
10031019
http.Error(w, fmt.Sprintf("failed to connect to VNC upstream: %v", err), http.StatusBadGateway)
10041020
return
10051021
}
10061022

10071023
hijacker, ok := w.(http.Hijacker)
10081024
if !ok {
1025+
if sid != "" {
1026+
log.Printf("[websockify sid=%s] rejected: hijacking unsupported", sid)
1027+
}
10091028
_ = vncConn.Close()
10101029
http.Error(w, "hijacking unsupported", http.StatusInternalServerError)
10111030
return
10121031
}
10131032

10141033
clientConn, rw, err := hijacker.Hijack()
10151034
if err != nil {
1035+
if sid != "" {
1036+
log.Printf("[websockify sid=%s] client hijack failed: %v", sid, err)
1037+
}
10161038
_ = vncConn.Close()
10171039
return
10181040
}
@@ -1039,7 +1061,22 @@ func proxyWebsocket(w http.ResponseWriter, r *http.Request, upstream string) {
10391061
reader: rw.Reader,
10401062
vnc: vncConn,
10411063
}
1042-
bridge.run()
1064+
bridgeID := websocketBridgeSequence.Add(1)
1065+
if sid != "" {
1066+
log.Printf("[websockify sid=%s bridge=%d] upgraded upstream=%s", sid, bridgeID, upstream)
1067+
}
1068+
result := bridge.run()
1069+
if sid != "" {
1070+
if result.closeCode != 0 {
1071+
log.Printf("[websockify sid=%s bridge=%d] closed direction=%s code=%d reason=%q", sid, bridgeID, result.direction, result.closeCode, result.closeReason)
1072+
} else {
1073+
log.Printf("[websockify sid=%s bridge=%d] closed direction=%s err=%v", sid, bridgeID, result.direction, result.err)
1074+
}
1075+
}
1076+
}
1077+
1078+
func websocketDiagSID(r *http.Request) string {
1079+
return klogSanitize(r.URL.Query().Get("diag_sid"), 24)
10431080
}
10441081

10451082
func isWebsocketRequest(r *http.Request) bool {
@@ -1085,26 +1122,32 @@ type wsBridge struct {
10851122
mu sync.Mutex
10861123
}
10871124

1088-
func (b *wsBridge) run() {
1089-
done := make(chan struct{}, 2)
1125+
type wsBridgeResult struct {
1126+
direction string
1127+
err error
1128+
closeCode int
1129+
closeReason string
1130+
}
1131+
1132+
func (b *wsBridge) run() wsBridgeResult {
1133+
done := make(chan wsBridgeResult, 2)
10901134
stopPing := make(chan struct{})
10911135

10921136
go func() {
1093-
b.copyWebsocketToVNC()
1094-
done <- struct{}{}
1137+
done <- b.copyWebsocketToVNC()
10951138
}()
10961139

10971140
go func() {
1098-
b.copyVNCToWebsocket()
1099-
done <- struct{}{}
1141+
done <- b.copyVNCToWebsocket()
11001142
}()
11011143

11021144
go b.pingLoop(stopPing)
11031145

1104-
<-done
1146+
result := <-done
11051147
close(stopPing)
11061148
_ = b.client.Close()
11071149
_ = b.vnc.Close()
1150+
return result
11081151
}
11091152

11101153
// pingLoop sends server->client WS pings so idle-but-alive connections stay warm
@@ -1124,7 +1167,7 @@ func (b *wsBridge) pingLoop(stop <-chan struct{}) {
11241167
}
11251168
}
11261169

1127-
func (b *wsBridge) copyWebsocketToVNC() {
1170+
func (b *wsBridge) copyWebsocketToVNC() wsBridgeResult {
11281171
for {
11291172
// Refresh the read deadline on every frame (client keystrokes AND the
11301173
// auto-pongs to our pings count as liveness), so a silent half-open
@@ -1135,7 +1178,7 @@ func (b *wsBridge) copyWebsocketToVNC() {
11351178
continue // e.g. an enormous clipboard paste: drop the message, keep the session
11361179
}
11371180
if err != nil {
1138-
return
1181+
return wsBridgeResult{direction: "client-read", err: err}
11391182
}
11401183

11411184
switch opcode {
@@ -1144,39 +1187,48 @@ func (b *wsBridge) copyWebsocketToVNC() {
11441187
continue
11451188
}
11461189
if _, err := b.vnc.Write(payload); err != nil {
1147-
return
1190+
return wsBridgeResult{direction: "vnc-write", err: err}
11481191
}
11491192
case 0x8:
11501193
_ = b.writeFrame(0x8, payload)
1151-
return
1194+
code, reason := websocketCloseInfo(payload)
1195+
return wsBridgeResult{direction: "client-close", closeCode: code, closeReason: reason}
11521196
case 0x9:
11531197
_ = b.writeFrame(0xA, payload)
11541198
case 0xA:
11551199
default:
11561200
_ = b.writeFrame(0x8, []byte{0x03, 0xEA})
1157-
return
1201+
return wsBridgeResult{direction: "client-protocol", err: fmt.Errorf("unsupported opcode %d", opcode)}
11581202
}
11591203
}
11601204
}
11611205

1162-
func (b *wsBridge) copyVNCToWebsocket() {
1206+
func (b *wsBridge) copyVNCToWebsocket() wsBridgeResult {
11631207
buf := make([]byte, 32*1024)
11641208
for {
11651209
n, err := b.vnc.Read(buf)
11661210
if n > 0 {
11671211
if writeErr := b.writeFrame(0x2, buf[:n]); writeErr != nil {
1168-
return
1212+
return wsBridgeResult{direction: "client-write", err: writeErr}
11691213
}
11701214
}
11711215
if err != nil {
11721216
if !errors.Is(err, io.EOF) {
11731217
_ = b.writeFrame(0x8, nil)
11741218
}
1175-
return
1219+
return wsBridgeResult{direction: "vnc-read", err: err}
11761220
}
11771221
}
11781222
}
11791223

1224+
func websocketCloseInfo(payload []byte) (int, string) {
1225+
if len(payload) < 2 {
1226+
return 0, ""
1227+
}
1228+
code := int(binary.BigEndian.Uint16(payload[:2]))
1229+
return code, klogSanitize(string(payload[2:]), 120)
1230+
}
1231+
11801232
func (b *wsBridge) writeFrame(opcode byte, payload []byte) error {
11811233
_ = b.client.SetWriteDeadline(time.Now().Add(wsWriteDeadline))
11821234
return writeFrameToConn(b.client, &b.mu, opcode, payload, false, true)

0 commit comments

Comments
 (0)