Fix server management issues and improve overall stability

Major server management fixes:
- Replace Flatpak-specific pkill with universal process tree termination using pstree + process.kill()
- Fix signal format errors (SIGTERM/SIGKILL instead of TERM/KILL strings)
- Add 5-second cooldown after server stop to prevent race conditions with external detection
- Enable Stop Server button for external servers in UI
- Implement proper timeout handling with process tree killing

ContentDB improvements:
- Fix download retry logic and "closed" error by preventing concurrent zip extraction
- Implement smart root directory detection and stripping during package extraction
- Add game-specific timeout handling (8s for VoxeLibre vs 3s for simple games)

World creation fixes:
- Make world creation asynchronous to prevent browser hangs
- Add WebSocket notifications for world creation completion status

Other improvements:
- Remove excessive debug logging
- Improve error handling and user feedback throughout the application
- Clean up temporary files and unnecessary logging

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nathan Schneider
2025-08-24 19:17:38 -06:00
parent 3aed09b60f
commit 2d3b1166fe
15 changed files with 851 additions and 536 deletions

View File

@@ -22,6 +22,7 @@ class ServerManager extends EventEmitter {
};
this.debugFileWatcher = null;
this.lastDebugFilePosition = 0;
this.lastStopTime = 0; // Track when we last stopped a server
}
async getServerStatus() {
@@ -58,10 +59,17 @@ class ServerManager extends EventEmitter {
}
}
// Always check for externally running Luanti servers if we don't have a running one
if (!this.isRunning) {
// Only check for external servers if we truly have no server process at all
// This prevents the state confusion that was causing managed servers to be marked as external
// Also add a cooldown period after stopping a managed server to prevent race conditions
const timeSinceLastStop = Date.now() - this.lastStopTime;
const cooldownPeriod = 5000; // 5 seconds cooldown after stopping a server
if (!this.serverProcess && timeSinceLastStop > cooldownPeriod) {
const externalServer = await this.detectExternalLuantiServer();
if (externalServer) {
console.log(`ServerManager: Detected truly external server (PID: ${externalServer.pid})`);
this.isRunning = true;
this.isReady = true;
this.startTime = externalServer.startTime;
@@ -142,9 +150,9 @@ class ServerManager extends EventEmitter {
}
// Check if minetest/luanti executable exists
const executable = await this.findMinetestExecutable();
const executableInfo = await this.findMinetestExecutable();
this.serverProcess = spawn(executable, args, {
this.serverProcess = spawn(executableInfo.command, [...executableInfo.args, ...args], {
cwd: paths.minetestDir,
stdio: ['pipe', 'pipe', 'pipe']
});
@@ -206,35 +214,189 @@ class ServerManager extends EventEmitter {
throw new Error('Server is not running');
}
const pid = this.serverProcess.pid;
const isExternal = this.serverProcess.external;
console.log(`ServerManager: Stopping server (PID: ${pid}, External: ${isExternal}, Force: ${force})`);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (this.serverProcess && this.isRunning) {
// Force kill if graceful shutdown failed
this.serverProcess.kill('SIGKILL');
resolve({ success: true, message: 'Server force-stopped' });
let timeoutHandle = null;
let resolved = false;
const cleanup = (message) => {
if (resolved) return;
resolved = true;
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}, 10000); // 10 second timeout
// Clean up state immediately
this.cleanupServerState();
// Add a delay to prevent race conditions with external detection
setTimeout(() => {
resolve({ success: true, message });
}, 2000);
};
this.serverProcess.on('exit', () => {
clearTimeout(timeout);
resolve({ success: true, message: 'Server stopped gracefully' });
});
// Set timeout for force kill
timeoutHandle = setTimeout(() => {
console.log(`ServerManager: Timeout reached, force-killing process tree for ${pid}`);
this.killProcessTree(pid, () => {
console.log(`ServerManager: Process tree killed for PID ${pid} (timeout)`);
cleanup('Server force-stopped after timeout');
});
}, isExternal ? 3000 : 10000);
// Try graceful shutdown first
if (force) {
this.serverProcess.kill('SIGTERM');
// Set up exit handler for managed processes
if (this.serverProcess.on && !isExternal) {
this.serverProcess.on('exit', () => {
console.log('ServerManager: Managed server process exited');
cleanup('Server stopped gracefully');
});
}
// For external servers or force stop, kill immediately
if (isExternal || force) {
console.log(`ServerManager: Killing process tree for PID ${pid}`);
this.killProcessTree(pid, () => {
console.log(`ServerManager: Process tree killed for PID ${pid}`);
cleanup('Server stopped');
});
} else {
// Send shutdown command to server
// Try graceful shutdown first for managed servers
try {
this.serverProcess.stdin.write('/shutdown\n');
} catch (error) {
// If stdin fails, use SIGTERM
this.serverProcess.kill('SIGTERM');
console.log('ServerManager: Sent shutdown command to server');
} catch (stdinError) {
console.warn('Failed to send shutdown command, using SIGTERM:', stdinError.message);
this.killProcessTree(pid, () => {
console.log(`ServerManager: Process tree killed for PID ${pid} (after stdin failure)`);
cleanup('Server stopped');
});
}
}
});
}
killProcessTree(pid, callback) {
console.log(`ServerManager: Attempting to kill process tree for PID ${pid}`);
const { spawn } = require('child_process');
// First, try to kill the process gracefully with SIGTERM
this.killProcessRecursive(pid, 'TERM', (termSuccess) => {
if (termSuccess) {
console.log('ServerManager: Process terminated gracefully');
callback();
return;
}
// If SIGTERM didn't work, wait a moment and try SIGKILL
console.log('ServerManager: SIGTERM failed, trying SIGKILL...');
setTimeout(() => {
this.killProcessRecursive(pid, 'KILL', (killSuccess) => {
if (killSuccess) {
console.log('ServerManager: Process force killed');
} else {
console.warn('ServerManager: Could not kill process');
}
callback();
});
}, 2000);
});
}
killProcessRecursive(pid, signal, callback) {
const { spawn } = require('child_process');
// Get all child processes first
const pstree = spawn('pstree', ['-p', pid.toString()]);
let pids = [pid];
let pstreeOutput = '';
pstree.stdout.on('data', (data) => {
pstreeOutput += data.toString();
});
pstree.on('close', (code) => {
// Extract all PIDs from pstree output
const pidMatches = pstreeOutput.match(/\((\d+)\)/g);
if (pidMatches) {
const childPids = pidMatches.map(match => parseInt(match.slice(1, -1)));
// Add child PIDs to our list, avoiding duplicates
childPids.forEach(childPid => {
if (!pids.includes(childPid)) {
pids.push(childPid);
}
});
}
console.log(`ServerManager: Found ${pids.length} processes to kill:`, pids);
// Kill all processes in reverse order (children first, then parent)
pids.reverse();
let processesKilled = 0;
let processesTotal = pids.length;
const killNextProcess = (index) => {
if (index >= pids.length) {
// All done
console.log(`ServerManager: Killed ${processesKilled}/${processesTotal} processes`);
callback(processesKilled > 0);
return;
}
const currentPid = pids[index];
console.log(`ServerManager: Sending SIG${signal} to PID ${currentPid}`);
try {
process.kill(currentPid, `SIG${signal}`);
processesKilled++;
console.log(`ServerManager: Successfully sent SIG${signal} to PID ${currentPid}`);
} catch (error) {
console.log(`ServerManager: Could not kill PID ${currentPid}:`, error.message);
}
// Move to next process after a short delay
setTimeout(() => killNextProcess(index + 1), 100);
};
killNextProcess(0);
});
pstree.on('error', (error) => {
console.warn('ServerManager: pstree failed, falling back to simple kill:', error.message);
// Fallback: just try to kill the main PID
try {
process.kill(pid, `SIG${signal}`);
console.log(`ServerManager: Successfully sent SIG${signal} to PID ${pid} (fallback)`);
callback(true);
} catch (error) {
console.log(`ServerManager: Could not kill PID ${pid}:`, error.message);
callback(false);
}
});
}
cleanupServerState() {
console.log('ServerManager: Cleaning up server state');
this.isRunning = false;
this.isReady = false;
this.serverProcess = null;
this.startTime = null;
this.lastStopTime = Date.now(); // Record when we stopped the server
this.stopDebugFileMonitoring();
// Reset player stats when server stops
this.serverStats.players = 0;
this.serverStats.memoryUsage = 0;
this.serverStats.cpuUsage = 0;
}
async restartServer(worldName = null) {
if (this.isRunning) {
await this.stopServer();
@@ -262,66 +424,153 @@ class ServerManager extends EventEmitter {
}
async findMinetestExecutable() {
// Whitelist of allowed executable names to prevent command injection
const allowedExecutables = ['luanti', 'minetest', 'minetestserver'];
const foundExecutables = [];
// Check different installation methods for Luanti
const installationMethods = [
// System packages
{ command: 'luanti', args: [] },
{ command: 'minetest', args: [] },
{ command: 'minetestserver', args: [] },
// Flatpak
{ command: 'flatpak', args: ['run', 'org.luanti.luanti'] },
{ command: 'flatpak', args: ['run', 'net.minetest.Minetest'] },
// Snap
{ command: 'snap', args: ['run', 'luanti'] },
{ command: 'snap', args: ['run', 'minetest'] }
];
for (const name of allowedExecutables) {
for (const method of installationMethods) {
try {
// Validate executable name against whitelist
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
continue;
}
const execPath = await new Promise((resolve, reject) => {
// Use spawn instead of exec to avoid command injection
// Test if the command exists and works
const testResult = await new Promise((resolve, reject) => {
const { spawn } = require('child_process');
const whichProcess = spawn('which', [name], { stdio: ['ignore', 'pipe', 'pipe'] });
const testProcess = spawn(method.command, [...method.args, '--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 5000
});
let stdout = '';
let stderr = '';
whichProcess.stdout.on('data', (data) => {
testProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
whichProcess.stderr.on('data', (data) => {
testProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
whichProcess.on('close', (code) => {
if (code === 0) {
resolve(stdout.trim());
testProcess.on('close', (code) => {
if ((code === 0 || code === null) && (stdout.includes('Luanti') || stdout.includes('Minetest'))) {
resolve({ command: method.command, args: method.args, output: stdout });
} else {
reject(new Error(`which command failed: ${stderr}`));
reject(new Error(`Command failed with code ${code}`));
}
});
whichProcess.on('error', (error) => {
testProcess.on('error', (error) => {
reject(error);
});
});
// Validate that the returned path is safe
if (execPath && path.isAbsolute(execPath)) {
foundExecutables.push({ name, path: execPath });
this.addLogLine('info', `Found executable: ${name} at ${execPath}`);
return execPath; // Return the full path for security
}
const executableInfo = {
command: testResult.command,
args: testResult.args,
fullCommand: testResult.args.length > 0 ? `${testResult.command} ${testResult.args.join(' ')}` : testResult.command
};
this.addLogLine('info', `Found Luanti: ${executableInfo.fullCommand}`);
return executableInfo;
} catch (error) {
// Continue to next possibility
// Continue to next method
continue;
}
}
// Provide detailed error message
const errorMsg = `Minetest/Luanti executable not found. Please install Luanti or add it to your PATH.\n` +
`Searched for: ${allowedExecutables.join(', ')}\n` +
`Try: sudo apt install luanti (Ubuntu/Debian) or your system's package manager`;
// Provide detailed error message with setup instructions
const errorMsg = `Luanti/Minetest not found or not working properly.\n\n` +
`Please install Luanti using one of these methods:\n` +
`• System package: sudo apt install luanti (Ubuntu/Debian)\n` +
`• Flatpak: flatpak install flathub org.luanti.luanti\n` +
`• Snap: sudo snap install luanti\n` +
`• Download from: https://www.luanti.org/downloads/\n\n` +
`This web interface requires Luanti to manage worlds and run servers.`;
this.addLogLine('error', errorMsg);
throw new Error(errorMsg);
}
async createWorld(worldName, gameId = 'minetest_game') {
try {
const executableInfo = await this.findMinetestExecutable();
const worldPath = paths.getWorldPath(worldName);
// Create world using Luanti --world command
// This will initialize the world properly with the correct structure
const createWorldArgs = [
'--world', worldPath,
'--gameid', gameId,
'--server',
'--go', // Skip main menu
'--quiet' // Reduce output
];
this.addLogLine('info', `Creating world "${worldName}" with game "${gameId}"`);
return new Promise((resolve, reject) => {
const createProcess = spawn(executableInfo.command, [...executableInfo.args, ...createWorldArgs], {
cwd: paths.minetestDir,
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 30000 // 30 second timeout
});
let stdout = '';
let stderr = '';
createProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
createProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
// Stop the server after a short time - we just want to initialize the world
// Complex games like VoxeLibre/MineCLone2 need more time to initialize
const timeout = gameId === 'mineclone2' || gameId.includes('clone') || gameId.includes('voxel') ? 8000 : 3000;
this.addLogLine('info', `Using ${timeout}ms timeout for game: ${gameId}`);
setTimeout(() => {
if (createProcess && !createProcess.killed) {
createProcess.kill('SIGTERM');
}
}, timeout);
createProcess.on('close', (code) => {
this.addLogLine('info', `World creation process finished with code: ${code}`);
// Check if world directory was created successfully
if (fsSync.existsSync(worldPath)) {
this.addLogLine('info', `World "${worldName}" created successfully`);
resolve({ success: true, worldPath });
} else {
this.addLogLine('error', `World directory not created: ${worldPath}`);
reject(new Error(`Failed to create world directory: ${worldPath}`));
}
});
createProcess.on('error', (error) => {
this.addLogLine('error', `World creation failed: ${error.message}`);
reject(error);
});
});
} catch (error) {
this.addLogLine('error', `Failed to create world: ${error.message}`);
throw error;
}
}
addLogLine(type, content) {
const timestamp = new Date().toISOString();
const logEntry = {
@@ -666,7 +915,6 @@ class ServerManager extends EventEmitter {
const playerData = new Map(); // Map to store player name -> player info
const cutoffTime = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago (extended from 5)
console.log('DEBUG: Looking for players active since:', cutoffTime.toISOString());
for (const line of lines.reverse()) {
// Parse timestamp from log line
@@ -688,7 +936,6 @@ class ServerManager extends EventEmitter {
const playerName = match[1];
const actionDescription = match[2] || type;
console.log('DEBUG: Found potential player:', playerName, 'action:', actionDescription);
// Filter out obvious non-player names and false positives
if (!playerName.includes('Entity') &&
@@ -701,7 +948,6 @@ class ServerManager extends EventEmitter {
playerName.length < 20 &&
/^[a-zA-Z0-9_]+$/.test(playerName)) {
console.log('DEBUG: Player passed filters:', playerName);
// Update player data with most recent activity
if (!playerData.has(playerName) || logTime > playerData.get(playerName).lastSeen) {
@@ -736,7 +982,6 @@ class ServerManager extends EventEmitter {
});
}
} else {
console.log('DEBUG: Player filtered out:', playerName, 'reason: failed validation');
}
}
}