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' 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