Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- Fixes an issue where the GitKraken MCP installation could fail or conflict across remote environments like WSL, SSH, or containers ([#4918](https://github.com/gitkraken/vscode-gitlens/issues/4918))
- Fixes an issue where diffing untracked files in the _Commit Composer_ could trigger unwanted file system events ([#4917](https://github.com/gitkraken/vscode-gitlens/issues/4917))
- Fixes an issue where commit messages become invisible during interactive rebase in some themes ([#4886](https://github.com/gitkraken/vscode-gitlens/issues/4886))
- Fixes an issue where authentication errors were not properly displayed in _Launchpad_ when GitLab integration credentials were revoked ([#4944](https://github.com/gitkraken/vscode-gitlens/issues/4944))
- Fixes issue in the _Commit Graph_ minimap where it only shows a spinner when the repo has no commits ([#4741](https://github.com/gitkraken/vscode-gitlens/issues/4741))
- Fixes an inline markdown rendering issue in the _Interactive Rebase Editor_ ([#4914](https://github.com/gitkraken/vscode-gitlens/issues/4914))
- Fixes an issue where opening a deep link to create a PR worktree would incorrectly prompt to add a remote that already exists ([#4926](https://github.com/gitkraken/vscode-gitlens/issues/4926))
Expand Down
54 changes: 32 additions & 22 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { ResourceDescriptor } from './git/models/resourceDescriptor.js';
import type { GitHostIntegration } from './plus/integrations/models/gitHostIntegration.js';
import type { IntegrationBase } from './plus/integrations/models/integration.js';
import { isPromise } from './system/promise.js';
import { CacheController } from './system/promiseCache.js';

type Caches = {
defaultBranch: { key: `repo:${string}`; value: DefaultBranch };
Expand All @@ -31,7 +32,7 @@ type CacheKey<T extends Cache> = Caches[T]['key'];
type CacheValue<T extends Cache> = Caches[T]['value'];
type CacheResult<T> = Promise<T | undefined> | T | undefined;

type Cacheable<T> = () => { value: CacheResult<T>; expiresAt?: number };
type Cacheable<T> = (cacheable: CacheController) => { value: CacheResult<T>; expiresAt?: number };
type Cached<T> =
| {
value: T | undefined;
Expand All @@ -46,6 +47,8 @@ type Cached<T> =
etag?: string;
};

type ExpiryOptions = { expiryOverride?: boolean | number; expireOnError?: boolean };

export class CacheProvider implements Disposable {
private readonly _cache = new Map<`${Cache}:${CacheKey<Cache>}`, Cached<CacheResult<CacheValue<Cache>>>>();

Expand All @@ -65,7 +68,7 @@ export class CacheProvider implements Disposable {
key: CacheKey<T>,
etag: string | undefined,
cacheable: Cacheable<CacheValue<T>>,
options?: { expiryOverride?: boolean | number },
options?: ExpiryOptions,
): CacheResult<CacheValue<T>> {
const item = this._cache.get(`${cache}:${key}`);

Expand All @@ -85,8 +88,18 @@ export class CacheProvider implements Disposable {
(expiry != null && expiry > 0 && expiry < Date.now()) ||
(item.etag != null && item.etag !== etag)
) {
const { value, expiresAt } = cacheable();
return this.set<T>(cache, key, value, etag, expiresAt)?.value as CacheResult<CacheValue<T>>;
const cacheController = new CacheController();
const { value, expiresAt } = cacheable(cacheController);
if (isPromise(value)) {
void value.finally(() => {
if (cacheController.invalidated) {
this.delete(cache, key);
}
});
}
return this.set<T>(cache, key, value, etag, expiresAt, options?.expireOnError)?.value as CacheResult<
CacheValue<T>
>;
}

return item.value as CacheResult<CacheValue<T>>;
Expand All @@ -95,7 +108,7 @@ export class CacheProvider implements Disposable {
getCurrentAccount(
integration: IntegrationBase,
cacheable: Cacheable<Account>,
options?: { expiryOverride?: boolean | number },
options?: ExpiryOptions,
): CacheResult<Account> {
const { key, etag } = getIntegrationKeyAndEtag(integration);
return this.get('currentAccount', `id:${key}`, etag, cacheable, options);
Expand All @@ -117,7 +130,7 @@ export class CacheProvider implements Disposable {
resource: ResourceDescriptor,
integration: IntegrationBase | undefined,
cacheable: Cacheable<IssueOrPullRequest>,
options?: { expiryOverride?: boolean | number },
options?: ExpiryOptions,
): CacheResult<IssueOrPullRequest> {
const { key, etag } = getResourceKeyAndEtag(resource, integration);

Expand All @@ -138,7 +151,7 @@ export class CacheProvider implements Disposable {
resource: ResourceDescriptor,
integration: IntegrationBase | undefined,
cacheable: Cacheable<Issue>,
options?: { expiryOverride?: boolean | number },
options?: ExpiryOptions,
): CacheResult<Issue> {
const { key, etag } = getResourceKeyAndEtag(resource, integration);

Expand All @@ -165,7 +178,7 @@ export class CacheProvider implements Disposable {
resource: ResourceDescriptor,
integration: IntegrationBase | undefined,
cacheable: Cacheable<PullRequest>,
options?: { expiryOverride?: boolean | number },
options?: ExpiryOptions,
): CacheResult<PullRequest> {
const { key, etag } = getResourceKeyAndEtag(resource, integration);

Expand All @@ -192,7 +205,7 @@ export class CacheProvider implements Disposable {
repo: ResourceDescriptor,
integration: GitHostIntegration | undefined,
cacheable: Cacheable<PullRequest>,
options?: { expiryOverride?: boolean | number },
options?: ExpiryOptions,
): CacheResult<PullRequest> {
const { key, etag } = getResourceKeyAndEtag(repo, integration);
// Wrap the cacheable so we can also add the result to the issuesOrPrsById cache
Expand All @@ -210,7 +223,7 @@ export class CacheProvider implements Disposable {
repo: ResourceDescriptor,
integration: GitHostIntegration | undefined,
cacheable: Cacheable<PullRequest>,
options?: { expiryOverride?: boolean | number },
options?: ExpiryOptions,
): CacheResult<PullRequest> {
const { key, etag } = getResourceKeyAndEtag(repo, integration);
// Wrap the cacheable so we can also add the result to the issuesOrPrsById cache
Expand All @@ -227,7 +240,7 @@ export class CacheProvider implements Disposable {
repo: ResourceDescriptor,
integration: GitHostIntegration | undefined,
cacheable: Cacheable<DefaultBranch>,
options?: { expiryOverride?: boolean | number },
options?: ExpiryOptions,
): CacheResult<DefaultBranch> {
const { key, etag } = getResourceKeyAndEtag(repo, integration);
return this.get('defaultBranch', `repo:${key}`, etag, cacheable, options);
Expand All @@ -237,7 +250,7 @@ export class CacheProvider implements Disposable {
repo: ResourceDescriptor,
integration: GitHostIntegration | undefined,
cacheable: Cacheable<RepositoryMetadata>,
options?: { expiryOverride?: boolean | number },
options?: ExpiryOptions,
): CacheResult<RepositoryMetadata> {
const { key, etag } = getResourceKeyAndEtag(repo, integration);
return this.get('repoMetadata', `repo:${key}`, etag, cacheable, options);
Expand All @@ -249,17 +262,14 @@ export class CacheProvider implements Disposable {
value: CacheResult<CacheValue<T>>,
etag: string | undefined,
expiresAt?: number,
expireOnError?: boolean,
): Cached<CacheResult<CacheValue<T>>> {
let item: Cached<CacheResult<CacheValue<T>>>;
if (isPromise(value)) {
void value.then(
v => {
this.set(cache, key, v, etag, expiresAt);
},
() => {
this.delete(cache, key);
},
);
void value.then(v => this.set(cache, key, v, etag, expiresAt, expireOnError));
if (expireOnError !== false) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When expireOnError === false, a rejected Promise remains cached, and since promise entries never get an expiresAt, this can effectively persist indefinitely and also overwrite any previously cached successful value. Consider whether call sites relying on this need a bounded retry/un-stick mechanism (e.g., explicit invalidation on credential change) so auth failures don’t become permanent.

Other Locations
  • src/plus/integrations/models/integration.ts:630

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

void value.catch(() => this.delete(cache, key));
}

item = { value: value, etag: etag, cachedAt: Date.now() };
} else {
Expand All @@ -280,8 +290,8 @@ export class CacheProvider implements Disposable {
key: string,
etag: string | undefined,
): Cacheable<PullRequest> {
return () => {
const item = cacheable();
return cacheController => {
const item = cacheable(cacheController);
if (isPromise(item.value)) {
void item.value.then(v => {
if (v != null) {
Expand Down
18 changes: 15 additions & 3 deletions src/plus/integrations/models/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,19 +603,31 @@ export abstract class IntegrationBase<

const currentAccount = await this.container.cache.getCurrentAccount(
this,
() => ({
cacheable => ({
value: (async () => {
try {
const account = await this.getProviderCurrentAccount?.(this._session!, opts);
this.resetRequestExceptionCount('getCurrentAccount');
return account;
} catch (ex) {
if (ex instanceof CancellationError) {
cacheable.invalidate();
return undefined;
}

this.handleProviderException('getCurrentAccount', ex, { scope: scope });
return undefined;

// Invalidate the cache on error, except for auth errors
if (!(ex instanceof AuthenticationError)) {
cacheable.invalidate();
}

// Re-throw to the caller
throw ex;
}
})(),
}),
{ expiryOverride: expiryOverride },
{ expiryOverride: expiryOverride, expireOnError: false },
);
return currentAccount;
}
Expand Down
Loading