refactor: improve subscription cleanup with token-based pattern

Replace object-based subscription tracking with token-based system to prevent memory leaks. Add dispose() method to BaseAIClass and ensure all components properly unsubscribe in onDestroy. Update SettingsService.subscribeToSettingsChanged to return subscription token instead of requiring subscriber object parameter.
This commit is contained in:
Andrew Beal 2026-05-25 18:22:07 +01:00
parent 4887a10764
commit 3fec768488
6 changed files with 31 additions and 17 deletions

View file

@ -20,13 +20,15 @@ import { AIToolUsageMode } from "Enums/AIToolUsageMode";
export abstract class BaseAIClass implements IAIClass {
protected apiKey: string;
protected readonly provider: AIProvider;
protected readonly abortService: AbortService;
protected readonly aiFileService: IAIFileService;
protected readonly settingsService: SettingsService;
protected readonly streamingService: StreamingService;
private readonly settingsSubscription: object;
private _systemPrompt: string = "";
private _userInstruction: string = "";
private _agentType: AgentType = AgentType.Main;
@ -40,12 +42,16 @@ export abstract class BaseAIClass implements IAIClass {
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
this.streamingService = Resolve<StreamingService>(Services.StreamingService);
this.settingsService.subscribeToSettingsChanged(this, () => {
this.settingsSubscription = this.settingsService.subscribeToSettingsChanged(() => {
this.apiKey = this.settingsService.getApiKeyForProvider(provider);
});
this.apiKey = this.settingsService.getApiKeyForProvider(provider);
}
public dispose(): void {
this.settingsService.unsubscribe(this.settingsSubscription);
}
public get currentProvider(): AIProvider {
return this.provider;
}

View file

@ -33,8 +33,6 @@
export let onSubmit: (userRequest: string, formattedRequest: string) => void;
export let onStop: () => void;
const componentToken = {};
const inputService: InputService = Resolve<InputService>(Services.InputService);
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
const userInputService: UserInputService = Resolve<UserInputService>(Services.UserInputService);
@ -75,14 +73,14 @@
const diffClosedRef: EventRef = eventService.on(Event.DiffClosed, () => { inputMode = InputMode.Normal; focusInput(); });
const rateLimitCountdownRef: EventRef = eventService.on(Event.RateLimitCountdown, (delayMs: number) => { startCountdown(delayMs); });
settingsService.subscribeToSettingsChanged(componentToken, () => {
const settingsSubscription: object = settingsService.subscribeToSettingsChanged(() => {
chatMode = settingsService.settings.chatMode;
editsAllowed = chatModeAllowsEdits(chatMode);
if (chatModeButton){
setIcon(chatModeButton, iconForChatMode(chatMode));
}
});
onMount(async () => {
userInstructionActive = (await aiPrompt.userInstruction()).trim() !== "";
inputInitialHeight = textareaElement.innerHeight;
@ -92,6 +90,7 @@
eventService.offref(diffOpenedRef);
eventService.offref(diffClosedRef);
eventService.offref(rateLimitCountdownRef);
settingsService.unsubscribe(settingsSubscription);
stopCountdown();
});

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { ChatMode, iconForChatMode } from "Enums/ChatMode";
import { Copy } from "Enums/Copy";
import { tick } from "svelte";
import { onDestroy, tick } from "svelte";
import { setIcon } from "obsidian";
import type { SettingsService } from "Services/SettingsService";
import { Resolve } from "Services/DependencyService";
@ -10,11 +10,11 @@
export let focusInput: () => void;
export let chatModeSelectionAreaActive: boolean;
const componentToken = {};
const settingsService: SettingsService = Resolve<SettingsService>(Services.SettingsService);
settingsService.subscribeToSettingsChanged(componentToken, () => currentChatMode = settingsService.settings.chatMode);
const settingsSubscription: object = settingsService.subscribeToSettingsChanged(() => currentChatMode = settingsService.settings.chatMode);
onDestroy(() => settingsService.unsubscribe(settingsSubscription));
let height = 0;

View file

@ -80,8 +80,9 @@
function handleInstructionSelect(e?: MouseEvent) {
if (selectedInstruction < userInstructions.length) {
settingsService.settings.userInstruction = userInstructions[selectedInstruction];
settingsService.saveSettings();
settingsService.updateSettings(settings => {
settings.userInstruction = userInstructions[selectedInstruction];
});
}
userInstructionAreaActive = false;

View file

@ -35,7 +35,13 @@ export function TryResolve<T>(type: symbol): T | undefined {
export function DeregisterAllServices() {
services.forEach((service) => {
if (service && typeof service === "object" && "unload" in service) {
if (!service || typeof service !== "object") {
return;
}
if ("dispose" in service && typeof service.dispose === "function") {
(service as { dispose: () => void }).dispose();
}
if ("unload" in service) {
(service as Component).unload();
}
});

View file

@ -98,9 +98,11 @@ export class SettingsService {
this.ensureValidModels();
}
public subscribeToSettingsChanged(subscriber: object, callback: (() => void) | (() => Promise<void>)): void {
this.subscribers.set(subscriber, callback);
this.subscriberRefs.add(new WeakRef(subscriber));
public subscribeToSettingsChanged(callback: (() => void) | (() => Promise<void>)): object {
const token = {};
this.subscribers.set(token, callback);
this.subscriberRefs.add(new WeakRef(token));
return token;
}
public unsubscribe(subscriber: object): void {