From 874ff381f96ac6b44727845a147dfc7cc937b097 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:09:45 -0500 Subject: [PATCH] fix: resolve OAuth re-auth deadlock and sync concurrency races (#88) * 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. * 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. * ci: format --- lua/pending/sync/gcal.lua | 14 +------------- lua/pending/sync/gtasks.lua | 18 +++--------------- lua/pending/sync/oauth.lua | 25 +++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 28 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 a2a6da0..5b19118 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -436,20 +436,8 @@ local function sync_setup(access_token) return tasklists, s, now_ts 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, 'gtasks', function(access_token) local tasklists, s, now_ts = sync_setup(access_token) if not tasklists then return @@ -475,7 +463,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 @@ -502,7 +490,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)