Fix potential memory leak in Stack App cache

This commit is contained in:
Konstantin Wohlwend 2025-11-06 14:31:14 -08:00
parent 3cdceb99f2
commit 659338dd1e
3 changed files with 26 additions and 14 deletions

View File

@ -372,7 +372,7 @@ const AdminAccessTokenExpired = createKnownErrorConstructor(
`Admin access token has expired. Please refresh it and try again.${expiredAt ? ` (The access token expired at ${expiredAt.toISOString()}.)`: ""}`,
{ expired_at_millis: expiredAt?.getTime() ?? null },
] as const,
(json: any) => [json.expired_at_millis ?? undefined] as const,
(json: any) => [json.expired_at_millis ? new Date(json.expired_at_millis) : undefined] as const,
);
const InvalidProjectForAdminAccessToken = createKnownErrorConstructor(

View File

@ -1,3 +1,4 @@
import { isBrowserLike } from "./env";
import { DependenciesMap } from "./maps";
import { filterUndefined } from "./objects";
import { RateLimitOptions, ReactPromise, pending, rateLimited, resolved, runAsynchronously, wait } from "./promises";
@ -183,10 +184,15 @@ class AsyncValueCache<T> {
this._store.set(value);
}
private _setAsync(value: Promise<T>): ReactPromise<boolean> {
private _setAsync(value: Promise<T>): ReactPromise<void> {
if (this._subscriptionsCount === 0 && !isBrowserLike()) {
// if we're in a server-like environment, we'd rather cache less aggressively to avoid memory leaks.
// hence, if no one is listening to this cache, let's invalidate it
this._invalidateCacheSoon();
}
const promise = pending(value);
this._pendingPromise = promise;
return pending(this._store.setAsync(promise));
return pending(this._store.setAsync(promise).then(() => undefined));
}
private _refetch(cacheStrategy: CacheStrategy): ReactPromise<T> {
@ -204,7 +210,7 @@ class AsyncValueCache<T> {
this._set(value);
}
forceSetCachedValueAsync(value: Promise<T>): ReactPromise<boolean> {
forceSetCachedValueAsync(value: Promise<T>): ReactPromise<void> {
return this._setAsync(value);
}
@ -212,13 +218,14 @@ class AsyncValueCache<T> {
* If anyone is listening to the cache, refreshes the value, and sets it without invalidating the cache.
*/
async refresh(): Promise<void> {
// note that we do the extra check here to save a request if no one is listening to the cache anyway
if (this._subscriptionsCount > 0) {
await this.getOrWait("write-only");
}
}
/**
* Invalidates the cache, marking it dirty (ie. it will be refreshed on the next read). It will refresh immediately.
* Invalidates the cache, marking it dirty (ie. it will be refreshed on the next read). If anyone is listening to the cache, it will refresh immediately.
*/
async invalidate(): Promise<void> {
this._store.setUnavailable();
@ -230,6 +237,18 @@ class AsyncValueCache<T> {
return this._pendingPromise === undefined;
}
_invalidateCacheSoon(): void {
// wait a few seconds; we want to keep the cache up during this time
// else we do unnecessary requests if we unsubscribe and then subscribe again immediately
const currentRefreshPromiseIndex = ++this._mostRecentRefreshPromiseIndex;
runAsynchronously(async () => {
await wait(5000);
if (this._subscriptionsCount === 0 && currentRefreshPromiseIndex === this._mostRecentRefreshPromiseIndex) {
await this.invalidate();
}
});
}
onStateChange(callback: (value: T, oldValue: T | undefined) => void): { unsubscribe: () => void } {
const storeObj = this._store.onChange(callback);
@ -249,15 +268,7 @@ class AsyncValueCache<T> {
hasUnsubscribed = true;
storeObj.unsubscribe();
if (--this._subscriptionsCount === 0) {
const currentRefreshPromiseIndex = ++this._mostRecentRefreshPromiseIndex;
runAsynchronously(async () => {
// wait a few seconds; we want to keep the cache up during this time
// else we do unnecessary requests if we unsubscribe and then subscribe again immediately
await wait(5000);
if (this._subscriptionsCount === 0 && currentRefreshPromiseIndex === this._mostRecentRefreshPromiseIndex) {
await this.invalidate();
}
});
this._invalidateCacheSoon();
for (const unsubscribe of this._unsubscribers) {
unsubscribe();

View File

@ -184,6 +184,7 @@ export class AsyncStore<T> implements ReadonlyAsyncStore<T> {
setUnavailable(): void {
this._lastSuccessfulUpdate = ++this._updateCounter;
this._mostRecentOkValue = undefined;
this._isAvailable = false;
this._isRejected = false;
this._rejectionError = undefined;