Blank window on startup: early proxy stop() leaves stoppable's 500 ms destroyAll timer armed, killing the in-flight build/lens.js download
Hi, I ran into some issues with my Lens installation and asked Claude (Fable) to do some intensive debugging. I also asked it to use your bug template to make a report with its findings. If this is not allowed please feel free to delete the issue.
Describe the bug
On startup Lens shows the window frame, title bar and application menu, but the content area stays permanently blank. Restarting does not help, and nothing is surfaced to the user: no error dialog, no notification, no entry in lens-main.log.
Root cause (full trace under Additional context): the internal Lens proxy is wrapped with the stoppable module. During startup stop() is invoked on the proxy server while that server is still binding (server.address() returns null). The close() inside stop() is therefore a no-op — the server begins listening ~8 ms later and serves requests normally — but the 500 ms grace-period timer armed by stop() is never cancelled. When it fires, destroyAll destroys every socket owned by the (by then healthy) server, discarding whatever is queued in each socket's write buffer. The renderer's in-flight download of build/lens.js (29,587,435 bytes) is destroyed with writableLength = 29587823, so the renderer receives only a truncated prefix of the bundle, executes no application JavaScript, and #app stays empty.
Workaround: pressing ⌘R (View → Reload) in the blank window reliably renders the UI — the grace timer only fires once, so a reload after startup downloads the full bundle.
To Reproduce
This is a startup timing race (see Why it is timing-dependent below), so it reproduces per-machine rather than per-steps:
- Cold-start Lens (via Finder/Dock or the binary directly — no difference).
- The window opens; traffic lights and application menu render.
- The content area stays blank forever. On this machine: 100 % of cold starts, 12+ consecutive launches.
The underlying socket kill can be shown deterministically from outside the app. Taking Proxy server has started at 127.0.0.1:<port> in lens-main.log as t=0, requests for /build/lens.js were issued from an external client (Python, Host: lens.app, TLS verification off) at fixed offsets, all in the same launch:
start result ended
+0.05s ABORTED (IncompleteRead) +0.522s
+0.15s ABORTED (IncompleteRead) +0.529s
+0.25s ABORTED (IncompleteRead) +0.508s
+0.35s ABORTED (IncompleteRead) +0.502s
+0.45s ABORTED (IncompleteRead) +0.494s
+0.55s OK, 29587435 bytes +0.750sEvery in-flight connection dies within a ~35 ms band regardless of when it started — one single event, not a per-connection problem. Everything started after that point completes normally. The connections are closed gracefully (FIN, not RST): Python reports IncompleteRead(0 bytes read) and Chromium reports ERR_INCOMPLETE_CHUNKED_ENCODING rather than a connection reset.
Expected behavior
The renderer downloads the full build/lens.js and the UI renders. If the bundle cannot be loaded, an error should surface somewhere (dialog, notification, log entry) instead of a permanently blank window.
Screenshots
Window frame, traffic lights and application menu render normally; the content area is simply empty. Screenshot available on request.
Environment (please complete the following information):
- Lens Version:
2026.6.260931-latest(update channellatest, bundle IDcom.electron.kontena-lens) - OS: macOS 26.5.2 (build 25F84), arm64
- Installation method: downloaded .dmg
- Electron 42.4.0, Chrome 148.0.7778.254, V8 14.8.178.29
- Machine: 32 GB RAM (89 % free, no swap in use), 278 GB free disk
Logs:
lens-main.log contains no error. The only anomaly is that the initial window show hits its 10 s fallback timeout because the renderer never signals ready:
Running runnables for "after-application-is-loaded-injection-token" took 10097.05 ms.
- lens-core-main:show-initial-window 10072.25 msRenderer console for the failing load (inspected via --remote-debugging-port):
[error/network] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING
https://lens.app:<port>/build/lens.jsRenderer state after the failed load:
document.readyState complete
document.title '' (never set)
document.querySelectorAll('*').length 8
body children div#app, div#terminal-init, script
#app.childElementCount 0
scripts https://lens.app:<port>/build/lens.jsAdditional context
Root cause trace
I patched net.Server.prototype.listen, net.Server.prototype.close and net.Socket.prototype.destroy in the main process (method collapsed below) and captured this — timestamps relative to the patch, S1 is the one and only proxy server instance:
+1021ms CLOSE S1 address=null pendingSockets=0
at Server.close (node:https:128:3)
at Immediate._onImmediate (app.asar/static/build/main.js:703:97283) ← stop()
at process.processImmediate (node:internal/timers:504:21)
+1029ms LISTEN S1 127.0.0.1:64322 https=true ← 8 ms later
+1521ms DESTROY socket of S1 127.0.0.1:64322 local=64322 remote=64324
bytesWritten=29587823 writableLength=29587823
at app.asar/static/build/main.js:703:97461 ← destroyAll
at Map.forEach (<anonymous>)
at Immediate._onImmediate (app.asar/static/build/main.js:703:97444)
at process.processImmediate (node:internal/timers:504:21)Three things to note:
close()is called on a server whoseaddress()is stillnull. It has no effect — the server goes on to listen on 64322 and serves every subsequent request successfully. Sostop()here is effectively a no-op except for its side effect.+1021 ms → +1521 msis exactly 500 ms, thestoppablegrace period. The timer is never cleared, so it fires long after the server has become healthy.writableLength = 29587823— the full response is still queued in the socket whendestroy()is called.destroy()discards it, which is what the client observes as a truncated chunked body.
stoppable as shipped in the bundle, deobfuscated (static/build/main.js line 703, cols ~96700–97480)module.exports = (server, grace) => {
grace = typeof grace > 'u' ? Infinity : grace;
let sockets = new Map(), stopping = false, gracefully = true;
server instanceof https.Server
? server.on('secureConnection', onConnection)
: server.on('connection', onConnection);
server.on('request', onRequest);
server.stop = stop;
server._pendingSockets = sockets;
return server;
function onConnection(socket) {
sockets.set(socket, 0);
socket.once('close', () => sockets.delete(socket));
}
function onRequest(req, res) {
sockets.set(req.socket, sockets.get(req.socket) + 1);
res.once('finish', () => {
let pending = sockets.get(req.socket) - 1;
sockets.set(req.socket, pending);
stopping && pending === 0 && req.socket.end();
});
}
function stop(cb) {
setImmediate(() => {
stopping = true;
grace < Infinity && setTimeout(destroyAll, grace).unref(); // never cleared
server.close(err => { cb && cb(err, gracefully); });
sockets.forEach(endIfIdle);
});
}
function endIfIdle(pending, socket) { pending === 0 && socket.end(); }
function destroyAll() {
gracefully = false;
sockets.forEach((pending, socket) => socket.end());
setImmediate(() => { sockets.forEach((pending, socket) => socket.destroy()); });
}
};Why it is timing-dependent
The window content load starts at ≈ proxy+0.37 s and the 29.6 MB bundle takes ≈ 0.2 s to transfer, so it needs to finish by proxy+0.57 s — but destroyAll fires at proxy+0.51 s. It is a race lost by roughly 40–60 ms. On this machine the startup path before the window load is dominated by Syncing shell environment (spawning /bin/zsh, ~280 ms), which is why the download starts as late as it does. That also explains why users would see this intermittently rather than always: anything that shifts startup timing by a few tens of milliseconds flips the outcome.
Bytes actually received by the renderer
Expected 29587435 bytes; measured across separate cold starts via Resource Timing encodedBodySize and summed CDP Network.dataReceived:
| run | bytes received | share |
|---|---|---|
| 1 | 14,843,495 | 50.2 % |
| 2 | 18,546,279 | 62.7 % |
| 3 | 15,203,943 | 51.4 % |
| 4 | 13,516,391 | 45.7 % |
| 5 | 16,514,663 | 55.8 % |
| 6 | 18,939,495 | 64.0 % |
| 7 | 16,383,591 | 55.4 % |
| 8 | 19,168,871 | 64.8 % |
Response is 200, http/1.1, Transfer-Encoding: chunked, no Content-Length, Connection: keep-alive, Keep-Alive: timeout=5, fromDiskCache=false.
Ruled out
| Hypothesis | Result |
|---|---|
| Proxy cannot serve the file | Serves it fine when idle: 8 concurrent 30 MB requests → 8/8 complete, ~0.22 s each |
| Corrupt app bundle | codesign --verify --deep --strict /Applications/Lens.app → valid on disk |
| Chromium HTTP cache | fromDiskCache=false; fetch() with no-store / reload / default all return the full 29,587,435 bytes |
| Memory / disk pressure | 32 GB RAM, 89 % free, no swap, 278 GB free |
| Expired Lens ID session | Reproduces identically before and after reactivation (failing refresh: InvalidGrantError: Offline user session not found; successful refresh: Successfully got new TokenSet). Teardown time is unchanged. |
| Kubeconfig sync / duplicate contexts | Removing a stray ~/.kube/config.bak-* picked up as a duplicate context changed nothing: 0/3 cold starts rendered |
| Debugger interference | Reproduces with no debugger attached and with waitForDebuggerOnStart: false |
Suggested fixes
- Do not call
stop()on a server that is not listening. Guard the startup path sostop()/restart()is only invoked on an active server. - Cancel the grace timer. Keep a handle to
setTimeout(destroyAll, grace)andclearTimeoutit when the server is (re)started or whenclose()reportsERR_SERVER_NOT_RUNNING. This is the robust fix — it also protects against any other path that stops and restarts the proxy. - Make
destroyAlldrain-aware. Prefersocket.end()and onlydestroy()after'drain'/'close'or whenwritableLength === 0; destroying a socket with ~30 MB queued guarantees a corrupt response. - Fail loudly. Serving
build/lens.jswith aContent-Lengthwould let Chromium reject the truncated response outright, and a renderer-side watchdog ("bundle did not execute within N seconds") would turn a silent blank window into an actionable error. Today the only hint isshow-initial-windowquietly taking 10 s.
/Applications/Lens.app/Contents/MacOS/Lens --inspect-brk=9229Then over CDP against http://127.0.0.1:9229/json:
Debugger.enable,Runtime.enable,Runtime.runIfWaitingForDebugger- wait for
Debugger.paused, takecallFrames[0].callFrameId Debugger.evaluateOnCallFrame— evaluating in the paused top frame gives access torequire, sonet/http/https/tlscan be patched before any application code runs:
const net = require('net'), https = require('https'), fs = require('fs');
const origListen = net.Server.prototype.listen;
net.Server.prototype.listen = function (...a) {
const r = origListen.apply(this, a);
setImmediate(() => log('LISTEN', this.address()));
return r;
};
const origClose = net.Server.prototype.close;
net.Server.prototype.close = function (...a) {
log('CLOSE', this.address(), this._pendingSockets && this._pendingSockets.size, new Error().stack);
return origClose.apply(this, a);
};
const origDestroy = net.Socket.prototype.destroy;
net.Socket.prototype.destroy = function (...a) {
if (this.server) log('DESTROY', this.bytesWritten, this.writableLength, new Error().stack);
return origDestroy.apply(this, a);
};Debugger.resume, then issue requests for/build/lens.jsat fixed offsets after the proxy's listen port appears inlens-main.log.
Investigated and drafted with AI assistance; all figures above are measured on the machine described under Environment.
Source: lensapp/lens