Compare commits

...

5 commits

Author SHA1 Message Date
90f1378cb6 refactor(sync): centralize with_token in oauth.lua with shared lock
Problem: `with_token` was duplicated in `gcal.lua` and `gtasks.lua`,
with the concurrency lock added only to the gtasks copy. Any new
backend would silently inherit the same race, and gcal back-to-back
push could still create duplicate remote calendar events.

Solution: lift `with_token` into `oauth.lua` as
`M.with_token(client, name, callback)` behind a module-level
`_sync_in_flight` guard. All backends share one implementation; the
lock covers gcal, gtasks, and any future backend automatically.
2026-03-06 16:03:46 -05:00
36a5025198 fix(gtasks): prevent concurrent push/pull from racing on the store
Problem: `push` and `pull` both run via `oauth.async`, so issuing them
back-to-back starts two coroutines that interleave at every curl yield.
Both snapshot `build_id_index` before either has mutated the store,
which can cause push to create a remote task that pull would have
recognized as already linked, producing duplicates on Google.

Solution: guard `with_token` with a module-level `_in_flight` flag set
before `oauth.async` is called so no second operation can start during
a token-refresh yield. A `pcall` around the callback guarantees the
flag is always cleared, even on an unexpected error.
2026-03-06 15:53:43 -05:00
480c832306 ci: format 2026-03-06 15:47:24 -05:00
f2c7efdd6f test(oauth): isolate bundled-credentials fallback from real filesystem
Problem: `resolve_credentials` reads from `vim.fn.stdpath('data')`,
the real Neovim data dir. The test passed only because `_wipe()` was
incidentally deleting the user's credential file mid-run.

Solution: stub `oauth.load_json_file` for the duration of the test so
real credential files cannot interfere with the fallback assertion.
2026-03-06 15:43:55 -05:00
2742d1f310 fix(oauth): resolve re-auth deadlock and improve flow robustness
Problem: in-flight TCP server held port 18392 for up to 120 seconds.
Calling `auth()` again caused `bind()` to fail silently — the browser
opened but no listener could receive the OAuth callback. `_wipe()` on
exchange failure also destroyed credentials, forcing full re-setup.

Solution: `_active_close` at module scope cancels any in-flight server
when `auth()` or `clear_tokens()` is called. Binding is guarded with
`pcall`; the browser only opens after the server is listening. Swapped
`_wipe()` for `clear_tokens()` in `_exchange_code` to preserve
credentials on failure. Added `select_account` to `prompt` so Google
always shows the account picker on re-auth.
2026-03-06 15:43:52 -05:00
4 changed files with 62 additions and 34 deletions

View file

