From 479d76f89c8d667beaa5c56401a62212af6f0ad9 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Apr 2026 09:07:29 +0100 Subject: [PATCH 01/14] fix(startup): defer connect and temperature boot tasks - move mothership, remote access, and temperature provider discovery off the Nest bootstrap path - before this change, startup could spend the full PM2 ready budget on non-critical background work - that caused restart-loop risk when Connect hit slow UPNP work and when temperature discovery stalled on disk sensor availability - the new startup listeners wait for the app.ready event and schedule that work asynchronously after readiness - temperature initialization is now idempotent and disk sensor availability no longer blocks on a full disk scan --- .../sensors/disk_sensors.service.ts | 8 +- .../temperature-startup-tasks.spec.ts | 84 ++++++++++++++ .../temperature/temperature-startup-tasks.ts | 49 +++++++++ .../metrics/temperature/temperature.module.ts | 2 + .../temperature/temperature.service.spec.ts | 40 +++---- .../temperature/temperature.service.ts | 33 ++++-- .../__test__/connect-startup-tasks.test.ts | 103 ++++++++++++++++++ .../mothership-proxy/mothership.controller.ts | 8 +- .../src/mothership-proxy/mothership.module.ts | 2 + .../dynamic-remote-access.service.ts | 10 +- .../src/startup/connect-startup-tasks.ts | 85 +++++++++++++++ 11 files changed, 377 insertions(+), 47 deletions(-) create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts create mode 100644 packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts create mode 100644 packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts index 1ab741a6ab..52b3216e28 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.ts @@ -18,13 +18,7 @@ export class DiskSensorsService implements TemperatureSensorProvider { constructor(private readonly disksService: DisksService) {} async isAvailable(): Promise { - // Disks are always "available" since DisksService exists - try { - const disks = await this.disksService.getDisks(); - return disks.length > 0; - } catch { - return false; - } + return true; } async read(): Promise { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts new file mode 100644 index 0000000000..5510e39da0 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AppReadyEvent } from '@app/unraid-api/app/app-lifecycle.events.js'; +import { TemperatureStartupTasksListener, scheduleTemperatureStartupTasks } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.js'; + +describe('scheduleTemperatureStartupTasks', () => { + it('schedules temperature startup work after the provided delay', async () => { + vi.useFakeTimers(); + + const initializeProviders = vi.fn().mockResolvedValue(undefined); + const logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + scheduleTemperatureStartupTasks({ initializeProviders }, logger, 250); + + expect(initializeProviders).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(250); + + expect(initializeProviders).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + + it('warns when background temperature startup rejects', async () => { + vi.useFakeTimers(); + + const backgroundError = new Error('disk scan failed'); + const logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + scheduleTemperatureStartupTasks( + { + initializeProviders: vi.fn().mockRejectedValue(backgroundError), + }, + logger, + 250 + ); + + await vi.advanceTimersByTimeAsync(250); + await vi.runAllTicks(); + + expect(logger.warn).toHaveBeenCalledWith( + backgroundError, + 'Temperature provider initialization after startup failed' + ); + + vi.useRealTimers(); + }); + + it('does nothing when the temperature service is unavailable', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + expect(() => scheduleTemperatureStartupTasks(undefined, logger)).not.toThrow(); + expect(logger.info).not.toHaveBeenCalled(); + }); +}); + +describe('TemperatureStartupTasksListener', () => { + it('schedules temperature startup work when the app ready event is emitted', async () => { + vi.useFakeTimers(); + + const initializeProviders = vi.fn().mockResolvedValue(undefined); + const listener = new TemperatureStartupTasksListener({ initializeProviders }); + const event: AppReadyEvent = { + reason: 'nestjs-server-listening', + }; + + listener.handleAppReady(event); + + await vi.advanceTimersByTimeAsync(0); + + expect(initializeProviders).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts new file mode 100644 index 0000000000..70265a5ccc --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts @@ -0,0 +1,49 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import type { AppReadyEvent } from '@app/unraid-api/app/app-lifecycle.events.js'; +import { APP_READY_EVENT } from '@app/unraid-api/app/app-lifecycle.events.js'; +import { apiLogger } from '@app/core/log.js'; +import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; + +const DEFAULT_TEMPERATURE_STARTUP_DELAY_MS = 0; + +interface TemperatureStartupLogger { + info: (message: string, ...args: unknown[]) => void; + warn: (error: unknown, message: string, ...args: unknown[]) => void; +} + +interface TemperatureStartupService { + initializeProviders: () => Promise; +} + +export const scheduleTemperatureStartupTasks = ( + temperatureService: TemperatureStartupService | null | undefined, + logger: TemperatureStartupLogger, + delayMs = DEFAULT_TEMPERATURE_STARTUP_DELAY_MS +): void => { + if (!temperatureService) { + return; + } + + logger.info('Scheduling temperature startup tasks to run in %dms', delayMs); + + setTimeout(() => { + void temperatureService.initializeProviders().catch((error: unknown) => { + logger.warn(error, 'Temperature provider initialization after startup failed'); + }); + }, delayMs); +}; + +@Injectable() +export class TemperatureStartupTasksListener { + constructor( + @Inject(TemperatureService) + private readonly temperatureService: TemperatureStartupService + ) {} + + @OnEvent(APP_READY_EVENT) + handleAppReady(_event: AppReadyEvent): void { + scheduleTemperatureStartupTasks(this.temperatureService, apiLogger); + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts index 41005a5095..75d5a96f2f 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts @@ -8,6 +8,7 @@ import { IpmiSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temp import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; import { TemperatureHistoryService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.js'; import { TemperatureConfigService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-config.service.js'; +import { TemperatureStartupTasksListener } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; @Module({ @@ -23,6 +24,7 @@ import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temp inject: [ConfigService], }, TemperatureService, + TemperatureStartupTasksListener, LmSensorsService, DiskSensorsService, IpmiSensorsService, diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 6699066a1c..25c0c0da43 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -80,7 +80,7 @@ describe('TemperatureService', () => { describe('initialization', () => { it('should initialize available providers', async () => { - await service.onModuleInit(); + await service.initializeProviders(); expect(lmSensors.isAvailable).toHaveBeenCalled(); expect(diskSensors.isAvailable).toHaveBeenCalled(); @@ -89,7 +89,7 @@ describe('TemperatureService', () => { it('should handle provider initialization errors gracefully', async () => { vi.mocked(lmSensors.isAvailable!).mockRejectedValue(new Error('Failed')); - await service.onModuleInit(); + await service.initializeProviders(); // Should not throw const metrics = await service.getMetrics(); @@ -99,7 +99,7 @@ describe('TemperatureService', () => { describe('getMetrics', () => { beforeEach(async () => { - await service.onModuleInit(); + await service.initializeProviders(); }); it('should return temperature metrics', async () => { @@ -123,7 +123,7 @@ describe('TemperatureService', () => { history, temperatureConfigService ); - await emptyService.onModuleInit(); + await emptyService.initializeProviders(); const metrics = await emptyService.getMetrics(); expect(metrics).toBeNull(); @@ -155,7 +155,7 @@ describe('TemperatureService', () => { thresholds: { cpu_warning: 60, cpu_critical: 80 }, }); - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -182,7 +182,7 @@ describe('TemperatureService', () => { thresholds: {}, }); - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -210,7 +210,7 @@ describe('TemperatureService', () => { thresholds: {}, }); - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -239,7 +239,7 @@ describe('TemperatureService', () => { thresholds: {}, }); - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -269,7 +269,7 @@ describe('TemperatureService', () => { thresholds: { cpu_warning: 160 }, }); - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -294,7 +294,7 @@ describe('TemperatureService', () => { describe('buildSummary', () => { it('should calculate correct average', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 'sensor1', @@ -317,7 +317,7 @@ describe('TemperatureService', () => { }); it('should identify hottest and coolest sensors', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { id: 's1', @@ -349,7 +349,7 @@ describe('TemperatureService', () => { }); describe('edge cases', () => { it('should handle provider read timeout gracefully', async () => { - await service.onModuleInit(); + await service.initializeProviders(); // Simulate a slow/hanging provider vi.mocked(lmSensors.read!).mockImplementation( @@ -362,7 +362,7 @@ describe('TemperatureService', () => { }, 10000); it('should deduplicate sensors with same ID from different providers', async () => { - await service.onModuleInit(); + await service.initializeProviders(); // Both providers return a sensor with the same ID vi.mocked(lmSensors.read!).mockResolvedValue([ @@ -391,7 +391,7 @@ describe('TemperatureService', () => { }); it('should handle empty sensor name', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -409,7 +409,7 @@ describe('TemperatureService', () => { }); it('should handle negative temperature values', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -428,7 +428,7 @@ describe('TemperatureService', () => { }); it('should handle extremely high temperature values', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -447,7 +447,7 @@ describe('TemperatureService', () => { }); it('should handle NaN temperature values', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -465,7 +465,7 @@ describe('TemperatureService', () => { }); it('should handle mix of valid and NaN temperature values', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockResolvedValue([ { @@ -493,7 +493,7 @@ describe('TemperatureService', () => { }); it('should handle all providers failing', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockRejectedValue(new Error('lm-sensors failed')); vi.mocked(diskSensors.read!).mockRejectedValue(new Error('disk sensors failed')); @@ -504,7 +504,7 @@ describe('TemperatureService', () => { }); it('should handle partial provider failures', async () => { - await service.onModuleInit(); + await service.initializeProviders(); vi.mocked(lmSensors.read!).mockRejectedValue(new Error('lm-sensors failed')); vi.mocked(diskSensors.read!).mockResolvedValue([ diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index d27340d601..a2cdd5f4ce 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; import { IpmiSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.js'; @@ -21,9 +21,11 @@ import { // temperature.service.ts @Injectable() -export class TemperatureService implements OnModuleInit { +export class TemperatureService { private readonly logger = new Logger(TemperatureService.name); private availableProviders: TemperatureSensorProvider[] = []; + private initializationPromise: Promise | null = null; + private initialized = false; private cache: TemperatureMetrics | null = null; private cacheTimestamp = 0; @@ -40,17 +42,29 @@ export class TemperatureService implements OnModuleInit { private readonly configService: TemperatureConfigService ) {} - async onModuleInit() { - // Initialize all providers and check availability - await this.initializeProviders(); + async initializeProviders(): Promise { + if (this.initialized) { + return; + } + + if (this.initializationPromise) { + return this.initializationPromise; + } + + this.initializationPromise = this.loadAvailableProviders().finally(() => { + this.initializationPromise = null; + }); + + return this.initializationPromise; } - private async initializeProviders(): Promise { + private async loadAvailableProviders(): Promise { // 1. Get sensor specific configs const config = this.configService.getConfig(false); const lmSensorsConfig = config?.sensors?.lm_sensors; const smartctlConfig = config?.sensors?.smartctl; const ipmiConfig = config?.sensors?.ipmi; + const availableProviders: TemperatureSensorProvider[] = []; // 2. Define providers with their config checks // We default to TRUE if the config is missing @@ -79,7 +93,7 @@ export class TemperatureService implements OnModuleInit { try { if (await provider.service.isAvailable()) { - this.availableProviders.push(provider.service); + availableProviders.push(provider.service); this.logger.log(`Temperature provider available: ${provider.service.id}`); } else { this.logger.debug(`Temperature provider not available: ${provider.service.id}`); @@ -89,12 +103,17 @@ export class TemperatureService implements OnModuleInit { } } + this.availableProviders = availableProviders; + this.initialized = true; + if (this.availableProviders.length === 0) { this.logger.warn('No temperature providers available'); } } async getMetrics(): Promise { + await this.initializeProviders(); + // Check if we can use recent history instead of re-reading sensors const mostRecent = this.history.getMostRecentReading(); const canUseHistory = diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts new file mode 100644 index 0000000000..b76f3d8673 --- /dev/null +++ b/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { MothershipController } from '../mothership-proxy/mothership.controller.js'; +import { DynamicRemoteAccessService } from '../remote-access/dynamic-remote-access.service.js'; +import { + ConnectStartupTasksListener, + scheduleConnectStartupTasks, +} from '../startup/connect-startup-tasks.js'; + +describe('scheduleConnectStartupTasks', () => { + it('schedules connect startup work after the provided delay', async () => { + vi.useFakeTimers(); + + const initRemoteAccess = vi.fn().mockResolvedValue(undefined); + const initOrRestart = vi.fn().mockResolvedValue(undefined); + const logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + scheduleConnectStartupTasks( + { + dynamicRemoteAccessService: { initRemoteAccess }, + mothershipController: { initOrRestart }, + }, + logger, + 250 + ); + + expect(initRemoteAccess).not.toHaveBeenCalled(); + expect(initOrRestart).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(250); + + expect(initRemoteAccess).toHaveBeenCalledTimes(1); + expect(initOrRestart).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + + it('warns when a background connect startup task rejects', async () => { + vi.useFakeTimers(); + + const backgroundError = new Error('network unavailable'); + const logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + scheduleConnectStartupTasks( + { + dynamicRemoteAccessService: { + initRemoteAccess: vi.fn().mockRejectedValue(backgroundError), + }, + }, + logger, + 250 + ); + + await vi.advanceTimersByTimeAsync(250); + await vi.runAllTicks(); + + expect(logger.warn).toHaveBeenCalledWith( + 'Dynamic remote access startup failed', + backgroundError + ); + + vi.useRealTimers(); + }); + + it('does nothing when connect providers are unavailable', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + expect(() => scheduleConnectStartupTasks({}, logger)).not.toThrow(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); + +describe('ConnectStartupTasksListener', () => { + it('schedules connect startup work when the app ready event is emitted', async () => { + vi.useFakeTimers(); + + const initRemoteAccess = vi.fn().mockResolvedValue(undefined); + const initOrRestart = vi.fn().mockResolvedValue(undefined); + const listener = new ConnectStartupTasksListener({ initRemoteAccess }, { initOrRestart }); + const event = { + reason: 'nestjs-server-listening', + } as const; + + listener.handleAppReady(event); + + await vi.advanceTimersByTimeAsync(0); + + expect(initRemoteAccess).toHaveBeenCalledTimes(1); + expect(initOrRestart).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); +}); diff --git a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.controller.ts b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.controller.ts index 237479aa3f..63b7871a55 100644 --- a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.controller.ts +++ b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.controller.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common'; +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; import { TimeoutCheckerJob } from '../connection-status/timeout-checker.job.js'; import { MothershipConnectionService } from './connection.service.js'; @@ -13,7 +13,7 @@ import { MothershipSubscriptionHandler } from './mothership-subscription.handler * - Connection service (controller for connection state & metadata) */ @Injectable() -export class MothershipController implements OnModuleDestroy, OnApplicationBootstrap { +export class MothershipController implements OnModuleDestroy { private readonly logger = new Logger(MothershipController.name); constructor( private readonly clientService: MothershipGraphqlClientService, @@ -26,10 +26,6 @@ export class MothershipController implements OnModuleDestroy, OnApplicationBoots await this.stop(); } - async onApplicationBootstrap() { - await this.initOrRestart(); - } - /** * Stops the mothership stack. Throws on first error. */ diff --git a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts index 267b438262..b3170929f4 100644 --- a/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts +++ b/packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts @@ -6,6 +6,7 @@ import { CloudService } from '../connection-status/cloud.service.js'; import { ConnectStatusWriterService } from '../connection-status/connect-status-writer.service.js'; import { TimeoutCheckerJob } from '../connection-status/timeout-checker.job.js'; import { RemoteAccessModule } from '../remote-access/remote-access.module.js'; +import { ConnectStartupTasksListener } from '../startup/connect-startup-tasks.js'; import { MothershipConnectionService } from './connection.service.js'; import { MothershipGraphqlClientService } from './graphql.client.js'; import { MothershipSubscriptionHandler } from './mothership-subscription.handler.js'; @@ -16,6 +17,7 @@ import { MothershipHandler } from './mothership.events.js'; imports: [RemoteAccessModule], providers: [ ConnectStatusWriterService, + ConnectStartupTasksListener, MothershipConnectionService, MothershipGraphqlClientService, MothershipHandler, diff --git a/packages/unraid-api-plugin-connect/src/remote-access/dynamic-remote-access.service.ts b/packages/unraid-api-plugin-connect/src/remote-access/dynamic-remote-access.service.ts index 3e9deae89f..5c2d48ff0e 100644 --- a/packages/unraid-api-plugin-connect/src/remote-access/dynamic-remote-access.service.ts +++ b/packages/unraid-api-plugin-connect/src/remote-access/dynamic-remote-access.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { URL_TYPE } from '@unraid/shared/network.model.js'; @@ -15,7 +15,7 @@ import { StaticRemoteAccessService } from './static-remote-access.service.js'; import { UpnpRemoteAccessService } from './upnp-remote-access.service.js'; @Injectable() -export class DynamicRemoteAccessService implements OnApplicationBootstrap { +export class DynamicRemoteAccessService { private readonly logger = new Logger(DynamicRemoteAccessService.name); constructor( @@ -24,10 +24,6 @@ export class DynamicRemoteAccessService implements OnApplicationBootstrap { private readonly upnpRemoteAccessService: UpnpRemoteAccessService ) {} - async onApplicationBootstrap() { - await this.initRemoteAccess(); - } - /** * Get the current state of dynamic remote access */ @@ -146,7 +142,7 @@ export class DynamicRemoteAccessService implements OnApplicationBootstrap { this.clearError(); } - private async initRemoteAccess() { + async initRemoteAccess() { this.logger.verbose('Initializing Remote Access'); const { wanaccess, upnpEnabled } = this.configService.get('connect.config', { infer: true }); if (wanaccess && upnpEnabled) { diff --git a/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts b/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts new file mode 100644 index 0000000000..674924bd58 --- /dev/null +++ b/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts @@ -0,0 +1,85 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import { MothershipController } from '../mothership-proxy/mothership.controller.js'; +import { DynamicRemoteAccessService } from '../remote-access/dynamic-remote-access.service.js'; + +const DEFAULT_CONNECT_STARTUP_DELAY_MS = 0; +const APP_READY_EVENT = 'app.ready'; + +interface AppReadyEvent { + reason: 'nestjs-server-listening'; +} + +interface ConnectStartupRemoteAccess { + initRemoteAccess: () => Promise; +} + +interface ConnectStartupMothership { + initOrRestart: () => Promise; +} + +interface ConnectStartupTasksDependencies { + dynamicRemoteAccessService?: ConnectStartupRemoteAccess | null; + mothershipController?: ConnectStartupMothership | null; +} + +interface ConnectStartupLogger { + info: (message: string) => void; + warn: (message: string, error: unknown) => void; +} + +export const scheduleConnectStartupTasks = ( + { dynamicRemoteAccessService, mothershipController }: ConnectStartupTasksDependencies, + logger: ConnectStartupLogger, + delayMs = DEFAULT_CONNECT_STARTUP_DELAY_MS +): void => { + if (!dynamicRemoteAccessService && !mothershipController) { + return; + } + + logger.info(`Scheduling Connect startup tasks to run in ${delayMs}ms`); + + if (dynamicRemoteAccessService) { + setTimeout(() => { + void dynamicRemoteAccessService.initRemoteAccess().catch((error: unknown) => { + logger.warn('Dynamic remote access startup failed', error); + }); + }, delayMs); + } + + if (mothershipController) { + setTimeout(() => { + void mothershipController.initOrRestart().catch((error: unknown) => { + logger.warn('Mothership startup failed', error); + }); + }, delayMs); + } +}; + +@Injectable() +export class ConnectStartupTasksListener { + private readonly logger = new Logger(ConnectStartupTasksListener.name); + + constructor( + @Inject(DynamicRemoteAccessService) + private readonly dynamicRemoteAccessService: ConnectStartupRemoteAccess, + @Inject(MothershipController) + private readonly mothershipController: ConnectStartupMothership + ) {} + + @OnEvent(APP_READY_EVENT) + handleAppReady(_event: AppReadyEvent): void { + scheduleConnectStartupTasks( + { + dynamicRemoteAccessService: this.dynamicRemoteAccessService, + mothershipController: this.mothershipController, + }, + { + info: (message: string) => this.logger.log(message), + warn: (message: string, error: unknown) => this.logger.warn(`${message}: ${String(error)}`), + } + ); + } +} From 7e2739bb2cae068bb83ba47d58b76188cbafb26c Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Apr 2026 09:13:24 +0100 Subject: [PATCH 02/14] refactor(startup): remove zero-delay startup timers - switch the post-ready temperature and connect listeners to direct async event handling - before this change both listeners wrapped work in setTimeout(..., 0), which added indirection without changing the readiness boundary - that made the startup path harder to reason about and less idiomatic than using the event emitter's async listener mode directly - the new listeners run from @OnEvent(..., { async: true }) and keep warning behavior intact - connect startup still isolates remote-access and mothership failures with Promise.allSettled so one rejection does not block the other --- .../temperature-startup-tasks.spec.ts | 46 +++---------- .../temperature/temperature-startup-tasks.ts | 28 +++----- .../__test__/connect-startup-tasks.test.ts | 69 ++++++++++--------- .../src/startup/connect-startup-tasks.ts | 36 ++++------ 4 files changed, 69 insertions(+), 110 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts index 5510e39da0..c325be915d 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts @@ -1,84 +1,58 @@ import { describe, expect, it, vi } from 'vitest'; import type { AppReadyEvent } from '@app/unraid-api/app/app-lifecycle.events.js'; -import { TemperatureStartupTasksListener, scheduleTemperatureStartupTasks } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.js'; - -describe('scheduleTemperatureStartupTasks', () => { - it('schedules temperature startup work after the provided delay', async () => { - vi.useFakeTimers(); +import { TemperatureStartupTasksListener, runTemperatureStartupTasks } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.js'; +describe('runTemperatureStartupTasks', () => { + it('runs temperature startup work immediately', async () => { const initializeProviders = vi.fn().mockResolvedValue(undefined); const logger = { - info: vi.fn(), warn: vi.fn(), }; - scheduleTemperatureStartupTasks({ initializeProviders }, logger, 250); - - expect(initializeProviders).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(250); + await runTemperatureStartupTasks({ initializeProviders }, logger); expect(initializeProviders).toHaveBeenCalledTimes(1); - - vi.useRealTimers(); }); - it('warns when background temperature startup rejects', async () => { - vi.useFakeTimers(); - + it('warns when temperature startup rejects', async () => { const backgroundError = new Error('disk scan failed'); const logger = { - info: vi.fn(), warn: vi.fn(), }; - scheduleTemperatureStartupTasks( + await runTemperatureStartupTasks( { initializeProviders: vi.fn().mockRejectedValue(backgroundError), }, - logger, - 250 + logger ); - await vi.advanceTimersByTimeAsync(250); - await vi.runAllTicks(); - expect(logger.warn).toHaveBeenCalledWith( backgroundError, 'Temperature provider initialization after startup failed' ); - - vi.useRealTimers(); }); it('does nothing when the temperature service is unavailable', () => { const logger = { - info: vi.fn(), warn: vi.fn(), }; - expect(() => scheduleTemperatureStartupTasks(undefined, logger)).not.toThrow(); - expect(logger.info).not.toHaveBeenCalled(); + expect(() => runTemperatureStartupTasks(undefined, logger)).not.toThrow(); }); }); describe('TemperatureStartupTasksListener', () => { - it('schedules temperature startup work when the app ready event is emitted', async () => { - vi.useFakeTimers(); - + it('runs temperature startup work when the app ready event is emitted', async () => { const initializeProviders = vi.fn().mockResolvedValue(undefined); const listener = new TemperatureStartupTasksListener({ initializeProviders }); const event: AppReadyEvent = { reason: 'nestjs-server-listening', }; - listener.handleAppReady(event); - - await vi.advanceTimersByTimeAsync(0); + await listener.handleAppReady(event); expect(initializeProviders).toHaveBeenCalledTimes(1); - - vi.useRealTimers(); }); }); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts index 70265a5ccc..12de20589f 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts @@ -6,10 +6,7 @@ import { APP_READY_EVENT } from '@app/unraid-api/app/app-lifecycle.events.js'; import { apiLogger } from '@app/core/log.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; -const DEFAULT_TEMPERATURE_STARTUP_DELAY_MS = 0; - interface TemperatureStartupLogger { - info: (message: string, ...args: unknown[]) => void; warn: (error: unknown, message: string, ...args: unknown[]) => void; } @@ -17,22 +14,19 @@ interface TemperatureStartupService { initializeProviders: () => Promise; } -export const scheduleTemperatureStartupTasks = ( +export const runTemperatureStartupTasks = async ( temperatureService: TemperatureStartupService | null | undefined, - logger: TemperatureStartupLogger, - delayMs = DEFAULT_TEMPERATURE_STARTUP_DELAY_MS -): void => { + logger: TemperatureStartupLogger +): Promise => { if (!temperatureService) { return; } - logger.info('Scheduling temperature startup tasks to run in %dms', delayMs); - - setTimeout(() => { - void temperatureService.initializeProviders().catch((error: unknown) => { - logger.warn(error, 'Temperature provider initialization after startup failed'); - }); - }, delayMs); + try { + await temperatureService.initializeProviders(); + } catch (error: unknown) { + logger.warn(error, 'Temperature provider initialization after startup failed'); + } }; @Injectable() @@ -42,8 +36,8 @@ export class TemperatureStartupTasksListener { private readonly temperatureService: TemperatureStartupService ) {} - @OnEvent(APP_READY_EVENT) - handleAppReady(_event: AppReadyEvent): void { - scheduleTemperatureStartupTasks(this.temperatureService, apiLogger); + @OnEvent(APP_READY_EVENT, { async: true }) + async handleAppReady(_event: AppReadyEvent): Promise { + await runTemperatureStartupTasks(this.temperatureService, apiLogger); } } diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts index b76f3d8673..256183e53c 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts @@ -4,13 +4,11 @@ import { MothershipController } from '../mothership-proxy/mothership.controller. import { DynamicRemoteAccessService } from '../remote-access/dynamic-remote-access.service.js'; import { ConnectStartupTasksListener, - scheduleConnectStartupTasks, + runConnectStartupTasks, } from '../startup/connect-startup-tasks.js'; -describe('scheduleConnectStartupTasks', () => { - it('schedules connect startup work after the provided delay', async () => { - vi.useFakeTimers(); - +describe('runConnectStartupTasks', () => { + it('runs connect startup work immediately', async () => { const initRemoteAccess = vi.fn().mockResolvedValue(undefined); const initOrRestart = vi.fn().mockResolvedValue(undefined); const logger = { @@ -18,54 +16,63 @@ describe('scheduleConnectStartupTasks', () => { warn: vi.fn(), }; - scheduleConnectStartupTasks( + await runConnectStartupTasks( { dynamicRemoteAccessService: { initRemoteAccess }, mothershipController: { initOrRestart }, }, - logger, - 250 + logger ); - expect(initRemoteAccess).not.toHaveBeenCalled(); - expect(initOrRestart).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(250); - expect(initRemoteAccess).toHaveBeenCalledTimes(1); expect(initOrRestart).toHaveBeenCalledTimes(1); - - vi.useRealTimers(); }); - it('warns when a background connect startup task rejects', async () => { - vi.useFakeTimers(); - + it('warns when a connect startup task rejects', async () => { const backgroundError = new Error('network unavailable'); const logger = { info: vi.fn(), warn: vi.fn(), }; - scheduleConnectStartupTasks( + await runConnectStartupTasks( { dynamicRemoteAccessService: { initRemoteAccess: vi.fn().mockRejectedValue(backgroundError), }, }, - logger, - 250 + logger ); - await vi.advanceTimersByTimeAsync(250); - await vi.runAllTicks(); - expect(logger.warn).toHaveBeenCalledWith( 'Dynamic remote access startup failed', backgroundError ); + }); + + it('still runs mothership startup when remote access startup rejects', async () => { + const backgroundError = new Error('network unavailable'); + const initOrRestart = vi.fn().mockResolvedValue(undefined); + const logger = { + info: vi.fn(), + warn: vi.fn(), + }; + + await runConnectStartupTasks( + { + dynamicRemoteAccessService: { + initRemoteAccess: vi.fn().mockRejectedValue(backgroundError), + }, + mothershipController: { initOrRestart }, + }, + logger + ); - vi.useRealTimers(); + expect(initOrRestart).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + 'Dynamic remote access startup failed', + backgroundError + ); }); it('does nothing when connect providers are unavailable', () => { @@ -74,16 +81,14 @@ describe('scheduleConnectStartupTasks', () => { warn: vi.fn(), }; - expect(() => scheduleConnectStartupTasks({}, logger)).not.toThrow(); + expect(() => runConnectStartupTasks({}, logger)).not.toThrow(); expect(logger.info).not.toHaveBeenCalled(); expect(logger.warn).not.toHaveBeenCalled(); }); }); describe('ConnectStartupTasksListener', () => { - it('schedules connect startup work when the app ready event is emitted', async () => { - vi.useFakeTimers(); - + it('runs connect startup work when the app ready event is emitted', async () => { const initRemoteAccess = vi.fn().mockResolvedValue(undefined); const initOrRestart = vi.fn().mockResolvedValue(undefined); const listener = new ConnectStartupTasksListener({ initRemoteAccess }, { initOrRestart }); @@ -91,13 +96,9 @@ describe('ConnectStartupTasksListener', () => { reason: 'nestjs-server-listening', } as const; - listener.handleAppReady(event); - - await vi.advanceTimersByTimeAsync(0); + await listener.handleAppReady(event); expect(initRemoteAccess).toHaveBeenCalledTimes(1); expect(initOrRestart).toHaveBeenCalledTimes(1); - - vi.useRealTimers(); }); }); diff --git a/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts b/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts index 674924bd58..37451ae010 100644 --- a/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts +++ b/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts @@ -4,8 +4,6 @@ import { OnEvent } from '@nestjs/event-emitter'; import { MothershipController } from '../mothership-proxy/mothership.controller.js'; import { DynamicRemoteAccessService } from '../remote-access/dynamic-remote-access.service.js'; - -const DEFAULT_CONNECT_STARTUP_DELAY_MS = 0; const APP_READY_EVENT = 'app.ready'; interface AppReadyEvent { @@ -30,32 +28,24 @@ interface ConnectStartupLogger { warn: (message: string, error: unknown) => void; } -export const scheduleConnectStartupTasks = ( +export const runConnectStartupTasks = async ( { dynamicRemoteAccessService, mothershipController }: ConnectStartupTasksDependencies, - logger: ConnectStartupLogger, - delayMs = DEFAULT_CONNECT_STARTUP_DELAY_MS -): void => { + logger: ConnectStartupLogger +): Promise => { if (!dynamicRemoteAccessService && !mothershipController) { return; } - logger.info(`Scheduling Connect startup tasks to run in ${delayMs}ms`); + logger.info('Running Connect startup tasks after app.ready'); - if (dynamicRemoteAccessService) { - setTimeout(() => { - void dynamicRemoteAccessService.initRemoteAccess().catch((error: unknown) => { + await Promise.allSettled([ + dynamicRemoteAccessService?.initRemoteAccess().catch((error: unknown) => { logger.warn('Dynamic remote access startup failed', error); - }); - }, delayMs); - } - - if (mothershipController) { - setTimeout(() => { - void mothershipController.initOrRestart().catch((error: unknown) => { + }), + mothershipController?.initOrRestart().catch((error: unknown) => { logger.warn('Mothership startup failed', error); - }); - }, delayMs); - } + }), + ]); }; @Injectable() @@ -69,9 +59,9 @@ export class ConnectStartupTasksListener { private readonly mothershipController: ConnectStartupMothership ) {} - @OnEvent(APP_READY_EVENT) - handleAppReady(_event: AppReadyEvent): void { - scheduleConnectStartupTasks( + @OnEvent(APP_READY_EVENT, { async: true }) + async handleAppReady(_event: AppReadyEvent): Promise { + await runConnectStartupTasks( { dynamicRemoteAccessService: this.dynamicRemoteAccessService, mothershipController: this.mothershipController, From 94bfa73d7398c24a6e651aa46c32e6bf07b4dce2 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Apr 2026 09:17:47 +0100 Subject: [PATCH 03/14] style(startup): fix prettier ordering in temperature tasks - align the temperature startup task files with repo formatting expectations - CI was failing in the Test API lint step on import ordering and wrapping only - no runtime behavior changed in this commit - formatting was applied from the api workspace and verified with pnpm run lint --- .../metrics/temperature/temperature-startup-tasks.spec.ts | 5 ++++- .../metrics/temperature/temperature-startup-tasks.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts index c325be915d..8de30a8271 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { AppReadyEvent } from '@app/unraid-api/app/app-lifecycle.events.js'; -import { TemperatureStartupTasksListener, runTemperatureStartupTasks } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.js'; +import { + runTemperatureStartupTasks, + TemperatureStartupTasksListener, +} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.js'; describe('runTemperatureStartupTasks', () => { it('runs temperature startup work immediately', async () => { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts index 12de20589f..69fc04d4c4 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts @@ -2,8 +2,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import type { AppReadyEvent } from '@app/unraid-api/app/app-lifecycle.events.js'; -import { APP_READY_EVENT } from '@app/unraid-api/app/app-lifecycle.events.js'; import { apiLogger } from '@app/core/log.js'; +import { APP_READY_EVENT } from '@app/unraid-api/app/app-lifecycle.events.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; interface TemperatureStartupLogger { From c983f43ac5234921212b75bc166f49fec283cf36 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Apr 2026 09:27:34 +0100 Subject: [PATCH 04/14] refactor(temperature): move app-ready startup into service - fold the post-ready temperature initialization handler into TemperatureService - before this change the temperature module registered a separate listener provider only to forward app.ready into initializeProviders - that added module-level indirection without adding real behavior or isolation - the service now owns its own app-ready handler and logs startup initialization failures directly - the standalone startup-task files were removed and the service spec now covers the post-ready path --- .../temperature-startup-tasks.spec.ts | 61 ------------------- .../temperature/temperature-startup-tasks.ts | 43 ------------- .../metrics/temperature/temperature.module.ts | 2 - .../temperature/temperature.service.spec.ts | 27 ++++++++ .../temperature/temperature.service.ts | 12 ++++ 5 files changed, 39 insertions(+), 106 deletions(-) delete mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts delete mode 100644 api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts deleted file mode 100644 index 8de30a8271..0000000000 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import type { AppReadyEvent } from '@app/unraid-api/app/app-lifecycle.events.js'; -import { - runTemperatureStartupTasks, - TemperatureStartupTasksListener, -} from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.js'; - -describe('runTemperatureStartupTasks', () => { - it('runs temperature startup work immediately', async () => { - const initializeProviders = vi.fn().mockResolvedValue(undefined); - const logger = { - warn: vi.fn(), - }; - - await runTemperatureStartupTasks({ initializeProviders }, logger); - - expect(initializeProviders).toHaveBeenCalledTimes(1); - }); - - it('warns when temperature startup rejects', async () => { - const backgroundError = new Error('disk scan failed'); - const logger = { - warn: vi.fn(), - }; - - await runTemperatureStartupTasks( - { - initializeProviders: vi.fn().mockRejectedValue(backgroundError), - }, - logger - ); - - expect(logger.warn).toHaveBeenCalledWith( - backgroundError, - 'Temperature provider initialization after startup failed' - ); - }); - - it('does nothing when the temperature service is unavailable', () => { - const logger = { - warn: vi.fn(), - }; - - expect(() => runTemperatureStartupTasks(undefined, logger)).not.toThrow(); - }); -}); - -describe('TemperatureStartupTasksListener', () => { - it('runs temperature startup work when the app ready event is emitted', async () => { - const initializeProviders = vi.fn().mockResolvedValue(undefined); - const listener = new TemperatureStartupTasksListener({ initializeProviders }); - const event: AppReadyEvent = { - reason: 'nestjs-server-listening', - }; - - await listener.handleAppReady(event); - - expect(initializeProviders).toHaveBeenCalledTimes(1); - }); -}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts deleted file mode 100644 index 69fc04d4c4..0000000000 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; - -import type { AppReadyEvent } from '@app/unraid-api/app/app-lifecycle.events.js'; -import { apiLogger } from '@app/core/log.js'; -import { APP_READY_EVENT } from '@app/unraid-api/app/app-lifecycle.events.js'; -import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; - -interface TemperatureStartupLogger { - warn: (error: unknown, message: string, ...args: unknown[]) => void; -} - -interface TemperatureStartupService { - initializeProviders: () => Promise; -} - -export const runTemperatureStartupTasks = async ( - temperatureService: TemperatureStartupService | null | undefined, - logger: TemperatureStartupLogger -): Promise => { - if (!temperatureService) { - return; - } - - try { - await temperatureService.initializeProviders(); - } catch (error: unknown) { - logger.warn(error, 'Temperature provider initialization after startup failed'); - } -}; - -@Injectable() -export class TemperatureStartupTasksListener { - constructor( - @Inject(TemperatureService) - private readonly temperatureService: TemperatureStartupService - ) {} - - @OnEvent(APP_READY_EVENT, { async: true }) - async handleAppReady(_event: AppReadyEvent): Promise { - await runTemperatureStartupTasks(this.temperatureService, apiLogger); - } -} diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts index 75d5a96f2f..41005a5095 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.module.ts @@ -8,7 +8,6 @@ import { IpmiSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temp import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; import { TemperatureHistoryService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature_history.service.js'; import { TemperatureConfigService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-config.service.js'; -import { TemperatureStartupTasksListener } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-startup-tasks.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; @Module({ @@ -24,7 +23,6 @@ import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temp inject: [ConfigService], }, TemperatureService, - TemperatureStartupTasksListener, LmSensorsService, DiskSensorsService, IpmiSensorsService, diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 25c0c0da43..18ffe7d697 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -2,6 +2,7 @@ import { ConfigService } from '@nestjs/config'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AppReadyEvent } from '@app/unraid-api/app/app-lifecycle.events.js'; import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; import { IpmiSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.js'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; @@ -95,6 +96,32 @@ describe('TemperatureService', () => { const metrics = await service.getMetrics(); expect(metrics).toBeDefined(); }); + + it('should initialize providers when the app ready event is emitted', async () => { + const event: AppReadyEvent = { + reason: 'nestjs-server-listening', + }; + + await service.handleAppReady(event); + + expect(lmSensors.isAvailable).toHaveBeenCalled(); + expect(diskSensors.isAvailable).toHaveBeenCalled(); + }); + + it('should warn when initialization fails after the app is ready', async () => { + const event: AppReadyEvent = { + reason: 'nestjs-server-listening', + }; + const warnSpy = vi.spyOn(service['logger'], 'warn'); + vi.spyOn(service, 'initializeProviders').mockRejectedValue(new Error('disk scan failed')); + + await service.handleAppReady(event); + + expect(warnSpy).toHaveBeenCalledWith( + 'Temperature provider initialization after startup failed', + expect.any(Error) + ); + }); }); describe('getMetrics', () => { diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index a2cdd5f4ce..4bb2d4698d 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -1,5 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import type { AppReadyEvent } from '@app/unraid-api/app/app-lifecycle.events.js'; +import { APP_READY_EVENT } from '@app/unraid-api/app/app-lifecycle.events.js'; import { DiskSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.js'; import { IpmiSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/ipmi_sensors.service.js'; import { LmSensorsService } from '@app/unraid-api/graph/resolvers/metrics/temperature/sensors/lm_sensors.service.js'; @@ -58,6 +61,15 @@ export class TemperatureService { return this.initializationPromise; } + @OnEvent(APP_READY_EVENT, { async: true }) + async handleAppReady(_event: AppReadyEvent): Promise { + try { + await this.initializeProviders(); + } catch (error: unknown) { + this.logger.warn('Temperature provider initialization after startup failed', error); + } + } + private async loadAvailableProviders(): Promise { // 1. Get sensor specific configs const config = this.configService.getConfig(false); From 119a8227b8c5e62a83243f726edf1d0ab5e2a4e4 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Apr 2026 09:39:42 +0100 Subject: [PATCH 05/14] test(temperature): update disk sensor availability spec - align DiskSensorsService tests with the new constant-time availability behavior - before this change CI still expected isAvailable() to return false when no disks existed or when DisksService threw - that no longer matched the startup fix, where disk sensor availability intentionally avoids calling getDisks - the spec now asserts that availability remains true and that isAvailable() does not touch DisksService --- .../sensors/disk_sensors.service.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts index 04945e7f25..1e736fd3b4 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts @@ -33,27 +33,30 @@ describe('DiskSensorsService', () => { }); describe('isAvailable', () => { - it('should return true when disks exist', async () => { + it('should return true without checking disks', async () => { vi.mocked(disksService.getDisks).mockResolvedValue([ { id: 'disk1', device: '/dev/sda', name: 'Test Disk' } as unknown as Disk, ]); const available = await service.isAvailable(); expect(available).toBe(true); + expect(disksService.getDisks).not.toHaveBeenCalled(); }); - it('should return false when no disks exist', async () => { + it('should return true when no disks exist', async () => { vi.mocked(disksService.getDisks).mockResolvedValue([]); const available = await service.isAvailable(); - expect(available).toBe(false); + expect(available).toBe(true); + expect(disksService.getDisks).not.toHaveBeenCalled(); }); - it('should return false when DisksService throws', async () => { + it('should return true when DisksService would throw', async () => { vi.mocked(disksService.getDisks).mockRejectedValue(new Error('Failed')); const available = await service.isAvailable(); - expect(available).toBe(false); + expect(available).toBe(true); + expect(disksService.getDisks).not.toHaveBeenCalled(); }); }); From d454d0853800ca6b99a47debd4e3319022967280 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Apr 2026 09:44:17 +0100 Subject: [PATCH 06/14] test(connect): await empty startup task run - update the connect startup task test to await runConnectStartupTasks in the no-provider case - before this change the test wrapped an async call in a synchronous not.toThrow assertion - that could miss promise-settlement semantics even when the implementation resolved immediately - the test now uses async/await and asserts the promise resolves before checking logger expectations --- .../src/__test__/connect-startup-tasks.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts index 256183e53c..cef9de1e8f 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts @@ -75,13 +75,13 @@ describe('runConnectStartupTasks', () => { ); }); - it('does nothing when connect providers are unavailable', () => { + it('does nothing when connect providers are unavailable', async () => { const logger = { info: vi.fn(), warn: vi.fn(), }; - expect(() => runConnectStartupTasks({}, logger)).not.toThrow(); + await expect(runConnectStartupTasks({}, logger)).resolves.toBeUndefined(); expect(logger.info).not.toHaveBeenCalled(); expect(logger.warn).not.toHaveBeenCalled(); }); From c1bfdd35a0cbcd53714afded211a5b78b4faa061 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 10 Apr 2026 11:10:18 +0100 Subject: [PATCH 07/14] fix(cli): harden pm2 service lifecycle commands - purpose: make the unraid-api CLI recover cleanly from wedged PM2 state on server startup - before: start, restart, status, and stop passed --mini-list to PM2, and start left PM2 daemon state behind after stop/delete - problem: on the coworker server, unraid-api start and pm2 start --mini-list were hanging for days while no managed unraid-api process existed in PM2 - now: the CLI removes --mini-list from PM2 lifecycle commands and start fully kills and clears the configured PM2 home before launching again - how: update the start/restart/status/stop command wrappers and add regression coverage for the exact PM2 arguments we send --- .../cli/__test__/pm2-commands.spec.ts | 99 +++++++++++++++++++ api/src/unraid-api/cli/restart.command.ts | 3 +- api/src/unraid-api/cli/start.command.ts | 5 +- api/src/unraid-api/cli/status.command.ts | 7 +- api/src/unraid-api/cli/stop.command.ts | 3 +- 5 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 api/src/unraid-api/cli/__test__/pm2-commands.spec.ts diff --git a/api/src/unraid-api/cli/__test__/pm2-commands.spec.ts b/api/src/unraid-api/cli/__test__/pm2-commands.spec.ts new file mode 100644 index 0000000000..0bac4ba273 --- /dev/null +++ b/api/src/unraid-api/cli/__test__/pm2-commands.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ECOSYSTEM_PATH } from '@app/environment.js'; +import { LogService } from '@app/unraid-api/cli/log.service.js'; +import { PM2Service } from '@app/unraid-api/cli/pm2.service.js'; +import { RestartCommand } from '@app/unraid-api/cli/restart.command.js'; +import { StartCommand } from '@app/unraid-api/cli/start.command.js'; +import { StatusCommand } from '@app/unraid-api/cli/status.command.js'; +import { StopCommand } from '@app/unraid-api/cli/stop.command.js'; + +const createLogger = (): LogService => + ({ + trace: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + log: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }) as unknown as LogService; + +const createPm2Service = () => + ({ + run: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + ensurePm2Dependencies: vi.fn().mockResolvedValue(undefined), + deleteDump: vi.fn().mockResolvedValue(undefined), + deletePm2Home: vi.fn().mockResolvedValue(undefined), + forceKillPm2Daemon: vi.fn().mockResolvedValue(undefined), + }) as unknown as PM2Service; + +describe('PM2-backed CLI commands', () => { + it('start clears PM2 state and starts without mini-list', async () => { + const logger = createLogger(); + const pm2 = createPm2Service(); + const command = new StartCommand(logger, pm2); + + await command.run([], { logLevel: 'info' }); + + expect(pm2.ensurePm2Dependencies).toHaveBeenCalledTimes(1); + expect(pm2.deleteDump).toHaveBeenCalledTimes(1); + expect(pm2.deletePm2Home).toHaveBeenCalledTimes(1); + expect(pm2.run).toHaveBeenNthCalledWith(1, { tag: 'PM2 Stop' }, 'stop', ECOSYSTEM_PATH); + expect(pm2.run).toHaveBeenNthCalledWith(2, { tag: 'PM2 Update' }, 'update'); + expect(pm2.run).toHaveBeenNthCalledWith(3, { tag: 'PM2 Delete' }, 'delete', ECOSYSTEM_PATH); + expect(pm2.run).toHaveBeenNthCalledWith(4, { tag: 'PM2 Kill' }, 'kill', '--no-autorestart'); + expect(pm2.run).toHaveBeenNthCalledWith( + 5, + { tag: 'PM2 Start', raw: true, extendEnv: true, env: { LOG_LEVEL: 'info' } }, + 'start', + ECOSYSTEM_PATH, + '--update-env' + ); + expect(vi.mocked(pm2.run).mock.calls.flat()).not.toContain('--mini-list'); + }); + + it('restart omits mini-list from the PM2 restart call', async () => { + const logger = createLogger(); + const pm2 = createPm2Service(); + const command = new RestartCommand(logger, pm2); + + await command.run([], { logLevel: 'info' }); + + expect(pm2.run).toHaveBeenCalledWith( + { tag: 'PM2 Restart', raw: true, extendEnv: true, env: { LOG_LEVEL: 'info' } }, + 'restart', + ECOSYSTEM_PATH, + '--update-env' + ); + expect(vi.mocked(pm2.run).mock.calls.flat()).not.toContain('--mini-list'); + }); + + it('status omits mini-list from the PM2 status call', async () => { + const pm2 = createPm2Service(); + const command = new StatusCommand(pm2); + + await command.run(); + + expect(pm2.run).toHaveBeenCalledWith( + { tag: 'PM2 Status', stdio: 'inherit', raw: true }, + 'status', + 'unraid-api' + ); + expect(vi.mocked(pm2.run).mock.calls.flat()).not.toContain('--mini-list'); + }); + + it('stop omits mini-list from the PM2 delete call', async () => { + const pm2 = createPm2Service(); + const command = new StopCommand(pm2); + + await command.run([], { delete: false }); + + expect(pm2.run).toHaveBeenCalledWith( + { tag: 'PM2 Delete', stdio: 'inherit' }, + 'delete', + ECOSYSTEM_PATH, + '--no-autorestart' + ); + expect(vi.mocked(pm2.run).mock.calls.flat()).not.toContain('--mini-list'); + }); +}); diff --git a/api/src/unraid-api/cli/restart.command.ts b/api/src/unraid-api/cli/restart.command.ts index 66d54a513e..da6005a68c 100644 --- a/api/src/unraid-api/cli/restart.command.ts +++ b/api/src/unraid-api/cli/restart.command.ts @@ -35,8 +35,7 @@ export class RestartCommand extends CommandRunner { { tag: 'PM2 Restart', raw: true, extendEnv: true, env }, 'restart', ECOSYSTEM_PATH, - '--update-env', - '--mini-list' + '--update-env' ); if (stderr) { diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index 64c7d890d0..2bcf6c6d2d 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -23,6 +23,8 @@ export class StartCommand extends CommandRunner { await this.pm2.run({ tag: 'PM2 Update' }, 'update'); await this.pm2.deleteDump(); await this.pm2.run({ tag: 'PM2 Delete' }, 'delete', ECOSYSTEM_PATH); + await this.pm2.run({ tag: 'PM2 Kill' }, 'kill', '--no-autorestart'); + await this.pm2.deletePm2Home(); } async run(_: string[], options: LogLevelOptions): Promise { @@ -33,8 +35,7 @@ export class StartCommand extends CommandRunner { { tag: 'PM2 Start', raw: true, extendEnv: true, env }, 'start', ECOSYSTEM_PATH, - '--update-env', - '--mini-list' + '--update-env' ); if (stdout) { this.logger.log(stdout.toString()); diff --git a/api/src/unraid-api/cli/status.command.ts b/api/src/unraid-api/cli/status.command.ts index 6e1b6b6e2e..4e78e938ab 100644 --- a/api/src/unraid-api/cli/status.command.ts +++ b/api/src/unraid-api/cli/status.command.ts @@ -8,11 +8,6 @@ export class StatusCommand extends CommandRunner { super(); } async run(): Promise { - await this.pm2.run( - { tag: 'PM2 Status', stdio: 'inherit', raw: true }, - 'status', - 'unraid-api', - '--mini-list' - ); + await this.pm2.run({ tag: 'PM2 Status', stdio: 'inherit', raw: true }, 'status', 'unraid-api'); } } diff --git a/api/src/unraid-api/cli/stop.command.ts b/api/src/unraid-api/cli/stop.command.ts index 995dd07437..f496263ec5 100644 --- a/api/src/unraid-api/cli/stop.command.ts +++ b/api/src/unraid-api/cli/stop.command.ts @@ -33,8 +33,7 @@ export class StopCommand extends CommandRunner { { tag: 'PM2 Delete', stdio: 'inherit' }, 'delete', ECOSYSTEM_PATH, - '--no-autorestart', - '--mini-list' + '--no-autorestart' ); } } From 072926689969cab811f8cdc6e88251d6383b3937 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 10 Apr 2026 12:28:11 +0100 Subject: [PATCH 08/14] fix(plugin): create recovery archive during install - Purpose: make plugin installs create the dependency recovery archive that startup already expects. - Before: installs wrote vendor archive metadata but never generated the archive, so a partial install that lost node_modules had no recovery path. - Problem: overlapping or interrupted installs on the server could leave /usr/local/unraid-api without dependencies, and restart failed immediately instead of self-healing. - Change: the plugin installer now runs archive-dependencies before first start, and install verification now checks both node_modules and the vendor archive. - How: add the archive creation step to the .plg install flow and extend verify_install.sh to validate dependency presence and vendor archive config integrity. --- plugin/plugins/dynamix.unraid.net.plg | 8 ++++ .../install/scripts/verify_install.sh | 48 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 6e8a60e695..7c307a782f 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -596,6 +596,14 @@ echo "DEBUG: Attempting to run unraid-api directly:" echo "If no additional messages appear within 30 seconds, it is safe to refresh the page." /etc/rc.d/rc.unraid-api plugins add unraid-api-plugin-connect -b --no-restart + +echo "Creating dependency recovery archive..." +if /etc/rc.d/rc.unraid-api archive-dependencies; then + echo "Dependency recovery archive created" +else + echo "⚠️ Warning: Failed to create dependency recovery archive" +fi + /etc/rc.d/rc.unraid-api start echo "Unraid API service started" diff --git a/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh b/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh index 0731bd976e..10670e6da0 100755 --- a/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh +++ b/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh @@ -96,6 +96,19 @@ check_symlink() { fi } +check_populated_dir() { + if [ -d "$1" ] && [ "$(ls -A "$1" 2>/dev/null)" ]; then + printf '✓ Directory %s exists and is populated\n' "$1" + return 0 + elif [ -d "$1" ]; then + printf '✗ Directory %s exists but is empty\n' "$1" + return 1 + else + printf '✗ Directory %s is missing\n' "$1" + return 1 + fi +} + # Check executable files echo "Checking executable files..." EXEC_ERRORS=0 @@ -138,6 +151,38 @@ else fi TOTAL_ERRORS=$((TOTAL_ERRORS + CONFIG_ERRORS)) +echo "Checking dependency installation..." +DEPENDENCY_ERRORS=0 +if ! check_populated_dir "/usr/local/unraid-api/node_modules"; then + DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) +fi + +VENDOR_ARCHIVE_CONFIG="/usr/local/share/dynamix.unraid.net/config/vendor_archive.json" +if [ -f "$VENDOR_ARCHIVE_CONFIG" ]; then + printf '✓ Vendor archive config %s exists\n' "$VENDOR_ARCHIVE_CONFIG" + if command -v jq >/dev/null 2>&1; then + VENDOR_ARCHIVE_PATH=$(jq -r '.vendor_store_path' "$VENDOR_ARCHIVE_CONFIG" 2>/dev/null) + if [ -n "$VENDOR_ARCHIVE_PATH" ] && [ "$VENDOR_ARCHIVE_PATH" != "null" ]; then + if [ -f "$VENDOR_ARCHIVE_PATH" ]; then + printf '✓ Vendor archive %s exists\n' "$VENDOR_ARCHIVE_PATH" + else + printf '✗ Vendor archive %s is missing\n' "$VENDOR_ARCHIVE_PATH" + DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) + fi + else + printf '✗ Vendor archive config %s is missing vendor_store_path\n' "$VENDOR_ARCHIVE_CONFIG" + DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) + fi + else + printf '✗ jq is required to validate vendor archive config\n' + DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) + fi +else + printf '✗ Vendor archive config %s is missing\n' "$VENDOR_ARCHIVE_CONFIG" + DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) +fi +TOTAL_ERRORS=$((TOTAL_ERRORS + DEPENDENCY_ERRORS)) + # Check for proper Slackware-style shutdown configuration echo "Checking shutdown configuration..." SHUTDOWN_ERRORS=0 @@ -193,6 +238,7 @@ echo "- Executable files errors: $EXEC_ERRORS" echo "- Directory errors: $DIR_ERRORS" echo "- Symlink errors: $SYMLINK_ERRORS" echo "- Configuration errors: $CONFIG_ERRORS" +echo "- Dependency errors: $DEPENDENCY_ERRORS" echo "- Shutdown configuration errors: $SHUTDOWN_ERRORS" echo "- Total errors: $TOTAL_ERRORS" @@ -206,4 +252,4 @@ else echo "Please review the errors above and contact support if needed." # We don't exit with error as this is just a verification script exit 0 -fi \ No newline at end of file +fi From 6a55fe0d9e10430fdd23c821b89dc404ff8f3079 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 15 Apr 2026 13:25:06 -0400 Subject: [PATCH 09/14] fix(startup): harden deferred task failures - Purpose: make deferred startup and temperature paths tolerate transient failures without wedging later reads. - Before: temperature provider discovery could permanently cache an empty provider list after transient probe failures, and getMetrics could bubble initialization failures. - Problem: a disk temperature query after a bad provider probe could return no data or fail instead of retrying cleanly. - Change: provider discovery now retries after empty scans with probe failures, getMetrics returns null on initialization errors, and Connect startup logs settled rejection reasons without wrapping errors. - How: inspect Promise.allSettled results directly for Connect startup, preserve structured Nest warning errors, and add behavior coverage for transient temperature probe failures. --- .../sensors/disk_sensors.service.spec.ts | 34 ++++++++---------- .../temperature/temperature.service.spec.ts | 23 ++++++++++++ .../temperature/temperature.service.ts | 35 ++++++++++--------- .../plugin/plugin-management.service.ts | 3 +- .../__test__/connect-startup-tasks.test.ts | 14 +++----- .../src/startup/connect-startup-tasks.ts | 20 ++++++----- 6 files changed, 74 insertions(+), 55 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts index 1e736fd3b4..1344ad1d05 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts @@ -33,28 +33,24 @@ describe('DiskSensorsService', () => { }); describe('isAvailable', () => { - it('should return true without checking disks', async () => { - vi.mocked(disksService.getDisks).mockResolvedValue([ - { id: 'disk1', device: '/dev/sda', name: 'Test Disk' } as unknown as Disk, - ]); - - const available = await service.isAvailable(); - expect(available).toBe(true); - expect(disksService.getDisks).not.toHaveBeenCalled(); - }); - - it('should return true when no disks exist', async () => { - vi.mocked(disksService.getDisks).mockResolvedValue([]); + it.each([ + [ + 'when disks exist', + () => + vi.mocked(disksService.getDisks).mockResolvedValue([ + { id: 'disk1', device: '/dev/sda', name: 'Test Disk' } as unknown as Disk, + ]), + ], + ['when no disks exist', () => vi.mocked(disksService.getDisks).mockResolvedValue([])], + [ + 'when DisksService would throw', + () => vi.mocked(disksService.getDisks).mockRejectedValue(new Error('Failed')), + ], + ])('should return true without checking disks %s', async (_label, setupMock) => { + setupMock(); const available = await service.isAvailable(); - expect(available).toBe(true); - expect(disksService.getDisks).not.toHaveBeenCalled(); - }); - - it('should return true when DisksService would throw', async () => { - vi.mocked(disksService.getDisks).mockRejectedValue(new Error('Failed')); - const available = await service.isAvailable(); expect(available).toBe(true); expect(disksService.getDisks).not.toHaveBeenCalled(); }); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts index 18ffe7d697..8579bc835d 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.spec.ts @@ -97,6 +97,23 @@ describe('TemperatureService', () => { expect(metrics).toBeDefined(); }); + it('retries provider discovery after an empty scan with probe failures', async () => { + vi.mocked(lmSensors.isAvailable!) + .mockRejectedValueOnce(new Error('probe failed')) + .mockResolvedValueOnce(true); + vi.mocked(diskSensors.isAvailable!) + .mockRejectedValueOnce(new Error('probe failed')) + .mockResolvedValueOnce(false); + + expect(await service.getMetrics()).toBeNull(); + + const metrics = await service.getMetrics(); + + expect(metrics?.sensors).toHaveLength(1); + expect(lmSensors.isAvailable).toHaveBeenCalledTimes(2); + expect(diskSensors.isAvailable).toHaveBeenCalledTimes(2); + }); + it('should initialize providers when the app ready event is emitted', async () => { const event: AppReadyEvent = { reason: 'nestjs-server-listening', @@ -138,6 +155,12 @@ describe('TemperatureService', () => { expect(metrics?.sensors[0].current.value).toBe(55); }); + it('should return null when provider initialization throws', async () => { + vi.spyOn(service, 'initializeProviders').mockRejectedValue(new Error('disk scan failed')); + + await expect(service.getMetrics()).resolves.toBeNull(); + }); + it('should return null when no providers available', async () => { vi.mocked(lmSensors.isAvailable!).mockResolvedValue(false); vi.mocked(diskSensors.isAvailable!).mockResolvedValue(false); diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 4bb2d4698d..8c8ad7a381 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -71,6 +71,8 @@ export class TemperatureService { } private async loadAvailableProviders(): Promise { + let hadProbeFailure = false; + // 1. Get sensor specific configs const config = this.configService.getConfig(false); const lmSensorsConfig = config?.sensors?.lm_sensors; @@ -111,12 +113,13 @@ export class TemperatureService { this.logger.debug(`Temperature provider not available: ${provider.service.id}`); } } catch (err) { + hadProbeFailure = true; this.logger.warn(`Failed to check provider ${provider.service.id}`, err); } } this.availableProviders = availableProviders; - this.initialized = true; + this.initialized = availableProviders.length > 0 || !hadProbeFailure; if (this.availableProviders.length === 0) { this.logger.warn('No temperature providers available'); @@ -124,25 +127,25 @@ export class TemperatureService { } async getMetrics(): Promise { - await this.initializeProviders(); + try { + await this.initializeProviders(); - // Check if we can use recent history instead of re-reading sensors - const mostRecent = this.history.getMostRecentReading(); - const canUseHistory = - mostRecent && Date.now() - mostRecent.timestamp.getTime() < this.CACHE_TTL_MS; + // Check if we can use recent history instead of re-reading sensors + const mostRecent = this.history.getMostRecentReading(); + const canUseHistory = + mostRecent && Date.now() - mostRecent.timestamp.getTime() < this.CACHE_TTL_MS; - if (canUseHistory) { - // Build from history (fast path) - return this.buildMetricsFromHistory(); - } + if (canUseHistory) { + // Build from history (fast path) + return this.buildMetricsFromHistory(); + } - // Read fresh data from sensors - if (this.availableProviders.length === 0) { - this.logger.debug('Temperature metrics unavailable (no providers)'); - return null; - } + // Read fresh data from sensors + if (this.availableProviders.length === 0) { + this.logger.debug('Temperature metrics unavailable (no providers)'); + return null; + } - try { const allRawSensors: RawTemperatureSensor[] = []; for (const provider of this.availableProviders) { diff --git a/api/src/unraid-api/plugin/plugin-management.service.ts b/api/src/unraid-api/plugin/plugin-management.service.ts index 9e27182b63..0cbbf48ad4 100644 --- a/api/src/unraid-api/plugin/plugin-management.service.ts +++ b/api/src/unraid-api/plugin/plugin-management.service.ts @@ -129,8 +129,7 @@ export class PluginManagementService { *------------------------------------------------------------------------**/ async addBundledPlugin(...plugins: string[]) { - const added = this.addPluginToConfig(...plugins); - return added; + return this.addPluginToConfig(...plugins); } /** diff --git a/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts b/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts index cef9de1e8f..3f535bff15 100644 --- a/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts +++ b/packages/unraid-api-plugin-connect/src/__test__/connect-startup-tasks.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { MothershipController } from '../mothership-proxy/mothership.controller.js'; -import { DynamicRemoteAccessService } from '../remote-access/dynamic-remote-access.service.js'; import { ConnectStartupTasksListener, runConnectStartupTasks, @@ -44,10 +42,8 @@ describe('runConnectStartupTasks', () => { logger ); - expect(logger.warn).toHaveBeenCalledWith( - 'Dynamic remote access startup failed', - backgroundError - ); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith(expect.any(String), backgroundError); }); it('still runs mothership startup when remote access startup rejects', async () => { @@ -69,10 +65,8 @@ describe('runConnectStartupTasks', () => { ); expect(initOrRestart).toHaveBeenCalledTimes(1); - expect(logger.warn).toHaveBeenCalledWith( - 'Dynamic remote access startup failed', - backgroundError - ); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith(expect.any(String), backgroundError); }); it('does nothing when connect providers are unavailable', async () => { diff --git a/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts b/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts index 37451ae010..d4c7459bf4 100644 --- a/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts +++ b/packages/unraid-api-plugin-connect/src/startup/connect-startup-tasks.ts @@ -38,14 +38,18 @@ export const runConnectStartupTasks = async ( logger.info('Running Connect startup tasks after app.ready'); - await Promise.allSettled([ - dynamicRemoteAccessService?.initRemoteAccess().catch((error: unknown) => { - logger.warn('Dynamic remote access startup failed', error); - }), - mothershipController?.initOrRestart().catch((error: unknown) => { - logger.warn('Mothership startup failed', error); - }), + const results = await Promise.allSettled([ + dynamicRemoteAccessService?.initRemoteAccess(), + mothershipController?.initOrRestart(), ]); + + if (results[0]?.status === 'rejected') { + logger.warn('Dynamic remote access startup failed', results[0].reason); + } + + if (results[1]?.status === 'rejected') { + logger.warn('Mothership startup failed', results[1].reason); + } }; @Injectable() @@ -68,7 +72,7 @@ export class ConnectStartupTasksListener { }, { info: (message: string) => this.logger.log(message), - warn: (message: string, error: unknown) => this.logger.warn(`${message}: ${String(error)}`), + warn: (message: string, error: unknown) => this.logger.warn(message, error), } ); } From 9c6935850720c80ac52bd7341b5d2113dcd298e9 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 15 Apr 2026 14:01:58 -0400 Subject: [PATCH 10/14] fix(temperature): keep provider probes retryable - Purpose: address review feedback for temperature provider initialization and test formatting. - Before: a transient provider probe failure could still mark initialization complete when another provider was found, and the disk sensor spec had a formatter-sensitive chained mock call. - Problem: a one-time probe error could hide later provider recovery, while CI could fail on Prettier formatting even when behavior was unchanged. - Now: initialization only completes after a clean provider scan with at least one provider, and the spec uses a local mocked function variable to avoid chained-call formatting churn. - Verification: pnpm --filter ./api lint and targeted temperature Vitest specs passed locally. --- .../sensors/disk_sensors.service.spec.ts | 13 +++++++++---- .../metrics/temperature/temperature.service.ts | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts index 1344ad1d05..191e8db74b 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/sensors/disk_sensors.service.spec.ts @@ -36,10 +36,15 @@ describe('DiskSensorsService', () => { it.each([ [ 'when disks exist', - () => - vi.mocked(disksService.getDisks).mockResolvedValue([ - { id: 'disk1', device: '/dev/sda', name: 'Test Disk' } as unknown as Disk, - ]), + () => { + const disk = { + id: 'disk1', + device: '/dev/sda', + name: 'Test Disk', + } as unknown as Disk; + const getDisks = vi.mocked(disksService.getDisks); + getDisks.mockResolvedValue([disk]); + }, ], ['when no disks exist', () => vi.mocked(disksService.getDisks).mockResolvedValue([])], [ diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts index 8c8ad7a381..dde99bcece 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.service.ts @@ -119,7 +119,7 @@ export class TemperatureService { } this.availableProviders = availableProviders; - this.initialized = availableProviders.length > 0 || !hadProbeFailure; + this.initialized = !hadProbeFailure && availableProviders.length > 0; if (this.availableProviders.length === 0) { this.logger.warn('No temperature providers available'); From 9e977da2c3d2495e1621dccd7ef761bfbe525a65 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 15 Apr 2026 14:07:27 -0400 Subject: [PATCH 11/14] fix(ci): start libvirt idempotently - Purpose: prevent the API CI setup step from failing when libvirt is already running on the GitHub runner. - Before: the workflow always launched /usr/sbin/libvirtd --daemon after installing libvirt-daemon-system. - Problem: the APT package can already start libvirtd, so the second daemon failed while trying to obtain the existing pidfile. - Now: the workflow first checks whether sudo virsh can reach libvirt, removes a stale pidfile only when no libvirtd process exists, and falls back through systemctl, service, and direct daemon startup. - Verification: parsed .github/workflows/main.yml as YAML and ran bash -n against the Setup libvirt script block. --- .github/workflows/main.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ac5563bddb..8e42dd1c3e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -82,15 +82,24 @@ jobs: sudo chown root:libvirt /var/run/libvirt sudo chmod 775 /var/run/libvirt - - # Start libvirtd in the background - sudo /usr/sbin/libvirtd --daemon + # Start libvirtd only when the package install did not already start it. + if sudo virsh list --all > /dev/null 2>&1; then + echo "libvirt is already running" + else + if ! sudo pgrep -x libvirtd > /dev/null 2>&1; then + sudo rm -f /var/run/libvirt/libvirtd.pid + fi + + sudo systemctl start libvirtd 2>/dev/null \ + || sudo service libvirtd start 2>/dev/null \ + || sudo /usr/sbin/libvirtd --daemon + fi # Wait a bit longer for libvirtd to start sleep 5 # Verify libvirt is running using sudo to bypass group membership delays - sudo virsh list --all || true + sudo virsh list --all - name: Build UI Package First run: | From 608fd716b59a5918e18fa89aac11ce72db255f2d Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 15 Apr 2026 14:18:14 -0400 Subject: [PATCH 12/14] fix(ci): keep libvirt diagnostics non-blocking - Purpose: avoid failing CI on a system libvirt diagnostic that is not the hypervisor path used by the VM tests. - Before: the setup step made sudo virsh list --all a hard gate after starting libvirtd. - Problem: GitHub runners can reset the qemu:///system connection even though the tests validate qemu:///session. - After: the workflow still starts libvirtd idempotently, then prints system virsh status without failing the job. - How it works: the final diagnostic virsh command is restored to non-blocking behavior with || true. --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8e42dd1c3e..9bbd4feb62 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -98,8 +98,8 @@ jobs: # Wait a bit longer for libvirtd to start sleep 5 - # Verify libvirt is running using sudo to bypass group membership delays - sudo virsh list --all + # Print system libvirt status for diagnostics only; VM tests use qemu:///session. + sudo virsh list --all || true - name: Build UI Package First run: | From cec65c3152e7c5daa80f1642dda17bb5dc0e2f2c Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 15 Apr 2026 14:26:17 -0400 Subject: [PATCH 13/14] fix(cli): restore compact PM2 output - Purpose: restore the raw compact PM2 lifecycle output expected by unraid-api CLI users. - Before: start, restart, status, and stop omitted --mini-list, so PM2 printed the formatted table output and startup banner. - Problem: the formatted output is noisy in terminal and plugin flows, and it regressed the branch away from the expected raw text mode. - New behavior: PM2 lifecycle commands pass --mini-list again for compact output while keeping the existing PM2 cleanup hardening. - How it works: re-add --mini-list to start, restart, status, and stop, then update the PM2 command regression spec to assert those arguments. --- .../cli/__test__/pm2-commands.spec.ts | 24 +++++++++---------- api/src/unraid-api/cli/restart.command.ts | 3 ++- api/src/unraid-api/cli/start.command.ts | 3 ++- api/src/unraid-api/cli/status.command.ts | 7 +++++- api/src/unraid-api/cli/stop.command.ts | 3 ++- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/api/src/unraid-api/cli/__test__/pm2-commands.spec.ts b/api/src/unraid-api/cli/__test__/pm2-commands.spec.ts index 0bac4ba273..e6174cd00a 100644 --- a/api/src/unraid-api/cli/__test__/pm2-commands.spec.ts +++ b/api/src/unraid-api/cli/__test__/pm2-commands.spec.ts @@ -28,7 +28,7 @@ const createPm2Service = () => }) as unknown as PM2Service; describe('PM2-backed CLI commands', () => { - it('start clears PM2 state and starts without mini-list', async () => { + it('start clears PM2 state and starts with mini-list output', async () => { const logger = createLogger(); const pm2 = createPm2Service(); const command = new StartCommand(logger, pm2); @@ -47,12 +47,12 @@ describe('PM2-backed CLI commands', () => { { tag: 'PM2 Start', raw: true, extendEnv: true, env: { LOG_LEVEL: 'info' } }, 'start', ECOSYSTEM_PATH, - '--update-env' + '--update-env', + '--mini-list' ); - expect(vi.mocked(pm2.run).mock.calls.flat()).not.toContain('--mini-list'); }); - it('restart omits mini-list from the PM2 restart call', async () => { + it('restart uses mini-list output for the PM2 restart call', async () => { const logger = createLogger(); const pm2 = createPm2Service(); const command = new RestartCommand(logger, pm2); @@ -63,12 +63,12 @@ describe('PM2-backed CLI commands', () => { { tag: 'PM2 Restart', raw: true, extendEnv: true, env: { LOG_LEVEL: 'info' } }, 'restart', ECOSYSTEM_PATH, - '--update-env' + '--update-env', + '--mini-list' ); - expect(vi.mocked(pm2.run).mock.calls.flat()).not.toContain('--mini-list'); }); - it('status omits mini-list from the PM2 status call', async () => { + it('status uses mini-list output for the PM2 status call', async () => { const pm2 = createPm2Service(); const command = new StatusCommand(pm2); @@ -77,12 +77,12 @@ describe('PM2-backed CLI commands', () => { expect(pm2.run).toHaveBeenCalledWith( { tag: 'PM2 Status', stdio: 'inherit', raw: true }, 'status', - 'unraid-api' + 'unraid-api', + '--mini-list' ); - expect(vi.mocked(pm2.run).mock.calls.flat()).not.toContain('--mini-list'); }); - it('stop omits mini-list from the PM2 delete call', async () => { + it('stop uses mini-list output for the PM2 delete call', async () => { const pm2 = createPm2Service(); const command = new StopCommand(pm2); @@ -92,8 +92,8 @@ describe('PM2-backed CLI commands', () => { { tag: 'PM2 Delete', stdio: 'inherit' }, 'delete', ECOSYSTEM_PATH, - '--no-autorestart' + '--no-autorestart', + '--mini-list' ); - expect(vi.mocked(pm2.run).mock.calls.flat()).not.toContain('--mini-list'); }); }); diff --git a/api/src/unraid-api/cli/restart.command.ts b/api/src/unraid-api/cli/restart.command.ts index da6005a68c..66d54a513e 100644 --- a/api/src/unraid-api/cli/restart.command.ts +++ b/api/src/unraid-api/cli/restart.command.ts @@ -35,7 +35,8 @@ export class RestartCommand extends CommandRunner { { tag: 'PM2 Restart', raw: true, extendEnv: true, env }, 'restart', ECOSYSTEM_PATH, - '--update-env' + '--update-env', + '--mini-list' ); if (stderr) { diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index 2bcf6c6d2d..50b3114478 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -35,7 +35,8 @@ export class StartCommand extends CommandRunner { { tag: 'PM2 Start', raw: true, extendEnv: true, env }, 'start', ECOSYSTEM_PATH, - '--update-env' + '--update-env', + '--mini-list' ); if (stdout) { this.logger.log(stdout.toString()); diff --git a/api/src/unraid-api/cli/status.command.ts b/api/src/unraid-api/cli/status.command.ts index 4e78e938ab..6e1b6b6e2e 100644 --- a/api/src/unraid-api/cli/status.command.ts +++ b/api/src/unraid-api/cli/status.command.ts @@ -8,6 +8,11 @@ export class StatusCommand extends CommandRunner { super(); } async run(): Promise { - await this.pm2.run({ tag: 'PM2 Status', stdio: 'inherit', raw: true }, 'status', 'unraid-api'); + await this.pm2.run( + { tag: 'PM2 Status', stdio: 'inherit', raw: true }, + 'status', + 'unraid-api', + '--mini-list' + ); } } diff --git a/api/src/unraid-api/cli/stop.command.ts b/api/src/unraid-api/cli/stop.command.ts index f496263ec5..995dd07437 100644 --- a/api/src/unraid-api/cli/stop.command.ts +++ b/api/src/unraid-api/cli/stop.command.ts @@ -33,7 +33,8 @@ export class StopCommand extends CommandRunner { { tag: 'PM2 Delete', stdio: 'inherit' }, 'delete', ECOSYSTEM_PATH, - '--no-autorestart' + '--no-autorestart', + '--mini-list' ); } } From 76bf831963d8ae12c787b21f72760b621c1c1345 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 15 Apr 2026 14:34:32 -0400 Subject: [PATCH 14/14] fix(plugin): remove dependency archive recovery requirement - Purpose: keep the plugin installer focused on the node_modules packaged in the txz artifact. - Before: the install flow created a boot-drive dependency recovery archive and verification required that archive to exist. - Why: that made the archive a second source of truth and caused install verification to depend on recovery plumbing instead of the packaged dependency tree. - What changed: remove the install-time archive creation and stop validating the vendor archive during install verification. - How: leave the node_modules population check in verify_install.sh while dropping the vendor archive existence check. --- plugin/plugins/dynamix.unraid.net.plg | 8 ------ .../install/scripts/verify_install.sh | 25 ------------------- 2 files changed, 33 deletions(-) diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index 7c307a782f..6e8a60e695 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -596,14 +596,6 @@ echo "DEBUG: Attempting to run unraid-api directly:" echo "If no additional messages appear within 30 seconds, it is safe to refresh the page." /etc/rc.d/rc.unraid-api plugins add unraid-api-plugin-connect -b --no-restart - -echo "Creating dependency recovery archive..." -if /etc/rc.d/rc.unraid-api archive-dependencies; then - echo "Dependency recovery archive created" -else - echo "⚠️ Warning: Failed to create dependency recovery archive" -fi - /etc/rc.d/rc.unraid-api start echo "Unraid API service started" diff --git a/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh b/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh index 10670e6da0..26b5d0d278 100755 --- a/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh +++ b/plugin/source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh @@ -156,31 +156,6 @@ DEPENDENCY_ERRORS=0 if ! check_populated_dir "/usr/local/unraid-api/node_modules"; then DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) fi - -VENDOR_ARCHIVE_CONFIG="/usr/local/share/dynamix.unraid.net/config/vendor_archive.json" -if [ -f "$VENDOR_ARCHIVE_CONFIG" ]; then - printf '✓ Vendor archive config %s exists\n' "$VENDOR_ARCHIVE_CONFIG" - if command -v jq >/dev/null 2>&1; then - VENDOR_ARCHIVE_PATH=$(jq -r '.vendor_store_path' "$VENDOR_ARCHIVE_CONFIG" 2>/dev/null) - if [ -n "$VENDOR_ARCHIVE_PATH" ] && [ "$VENDOR_ARCHIVE_PATH" != "null" ]; then - if [ -f "$VENDOR_ARCHIVE_PATH" ]; then - printf '✓ Vendor archive %s exists\n' "$VENDOR_ARCHIVE_PATH" - else - printf '✗ Vendor archive %s is missing\n' "$VENDOR_ARCHIVE_PATH" - DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) - fi - else - printf '✗ Vendor archive config %s is missing vendor_store_path\n' "$VENDOR_ARCHIVE_CONFIG" - DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) - fi - else - printf '✗ jq is required to validate vendor archive config\n' - DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) - fi -else - printf '✗ Vendor archive config %s is missing\n' "$VENDOR_ARCHIVE_CONFIG" - DEPENDENCY_ERRORS=$((DEPENDENCY_ERRORS + 1)) -fi TOTAL_ERRORS=$((TOTAL_ERRORS + DEPENDENCY_ERRORS)) # Check for proper Slackware-style shutdown configuration