Skip to content

Commit c2103f5

Browse files
refactor(core): improve server:watch script with better error handling and port management
- Add port availability checks before restart to prevent race conditions - Add error handler for spawn failures - Extract hardcoded values to constants (RESTART_DELAY_MS, PORT_CHECK_*) - Add getConfigFilePath() helper for cleaner config path resolution - Watch js/ and serveronly/ directories in addition to modules/ and config/ - Watch all JS files (.js, .mjs, .cjs) instead of just node_helper.js - Improve restart mechanism with isRestarting flag - Better error messages for watcher and spawn failures - Remove unused app.restart() method from app.js These changes make the watcher more robust and easier to maintain.
1 parent dfb1a55 commit c2103f5

File tree

4 files changed

+208
-39
lines changed

4 files changed

+208
-39
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ planned for 2026-01-01
1414
### Added
1515

1616
- [weather] feat: add configurable forecast date format option (#3918)
17-
- [core] Add new script `server:watch` to start MagicMirror² server only without electron but with live reload support on changes for `node_helpers.js` files in `modules` directory and `config` folder
17+
- [core] Add new `server:watch` script to run MagicMirror² server-only with automatic restarts when JS files in `modules`, `js`, `serveronly`, or the `config` files changes (#3920)
1818

1919
### Changed
2020

js/app.js

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -388,16 +388,6 @@ function App () {
388388
return httpServer.close();
389389
};
390390

391-
392-
this.restart = async function () {
393-
Log.info("Restarting MagicMirror...");
394-
395-
await this.stop();
396-
await this.start();
397-
398-
Log.info("MagicMirror restarted!");
399-
};
400-
401391
/**
402392
* Listen for SIGINT signal and call stop() function.
403393
*
@@ -426,17 +416,6 @@ function App () {
426416
await this.stop();
427417
process.exit(0);
428418
});
429-
430-
/**
431-
* Listen for input 'restart' or 'rs' on stdin to restart the server.
432-
*/
433-
process.stdin.setEncoding("utf8").on("data", (data) => {
434-
const input = data.trim();
435-
436-
if (input === "restart" || input === "rs") {
437-
this.restart();
438-
}
439-
});
440419
}
441420

442421
module.exports = new App();

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"lint:prettier": "prettier . --write",
4444
"prepare": "[ -f node_modules/.bin/husky ] && husky || echo no husky installed.",
4545
"server": "node ./serveronly",
46-
"server:watch": "node serveronly/watcher.js",
46+
"server:watch": "node ./serveronly/watcher.js",
4747
"start": "node --run start:x11",
4848
"start:dev": "node --run start:x11 -- dev",
4949
"start:wayland": "WAYLAND_DISPLAY=\"${WAYLAND_DISPLAY:=wayland-1}\" ./node_modules/.bin/electron js/electron.js --enable-features=UseOzonePlatform --ozone-platform=wayland",

serveronly/watcher.js

Lines changed: 206 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,238 @@
11
const { spawn } = require("child_process");
22
const fs = require("fs");
33
const path = require("path");
4+
const net = require("net");
45
const Log = require("../js/logger");
56

7+
const RESTART_DELAY_MS = 500;
8+
const PORT_CHECK_MAX_ATTEMPTS = 20;
9+
const PORT_CHECK_INTERVAL_MS = 500;
10+
611
let child = null;
712
let restartTimer = null;
13+
let isShuttingDown = false;
14+
let isRestarting = false;
15+
let watcherErrorLogged = false;
16+
let serverPort = null;
17+
18+
/**
19+
* Get the server port from config
20+
* @returns {number} The port number
21+
*/
22+
function getServerPort () {
23+
if (serverPort) return serverPort;
24+
25+
try {
26+
// Try to read the config file to get the port
27+
const configPath = path.join(__dirname, "..", "config", "config.js");
28+
delete require.cache[require.resolve(configPath)];
29+
const config = require(configPath);
30+
serverPort = global.mmPort || config.port || 8080;
31+
} catch (err) {
32+
serverPort = 8080;
33+
}
34+
35+
return serverPort;
36+
}
37+
38+
/**
39+
* Check if a port is available
40+
* @param {number} port The port to check
41+
* @returns {Promise<boolean>} True if port is available
42+
*/
43+
function isPortAvailable (port) {
44+
return new Promise((resolve) => {
45+
const server = net.createServer();
46+
47+
server.once("error", () => {
48+
resolve(false);
49+
});
50+
51+
server.once("listening", () => {
52+
server.close();
53+
resolve(true);
54+
});
55+
56+
server.listen(port, "127.0.0.1");
57+
});
58+
}
59+
60+
/**
61+
* Wait until port is available
62+
* @param {number} port The port to wait for
63+
* @param {number} maxAttempts Maximum number of attempts
64+
* @returns {Promise<void>}
65+
*/
66+
async function waitForPort (port, maxAttempts = PORT_CHECK_MAX_ATTEMPTS) {
67+
for (let i = 0; i < maxAttempts; i++) {
68+
if (await isPortAvailable(port)) {
69+
Log.info(`Port ${port} is now available`);
70+
return;
71+
}
72+
await new Promise((resolve) => setTimeout(resolve, PORT_CHECK_INTERVAL_MS));
73+
}
74+
Log.warn(`Port ${port} still not available after ${maxAttempts} attempts`);
75+
}
876

977
/**
1078
* Start the server process
1179
*/
1280
function startServer () {
13-
child = spawn("npm", ["run", "server"], { stdio: "inherit" });
81+
// Start node directly instead of via npm to avoid process tree issues
82+
child = spawn("node", ["./serveronly"], {
83+
stdio: "inherit",
84+
cwd: path.join(__dirname, "..")
85+
});
1486

15-
child.on("exit", (code) => {
16-
Log.info(`Changes detected : ${code}`);
17-
Log.info("Restarting server...");
18-
startServer();
87+
child.on("error", (error) => {
88+
Log.error("Failed to start server process:", error.message);
89+
child = null;
1990
});
91+
92+
child.on("exit", (code, signal) => {
93+
child = null;
94+
95+
if (isShuttingDown) {
96+
return;
97+
}
98+
99+
if (isRestarting) {
100+
// Expected restart - don't log as error
101+
isRestarting = false;
102+
} else {
103+
// Unexpected exit
104+
Log.error(`Server exited unexpectedly with code ${code} and signal ${signal}`);
105+
}
106+
});
107+
}
108+
109+
/**
110+
* Restart the server process
111+
* @param {string} reason The reason for the restart
112+
*/
113+
async function restartServer (reason) {
114+
if (restartTimer) clearTimeout(restartTimer);
115+
116+
restartTimer = setTimeout(async () => {
117+
Log.info(reason);
118+
119+
if (child) {
120+
isRestarting = true;
121+
122+
// Get the actual port being used
123+
const port = getServerPort();
124+
125+
// Set up one-time listener for the exit event
126+
child.once("exit", async () => {
127+
// Wait until port is actually available
128+
await waitForPort(port);
129+
// Reset port cache in case config changed
130+
serverPort = null;
131+
startServer();
132+
});
133+
134+
child.kill("SIGTERM");
135+
} else {
136+
startServer();
137+
}
138+
}, RESTART_DELAY_MS);
20139
}
21140

22141
/**
23142
* Watch a directory for changes and restart the server on change
24-
* @param dir
143+
* @param {string} dir The directory path to watch
25144
*/
26145
function watchDir (dir) {
27-
fs.watch(dir, { recursive: true }, (_eventType, filename) => {
28-
if (!filename) return;
146+
try {
147+
const watcher = fs.watch(dir, { recursive: true }, (_eventType, filename) => {
148+
if (!filename) return;
29149

30-
if (dir.includes("modules") && !filename.endsWith("node_helper.js")) return;
150+
// Ignore node_modules - too many changes during npm install
151+
// After installing dependencies, manually restart the watcher
152+
if (filename.includes("node_modules")) return;
31153

32-
if (restartTimer) clearTimeout(restartTimer);
154+
// Only watch .js, .mjs and .cjs files
155+
if (!filename.endsWith(".js") && !filename.endsWith(".mjs") && !filename.endsWith(".cjs")) return;
33156

34-
restartTimer = setTimeout(() => {
35-
Log.info(`Changes detected in ${dir}: ${filename} — restarting...`);
36-
if (child) child.kill("SIGTERM");
37-
}, 500);
38-
});
157+
if (restartTimer) clearTimeout(restartTimer);
158+
159+
restartTimer = setTimeout(() => {
160+
restartServer(`Changes detected in ${dir}: ${filename} — restarting...`);
161+
}, RESTART_DELAY_MS);
162+
});
163+
164+
watcher.on("error", (error) => {
165+
if (error.code === "ENOSPC") {
166+
if (!watcherErrorLogged) {
167+
watcherErrorLogged = true;
168+
Log.error("System limit for file watchers reached. Try increasing: sudo sysctl fs.inotify.max_user_watches=524288");
169+
}
170+
} else {
171+
Log.error(`Watcher error for ${dir}:`, error.message);
172+
}
173+
});
174+
} catch (error) {
175+
Log.error(`Failed to watch directory ${dir}:`, error.message);
176+
}
177+
}
178+
179+
/**
180+
* Watch a specific file for changes and restart the server on change
181+
* @param {string} file The file path to watch
182+
*/
183+
function watchFile (file) {
184+
try {
185+
const watcher = fs.watch(file, (_eventType) => {
186+
if (restartTimer) clearTimeout(restartTimer);
187+
188+
restartTimer = setTimeout(() => {
189+
restartServer(`Config file changed: ${path.basename(file)} — restarting...`);
190+
}, RESTART_DELAY_MS);
191+
});
192+
193+
watcher.on("error", (error) => {
194+
Log.error(`Watcher error for ${file}:`, error.message);
195+
});
196+
197+
Log.log(`Watching config file: ${file}`);
198+
} catch (error) {
199+
Log.error(`Failed to watch file ${file}:`, error.message);
200+
}
201+
}
202+
203+
/**
204+
* Get the config file path from environment or default location
205+
* @returns {string} The config file path
206+
*/
207+
function getConfigFilePath () {
208+
if (process.env.MM_CONFIG_FILE) {
209+
return process.env.MM_CONFIG_FILE;
210+
}
211+
212+
if (global.configuration_file && global.root_path) {
213+
return path.resolve(global.root_path, global.configuration_file);
214+
}
215+
216+
return path.join(__dirname, "..", "config", "config.js");
39217
}
40218

41219
startServer();
42-
watchDir(path.join(__dirname, "..", "config"));
220+
221+
// Watch the config file (might be in custom location)
222+
// Priority: MM_CONFIG_FILE env var, then global.configuration_file, then default
223+
const configFile = getConfigFilePath();
224+
watchFile(configFile);
225+
226+
// Watch core directories (modules, js and serveronly)
227+
// We watch specific directories instead of the whole project root to avoid
228+
// watching unnecessary files like node_modules (even though we filter it),
229+
// tests, translations, css, fonts, vendor, etc.
43230
watchDir(path.join(__dirname, "..", "modules"));
231+
watchDir(path.join(__dirname, "..", "js"));
232+
watchDir(path.join(__dirname)); // serveronly
44233

45234
process.on("SIGINT", () => {
235+
isShuttingDown = true;
46236
if (restartTimer) clearTimeout(restartTimer);
47237
if (child) child.kill("SIGTERM");
48238
process.exit(0);

0 commit comments

Comments
 (0)