Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 29 additions & 15 deletions omega-target-chromium-extension/src/coffee/omega_target_web.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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'
Expand Down
21 changes: 19 additions & 2 deletions omega-target/src/options.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ class Options
init: (startupCheck = -> true) ->
# startupProfileName 如果为空,就使用当前的 currentProfileName
# 如果没有 currentProfileName, 就使用默认的 fallbackProfileName
@ready = @loadOptions().then(=>
applyStartupProfile = =>
if startupCheck() and
@_options['-startupProfileName']
console.log(
Expand All @@ -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 =>
Expand Down
86 changes: 86 additions & 0 deletions omega-target/test/options_startup.coffee
Original file line number Diff line number Diff line change
@@ -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