diff --git a/api/src/unraid-api/cli/nodemon.service.spec.ts b/api/src/unraid-api/cli/nodemon.service.spec.ts index 1834d8392..f29803b62 100644 --- a/api/src/unraid-api/cli/nodemon.service.spec.ts +++ b/api/src/unraid-api/cli/nodemon.service.spec.ts @@ -48,6 +48,14 @@ describe('NodemonService', () => { NodemonService.prototype as unknown as { findMatchingNodemonPids: () => Promise }, 'findMatchingNodemonPids' ); + const findDirectMainSpy = vi.spyOn( + NodemonService.prototype as unknown as { findDirectMainPids: () => Promise }, + 'findDirectMainPids' + ); + const terminateSpy = vi.spyOn( + NodemonService.prototype as unknown as { terminatePids: (pids: number[]) => Promise }, + 'terminatePids' + ); beforeEach(() => { vi.clearAllMocks(); @@ -57,6 +65,8 @@ describe('NodemonService', () => { vi.mocked(fileExists).mockResolvedValue(false); killSpy.mockReturnValue(true); findMatchingSpy.mockResolvedValue([]); + findDirectMainSpy.mockResolvedValue([]); + terminateSpy.mockResolvedValue(); stopPm2Spy.mockResolvedValue(); }); @@ -272,6 +282,35 @@ describe('NodemonService', () => { expect(execa).not.toHaveBeenCalled(); }); + it('terminates direct main.js processes before starting nodemon', async () => { + const service = new NodemonService(logger); + findMatchingSpy.mockResolvedValue([]); + findDirectMainSpy.mockResolvedValue([321, 654]); + + 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: 777, + stdout, + stderr, + unref, + } as unknown as ReturnType); + + await service.start(); + + expect(terminateSpy).toHaveBeenCalledWith([321, 654]); + expect(execa).toHaveBeenCalledWith( + '/usr/bin/nodemon', + ['--config', '/etc/unraid-api/nodemon.json', '--quiet'], + expect.objectContaining({ cwd: '/usr/local/unraid-api' }) + ); + }); + it('returns not running when pid file is missing', async () => { const service = new NodemonService(logger); vi.mocked(fileExists).mockResolvedValue(false); diff --git a/api/src/unraid-api/cli/nodemon.service.ts b/api/src/unraid-api/cli/nodemon.service.ts index 1b5db069b..b6971f3af 100644 --- a/api/src/unraid-api/cli/nodemon.service.ts +++ b/api/src/unraid-api/cli/nodemon.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { createWriteStream } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { execa } from 'execa'; @@ -51,20 +50,34 @@ export class NodemonService { ) ).find(Boolean) ?? null; - if (!pm2Path) return; - - try { - const { stdout } = await execa(pm2Path, ['jlist']); - const processes = JSON.parse(stdout); - const hasUnraid = - Array.isArray(processes) && processes.some((proc) => proc?.name === 'unraid-api'); - if (!hasUnraid) return; - await execa(pm2Path, ['delete', 'unraid-api']); - this.logger.info('Stopped pm2-managed unraid-api before starting nodemon.'); - } catch (error) { - // PM2 may not be installed or responding; keep this quiet to avoid noisy startup. - this.logger.debug?.('Skipping pm2 cleanup (not installed or not running).'); + if (pm2Path) { + try { + const { stdout } = await execa(pm2Path, ['jlist']); + const processes = JSON.parse(stdout); + const hasUnraid = + Array.isArray(processes) && processes.some((proc) => proc?.name === 'unraid-api'); + if (hasUnraid) { + await execa(pm2Path, ['delete', 'unraid-api']); + this.logger.info('Stopped pm2-managed unraid-api before starting nodemon.'); + } + } catch (error) { + // PM2 may not be installed or responding; keep this quiet to avoid noisy startup. + this.logger.debug?.('Skipping pm2 cleanup (not installed or not running).'); + } } + + // Fallback: directly kill the pm2 daemon and remove its state, even if pm2 binary is missing. + try { + const pidText = (await readFile(pm2PidPath, 'utf-8')).trim(); + const pid = Number.parseInt(pidText, 10); + if (!Number.isNaN(pid)) { + process.kill(pid, 'SIGTERM'); + this.logger.debug?.(`Sent SIGTERM to pm2 daemon (pid ${pid}).`); + } + } catch { + // ignore + } + await rm('/var/log/.pm2', { recursive: true, force: true }); } private async getStoredPid(): Promise { @@ -100,6 +113,37 @@ export class NodemonService { } } + private async findDirectMainPids(): Promise { + try { + const mainPath = join(UNRAID_API_CWD, 'dist', 'main.js'); + const { stdout } = await execa('ps', ['-eo', 'pid,args']); + return stdout + .split('\n') + .map((line) => line.trim()) + .map((line) => line.match(/^(\d+)\s+(.*)$/)) + .filter((match): match is RegExpMatchArray => Boolean(match)) + .map(([, pid, cmd]) => ({ pid: Number.parseInt(pid, 10), cmd })) + .filter(({ cmd }) => cmd.includes(mainPath)) + .map(({ pid }) => pid) + .filter((pid) => Number.isInteger(pid)); + } catch { + return []; + } + } + + private async terminatePids(pids: number[]) { + for (const pid of pids) { + try { + process.kill(pid, 'SIGTERM'); + this.logger.debug?.(`Sent SIGTERM to existing unraid-api process (pid ${pid}).`); + } catch (error) { + this.logger.debug?.( + `Failed to send SIGTERM to pid ${pid}: ${error instanceof Error ? error.message : error}` + ); + } + } + } + async start(options: StartOptions = {}) { try { await this.ensureNodemonDependencies(); @@ -137,6 +181,14 @@ export class NodemonService { return; } + const directMainPids = await this.findDirectMainPids(); + if (directMainPids.length > 0) { + this.logger.warn( + `Found existing unraid-api process(es) running directly: ${directMainPids.join(', ')}. Stopping them before starting nodemon.` + ); + await this.terminatePids(directMainPids); + } + const overrides = Object.fromEntries( Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined) );