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
This commit is contained in:
Barrett Ruth 2026-03-06 16:09:45 -05:00 committed by GitHub
parent b641c93a0a
commit 874ff381f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 29 additions and 28 deletions

View file

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

View file

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

View file

@ -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)