diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index cd317a7e8..4c94c82d7 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -207,8 +207,13 @@ describe('NodemonService', () => { expect(logsSpy).toHaveBeenCalledWith(50); }); - it('is a no-op when a recorded nodemon pid is already running', async () => { + it('restarts when a recorded nodemon pid is already running', async () => { const service = new NodemonService(logger); + const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue(); + vi.spyOn( + service as unknown as { waitForNodemonExit: () => Promise }, + 'waitForNodemonExit' + ).mockResolvedValue(); vi.spyOn( service as unknown as { getStoredPid: () => Promise }, 'getStoredPid' @@ -218,13 +223,28 @@ describe('NodemonService', () => { 'isPidRunning' ).mockResolvedValue(true); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 456, + stdout, + stderr, + unref, + } as unknown as ReturnType); + await service.start(); + expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); + expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true }); + expect(execa).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith( - 'unraid-api already running under nodemon (pid 999); skipping start.' + 'unraid-api already running under nodemon (pid 999); restarting for a fresh start.' ); - expect(execa).not.toHaveBeenCalled(); - expect(mockRm).not.toHaveBeenCalled(); }); it('removes stale pid file and starts when recorded pid is dead', async () => { @@ -265,21 +285,36 @@ describe('NodemonService', () => { ); }); - it('adopts an already-running nodemon when no pid file exists', async () => { + it('cleans up stray nodemon when no pid file exists', async () => { const service = new NodemonService(logger); findMatchingSpy.mockResolvedValue([888]); vi.spyOn( service as unknown as { isPidRunning: (pid: number) => Promise }, 'isPidRunning' ).mockResolvedValue(true); + vi.spyOn( + service as unknown as { waitForNodemonExit: () => Promise }, + 'waitForNodemonExit' + ).mockResolvedValue(); + + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 222, + stdout, + stderr, + unref, + } as unknown as ReturnType); await service.start(); - expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '888'); - expect(logger.info).toHaveBeenCalledWith( - 'unraid-api already running under nodemon (pid 888); discovered via process scan.' - ); - expect(execa).not.toHaveBeenCalled(); + expect(terminateSpy).toHaveBeenCalledWith([888]); + expect(execa).toHaveBeenCalled(); }); it('terminates direct main.js processes before starting nodemon', async () => { @@ -362,6 +397,44 @@ describe('NodemonService', () => { }); it('waits for nodemon to exit during restart before starting again', async () => { + const service = new NodemonService(logger); + const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue(); + const waitSpy = vi + .spyOn( + service as unknown as { waitForNodemonExit: () => Promise }, + 'waitForNodemonExit' + ) + .mockResolvedValue(); + vi.spyOn( + service as unknown as { getStoredPid: () => Promise }, + 'getStoredPid' + ).mockResolvedValue(123); + vi.spyOn( + service as unknown as { isPidRunning: (pid: number) => Promise }, + 'isPidRunning' + ).mockResolvedValue(true); + const logStream = { pipe: vi.fn(), close: vi.fn() }; + vi.mocked(createWriteStream).mockReturnValue( + logStream as unknown as ReturnType + ); + const stdout = { pipe: vi.fn() }; + const stderr = { pipe: vi.fn() }; + const unref = vi.fn(); + vi.mocked(execa).mockReturnValue({ + pid: 456, + stdout, + stderr, + unref, + } as unknown as ReturnType); + + await service.restart({ env: { LOG_LEVEL: 'DEBUG' } }); + + expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); + expect(waitSpy).toHaveBeenCalled(); + expect(execa).toHaveBeenCalled(); + }); + + it('performs clean start on restart when nodemon is not running', async () => { const service = new NodemonService(logger); const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue(); const startSpy = vi.spyOn(service, 'start').mockResolvedValue(); @@ -371,11 +444,15 @@ describe('NodemonService', () => { 'waitForNodemonExit' ) .mockResolvedValue(); + vi.spyOn( + service as unknown as { getStoredPid: () => Promise }, + 'getStoredPid' + ).mockResolvedValue(null); - await service.restart({ env: { LOG_LEVEL: 'DEBUG' } }); + await service.restart(); - expect(stopSpy).toHaveBeenCalledWith({ quiet: true }); - expect(waitSpy).toHaveBeenCalled(); - expect(startSpy).toHaveBeenCalledWith({ env: { LOG_LEVEL: 'DEBUG' } }); + expect(stopSpy).not.toHaveBeenCalled(); + expect(waitSpy).not.toHaveBeenCalled(); + expect(startSpy).toHaveBeenCalled(); }); }); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 41da0468d..57a134421 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -178,24 +178,29 @@ export class NodemonService { const running = await this.isPidRunning(existingPid); if (running) { this.logger.info( - `unraid-api already running under nodemon (pid ${existingPid}); skipping start.` + `unraid-api already running under nodemon (pid ${existingPid}); restarting for a fresh start.` ); - return; + await this.stop({ quiet: true }); + await this.waitForNodemonExit(); + await rm(NODEMON_PID_PATH, { force: true }); + } else { + this.logger.warn( + `Found nodemon pid file (${existingPid}) but the process is not running. Cleaning up.` + ); + await rm(NODEMON_PID_PATH, { force: true }); } - this.logger.warn( - `Found nodemon pid file (${existingPid}) but the process is not running. Cleaning up.` - ); - await rm(NODEMON_PID_PATH, { force: true }); } const discoveredPids = await this.findMatchingNodemonPids(); - const discoveredPid = discoveredPids.at(0); - if (discoveredPid && (await this.isPidRunning(discoveredPid))) { - await writeFile(NODEMON_PID_PATH, `${discoveredPid}`); + const liveDiscoveredPids = await Promise.all( + discoveredPids.map(async (pid) => ((await this.isPidRunning(pid)) ? pid : null)) + ).then((pids) => pids.filter((pid): pid is number => pid !== null)); + if (liveDiscoveredPids.length > 0) { this.logger.info( - `unraid-api already running under nodemon (pid ${discoveredPid}); discovered via process scan.` + `Found nodemon process(es) (${liveDiscoveredPids.join(', ')}) without a pid file; restarting for a fresh start.` ); - return; + await this.terminatePids(liveDiscoveredPids); + await this.waitForNodemonExit(); } const directMainPids = await this.findDirectMainPids(); @@ -272,8 +277,7 @@ export class NodemonService { } async restart(options: StartOptions = {}) { - await this.stop({ quiet: true }); - await this.waitForNodemonExit(); + // Delegate to start so both commands share identical logic await this.start(options); }