diff --git a/api/src/__test__/store/modules/emhttp-replacement.test.ts b/api/src/__test__/store/modules/emhttp-replacement.test.ts new file mode 100644 index 0000000000..6d54df2627 --- /dev/null +++ b/api/src/__test__/store/modules/emhttp-replacement.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const VAR_FIXTURE = readFileSync(new URL('../../../../dev/states/var.ini', import.meta.url), 'utf-8'); + +const writeVarFixture = (dir: string, { fsState, mdState }: { fsState: string; mdState: string }) => { + const content = VAR_FIXTURE.replace(/mdState="[^"]*"/, `mdState="${mdState}"`).replace( + /fsState="[^"]*"/, + `fsState="${fsState}"` + ); + writeFileSync(join(dir, 'var.ini'), content); +}; + +describe('emhttp state reload after file replacement', () => { + let tempDir: string; + + beforeEach(() => { + vi.resetModules(); + tempDir = mkdtempSync(join(tmpdir(), 'emhttp-replacement-')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test('reloads a replaced var.ini from the stale STARTED/Stopping state seen on Unraid hosts', async () => { + writeVarFixture(tempDir, { mdState: 'STOPPED', fsState: 'Stopped' }); + + const { store } = await import('@app/store/index.js'); + const { getArrayData } = await import('@app/core/modules/array/get-array-data.js'); + const { ArrayState } = await import('@app/unraid-api/graph/resolvers/array/array.model.js'); + const { loadSingleStateFile, loadStateFiles, updateEmhttpState } = await import( + '@app/store/modules/emhttp.js' + ); + const { StateFileKey } = await import('@app/store/types.js'); + + const originalGetState = store.getState.bind(store); + vi.spyOn(store, 'getState').mockImplementation(() => ({ + ...originalGetState(), + paths: { + ...originalGetState().paths, + states: tempDir, + }, + })); + + await store.dispatch(loadStateFiles()); + + store.dispatch( + updateEmhttpState({ + field: StateFileKey.var, + state: { + mdState: ArrayState.STARTED, + fsState: 'Stopping', + }, + }) + ); + + expect(getArrayData(() => store.getState()).state).toBe(ArrayState.STARTED); + expect(store.getState().emhttp.var.fsState).toBe('Stopping'); + + await store.dispatch(loadSingleStateFile(StateFileKey.var)); + + expect(store.getState().emhttp.var.mdState).toBe(ArrayState.STOPPED); + expect(store.getState().emhttp.var.fsState).toBe('Stopped'); + expect(getArrayData(() => store.getState()).state).toBe(ArrayState.STOPPED); + }); +}); diff --git a/api/src/__test__/store/watch/state-watch.test.ts b/api/src/__test__/store/watch/state-watch.test.ts index a7315e0e8f..0f6625733c 100644 --- a/api/src/__test__/store/watch/state-watch.test.ts +++ b/api/src/__test__/store/watch/state-watch.test.ts @@ -3,19 +3,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { StateFileKey } from '@app/store/types.js'; type WatchHandler = (path: string) => Promise; +type WatchRegistration = { + path: string; + options: Record; + handlers: Partial>; +}; -const handlersByPath = new Map>>(); +const watchRegistrations: WatchRegistration[] = []; -const createWatcher = (path: string) => ({ +const createWatcher = (registration: WatchRegistration) => ({ on: vi.fn((event: 'add' | 'change', handler: WatchHandler) => { - const existingHandlers = handlersByPath.get(path) ?? {}; - existingHandlers[event] = handler; - handlersByPath.set(path, existingHandlers); - return createWatcher(path); + registration.handlers[event] = handler; + return createWatcher(registration); }), }); -const chokidarWatch = vi.fn((path: string) => createWatcher(path)); +const chokidarWatch = vi.fn((path: string, options: Record = {}) => { + const registration: WatchRegistration = { + path, + options, + handlers: {}, + }; + watchRegistrations.push(registration); + return createWatcher(registration); +}); vi.mock('chokidar', () => ({ watch: chokidarWatch, @@ -56,30 +67,78 @@ describe('StateManager', () => { beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); - handlersByPath.clear(); + watchRegistrations.length = 0; const { StateManager } = await import('@app/store/watch/state-watch.js'); StateManager.instance = null; }); - it('watches devs.ini alongside the other emhttp state files', async () => { + it('watches the emhttp state directory and keeps polling scoped to replacement-prone files', async () => { + const { StateManager } = await import('@app/store/watch/state-watch.js'); + + await StateManager.getInstance().ready; + + expect(chokidarWatch).toHaveBeenCalledTimes(3); + expect(chokidarWatch).toHaveBeenNthCalledWith( + 1, + '/usr/local/emhttp/state', + expect.objectContaining({ + ignoreInitial: true, + usePolling: false, + ignored: expect.any(Function), + }) + ); + expect(chokidarWatch).toHaveBeenNthCalledWith( + 2, + '/usr/local/emhttp/state/disks.ini', + expect.objectContaining({ + ignoreInitial: true, + usePolling: true, + interval: 10_000, + }) + ); + expect(chokidarWatch).toHaveBeenNthCalledWith( + 3, + '/usr/local/emhttp/state/shares.ini', + expect.objectContaining({ + ignoreInitial: true, + usePolling: true, + interval: 10_000, + }) + ); + }); + + it('reconciles all emhttp state files after watchers are attached', async () => { const { StateManager } = await import('@app/store/watch/state-watch.js'); + const { store } = await import('@app/store/index.js'); + const { loadSingleStateFile } = await import('@app/store/modules/emhttp.js'); + const { loadRegistrationKey } = await import('@app/store/modules/registration.js'); - StateManager.getInstance(); + await StateManager.getInstance().ready; - expect(chokidarWatch).toHaveBeenCalledWith('/usr/local/emhttp/state/devs.ini', { - usePolling: false, - }); + expect(store.dispatch).toHaveBeenNthCalledWith(1, loadSingleStateFile(StateFileKey.var)); + expect(store.dispatch).toHaveBeenNthCalledWith(2, loadRegistrationKey()); + + const dispatchedStateLoads = vi + .mocked(store.dispatch) + .mock.calls.filter(([action]) => action?.type === 'emhttp/load-single-state-file') + .map(([action]) => action.payload); + + expect(dispatchedStateLoads).toEqual(Object.values(StateFileKey)); }); - it('reloads the devs state when devs.ini changes', async () => { + it('routes non-polled state files through the standard directory watcher', async () => { const { StateManager } = await import('@app/store/watch/state-watch.js'); const { store } = await import('@app/store/index.js'); const { loadSingleStateFile } = await import('@app/store/modules/emhttp.js'); - StateManager.getInstance(); + await StateManager.getInstance().ready; + vi.mocked(store.dispatch).mockClear(); - const changeHandler = handlersByPath.get('/usr/local/emhttp/state/devs.ini')?.change; + const standardWatcher = watchRegistrations.find( + (registration) => registration.options.usePolling === false + ); + const changeHandler = standardWatcher?.handlers.change; expect(changeHandler).toBeDefined(); await changeHandler?.('/usr/local/emhttp/state/devs.ini'); @@ -87,20 +146,59 @@ describe('StateManager', () => { expect(store.dispatch).toHaveBeenCalledWith(loadSingleStateFile(StateFileKey.devs)); }); - it('reloads registration key when var.ini changes', async () => { + it('ignores non-state files while still allowing non-polled state files through the directory watcher', async () => { + const { StateManager } = await import('@app/store/watch/state-watch.js'); + + await StateManager.getInstance().ready; + + const standardWatcher = watchRegistrations.find( + (registration) => registration.options.usePolling === false + ); + const ignored = standardWatcher?.options.ignored; + + expect(ignored).toBeTypeOf('function'); + expect((ignored as (path: string) => boolean)('/usr/local/emhttp/state/README.txt')).toBe(true); + expect((ignored as (path: string) => boolean)('/usr/local/emhttp/state/devs.ini')).toBe(false); + expect((ignored as (path: string) => boolean)('/usr/local/emhttp/state/disks.ini')).toBe(true); + }); + + it('reloads registration key when var.ini is replaced after boot', async () => { const { StateManager } = await import('@app/store/watch/state-watch.js'); const { store } = await import('@app/store/index.js'); const { loadSingleStateFile } = await import('@app/store/modules/emhttp.js'); const { loadRegistrationKey } = await import('@app/store/modules/registration.js'); - StateManager.getInstance(); + await StateManager.getInstance().ready; + vi.mocked(store.dispatch).mockClear(); - const changeHandler = handlersByPath.get('/usr/local/emhttp/state/var.ini')?.change; - expect(changeHandler).toBeDefined(); + const standardWatcher = watchRegistrations.find( + (registration) => registration.options.usePolling === false + ); + const addHandler = standardWatcher?.handlers.add; + expect(addHandler).toBeDefined(); - await changeHandler?.('/usr/local/emhttp/state/var.ini'); + await addHandler?.('/usr/local/emhttp/state/var.ini'); expect(store.dispatch).toHaveBeenNthCalledWith(1, loadSingleStateFile(StateFileKey.var)); expect(store.dispatch).toHaveBeenNthCalledWith(2, loadRegistrationKey()); }); + + it('routes polled state files through the polling directory watcher', async () => { + const { StateManager } = await import('@app/store/watch/state-watch.js'); + const { store } = await import('@app/store/index.js'); + const { loadSingleStateFile } = await import('@app/store/modules/emhttp.js'); + + await StateManager.getInstance().ready; + vi.mocked(store.dispatch).mockClear(); + + const pollingWatcher = watchRegistrations.find( + (registration) => registration.path === '/usr/local/emhttp/state/disks.ini' + ); + const changeHandler = pollingWatcher?.handlers.change; + expect(changeHandler).toBeDefined(); + + await changeHandler?.('/usr/local/emhttp/state/disks.ini'); + + expect(store.dispatch).toHaveBeenCalledWith(loadSingleStateFile(StateFileKey.disks)); + }); }); diff --git a/api/src/index.ts b/api/src/index.ts index 48ab534664..457bf38267 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -121,7 +121,9 @@ export const viteNodeApp = async (): Promise> => { - if ([StateFileKey.disks, StateFileKey.shares].includes(key)) { - return { - usePolling: true, - interval: 10_000, - }; +const POLLED_STATE_KEYS = [StateFileKey.disks, StateFileKey.shares] as const; +const POLLED_STATE_KEY_SET = new Set(POLLED_STATE_KEYS); +const STATE_FILE_NAMES = new Set(Object.values(StateFileKey).map((key) => `${key}.ini`)); + +const shouldIgnoreStatePath = (path: string): boolean => { + const parsed = parse(path); + const isStateFile = parsed.ext === '.ini' && STATE_FILE_NAMES.has(parsed.base); + + if (!isStateFile) { + return true; } - return { usePolling: CHOKIDAR_USEPOLLING }; + + const stateFileKey = StateFileKey[parsed.name]; + return POLLED_STATE_KEY_SET.has(stateFileKey); }; +const chokidarOptionsForStateDirectory = (): ChokidarOptions => ({ + ignoreInitial: true, + ignored: (path, stats) => { + if (stats?.isDirectory()) { + return false; + } + return shouldIgnoreStatePath(path); + }, + usePolling: CHOKIDAR_USEPOLLING, +}); + export class StateManager { public static instance: StateManager | null = null; + public readonly ready: Promise; private readonly fileWatchers: FSWatcher[] = []; private constructor() { - this.setupChokidarWatchForState(); + this.ready = this.setupChokidarWatchForState(); } public static getInstance(): StateManager { @@ -43,6 +59,14 @@ export class StateManager { return StateFileKey[parsed.name]; } + private async reloadStateFile(stateFile: StateFileKey, reason: 'add' | 'change' | 'startup-sync') { + emhttpLogger.debug('Loading state file for %s after %s', stateFile, reason); + await store.dispatch(loadSingleStateFile(stateFile)); + if (stateFile === StateFileKey.var) { + await store.dispatch(loadRegistrationKey()); + } + } + private async handleStateFileUpdate(eventPath: string, event: 'add' | 'change') { const stateFile = this.getStateFileKeyFromPath(eventPath); if (!stateFile) { @@ -51,11 +75,7 @@ export class StateManager { } try { - emhttpLogger.debug('Loading state file for %s after %s event', stateFile, event); - await store.dispatch(loadSingleStateFile(stateFile)); - if (stateFile === StateFileKey.var) { - await store.dispatch(loadRegistrationKey()); - } + await this.reloadStateFile(stateFile, event); } catch (error: unknown) { emhttpLogger.error( 'Failed to load state file: [%s] after %s event\nerror: %o', @@ -66,15 +86,34 @@ export class StateManager { } } - private readonly setupChokidarWatchForState = () => { + private readonly reconcileStateAfterWatchSetup = async () => { + for (const stateFile of Object.values(StateFileKey)) { + await this.reloadStateFile(stateFile, 'startup-sync'); + } + }; + + private readonly setupChokidarWatchForState = async () => { const { states } = getters.paths(); - for (const key of Object.values(StateFileKey)) { + + emhttpLogger.debug('Setting up watch for path: %s', states); + const directoryWatch = watch(states, chokidarOptionsForStateDirectory()); + directoryWatch.on('add', async (path) => this.handleStateFileUpdate(path, 'add')); + directoryWatch.on('change', async (path) => this.handleStateFileUpdate(path, 'change')); + this.fileWatchers.push(directoryWatch); + + for (const key of POLLED_STATE_KEYS) { const pathToWatch = join(states, `${key}.ini`); emhttpLogger.debug('Setting up watch for path: %s', pathToWatch); - const stateWatch = watch(pathToWatch, chokidarOptionsForStateKey(key)); + const stateWatch = watch(pathToWatch, { + ignoreInitial: true, + usePolling: true, + interval: 10_000, + }); stateWatch.on('add', async (path) => this.handleStateFileUpdate(path, 'add')); stateWatch.on('change', async (path) => this.handleStateFileUpdate(path, 'change')); this.fileWatchers.push(stateWatch); } + + await this.reconcileStateAfterWatchSetup(); }; } diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts index 80209d04ce..73d05e46d9 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts @@ -39,6 +39,88 @@ const mockBlockDevices = blockDevices as unknown as MockedFunction; const mockBatchProcess = batchProcess as unknown as MockedFunction; +type LsblkPartition = { + name: string; + path: string; + type: 'part'; + partlabel: string; + parttype: string; +}; + +type LsblkDisk = { + name: string; + path: string; + type: 'disk'; + children: LsblkPartition[]; +}; + +type LsblkPayload = { + blockdevices: LsblkDisk[]; +}; + +const makeInternalBootDisk = (name: string): LsblkDisk => ({ + name, + path: `/dev/${name}`, + type: 'disk', + children: [ + { + name: `${name}1`, + path: `/dev/${name}1`, + type: 'part', + partlabel: 'BIOS Boot Partition', + parttype: '21686148-6449-6e6f-744e-656564454649', + }, + { + name: `${name}2`, + path: `/dev/${name}2`, + type: 'part', + partlabel: 'EFI System Partition', + parttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b', + }, + { + name: `${name}3`, + path: `/dev/${name}3`, + type: 'part', + partlabel: 'Unraid Boot Partition', + parttype: '0fc63daf-8483-4772-8e79-3d69d8477de4', + }, + { + name: `${name}4`, + path: `/dev/${name}4`, + type: 'part', + partlabel: '', + parttype: '0fc63daf-8483-4772-8e79-3d69d8477de4', + }, + ], +}); + +const makeEfiOnlyDisk = (name: string): LsblkDisk => ({ + name, + path: `/dev/${name}`, + type: 'disk', + children: [ + { + name: `${name}1`, + path: `/dev/${name}1`, + type: 'part', + partlabel: 'EFI System Partition', + parttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b', + }, + ], +}); + +const makeLsblkPayload = (...blockdevices: LsblkDisk[]): LsblkPayload => ({ + blockdevices, +}); + +const lsblkFixtures = { + internalBootCandidate: makeLsblkPayload(makeInternalBootDisk('sda'), makeEfiOnlyDisk('sdb')), + multipleInternalBootCandidates: makeLsblkPayload( + makeInternalBootDisk('sda'), + makeInternalBootDisk('sdb') + ), +} satisfies Record; + describe('DisksService', () => { let service: DisksService; let configService: ConfigService; @@ -565,61 +647,80 @@ describe('DisksService', () => { }); describe('getInternalBootDevices', () => { + it('returns internal boot candidate device names from the shared lsblk matcher', async () => { + mockExeca.mockResolvedValue({ + stdout: JSON.stringify(lsblkFixtures.internalBootCandidate), + stderr: '', + exitCode: 0, + failed: false, + command: '', + cwd: '', + isCanceled: false, + } as unknown as Awaited>); + + await expect(service.getInternalBootDeviceNames()).resolves.toEqual(new Set(['sda'])); + }); + + it('filters detailed disks through the shared internal boot name lookup', async () => { + const getInternalBootDeviceNamesSpy = vi + .spyOn(service, 'getInternalBootDeviceNames') + .mockResolvedValue(new Set(['sda', 'sdb'])); + const getDisksSpy = vi.spyOn(service, 'getDisks').mockResolvedValue([ + { + id: 'internal', + device: '/dev/sda', + type: 'HD', + name: 'Internal Boot', + vendor: 'Samsung', + size: 512110190592, + bytesPerSector: 512, + totalCylinders: 1, + totalHeads: 1, + totalSectors: 1, + totalTracks: 1, + tracksPerCylinder: 1, + sectorsPerTrack: 1, + firmwareRevision: '1', + serialNum: 'internal', + interfaceType: DiskInterfaceType.PCIE, + smartStatus: DiskSmartStatus.OK, + partitions: [], + isSpinning: false, + }, + { + id: 'usb', + device: '/dev/sdb', + type: 'HD', + name: 'USB Internal Boot', + vendor: 'SanDisk', + size: 128000000000, + bytesPerSector: 512, + totalCylinders: 1, + totalHeads: 1, + totalSectors: 1, + totalTracks: 1, + tracksPerCylinder: 1, + sectorsPerTrack: 1, + firmwareRevision: '1', + serialNum: 'usb', + interfaceType: DiskInterfaceType.USB, + smartStatus: DiskSmartStatus.OK, + partitions: [], + isSpinning: false, + }, + ]); + + const disks = await service.getInternalBootDevices(); + + expect(disks).toHaveLength(1); + expect(disks[0]?.device).toBe('/dev/sda'); + expect(getInternalBootDeviceNamesSpy).toHaveBeenCalledTimes(1); + expect(getDisksSpy).toHaveBeenCalledTimes(1); + }); + it('should return disks that match the Unraid internal boot partition layout', async () => { mockExeca.mockResolvedValue({ - stdout: JSON.stringify({ - blockdevices: [ - { - name: 'sda', - path: '/dev/sda', - type: 'disk', - children: [ - { - name: 'sda1', - path: '/dev/sda1', - type: 'part', - partlabel: 'BIOS Boot Partition', - parttype: '21686148-6449-6e6f-744e-656564454649', - }, - { - name: 'sda2', - path: '/dev/sda2', - type: 'part', - partlabel: 'EFI System Partition', - parttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b', - }, - { - name: 'sda3', - path: '/dev/sda3', - type: 'part', - partlabel: 'Unraid Boot Partition', - parttype: '0fc63daf-8483-4772-8e79-3d69d8477de4', - }, - { - name: 'sda4', - path: '/dev/sda4', - type: 'part', - partlabel: '', - parttype: '0fc63daf-8483-4772-8e79-3d69d8477de4', - }, - ], - }, - { - name: 'sdb', - path: '/dev/sdb', - type: 'disk', - children: [ - { - name: 'sdb1', - path: '/dev/sdb1', - type: 'part', - partlabel: 'EFI System Partition', - parttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b', - }, - ], - }, - ], - }), + stdout: JSON.stringify(lsblkFixtures.internalBootCandidate), stderr: '', exitCode: 0, failed: false, @@ -692,80 +793,7 @@ describe('DisksService', () => { }, ]); mockExeca.mockResolvedValue({ - stdout: JSON.stringify({ - blockdevices: [ - { - name: 'sda', - path: '/dev/sda', - type: 'disk', - children: [ - { - name: 'sda1', - path: '/dev/sda1', - type: 'part', - partlabel: 'BIOS Boot Partition', - parttype: '21686148-6449-6e6f-744e-656564454649', - }, - { - name: 'sda2', - path: '/dev/sda2', - type: 'part', - partlabel: 'EFI System Partition', - parttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b', - }, - { - name: 'sda3', - path: '/dev/sda3', - type: 'part', - partlabel: 'Unraid Boot Partition', - parttype: '0fc63daf-8483-4772-8e79-3d69d8477de4', - }, - { - name: 'sda4', - path: '/dev/sda4', - type: 'part', - partlabel: '', - parttype: '0fc63daf-8483-4772-8e79-3d69d8477de4', - }, - ], - }, - { - name: 'sdb', - path: '/dev/sdb', - type: 'disk', - children: [ - { - name: 'sdb1', - path: '/dev/sdb1', - type: 'part', - partlabel: 'BIOS Boot Partition', - parttype: '21686148-6449-6e6f-744e-656564454649', - }, - { - name: 'sdb2', - path: '/dev/sdb2', - type: 'part', - partlabel: 'EFI System Partition', - parttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b', - }, - { - name: 'sdb3', - path: '/dev/sdb3', - type: 'part', - partlabel: 'Unraid Boot Partition', - parttype: '0fc63daf-8483-4772-8e79-3d69d8477de4', - }, - { - name: 'sdb4', - path: '/dev/sdb4', - type: 'part', - partlabel: '', - parttype: '0fc63daf-8483-4772-8e79-3d69d8477de4', - }, - ], - }, - ], - }), + stdout: JSON.stringify(lsblkFixtures.multipleInternalBootCandidates), stderr: '', exitCode: 0, failed: false, diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts index 69df3a3ac2..aa49ed52c0 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts @@ -181,7 +181,7 @@ export class DisksService { ); } - private async getInternalBootDeviceNames(): Promise> { + public async getInternalBootDeviceNames(): Promise> { try { const { stdout } = await execa('lsblk', ['-J', '-o', 'NAME,PATH,TYPE,PARTLABEL,PARTTYPE']); diff --git a/api/src/unraid-api/graph/resolvers/disks/internal-boot-state.service.spec.ts b/api/src/unraid-api/graph/resolvers/disks/internal-boot-state.service.spec.ts index c0557012c7..575faf24ef 100644 --- a/api/src/unraid-api/graph/resolvers/disks/internal-boot-state.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/disks/internal-boot-state.service.spec.ts @@ -12,6 +12,7 @@ describe('InternalBootStateService', () => { getArrayData: vi.fn(), }; const disksService = { + getInternalBootDeviceNames: vi.fn(), getInternalBootDevices: vi.fn(), }; const cacheManager = { @@ -50,12 +51,13 @@ describe('InternalBootStateService', () => { }); expect(result).toBe(false); + expect(disksService.getInternalBootDeviceNames).not.toHaveBeenCalled(); expect(disksService.getInternalBootDevices).not.toHaveBeenCalled(); expect(getSpy).not.toHaveBeenCalled(); }); it('caches the internal boot device lookup result', async () => { - disksService.getInternalBootDevices.mockResolvedValue([{ device: '/dev/nvme0n1' }]); + disksService.getInternalBootDeviceNames.mockResolvedValue(new Set(['/dev/nvme0n1'])); const service = createService(); const firstResult = await service.getBootedFromFlashWithInternalBootSetupForBootDisk({ @@ -67,16 +69,17 @@ describe('InternalBootStateService', () => { expect(firstResult).toBe(true); expect(secondResult).toBe(true); - expect(disksService.getInternalBootDevices).toHaveBeenCalledTimes(1); + expect(disksService.getInternalBootDeviceNames).toHaveBeenCalledTimes(1); + expect(disksService.getInternalBootDevices).not.toHaveBeenCalled(); expect(setSpy).toHaveBeenCalledTimes(1); }); it('coalesces concurrent cache misses into a single disk scan', async () => { - let resolveLookup: ((value: Array<{ device: string }>) => void) | undefined; + let resolveLookup: ((value: Set) => void) | undefined; - disksService.getInternalBootDevices.mockImplementation( + disksService.getInternalBootDeviceNames.mockImplementation( () => - new Promise>((resolve) => { + new Promise>((resolve) => { resolveLookup = resolve; }) ); @@ -91,9 +94,10 @@ describe('InternalBootStateService', () => { await Promise.resolve(); - expect(disksService.getInternalBootDevices).toHaveBeenCalledTimes(1); + expect(disksService.getInternalBootDeviceNames).toHaveBeenCalledTimes(1); + expect(disksService.getInternalBootDevices).not.toHaveBeenCalled(); - resolveLookup?.([{ device: '/dev/nvme0n1' }]); + resolveLookup?.(new Set(['/dev/nvme0n1'])); await expect(firstLookup).resolves.toBe(true); await expect(secondLookup).resolves.toBe(true); @@ -101,9 +105,9 @@ describe('InternalBootStateService', () => { }); it('invalidates the cached lookup result when requested', async () => { - disksService.getInternalBootDevices - .mockResolvedValueOnce([{ device: '/dev/nvme0n1' }]) - .mockResolvedValueOnce([]); + disksService.getInternalBootDeviceNames + .mockResolvedValueOnce(new Set(['/dev/nvme0n1'])) + .mockResolvedValueOnce(new Set()); const service = createService(); const initialResult = await service.getBootedFromFlashWithInternalBootSetupForBootDisk({ @@ -118,24 +122,25 @@ describe('InternalBootStateService', () => { expect(initialResult).toBe(true); expect(refreshedResult).toBe(false); - expect(disksService.getInternalBootDevices).toHaveBeenCalledTimes(2); + expect(disksService.getInternalBootDeviceNames).toHaveBeenCalledTimes(2); + expect(disksService.getInternalBootDevices).not.toHaveBeenCalled(); expect(delSpy).toHaveBeenCalledTimes(1); }); it('does not repopulate the cache with a stale in-flight lookup after invalidation', async () => { - let resolveFirstLookup: ((value: Array<{ device: string }>) => void) | undefined; - let resolveSecondLookup: ((value: Array<{ device: string }>) => void) | undefined; + let resolveFirstLookup: ((value: Set) => void) | undefined; + let resolveSecondLookup: ((value: Set) => void) | undefined; - disksService.getInternalBootDevices + disksService.getInternalBootDeviceNames .mockImplementationOnce( () => - new Promise>((resolve) => { + new Promise>((resolve) => { resolveFirstLookup = resolve; }) ) .mockImplementationOnce( () => - new Promise>((resolve) => { + new Promise>((resolve) => { resolveSecondLookup = resolve; }) ); @@ -146,10 +151,11 @@ describe('InternalBootStateService', () => { }); await Promise.resolve(); - expect(disksService.getInternalBootDevices).toHaveBeenCalledTimes(1); + expect(disksService.getInternalBootDeviceNames).toHaveBeenCalledTimes(1); + expect(disksService.getInternalBootDevices).not.toHaveBeenCalled(); await service.invalidateCachedInternalBootDeviceState(); - resolveFirstLookup?.([{ device: '/dev/nvme0n1' }]); + resolveFirstLookup?.(new Set(['/dev/nvme0n1'])); await expect(firstLookup).resolves.toBe(true); expect(setSpy).not.toHaveBeenCalled(); @@ -159,9 +165,10 @@ describe('InternalBootStateService', () => { }); await Promise.resolve(); - expect(disksService.getInternalBootDevices).toHaveBeenCalledTimes(2); + expect(disksService.getInternalBootDeviceNames).toHaveBeenCalledTimes(2); + expect(disksService.getInternalBootDevices).not.toHaveBeenCalled(); - resolveSecondLookup?.([]); + resolveSecondLookup?.(new Set()); await expect(secondLookup).resolves.toBe(false); expect(setSpy).toHaveBeenCalledTimes(1); @@ -178,11 +185,12 @@ describe('InternalBootStateService', () => { type: ArrayDiskType.FLASH, }, }); - disksService.getInternalBootDevices.mockResolvedValue([{ device: '/dev/nvme0n1' }]); + disksService.getInternalBootDeviceNames.mockResolvedValue(new Set(['/dev/nvme0n1'])); const service = createService(); await expect(service.getBootedFromFlashWithInternalBootSetup()).resolves.toBe(true); expect(arrayService.getArrayData).toHaveBeenCalledTimes(1); - expect(disksService.getInternalBootDevices).toHaveBeenCalledTimes(1); + expect(disksService.getInternalBootDeviceNames).toHaveBeenCalledTimes(1); + expect(disksService.getInternalBootDevices).not.toHaveBeenCalled(); }); }); diff --git a/api/src/unraid-api/graph/resolvers/disks/internal-boot-state.service.ts b/api/src/unraid-api/graph/resolvers/disks/internal-boot-state.service.ts index b8755009d2..afeba16636 100644 --- a/api/src/unraid-api/graph/resolvers/disks/internal-boot-state.service.ts +++ b/api/src/unraid-api/graph/resolvers/disks/internal-boot-state.service.ts @@ -67,7 +67,8 @@ export class InternalBootStateService { private async loadHasInternalBootDevices(lookupGeneration: number): Promise { try { - const hasInternalBootDevices = (await this.disksService.getInternalBootDevices()).length > 0; + const hasInternalBootDevices = + (await this.disksService.getInternalBootDeviceNames()).size > 0; if (lookupGeneration === this.internalBootLookupGeneration) { await this.cacheManager.set( diff --git a/api/src/unraid-api/graph/resolvers/vars/vars.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/vars/vars.resolver.spec.ts index 83e40a128e..d9f48ea966 100644 --- a/api/src/unraid-api/graph/resolvers/vars/vars.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/vars/vars.resolver.spec.ts @@ -1,10 +1,14 @@ import type { TestingModule } from '@nestjs/testing'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Logger } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getters } from '@app/store/index.js'; +import { ArrayDiskType } from '@app/unraid-api/graph/resolvers/array/array.model.js'; +import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; +import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; import { InternalBootStateService } from '@app/unraid-api/graph/resolvers/disks/internal-boot-state.service.js'; import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js'; import { VarsService } from '@app/unraid-api/graph/resolvers/vars/vars.service.js'; @@ -17,6 +21,11 @@ vi.mock('@app/store/index.js', () => ({ describe('VarsResolver', () => { let resolver: VarsResolver; + const cacheManager = { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + }; const internalBootStateService = { getBootedFromFlashWithInternalBootSetup: vi.fn(), }; @@ -68,4 +77,54 @@ describe('VarsResolver', () => { 'Failed to resolve bootedFromFlashWithInternalBootSetup in vars(): lookup failed' ); }); + + it('resolves vars via the lightweight internal boot lookup without scanning full disk inventory', async () => { + const arrayService = { + getArrayData: vi.fn().mockResolvedValue({ + boot: { + type: ArrayDiskType.FLASH, + }, + }), + }; + const disksService = { + getInternalBootDeviceNames: vi.fn().mockResolvedValue(new Set(['/dev/nvme0n1'])), + getInternalBootDevices: vi.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VarsResolver, + InternalBootStateService, + { + provide: VarsService, + useValue: {}, + }, + { + provide: ArrayService, + useValue: arrayService, + }, + { + provide: DisksService, + useValue: disksService, + }, + { + provide: CACHE_MANAGER, + useValue: cacheManager, + }, + ], + }).compile(); + + const realResolver = module.get(VarsResolver); + + const result = await realResolver.vars(); + + expect(result).toMatchObject({ + id: 'vars', + enableBootTransfer: 'yes', + bootedFromFlashWithInternalBootSetup: true, + }); + expect(arrayService.getArrayData).toHaveBeenCalledTimes(1); + expect(disksService.getInternalBootDeviceNames).toHaveBeenCalledTimes(1); + expect(disksService.getInternalBootDevices).not.toHaveBeenCalled(); + }); });