跳到正文
DEV Community· LastBrowser·· 3 小時前AI 評分47

桌面應用程式 30 秒啟動逾時:一把被忽略的鎖如何阻塞狀態檢查

A 30-Second Startup Timeout Caused by a Lock Nobody Was Looking At

AI 導讀

桌面應用程式在部分 Windows 電腦上,因 OAuth 更新持有的憑證檔案鎖阻塞啟動狀態檢查,導致 30 秒逾時;修正已納入版本 0.1.60。兩種狀態檢查改用獨立執行緒池,逾時後回傳 503 和 Retry-After;Electron shell 亦會先等待後端健康檢查通過。

正文

Our desktop app has a startup gate. Before the browser window shows anything useful, the Electron main process asks the bundled Python backend one small question: is authentication enabled, and is this user logged in? It is the cheapest request in the whole app. It reads a setting and checks a session cookie.

On some Windows machines, that question never got an answer. Users saw a blank startup screen and then this:

Startup could not be completed. Error invoking remote method
'lastbrowser:sidekick:getAccessAuthStatus': Error: Sidekick API request
timed out after 30000 ms.

In the worst reports it happened on every start, even after killing every process and starting again. This post is about how a trivial status check got stuck behind slow work it had nothing to do with, the fix we shipped in version 0.1.60, and the parts we still could not prove.

The setup

The app has two halves. The Electron shell is the browser. A Python backend (FastAPI behind uvicorn) runs the AI assistant, which we call Sidekick, and listens on a local port. On startup the shell calls GET /api/auth/status and blocks until it gets an answer or 30 seconds pass.

Some older routes, including that one, were not served by FastAPI directly. They went through a legacy in-process bridge that dispatches requests onto a bounded thread pool. That pool is shared by every bridged request.

Symptom: a server that accepts but never answers

The most useful evidence came from comparing two startup logs from the same machine, one start that worked and one that hung.

Both logs were identical up to the same line, where the backend had finished preparing its static assets. In the working start, the next thing that happened was an OAuth token refresh against the model provider, and then everything continued normally. In the hanging start, nothing came next. No more log lines, and not a single outgoing network connection.

The backend was still alive in a narrow sense. It accepted TCP connections on its port, but it never replied to an HTTP request, and those connections piled up in CLOSE_WAIT. So "is the process running?" said yes, while "can it answer?" said no.

First theory: pool starvation

Our first draft fix (it never merged on its own) blamed the bridge pool. If one early request stalls, for example provider or OAuth resolution, a scan of available models, or a prewarm of the free chat model, it holds a worker. A few of those and the pool is exhausted. After that, even the trivial auth probe cannot get a thread and waits until the shell gives up at 30 seconds.

The idea was simple: answer /api/auth/status natively in FastAPI and skip the bridge entirely. That part was right, and it is in the final fix. But it was not enough.

The actual cause: one lock, many waiters

Looking closer at what the startup path really waits on, we found that the shell's startup flow also polls an onboarding status route. That route assembles its answer from synchronous filesystem reads, stored provider credentials, and model catalog discovery, including a check for one cloud provider's configuration.

The credential part was the key. Refreshing an OAuth token holds a lock on the stored auth file for the duration of the refresh. Everything that wants to read credentials waits behind that lock, and the model scan and the provider check queue up behind it too. If the refresh hangs, the status route hangs, and it can hang for minutes.

This also explains why a native fast path alone would only move the problem. The auth probe itself reads settings and the session store. If those reads block on the same state, a native route just waits in a different place. The fix had to put a time limit on the waiting itself, not only change who does the waiting.

The fix, in three parts

1. Status checks get their own lanes and deadlines

Both status routes now run their blocking work in small dedicated thread pools, one per kind of check, so a stuck onboarding status cannot starve the auth probe. Each pool caps how many calls can be in flight, so repeated polling cannot pile up unlimited threads. If a call misses its deadline (5 seconds for auth status, 20 seconds for onboarding status, both configurable), the route returns a 503 with Retry-After instead of hanging.

