diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs index 51450a2..f9c79a6 100644 --- a/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs +++ b/src/NextIteration.SpectreConsole.Auth/Persistence/Keychain/KeychainCredentialManager.cs @@ -74,9 +74,40 @@ public Task AddCredentialAsync(string providerName, string accountName, }; AddItem(attrs); + + // SecItemAdd can lag SecItemCopyMatching visibility under concurrent + // keychain access, so a caller doing add-then-select/delete — e.g. + // `accounts add` offering to activate the new credential — can race + // the item's own appearance and see the follow-up lookup miss it. + // Confirm the item is queryable before returning so that can't happen. + ConfirmItemVisible(attrs.Service, accountId); + return Task.FromResult(accountId); } + /// + /// Polls for a just-added item to become visible to + /// SecItemCopyMatching, closing the brief add-visibility window. + /// Best-effort: returns after a bounded wait even if the item never + /// appears, leaving any genuine failure to the caller's own lookup. + /// + private static void ConfirmItemVisible(string service, string account) + { + const int maxAttempts = 20; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + if (QuerySingleItem(service, account, includeData: false) is not null) + { + return; + } + + if (attempt < maxAttempts) + { + Thread.Sleep(25); + } + } + } + /// public Task> ListCredentialsAsync(string providerName) { diff --git a/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs b/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs index c042a1e..6ad25a8 100644 --- a/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs +++ b/src/NextIteration.SpectreConsole.Auth/Persistence/Libsecret/LibsecretCredentialManager.cs @@ -91,9 +91,39 @@ public Task AddCredentialAsync(string providerName, string accountName, var label = $"{_appIdentifier}: {providerName}/{accountName}"; StoreItem(attrs, label, credentialData); + + // A just-stored Secret Service item isn't always immediately visible + // to a follow-up search under concurrent access, so a caller doing + // add-then-select/delete can race the item's appearance. Confirm it's + // queryable before returning so that can't happen. + ConfirmItemVisible(providerName, accountId); + return Task.FromResult(accountId); } + /// + /// Polls for a just-stored item to become visible to a Secret Service + /// lookup, closing the brief store-visibility window. Best-effort: + /// returns after a bounded wait even if the item never appears, leaving + /// any genuine failure to the caller's own lookup. + /// + private void ConfirmItemVisible(string providerName, string accountId) + { + const int maxAttempts = 20; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + if (LookupCredentialByAccountId(providerName, accountId) is not null) + { + return; + } + + if (attempt < maxAttempts) + { + Thread.Sleep(25); + } + } + } + /// public Task> ListCredentialsAsync(string providerName) {