@ -169,20 +169,8 @@ local function fmt_counts(parts)
return table.concat(items, ' | ') return table.concat(items, ' | ')
end end
---@param callback fun(access_token: string): nil
local function with_token(callback)
oauth.async(function()
local token = oauth.google_client:get_access_token()
if not token then
log.warn('not authenticated — run :Pending auth')
return
end
callback(token)
end)
end
function M.push() function M.push()
with_token(function(access_token) oauth.with_token(oauth.google_client, 'gcal', function(access_token)
local calendars, cal_err = get_all_calendars(access_token) local calendars, cal_err = get_all_calendars(access_token)
if cal_err or not calendars then if cal_err or not calendars then
log.error(cal_err or 'failed to fetch calendars') log.error(cal_err or 'failed to fetch calendars')

View file

@ -436,20 +436,9 @@ local function sync_setup(access_token)
return tasklists, s, now_ts return tasklists, s, now_ts
end end
---@param callback fun(access_token: string): nil
local function with_token(callback)
oauth.async(function()
local token = oauth.google_client:get_access_token()
if not token then
log.warn('not authenticated — run :Pending auth')
return
end
callback(token)
end)
end
function M.push() function M.push()
with_token(function(access_token) oauth.with_token(oauth.google_client, 'gtasks', function(access_token)
local tasklists, s, now_ts = sync_setup(access_token) local tasklists, s, now_ts = sync_setup(access_token)
if not tasklists then if not tasklists then
return return
@ -475,7 +464,7 @@ function M.push()
end end
function M.pull() function M.pull()
with_token(function(access_token) oauth.with_token(oauth.google_client, 'gtasks', function(access_token)
local tasklists, s, now_ts = sync_setup(access_token) local tasklists, s, now_ts = sync_setup(access_token)
if not tasklists then if not tasklists then
return return
@ -502,7 +491,7 @@ function M.pull()
end end
function M.sync() function M.sync()
with_token(function(access_token) oauth.with_token(oauth.google_client, 'gtasks', function(access_token)
local tasklists, s, now_ts = sync_setup(access_token) local tasklists, s, now_ts = sync_setup(access_token)
if not tasklists then if not tasklists then
return return

View file

@ -25,6 +25,9 @@ local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER'
local OAuthClient = {} local OAuthClient = {}
OAuthClient.__index = OAuthClient OAuthClient.__index = OAuthClient
local _active_close = nil
local _sync_in_flight = false
---@class pending.oauth ---@class pending.oauth
local M = {} local M = {}
@ -49,6 +52,30 @@ function M.async(fn)
coroutine.resume(coroutine.create(fn)) coroutine.resume(coroutine.create(fn))
end end
---@param client pending.OAuthClient
---@param name string
---@param callback fun(access_token: string): nil
function M.with_token(client, name, callback)
if _sync_in_flight then
require('pending.log').warn(name .. ': sync operation in progress — please wait')
return
end
_sync_in_flight = true
M.async(function()
local token = client:get_access_token()
if not token then
_sync_in_flight = false
require('pending.log').warn(name .. ': not authenticated — run :Pending auth')
return
end
local ok, err = pcall(callback, token)
_sync_in_flight = false
if not ok then
require('pending.log').error(name .. ': ' .. tostring(err))
end
end)
end
---@param str string ---@param str string
---@return string ---@return string
function M.url_encode(str) function M.url_encode(str)
@ -348,6 +375,11 @@ end
---@param on_complete? fun(): nil ---@param on_complete? fun(): nil
---@return nil ---@return nil
function OAuthClient:auth(on_complete) function OAuthClient:auth(on_complete)
if _active_close then
_active_close()
_active_close = nil
end
local creds = self:resolve_credentials() local creds = self:resolve_credentials()
if creds.client_id == BUNDLED_CLIENT_ID then if creds.client_id == BUNDLED_CLIENT_ID then
log.error(self.name .. ': no credentials configured — run :Pending auth') log.error(self.name .. ': no credentials configured — run :Pending auth')
@ -379,14 +411,11 @@ function OAuthClient:auth(on_complete)
.. '&scope=' .. '&scope='
.. M.url_encode(self.scope) .. M.url_encode(self.scope)
.. '&access_type=offline' .. '&access_type=offline'
.. '&prompt=consent' .. '&prompt=select_account%20consent'
.. '&code_challenge=' .. '&code_challenge='
.. M.url_encode(code_challenge) .. M.url_encode(code_challenge)
.. '&code_challenge_method=S256' .. '&code_challenge_method=S256'
vim.ui.open(auth_url)
log.info('Opening browser for Google authorization...')
local server = vim.uv.new_tcp() local server = vim.uv.new_tcp()
local server_closed = false local server_closed = false
local function close_server() local function close_server()
@ -394,10 +423,20 @@ function OAuthClient:auth(on_complete)
return return
end end
server_closed = true server_closed = true
if _active_close == close_server then
_active_close = nil
end
server:close() server:close()
end end
_active_close = close_server
local bind_ok, bind_err = pcall(server.bind, server, '127.0.0.1', port)
if not bind_ok or bind_err == nil then
close_server()
log.error(self.name .. ': port ' .. port .. ' already in use — try again in a moment')
return
end
server:bind('127.0.0.1', port)
server:listen(1, function(err) server:listen(1, function(err)
if err then if err then
return return
@ -430,6 +469,9 @@ function OAuthClient:auth(on_complete)
end) end)
end) end)
vim.ui.open(auth_url)
log.info('Opening browser for Google authorization...')
vim.defer_fn(function() vim.defer_fn(function()
if not server_closed then if not server_closed then
close_server() close_server()
@ -470,14 +512,14 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet
}, { text = true }) }, { text = true })
if result.code ~= 0 then if result.code ~= 0 then
self:_wipe() self:clear_tokens()
log.error('Token exchange failed.') log.error('Token exchange failed.')
return return
end end
local ok, decoded = pcall(vim.json.decode, result.stdout or '') local ok, decoded = pcall(vim.json.decode, result.stdout or '')
if not ok or not decoded.access_token then if not ok or not decoded.access_token then
self:_wipe() self:clear_tokens()
log.error('Invalid token response.') log.error('Invalid token response.')
return return
end end
@ -498,6 +540,10 @@ end
---@return nil ---@return nil
function OAuthClient:clear_tokens() function OAuthClient:clear_tokens()
if _active_close then
_active_close()
_active_close = nil
end
os.remove(self:token_path()) os.remove(self:token_path())
end end

View file

@ -142,8 +142,13 @@ describe('oauth', function()
it('falls back to bundled credentials', function() it('falls back to bundled credentials', function()
config.reset() config.reset()
vim.g.pending = { data_path = tmpdir .. '/tasks.json' } vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
local orig_load = oauth.load_json_file
oauth.load_json_file = function()
return nil
end
local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' })
local creds = c:resolve_credentials() local creds = c:resolve_credentials()
oauth.load_json_file = orig_load
assert.equals(oauth._BUNDLED_CLIENT_ID, creds.client_id) assert.equals(oauth._BUNDLED_CLIENT_ID, creds.client_id)
assert.equals(oauth._BUNDLED_CLIENT_SECRET, creds.client_secret) assert.equals(oauth._BUNDLED_CLIENT_SECRET, creds.client_secret)
end) end)