The core of it is short:

class _ProbeLane:
    def __init__(self, name: str, max_inflight: int) -> None:
        self.name = name
        self.max_inflight = max_inflight
        self._lock = threading.Lock()
        self._inflight = 0
        self._executor = None

    async def run(self, fn, *, timeout: float):
        with self._lock:
            if self._inflight >= self.max_inflight:
                raise ProbeBusyError(f"{self.name} probe workers are busy")
            self._inflight += 1
        ctx = contextvars.copy_context()

        def _run():
            try:
                return ctx.run(fn)
            finally:
                with self._lock:
                    self._inflight -= 1

        loop = asyncio.get_running_loop()
        future = loop.run_in_executor(self._get_executor(), _run)
        return await asyncio.wait_for(future, timeout=timeout)

(The real version also handles a failure while submitting the job and creates the executor lazily.)

Two details matter here. First, contextvars.copy_context() keeps request context, such as the selected user profile, which the old asyncio.to_thread call also preserved. Second, and this is the honest part, asyncio.wait_for does not kill the stuck thread. Python threads cannot be cancelled from outside. The deadline stops the waiting, not the work. A stuck worker keeps running until it finishes, and that is exactly why the in-flight cap exists: without it, every poll would leave another stuck thread behind.

We also kept the auth probe failing closed. If the auth setting cannot be read, the probe reports auth as enabled, so the user sees a login gate rather than silently getting access.

2. The shell waits for health, with backoff

On the Electron side, the shell now waits for the backend's /health endpoint before asking anything else. It polls with exponential backoff, starting at 150 ms and growing by 1.5x up to 1.5 seconds, so a fast start is noticed almost immediately and a slow cold start does not spin.

When the auth check fails, the error message now says what actually happened. If /health also fails, the user is told that the backend is not responding on its port, that a previous process may be hung, and that it is being restarted. If /health works but the auth check does not, the message says that instead. "Timed out after 30000 ms" told nobody anything.

3. Recovering a hung backend on the port

Before this change, when the preferred port was taken, a new start quietly moved to the next free port. That sounds robust, but it meant a hung process from an earlier session kept its listener forever while new starts piled up next to it.

Now, before starting the backend, the shell checks the preferred port. If something is listening and answering health checks, it is used. If something is listening but not answering, the shell looks up the process ID (via netstat -ano on Windows and ss -ltnp on Linux), reads its command line, and only terminates it if the command line identifies it as our own backend. Anything else is left alone and reported as a foreign process.

One small lesson from the parsing code: on Windows the state column in netstat output is localized, so matching the word LISTENING breaks on non-English systems. We identify listener rows by a foreign address ending in :0 instead, which does not depend on the language.

What we could not prove

We want to be clear about the limits of this fix.

  • One variant of the report was a completely frozen event loop, where even GET / timed out. We could not reproduce that in our test container. Diagnosing it properly would need a stack dump (for example from py-spy or faulthandler) taken on an affected Windows machine. The deadlines above do not help if the event loop itself is blocked.
  • At merge time, the Windows path that finds and kills the hung process had not been tested on Windows. It has unit tests for the parsing and the decision logic, but not a run against real processes.
  • The fix makes startup answer quickly and recover. It does not make a hanging OAuth refresh finish faster.

The change shipped with 11 new Python tests, and the affected desktop test suites passed.

What we took away

  1. A process that accepts connections is not a healthy process. Check whether it answers, not whether it exists.
  2. Cheap requests are not cheap if they share a queue with expensive ones. Isolate the checks that a startup screen depends on.
  3. A timeout on an await is not a cancellation. In Python, the thread keeps going. Cap how many of them can be stuck at once.
  4. Error messages are part of the fix. "Not responding on port X, restarting it" gives users and us something to act on.
  5. Write down what you could not reproduce. It keeps the next person from assuming the problem is fully gone.

We build LastBrowser, a desktop browser with a built-in AI assistant. The code for this fix is in the GitHub repository.

來源:DEV Community · dev.to