From 2742d1f310a81e0cd97977f839fb7575a4cf511d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 15:43:52 -0500 Subject: [PATCH 1/5] fix(oauth): resolve re-auth deadlock and improve flow robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lua/pending/sync/oauth.lua | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 8539ea6..fd6e88d 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -25,6 +25,8 @@ local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER' local OAuthClient = {} OAuthClient.__index = OAuthClient +local _active_close = nil + ---@class pending.oauth local M = {} @@ -348,6 +350,11 @@ end ---@param on_complete? fun(): nil ---@return nil function OAuthClient:auth(on_complete) + if _active_close then + _active_close() + _active_close = nil + end + local creds = self:resolve_credentials() if creds.client_id == BUNDLED_CLIENT_ID then log.error(self.name .. ': no credentials configured — run :Pending auth') @@ -379,14 +386,11 @@ function OAuthClient:auth(on_complete) .. '&scope=' .. M.url_encode(self.scope) .. '&access_type=offline' - .. '&prompt=consent' + .. '&prompt=select_account%20consent' .. '&code_challenge=' .. M.url_encode(code_challenge) .. '&code_challenge_method=S256' - vim.ui.open(auth_url) - log.info('Opening browser for Google authorization...') - local server = vim.uv.new_tcp() local server_closed = false local function close_server() @@ -394,10 +398,20 @@ function OAuthClient:auth(on_complete) return end server_closed = true + if _active_close == close_server then + _active_close = nil + end server:close() 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) if err then return @@ -430,6 +444,9 @@ function OAuthClient:auth(on_complete) end) end) + vim.ui.open(auth_url) + log.info('Opening browser for Google authorization...') + vim.defer_fn(function() if not server_closed then close_server() @@ -470,14 +487,14 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet }, { text = true }) if result.code ~= 0 then - self:_wipe() + self:clear_tokens() log.error('Token exchange failed.') return end local ok, decoded = pcall(vim.json.decode, result.stdout or '') if not ok or not decoded.access_token then - self:_wipe() + self:clear_tokens() log.error('Invalid token response.') return end @@ -498,6 +515,10 @@ end ---@return nil function OAuthClient:clear_tokens() + if _active_close then + _active_close() + _active_close = nil + end os.remove(self:token_path()) end From f2c7efdd6fd735eff9acd04163afb9d477cf3f3c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 15:43:55 -0500 Subject: [PATCH 2/5] 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. --- spec/oauth_spec.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/oauth_spec.lua b/spec/oauth_spec.lua index 520227d..caf857a 100644 --- a/spec/oauth_spec.lua +++ b/spec/oauth_spec.lua @@ -142,8 +142,11 @@ describe('oauth', function() it('falls back to bundled credentials', function() config.reset() 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 creds = c:resolve_credentials() + oauth.load_json_file = orig_load assert.equals(oauth._BUNDLED_CLIENT_ID, creds.client_id) assert.equals(oauth._BUNDLED_CLIENT_SECRET, creds.client_secret) end) From 480c8323067a79fda65bdf8d01edc3a8359e2e1c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 15:47:24 -0500 Subject: [PATCH 3/5] ci: format --- spec/oauth_spec.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/oauth_spec.lua b/spec/oauth_spec.lua index caf857a..a4a6f1d 100644 --- a/spec/oauth_spec.lua +++ b/spec/oauth_spec.lua @@ -143,7 +143,9 @@ describe('oauth', function() config.reset() vim.g.pending = { data_path = tmpdir .. '/tasks.json' } local orig_load = oauth.load_json_file - oauth.load_json_file = function() return nil end + oauth.load_json_file = function() + return nil + end local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) local creds = c:resolve_credentials() oauth.load_json_file = orig_load From 36a5025198171887bd03b01d7ddfea8847012f3f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 15:53:43 -0500 Subject: [PATCH 4/5] 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. --- lua/pending/sync/gtasks.lua | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index a2a6da0..8b918c2 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -436,15 +436,27 @@ local function sync_setup(access_token) return tasklists, s, now_ts end +local _in_flight = false + ---@param callback fun(access_token: string): nil local function with_token(callback) + if _in_flight then + log.warn('gtasks: operation in progress — please wait') + return + end + _in_flight = true oauth.async(function() local token = oauth.google_client:get_access_token() if not token then + _in_flight = false log.warn('not authenticated — run :Pending auth') return end - callback(token) + local ok, err = pcall(callback, token) + _in_flight = false + if not ok then + log.error('gtasks: ' .. tostring(err)) + end end) end From 90f1378cb6204be8c5b75feb321783d1578296f9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 16:03:46 -0500 Subject: [PATCH 5/5] 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. --- lua/pending/sync/gcal.lua | 14 +------------- lua/pending/sync/gtasks.lua | 29 +++-------------------------- lua/pending/sync/oauth.lua | 25 +++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 39 deletions(-) diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 12264dc..4669b89 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -169,20 +169,8 @@ local function fmt_counts(parts) return table.concat(items, ' | ') 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() - with_token(function(access_token) + oauth.with_token(oauth.google_client, 'gcal', function(access_token) local calendars, cal_err = get_all_calendars(access_token) if cal_err or not calendars then log.error(cal_err or 'failed to fetch calendars') diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 8b918c2..6a5e570 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -436,32 +436,9 @@ local function sync_setup(access_token) return tasklists, s, now_ts end -local _in_flight = false - ----@param callback fun(access_token: string): nil -local function with_token(callback) - if _in_flight then - log.warn('gtasks: operation in progress — please wait') - return - end - _in_flight = true - oauth.async(function() - local token = oauth.google_client:get_access_token() - if not token then - _in_flight = false - log.warn('not authenticated — run :Pending auth') - return - end - local ok, err = pcall(callback, token) - _in_flight = false - if not ok then - log.error('gtasks: ' .. tostring(err)) - end - end) -end 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) if not tasklists then return @@ -487,7 +464,7 @@ function M.push() end 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) if not tasklists then return @@ -514,7 +491,7 @@ function M.pull() end 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) if not tasklists then return diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index fd6e88d..8c30268 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -26,6 +26,7 @@ local OAuthClient = {} OAuthClient.__index = OAuthClient local _active_close = nil +local _sync_in_flight = false ---@class pending.oauth local M = {} @@ -51,6 +52,30 @@ function M.async(fn) coroutine.resume(coroutine.create(fn)) 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 ---@return string function M.url_encode(str)