Skip to content

Commit c6cb6d2

Browse files
committed
feat: Refactor nodemon configuration and improve error handling in NodemonService
- Updated nodemon.json to remove unnecessary watch entry. - Adjusted NODEMON_CONFIG_PATH and UNRAID_API_CWD paths for better structure. - Enhanced error handling in isUnraidApiRunning and start methods of NodemonService to ensure proper logging and resource management. - Added tests for error scenarios in NodemonService to ensure robustness.
1 parent f2ee4ba commit c6cb6d2

File tree

5 files changed

+174
-40
lines changed

5 files changed

+174
-40
lines changed

api/nodemon.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
{
22
"watch": [
3-
"dist/main.js",
4-
"myservers.cfg"
3+
"dist/main.js"
54
],
65
"ignore": [
76
"node_modules",

api/src/core/utils/process/unraid-api-running.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ import { fileExists } from '@app/core/utils/files/file-exists.js';
44
import { NODEMON_PID_PATH } from '@app/environment.js';
55

66
export const isUnraidApiRunning = async (): Promise<boolean> => {
7-
if (!(await fileExists(NODEMON_PID_PATH))) {
8-
return false;
9-
}
7+
try {
8+
if (!(await fileExists(NODEMON_PID_PATH))) {
9+
return false;
10+
}
1011

11-
const pidText = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim();
12-
const pid = Number.parseInt(pidText, 10);
13-
if (Number.isNaN(pid)) {
14-
return false;
15-
}
12+
const pidText = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim();
13+
const pid = Number.parseInt(pidText, 10);
14+
if (Number.isNaN(pid)) {
15+
return false;
16+
}
1617

17-
try {
1818
process.kill(pid, 0);
1919
return true;
2020
} catch {

api/src/environment.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,9 @@ export const NODEMON_PATH = join(
110110
'bin',
111111
'nodemon.js'
112112
);
113-
export const NODEMON_CONFIG_PATH = join(import.meta.dirname, '../../', 'nodemon.json');
113+
export const NODEMON_CONFIG_PATH = join(import.meta.dirname, '../', 'nodemon.json');
114114
export const NODEMON_PID_PATH = process.env.NODEMON_PID_PATH ?? '/var/run/unraid-api/nodemon.pid';
115-
export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? join(import.meta.dirname, '../../');
115+
export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? join(import.meta.dirname, '../');
116116

117117
export const PATHS_CONFIG_MODULES =
118118
process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs';

api/src/unraid-api/cli/nodemon.service.spec.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { fileExists } from '@app/core/utils/files/file-exists.js';
88
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
99

1010
vi.mock('node:fs', () => ({
11-
createWriteStream: vi.fn(() => ({ pipe: vi.fn() })),
11+
createWriteStream: vi.fn(() => ({ pipe: vi.fn(), close: vi.fn() })),
1212
}));
1313
vi.mock('node:fs/promises');
1414
vi.mock('execa', () => ({ execa: vi.fn() }));
@@ -57,8 +57,21 @@ describe('NodemonService', () => {
5757
expect(mockMkdir).toHaveBeenCalledWith('/var/run/unraid-api', { recursive: true });
5858
});
5959

60+
it('throws error when directory creation fails', async () => {
61+
const service = new NodemonService(logger);
62+
const error = new Error('Permission denied');
63+
mockMkdir.mockRejectedValue(error);
64+
65+
await expect(service.ensureNodemonDependencies()).rejects.toThrow('Permission denied');
66+
expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true });
67+
});
68+
6069
it('starts nodemon and writes pid file', async () => {
6170
const service = new NodemonService(logger);
71+
const logStream = { pipe: vi.fn(), close: vi.fn() };
72+
vi.mocked(createWriteStream).mockReturnValue(
73+
logStream as unknown as ReturnType<typeof createWriteStream>
74+
);
6275
const stdout = { pipe: vi.fn() };
6376
const stderr = { pipe: vi.fn() };
6477
const unref = vi.fn();
@@ -87,6 +100,60 @@ describe('NodemonService', () => {
87100
expect(unref).toHaveBeenCalled();
88101
expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123');
89102
expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)');
103+
expect(logStream.close).not.toHaveBeenCalled();
104+
});
105+
106+
it('throws error and aborts start when directory creation fails', async () => {
107+
const service = new NodemonService(logger);
108+
const error = new Error('Permission denied');
109+
mockMkdir.mockRejectedValue(error);
110+
111+
await expect(service.start()).rejects.toThrow('Permission denied');
112+
expect(logger.error).toHaveBeenCalledWith(
113+
'Failed to ensure nodemon dependencies: Permission denied'
114+
);
115+
expect(execa).not.toHaveBeenCalled();
116+
});
117+
118+
it('throws error and closes logStream when execa fails', async () => {
119+
const service = new NodemonService(logger);
120+
const logStream = { pipe: vi.fn(), close: vi.fn() };
121+
vi.mocked(createWriteStream).mockReturnValue(
122+
logStream as unknown as ReturnType<typeof createWriteStream>
123+
);
124+
const error = new Error('Command not found');
125+
vi.mocked(execa).mockImplementation(() => {
126+
throw error;
127+
});
128+
129+
await expect(service.start()).rejects.toThrow('Failed to start nodemon: Command not found');
130+
expect(logStream.close).toHaveBeenCalled();
131+
expect(mockWriteFile).not.toHaveBeenCalled();
132+
expect(logger.info).not.toHaveBeenCalled();
133+
});
134+
135+
it('throws error and closes logStream when pid is missing', async () => {
136+
const service = new NodemonService(logger);
137+
const logStream = { pipe: vi.fn(), close: vi.fn() };
138+
vi.mocked(createWriteStream).mockReturnValue(
139+
logStream as unknown as ReturnType<typeof createWriteStream>
140+
);
141+
const stdout = { pipe: vi.fn() };
142+
const stderr = { pipe: vi.fn() };
143+
const unref = vi.fn();
144+
vi.mocked(execa).mockReturnValue({
145+
pid: undefined,
146+
stdout,
147+
stderr,
148+
unref,
149+
} as unknown as ReturnType<typeof execa>);
150+
151+
await expect(service.start()).rejects.toThrow(
152+
'Failed to start nodemon: process spawned but no PID was assigned'
153+
);
154+
expect(logStream.close).toHaveBeenCalled();
155+
expect(mockWriteFile).not.toHaveBeenCalled();
156+
expect(logger.info).not.toHaveBeenCalled();
90157
});
91158

92159
it('returns not running when pid file is missing', async () => {
@@ -98,4 +165,44 @@ describe('NodemonService', () => {
98165
expect(result).toBe(false);
99166
expect(logger.info).toHaveBeenCalledWith('unraid-api is not running (no pid file).');
100167
});
168+
169+
it('logs stdout when tail succeeds', async () => {
170+
const service = new NodemonService(logger);
171+
vi.mocked(execa).mockResolvedValue({
172+
stdout: 'log line 1\nlog line 2',
173+
} as unknown as Awaited<ReturnType<typeof execa>>);
174+
175+
const result = await service.logs(50);
176+
177+
expect(execa).toHaveBeenCalledWith('tail', ['-n', '50', '/var/log/graphql-api.log']);
178+
expect(logger.log).toHaveBeenCalledWith('log line 1\nlog line 2');
179+
expect(result).toBe('log line 1\nlog line 2');
180+
});
181+
182+
it('handles ENOENT error when log file is missing', async () => {
183+
const service = new NodemonService(logger);
184+
const error = new Error('ENOENT: no such file or directory');
185+
(error as Error & { code?: string }).code = 'ENOENT';
186+
vi.mocked(execa).mockRejectedValue(error);
187+
188+
const result = await service.logs();
189+
190+
expect(logger.error).toHaveBeenCalledWith(
191+
'Log file not found: /var/log/graphql-api.log (ENOENT: no such file or directory)'
192+
);
193+
expect(result).toBe('');
194+
});
195+
196+
it('handles non-zero exit error from tail', async () => {
197+
const service = new NodemonService(logger);
198+
const error = new Error('Command failed with exit code 1');
199+
vi.mocked(execa).mockRejectedValue(error);
200+
201+
const result = await service.logs(100);
202+
203+
expect(logger.error).toHaveBeenCalledWith(
204+
'Failed to read logs from /var/log/graphql-api.log: Command failed with exit code 1'
205+
);
206+
expect(result).toBe('');
207+
});
101208
});

api/src/unraid-api/cli/nodemon.service.ts

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,8 @@ export class NodemonService {
3232
constructor(private readonly logger: LogService) {}
3333

3434
async ensureNodemonDependencies() {
35-
try {
36-
await mkdir(PATHS_LOGS_DIR, { recursive: true });
37-
await mkdir(dirname(NODEMON_PID_PATH), { recursive: true });
38-
} catch (error) {
39-
this.logger.error(
40-
`Failed to fully ensure nodemon dependencies: ${error instanceof Error ? error.message : error}`
41-
);
42-
}
35+
await mkdir(PATHS_LOGS_DIR, { recursive: true });
36+
await mkdir(dirname(NODEMON_PID_PATH), { recursive: true });
4337
}
4438

4539
private async getStoredPid(): Promise<number | null> {
@@ -59,28 +53,47 @@ export class NodemonService {
5953
}
6054

6155
async start(options: StartOptions = {}) {
62-
await this.ensureNodemonDependencies();
56+
try {
57+
await this.ensureNodemonDependencies();
58+
} catch (error) {
59+
this.logger.error(
60+
`Failed to ensure nodemon dependencies: ${error instanceof Error ? error.message : error}`
61+
);
62+
throw error;
63+
}
64+
6365
await this.stop({ quiet: true });
6466

65-
const env = { ...process.env, ...options.env } as Record<string, string>;
67+
const overrides = Object.fromEntries(
68+
Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined)
69+
);
70+
const env = { ...process.env, ...overrides } as Record<string, string>;
6671
const logStream = createWriteStream(PATHS_LOGS_FILE, { flags: 'a' });
6772

68-
const nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], {
69-
cwd: UNRAID_API_CWD,
70-
env,
71-
detached: true,
72-
stdio: ['ignore', 'pipe', 'pipe'],
73-
});
74-
75-
nodemonProcess.stdout?.pipe(logStream);
76-
nodemonProcess.stderr?.pipe(logStream);
77-
nodemonProcess.unref();
73+
let nodemonProcess;
74+
try {
75+
nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], {
76+
cwd: UNRAID_API_CWD,
77+
env,
78+
detached: true,
79+
stdio: ['ignore', 'pipe', 'pipe'],
80+
});
81+
82+
nodemonProcess.stdout?.pipe(logStream);
83+
nodemonProcess.stderr?.pipe(logStream);
84+
nodemonProcess.unref();
85+
86+
if (!nodemonProcess.pid) {
87+
logStream.close();
88+
throw new Error('Failed to start nodemon: process spawned but no PID was assigned');
89+
}
7890

79-
if (nodemonProcess.pid) {
8091
await writeFile(NODEMON_PID_PATH, `${nodemonProcess.pid}`);
8192
this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`);
82-
} else {
83-
this.logger.error('Failed to determine nodemon pid.');
93+
} catch (error) {
94+
logStream.close();
95+
const errorMessage = error instanceof Error ? error.message : String(error);
96+
throw new Error(`Failed to start nodemon: ${errorMessage}`);
8497
}
8598
}
8699

@@ -126,8 +139,23 @@ export class NodemonService {
126139
return running;
127140
}
128141

129-
async logs(lines = 100) {
130-
const { stdout } = await execa('tail', ['-n', `${lines}`, PATHS_LOGS_FILE]);
131-
this.logger.log(stdout);
142+
async logs(lines = 100): Promise<string> {
143+
try {
144+
const { stdout } = await execa('tail', ['-n', `${lines}`, PATHS_LOGS_FILE]);
145+
this.logger.log(stdout);
146+
return stdout;
147+
} catch (error) {
148+
const errorMessage = error instanceof Error ? error.message : String(error);
149+
const isFileNotFound =
150+
errorMessage.includes('ENOENT') ||
151+
(error instanceof Error && 'code' in error && error.code === 'ENOENT');
152+
153+
if (isFileNotFound) {
154+
this.logger.error(`Log file not found: ${PATHS_LOGS_FILE} (${errorMessage})`);
155+
} else {
156+
this.logger.error(`Failed to read logs from ${PATHS_LOGS_FILE}: ${errorMessage}`);
157+
}
158+
return '';
159+
}
132160
}
133161
}

0 commit comments

Comments
 (0)