feat: Add process termination and management in NodemonService

- Implemented findDirectMainPids and terminatePids methods to identify and terminate existing unraid-api processes before starting nodemon.
- Enhanced the start method to include checks for running processes, ensuring proper cleanup and logging.
- Updated unit tests to validate the new process management functionality, improving overall robustness.
This commit is contained in:
Eli Bosley
2025-11-21 21:55:59 -05:00
parent 1d9c76f410
commit 9253250dc5
2 changed files with 105 additions and 14 deletions

View File

@@ -48,6 +48,14 @@ describe('NodemonService', () => {
NodemonService.prototype as unknown as { findMatchingNodemonPids: () => Promise<number[]> },
'findMatchingNodemonPids'
);
const findDirectMainSpy = vi.spyOn(
NodemonService.prototype as unknown as { findDirectMainPids: () => Promise<number[]> },
'findDirectMainPids'
);
const terminateSpy = vi.spyOn(
NodemonService.prototype as unknown as { terminatePids: (pids: number[]) => Promise<void> },
'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<typeof createWriteStream>
);
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<typeof execa>);
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);

View File

@@ -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<number | null> {
@@ -100,6 +113,37 @@ export class NodemonService {
}
}
private async findDirectMainPids(): Promise<number[]> {
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)
);