-
Notifications
You must be signed in to change notification settings - Fork 22
fix(api): reconcile emhttp state without spinning disks #1946
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
64b7761
36ab3a8
0baa00b
6297eff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import { join, parse } from 'path'; | ||
|
|
||
| import type { FSWatcher, FSWInstanceOptions } from 'chokidar'; | ||
| import type { ChokidarOptions, FSWatcher } from 'chokidar'; | ||
| import { watch } from 'chokidar'; | ||
|
|
||
| import { emhttpLogger } from '@app/core/log.js'; | ||
|
|
@@ -10,24 +10,40 @@ import { loadSingleStateFile } from '@app/store/modules/emhttp.js'; | |
| import { loadRegistrationKey } from '@app/store/modules/registration.js'; | ||
| import { StateFileKey } from '@app/store/types.js'; | ||
|
|
||
| const chokidarOptionsForStateKey = ( | ||
| key: StateFileKey | ||
| ): Partial<Pick<FSWInstanceOptions, 'usePolling' | 'interval'>> => { | ||
| 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<StateFileKey>(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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Setting Useful? React with 👍 / 👎. |
||
| 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<void>; | ||
| 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(); | ||
| }; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.