#8322·lens

Blank window on startup: early proxy stop() leaves stoppable's 500 ms destroyAll timer armed, killing the in-flight build/lens.js download

Author: zap-pyCreated Jul 27, 2026Updated Aug 20, 2026
Labelsbug

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:

  1. Cold-start Lens (via Finder/Dock or the binary directly — no difference).
  2. The window opens; traffic lights and application menu render.
  3. 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.750s

Every 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 channel latest, bundle ID com.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 ms

Renderer 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.js

Renderer 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.js

Additional 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:

  1. close() is called on a server whose address() is still null. It has no effect — the server goes on to listen on 64322 and serves every subsequent request successfully. So stop() here is effectively a no-op except for its side effect.
  2. +1021 ms → +1521 ms is exactly 500 ms, the stoppable grace period. The timer is never cleared, so it fires long after the server has become healthy.
  3. writableLength = 29587823 — the full response is still queued in the socket when destroy() 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)
javascript
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.appvalid 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

  1. Do not call stop() on a server that is not listening. Guard the startup path so stop()/restart() is only invoked on an active server.
  2. Cancel the grace timer. Keep a handle to setTimeout(destroyAll, grace) and clearTimeout it when the server is (re)started or when close() reports ERR_SERVER_NOT_RUNNING. This is the robust fix — it also protects against any other path that stops and restarts the proxy.
  3. Make destroyAll drain-aware. Prefer socket.end() and only destroy() after 'drain'/'close' or when writableLength === 0; destroying a socket with ~30 MB queued guarantees a corrupt response.
  4. Fail loudly. Serving build/lens.js with a Content-Length would 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 is show-initial-window quietly taking 10 s.
How the trace was captured
/Applications/Lens.app/Contents/MacOS/Lens --inspect-brk=9229

Then over CDP against http://127.0.0.1:9229/json:

  1. Debugger.enable, Runtime.enable, Runtime.runIfWaitingForDebugger
  2. wait for Debugger.paused, take callFrames[0].callFrameId
  3. Debugger.evaluateOnCallFrame — evaluating in the paused top frame gives access to require, so net/http/https/tls can be patched before any application code runs:
javascript
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);
};
  1. Debugger.resume, then issue requests for /build/lens.js at fixed offsets after the proxy's listen port appears in lens-main.log.

Investigated and drafted with AI assistance; all figures above are measured on the machine described under Environment.