From c29291d7b14094cee0be02cdf91b36c22b7ea266 Mon Sep 17 00:00:00 2001 From: "hugh.li" Date: Wed, 10 Jun 2026 17:54:46 +0800 Subject: [PATCH 1/2] fix: popup hangs blank on browser cold start (#276) On Manifest V3 the background service worker may still be asleep or initializing when the popup is opened right after a browser restart. The popup's first getState message then fails with chrome.runtime.lastError and no response is delivered. The omegaTarget.state() wrapper only resolved its deferred inside .then(), with no rejection handler, so a single missed response left the deferred unsettled forever: the popup stayed blank, showing only the toolbar icon, with no way to recover except reloading the extension. - Retry callBackground() a few times with a small backoff when sendMessage fails with chrome.runtime.lastError, to ride out the service-worker wake-up race. - Propagate rejections in omegaTarget.state() so a permanently failed call rejects the promise instead of hanging silently. Closes #276 --- .../src/coffee/omega_target_web.coffee | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/omega-target-chromium-extension/src/coffee/omega_target_web.coffee b/omega-target-chromium-extension/src/coffee/omega_target_web.coffee index 4e5ca6d8..666a871c 100644 --- a/omega-target-chromium-extension/src/coffee/omega_target_web.coffee +++ b/omega-target-chromium-extension/src/coffee/omega_target_web.coffee @@ -34,18 +34,32 @@ angular.module('omegaTarget', []).factory 'omegaTarget', ($q) -> }) callBackground = (method, args...) -> d = $q['defer']() - chrome.runtime.sendMessage({ - method: method - args: args - }, (response) -> - if chrome.runtime.lastError? - d.reject(chrome.runtime.lastError) - return - if response.error - d.reject(decodeError(response.error)) - else - d.resolve(response.result) - ) + # On Manifest V3 the background service worker may be asleep or still + # initializing when the popup is opened right after a browser restart. + # In that case sendMessage fails with chrome.runtime.lastError (e.g. + # "Could not establish connection" or "The message port closed before a + # response was received") and no response is ever delivered. Without a + # retry the popup would hang forever showing only the toolbar icon, so + # retry a few times with a small backoff to ride out the wake-up race. + maxAttempts = 5 + send = (attempt) -> + chrome.runtime.sendMessage({ + method: method + args: args + }, (response) -> + if chrome.runtime.lastError? + if attempt < maxAttempts + setTimeout((-> send(attempt + 1)), 100 * attempt) + else + d.reject(chrome.runtime.lastError) + return + if response?.error + d.reject(decodeError(response.error)) + else + d.resolve(response?.result) + ) + return + send(1) return d.promise connectBackground = (name, message, callback) -> port = chrome.runtime.connect({name: name}) @@ -73,17 +87,17 @@ angular.module('omegaTarget', []).factory 'omegaTarget', ($q) -> if Array.isArray(name) callBackground('getState', name).then((values) -> d.resolve(name.map((key) -> values[key])) - ) + , d.reject) else callBackground('getState', [name]).then( (values) -> d.resolve(values[name]) - ) + , d.reject) else newItem = {} newItem[name] = value callBackground('setState', newItem).then( -> d.resolve(value) - ) + , d.reject) return d.promise lastUrl: (url) -> name = 'web.last_url' From 7aee87515f8f4fc74d97f02d3b094e807e621356 Mon Sep 17 00:00:00 2001 From: "hugh.li" Date: Wed, 10 Jun 2026 18:39:11 +0800 Subject: [PATCH 2/2] fix: apply a profile on cold start even if a storage read stalls (#276) On a service-worker cold start, Options#init awaited a state (IndexedDB) read BEFORE applying a profile. When that read stalls (which happens on cold starts), options.ready never resolved: applyProfile never ran so the proxy was never configured, and the popup never got a getState reply so it stayed blank ("only an icon"), until the extension was reloaded by hand. Wrap startup in a timeout. If loadOptions plus applying the initial profile does not finish in time, fall back to applying the configured startup profile (or the fallback profile) so the proxy gets set and ready resolves. Add a deterministic regression test that simulates a stalling state read. --- omega-target/src/options.coffee | 21 +++++- omega-target/test/options_startup.coffee | 86 ++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 omega-target/test/options_startup.coffee diff --git a/omega-target/src/options.coffee b/omega-target/src/options.coffee index 82a5a09d..0ab6c335 100644 --- a/omega-target/src/options.coffee +++ b/omega-target/src/options.coffee @@ -264,7 +264,7 @@ class Options init: (startupCheck = -> true) -> # startupProfileName 如果为空,就使用当前的 currentProfileName # 如果没有 currentProfileName, 就使用默认的 fallbackProfileName - @ready = @loadOptions().then(=> + applyStartupProfile = => if startupCheck() and @_options['-startupProfileName'] console.log( @@ -282,12 +282,29 @@ class Options @applyProfile('system') else @applyProfile(st['currentProfileName'] || @fallbackProfileName) - ).catch((err) => + + startup = @loadOptions().then(applyStartupProfile).catch((err) => if not err instanceof ProfileNotExistError @log.error(err) @applyProfile(@fallbackProfileName) ).catch((err) => @log.error(err) + ) + + # Startup safety net (issue #276): on a service-worker cold start a storage + # read (e.g. IndexedDB) can stall indefinitely. Without a time bound, + # loadOptions never settles, applyProfile never runs, and BOTH the proxy + # and the popup are left dead until the extension is reloaded by hand. If + # startup does not finish in time, force a profile to be applied from the + # configured startup profile (or the fallback) so the proxy gets set and + # options.ready resolves. + @ready = startup.timeout(@startupTimeout).catch(Promise.TimeoutError, (err) => + @log.error('Options#init timed out on startup; applying a fallback ' + + 'profile so the proxy and popup can recover. ' + err) + name = @_options['-startupProfileName'] || @fallbackProfileName + @applyProfile(name).catch (e) => + @log.error(e) + @applyProfile(@fallbackProfileName) if name != @fallbackProfileName ).then => @getAll() @ready.then => diff --git a/omega-target/test/options_startup.coffee b/omega-target/test/options_startup.coffee new file mode 100644 index 00000000..3fef1881 --- /dev/null +++ b/omega-target/test/options_startup.coffee @@ -0,0 +1,86 @@ +chai = require 'chai' +should = chai.should() +sinon = require 'sinon' +chai.use require('sinon-chai') +Promise = require 'bluebird' + +# Regression tests for issue #276: +# "On a Chrome cold start the extension very often shows only the icon, the +# popup won't load and clicking does nothing, so the proxy is unusable." +# +# Root cause: on a service-worker cold start a storage read (the IndexedDB +# backed `state`) can stall indefinitely. Options#init awaits that read +# BEFORE it applies a profile, so when the read never resolves: +# * options.ready never resolves -> the popup's getState never gets a +# reply -> the popup stays blank ("only an icon"), and +# * applyProfile is never called -> chrome.proxy.settings is never set -> +# the whole proxy is dead, +# until the extension is manually reloaded. +# +# These tests simulate the stalling state read deterministically (no browser, +# no network, no credentials needed) and assert that startup still recovers. +describe 'Options startup resilience (issue #276)', -> + Options = require '../src/options' + Storage = require '../src/storage' + Log = require '../src/log' + defaultOptions = require '../src/default_options' + + before -> + sinon.stub(Log, 'log') + sinon.stub(Log, 'error') + after -> + Log.log.restore() + Log.error.restore() + + makeProxyImpl = -> + applyProfile: sinon.spy(-> Promise.resolve()) + watchProxyChange: -> + setProxyAuth: -> Promise.resolve() + features: [] + + # A `state` whose reads never resolve, simulating IndexedDB stalling during + # a service-worker cold start. Writes resolve so they don't mask the read. + makeHangingState = -> + get: -> new Promise(-> ) # never resolves + set: -> Promise.resolve({}) + remove: -> Promise.resolve() + watch: -> (-> ) + apply: -> Promise.resolve() + + # A `state` that behaves normally (reads resolve with defaults). + makeWorkingState = -> + state = makeHangingState() + state.get = (keys) -> + if keys? and typeof keys == 'object' and not Array.isArray(keys) + Promise.resolve(keys) + else + Promise.resolve({}) + state + + buildOptions = (state) -> + storage = new Storage() + # The local profile storage (chrome.storage.local) loads fine; only the + # IndexedDB-backed `state` stalls. + storage.set(defaultOptions()) + options = new Options(storage, state, Log, null, makeProxyImpl()) + # Keep the test fast: a real cold start would use a multi-second timeout. + options.startupTimeout = 200 + # _watch pulls in csso / quick-switch / inspect machinery that needs the + # extension environment; it is irrelevant to startup control flow here. + sinon.stub(options, '_watch').returns(-> ) + # Short-circuit applyProfile so we test init's control flow, not the proxy + # plumbing. We only care THAT a profile gets applied. + sinon.stub(options, 'applyProfile').returns(Promise.resolve()) + options + + it 'applies a profile and settles ready even when a state read stalls', -> + options = buildOptions(makeHangingState()) + options.initWithOptions(null, -> false) + return options.ready.timeout(2000).then -> + options.applyProfile.should.have.been.called + + it 'still applies a profile normally when state reads succeed', -> + options = buildOptions(makeWorkingState()) + options.initWithOptions(null, -> false) + return options.ready.timeout(2000).then -> + options.applyProfile.should.have.been.called