1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-31 00:33:33 +00:00

Move loading blob to memory to rxjs pipeline triggered implicitly. Removed from constructor. Added dispose to guard against memory leaks (#18480)

This commit is contained in:
Leslie Tilton
2026-01-21 15:09:02 -06:00
committed by GitHub
parent 1db601b82f
commit 714ff1aba3
3 changed files with 82 additions and 26 deletions

View File

@@ -319,7 +319,10 @@ describe("PhishingDataService", () => {
jest.spyOn(service as any, "_decompressString").mockResolvedValue("phish.com\nbadguy.net");
await service["_loadBlobToMemory"]();
// Trigger the load pipeline and allow async RxJS processing to complete
service["_loadBlobToMemory"]();
await flushPromises();
const set = service["_webAddressesSet"] as Set<string>;
expect(set).toBeDefined();
expect(set.has("phish.com")).toBe(true);

View File

@@ -3,11 +3,15 @@ import {
EMPTY,
first,
firstValueFrom,
from,
of,
share,
takeUntil,
startWith,
Subject,
switchMap,
tap,
map,
} from "rxjs";
import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags";
@@ -64,12 +68,20 @@ export const PHISHING_DOMAINS_BLOB_KEY = new KeyDefinition<string>(
/** Coordinates fetching, caching, and patching of known phishing web addresses */
export class PhishingDataService {
// While background scripts do not necessarily need destroying,
// processes in PhishingDataService are memory intensive.
// We are adding the destroy to guard against accidental leaks.
private _destroy$ = new Subject<void>();
private _testWebAddresses = this.getTestWebAddresses().concat("phishing.testcategory.com"); // Included for QA to test in prod
private _phishingMetaState = this.globalStateProvider.get(PHISHING_DOMAINS_META_KEY);
private _phishingBlobState = this.globalStateProvider.get(PHISHING_DOMAINS_BLOB_KEY);
// In-memory set loaded from blob for fast lookups without reading large storage repeatedly
private _webAddressesSet: Set<string> | null = null;
// Loading variables for web addresses set
// Triggers a load for _webAddressesSet
private _loadTrigger$ = new Subject<void>();
// How often are new web addresses added to the remote?
readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours
@@ -81,6 +93,10 @@ export class PhishingDataService {
this._phishingMetaState.state$.pipe(
first(), // Only take the first value to avoid an infinite loop when updating the cache below
tap((metaState) => {
// Initial loading of web addresses set if not already loaded
if (!this._webAddressesSet) {
this._loadBlobToMemory();
}
// Perform any updates in the background if needed
void this._backgroundUpdate(metaState);
}),
@@ -90,6 +106,8 @@ export class PhishingDataService {
}),
),
),
// Stop emitting when dispose() is called
takeUntil(this._destroy$),
share(),
);
@@ -109,7 +127,18 @@ export class PhishingDataService {
ScheduledTaskNames.phishingDomainUpdate,
this.UPDATE_INTERVAL_DURATION,
);
void this._loadBlobToMemory();
this._setupLoadPipeline();
}
dispose(): void {
// Signal all pipelines to stop and unsubscribe stored subscriptions
this._destroy$.next();
this._destroy$.complete();
// Clear web addresses set from memory
if (this._webAddressesSet !== null) {
this._webAddressesSet = null;
}
}
/**
@@ -269,7 +298,7 @@ export class PhishingDataService {
}
if (next.blob) {
await this._phishingBlobState.update(() => next!.blob!);
await this._loadBlobToMemory();
this._loadBlobToMemory();
}
// Performance logging
@@ -293,6 +322,47 @@ export class PhishingDataService {
}
}
// Sets up the load pipeline to load the blob into memory when triggered
private _setupLoadPipeline(): void {
this._loadTrigger$
.pipe(
switchMap(() =>
this._phishingBlobState.state$.pipe(
first(),
switchMap((blobBase64) => {
if (!blobBase64) {
return of(undefined);
}
// Note: _decompressString wraps a promise that cannot be aborted
// If performance improvements are needed, consider migrating to a cancellable approach
return from(this._decompressString(blobBase64)).pipe(
map((text) => {
const lines = text.split(/\r?\n/);
const newWebAddressesSet = new Set(lines);
this._testWebAddresses.forEach((a) => newWebAddressesSet.add(a));
this._webAddressesSet = new Set(newWebAddressesSet);
this.logService.info(
`[PhishingDataService] loaded ${this._webAddressesSet.size} addresses into memory from blob`,
);
}),
);
}),
catchError((err: unknown) => {
this.logService.error("[PhishingDataService] Failed to load blob into memory", err);
return of(undefined);
}),
),
),
catchError((err: unknown) => {
this.logService.error("[PhishingDataService] Load pipeline failed", err);
return of(undefined);
}),
takeUntil(this._destroy$),
share(),
)
.subscribe();
}
// [FIXME] Move compression helpers to a shared utils library
// to separate from phishing data service.
// ------------------------- Blob and Compression Handling -------------------------
@@ -337,29 +407,9 @@ export class PhishingDataService {
}
}
// Try to load compressed newline blob into an in-memory Set for fast lookups
private async _loadBlobToMemory(): Promise<void> {
this.logService.debug("[PhishingDataService] Loading data blob into memory...");
try {
const blobBase64 = await firstValueFrom(this._phishingBlobState.state$);
if (!blobBase64) {
return;
}
const text = await this._decompressString(blobBase64);
// Split and filter
const lines = text.split(/\r?\n/);
const newWebAddressesSet = new Set(lines);
// Add test addresses
this._testWebAddresses.forEach((a) => newWebAddressesSet.add(a));
this._webAddressesSet = new Set(newWebAddressesSet);
this.logService.info(
`[PhishingDataService] loaded ${this._webAddressesSet.size} addresses into memory from blob`,
);
} catch (err) {
this.logService.error("[PhishingDataService] Failed to load blob into memory", err);
}
// Trigger a load of the blob into memory
private _loadBlobToMemory(): void {
this._loadTrigger$.next();
}
private _uint8ToBase64Fallback(bytes: Uint8Array): string {
const CHUNK_SIZE = 0x8000; // 32KB chunks

View File

@@ -137,6 +137,9 @@ export class PhishingDetectionService {
this._didInit = true;
return () => {
// Dispose phishing data service resources
phishingDataService.dispose();
initSub.unsubscribe();
this._didInit = false;