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:
@@ -111,14 +111,45 @@ class ContentDBClient {
|
||||
downloadUrl = 'https://content.luanti.org' + downloadUrl;
|
||||
}
|
||||
|
||||
// Download the package
|
||||
const downloadResponse = await axios.get(downloadUrl, {
|
||||
responseType: 'stream',
|
||||
timeout: 120000, // 2 minutes for download
|
||||
headers: {
|
||||
'User-Agent': 'LuHost/1.0'
|
||||
// Download the package with retry logic
|
||||
let downloadResponse;
|
||||
let retryCount = 0;
|
||||
const maxRetries = 3;
|
||||
|
||||
while (retryCount <= maxRetries) {
|
||||
try {
|
||||
console.log(`ContentDB: Attempting download from ${downloadUrl} (attempt ${retryCount + 1}/${maxRetries + 1})`);
|
||||
downloadResponse = await axios.get(downloadUrl, {
|
||||
responseType: 'stream',
|
||||
timeout: 60000, // 1 minute timeout per attempt
|
||||
headers: {
|
||||
'User-Agent': 'LuHost/1.0',
|
||||
'Accept': '*/*',
|
||||
'Connection': 'keep-alive'
|
||||
},
|
||||
// Increase buffer limits to handle larger downloads
|
||||
maxContentLength: 100 * 1024 * 1024, // 100MB
|
||||
maxBodyLength: 100 * 1024 * 1024
|
||||
});
|
||||
break; // Success, exit retry loop
|
||||
} catch (downloadError) {
|
||||
retryCount++;
|
||||
console.warn(`ContentDB: Download attempt ${retryCount} failed:`, downloadError.message);
|
||||
|
||||
if (retryCount > maxRetries) {
|
||||
// All retries exhausted
|
||||
const errorMsg = downloadError.code === 'ECONNRESET' || downloadError.message.includes('closed')
|
||||
? 'Connection was closed by the server. This may be due to network issues or server load. Please try again later.'
|
||||
: `Download failed: ${downloadError.message}`;
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Wait before retrying (exponential backoff)
|
||||
const delayMs = Math.pow(2, retryCount - 1) * 1000; // 1s, 2s, 4s
|
||||
console.log(`ContentDB: Retrying in ${delayMs}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create target directory
|
||||
await fs.mkdir(targetPath, { recursive: true });
|
||||
@@ -137,10 +168,28 @@ class ContentDBClient {
|
||||
});
|
||||
|
||||
// Extract zip file
|
||||
await this.extractZipFile(tempZipPath, targetPath);
|
||||
try {
|
||||
await this.extractZipFile(tempZipPath, targetPath);
|
||||
console.log(`ContentDB: Successfully extracted zip to ${targetPath}`);
|
||||
} catch (extractError) {
|
||||
console.error(`ContentDB: Extraction failed:`, extractError);
|
||||
// Clean up temp file before rethrowing
|
||||
try {
|
||||
await fs.unlink(tempZipPath);
|
||||
} catch (cleanupError) {
|
||||
console.warn(`ContentDB: Failed to cleanup temp file:`, cleanupError.message);
|
||||
}
|
||||
throw extractError;
|
||||
}
|
||||
|
||||
// Remove temp zip file
|
||||
await fs.unlink(tempZipPath);
|
||||
try {
|
||||
await fs.unlink(tempZipPath);
|
||||
console.log(`ContentDB: Cleaned up temp zip file`);
|
||||
} catch (cleanupError) {
|
||||
console.warn(`ContentDB: Failed to remove temp zip file:`, cleanupError.message);
|
||||
// Don't throw - extraction succeeded, cleanup failure is not critical
|
||||
}
|
||||
} else {
|
||||
// For non-zip files, save directly
|
||||
const fileName = path.basename(release.url) || 'download';
|
||||
@@ -173,57 +222,151 @@ class ContentDBClient {
|
||||
return;
|
||||
}
|
||||
|
||||
zipfile.readEntry();
|
||||
const entries = [];
|
||||
|
||||
zipfile.on('entry', async (entry) => {
|
||||
const entryPath = path.join(targetPath, entry.fileName);
|
||||
|
||||
// Ensure the entry path is within target directory (security)
|
||||
const normalizedPath = path.normalize(entryPath);
|
||||
if (!normalizedPath.startsWith(path.normalize(targetPath))) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (/\/$/.test(entry.fileName)) {
|
||||
// Directory entry
|
||||
try {
|
||||
await fs.mkdir(normalizedPath, { recursive: true });
|
||||
zipfile.readEntry();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
// First pass: collect all entries to analyze structure
|
||||
zipfile.on('entry', (entry) => {
|
||||
entries.push(entry);
|
||||
zipfile.readEntry();
|
||||
});
|
||||
|
||||
zipfile.on('end', async () => {
|
||||
try {
|
||||
// Analyze if we have a common root directory that should be stripped
|
||||
let commonRoot = null;
|
||||
let shouldStripRoot = false;
|
||||
|
||||
if (entries.length > 0) {
|
||||
// Find files (not directories) to analyze structure
|
||||
const fileEntries = entries.filter(e => !e.fileName.endsWith('/') && e.fileName.trim() !== '');
|
||||
|
||||
if (fileEntries.length > 0) {
|
||||
// Check if all files are in the same top-level directory
|
||||
const firstPath = fileEntries[0].fileName;
|
||||
const firstSlash = firstPath.indexOf('/');
|
||||
|
||||
if (firstSlash > 0) {
|
||||
const potentialRoot = firstPath.substring(0, firstSlash);
|
||||
|
||||
// Check if ALL file entries start with this root directory
|
||||
const allInSameRoot = fileEntries.every(entry =>
|
||||
entry.fileName.startsWith(potentialRoot + '/')
|
||||
);
|
||||
|
||||
if (allInSameRoot) {
|
||||
commonRoot = potentialRoot;
|
||||
shouldStripRoot = true;
|
||||
console.log(`ContentDB: Detected common root directory "${commonRoot}", will strip it during extraction`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// File entry
|
||||
zipfile.openReadStream(entry, (err, readStream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
|
||||
zipfile.close();
|
||||
|
||||
// Second pass: reopen zip and extract files sequentially
|
||||
yauzl.open(zipPath, { lazyEntries: true }, (reopenErr, newZipfile) => {
|
||||
if (reopenErr) {
|
||||
reject(reopenErr);
|
||||
return;
|
||||
}
|
||||
|
||||
let entryIndex = 0;
|
||||
|
||||
const processNextEntry = () => {
|
||||
if (entryIndex >= entries.length) {
|
||||
newZipfile.close();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = entries[entryIndex++];
|
||||
let fileName = entry.fileName;
|
||||
|
||||
// Strip common root if detected
|
||||
if (shouldStripRoot && commonRoot) {
|
||||
if (fileName === commonRoot || fileName === commonRoot + '/') {
|
||||
processNextEntry(); // Skip the root directory itself
|
||||
return;
|
||||
}
|
||||
if (fileName.startsWith(commonRoot + '/')) {
|
||||
fileName = fileName.substring(commonRoot.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip empty filenames
|
||||
if (!fileName || fileName.trim() === '') {
|
||||
processNextEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
const entryPath = path.join(targetPath, fileName);
|
||||
|
||||
// Ensure the entry path is within target directory (security)
|
||||
const normalizedPath = path.normalize(entryPath);
|
||||
if (!normalizedPath.startsWith(path.normalize(targetPath))) {
|
||||
processNextEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(normalizedPath);
|
||||
fs.mkdir(parentDir, { recursive: true })
|
||||
.then(() => {
|
||||
const writeStream = require('fs').createWriteStream(normalizedPath);
|
||||
readStream.pipe(writeStream);
|
||||
|
||||
writeStream.on('finish', () => {
|
||||
zipfile.readEntry();
|
||||
if (fileName.endsWith('/')) {
|
||||
// Directory entry
|
||||
fs.mkdir(normalizedPath, { recursive: true })
|
||||
.then(() => processNextEntry())
|
||||
.catch(reject);
|
||||
} else {
|
||||
// File entry
|
||||
newZipfile.openReadStream(entry, async (streamErr, readStream) => {
|
||||
if (streamErr) {
|
||||
newZipfile.close();
|
||||
reject(streamErr);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(normalizedPath);
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
|
||||
const writeStream = require('fs').createWriteStream(normalizedPath);
|
||||
readStream.pipe(writeStream);
|
||||
|
||||
writeStream.on('finish', () => {
|
||||
processNextEntry();
|
||||
});
|
||||
|
||||
writeStream.on('error', (writeError) => {
|
||||
newZipfile.close();
|
||||
reject(writeError);
|
||||
});
|
||||
} catch (mkdirError) {
|
||||
newZipfile.close();
|
||||
reject(mkdirError);
|
||||
}
|
||||
});
|
||||
|
||||
writeStream.on('error', reject);
|
||||
})
|
||||
.catch(reject);
|
||||
}
|
||||
};
|
||||
|
||||
newZipfile.on('error', (zipError) => {
|
||||
newZipfile.close();
|
||||
reject(zipError);
|
||||
});
|
||||
|
||||
processNextEntry();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
zipfile.close();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
zipfile.on('end', () => {
|
||||
resolve();
|
||||
zipfile.on('error', (error) => {
|
||||
zipfile.close();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
zipfile.on('error', reject);
|
||||
|
||||
zipfile.readEntry();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -3,12 +3,20 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
class PackageRegistry {
|
||||
constructor(dbPath = 'data/packages.db') {
|
||||
constructor(dbPath = null) {
|
||||
// If no dbPath provided, we'll set it during init based on current data directory
|
||||
this.dbPath = dbPath;
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Set database path based on current data directory if not already set
|
||||
if (!this.dbPath) {
|
||||
const paths = require('./paths');
|
||||
await paths.initialize();
|
||||
this.dbPath = path.join(paths.minetestDir, 'luhost_packages.db');
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
const dir = path.dirname(this.dbPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
@@ -24,6 +32,24 @@ class PackageRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
async reinitialize() {
|
||||
// Close existing database connection
|
||||
if (this.db) {
|
||||
await new Promise((resolve) => {
|
||||
this.db.close((err) => {
|
||||
if (err) console.error('Error closing database:', err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Clear the path so it gets recalculated
|
||||
this.dbPath = null;
|
||||
|
||||
// Reinitialize with new path
|
||||
return this.init();
|
||||
}
|
||||
|
||||
async createTables() {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS installed_packages (
|
||||
|
107
utils/paths.js
107
utils/paths.js
@@ -14,24 +14,104 @@ class LuantiPaths {
|
||||
await appConfig.load();
|
||||
const configuredDataDir = appConfig.getDataDirectory();
|
||||
this.setDataDirectory(configuredDataDir);
|
||||
console.log(`Paths initialized with data directory: ${configuredDataDir}`);
|
||||
}
|
||||
|
||||
async forceReload() {
|
||||
// Force reload the app config and reinitialize paths
|
||||
delete require.cache[require.resolve('./app-config')];
|
||||
const appConfig = require('./app-config');
|
||||
await appConfig.load();
|
||||
const configuredDataDir = appConfig.getDataDirectory();
|
||||
this.setDataDirectory(configuredDataDir);
|
||||
console.log(`Paths force reloaded with data directory: ${configuredDataDir}`);
|
||||
}
|
||||
|
||||
getDefaultDataDirectory() {
|
||||
// Check for common Luanti data directories
|
||||
const validDirs = this.detectLuantiDataDirectories();
|
||||
return validDirs.length > 0 ? validDirs[0].path : path.join(os.homedir(), '.minetest');
|
||||
}
|
||||
|
||||
detectLuantiDataDirectories() {
|
||||
const homeDir = os.homedir();
|
||||
const possibleDirs = [
|
||||
path.join(homeDir, '.luanti'),
|
||||
path.join(homeDir, '.minetest')
|
||||
const candidateDirs = [
|
||||
// Flatpak installations
|
||||
{
|
||||
path: path.join(homeDir, '.var/app/org.luanti.luanti/.minetest'),
|
||||
type: 'Flatpak (org.luanti.luanti)',
|
||||
description: 'Luanti installed via Flatpak'
|
||||
},
|
||||
{
|
||||
path: path.join(homeDir, '.var/app/net.minetest.Minetest/.minetest'),
|
||||
type: 'Flatpak (net.minetest.Minetest)',
|
||||
description: 'Minetest installed via Flatpak'
|
||||
},
|
||||
// Snap installations (they typically use ~/snap/app-name/current/.local/share/minetest)
|
||||
{
|
||||
path: path.join(homeDir, 'snap/luanti/current/.local/share/minetest'),
|
||||
type: 'Snap (luanti)',
|
||||
description: 'Luanti installed via Snap'
|
||||
},
|
||||
{
|
||||
path: path.join(homeDir, 'snap/minetest/current/.local/share/minetest'),
|
||||
type: 'Snap (minetest)',
|
||||
description: 'Minetest installed via Snap'
|
||||
},
|
||||
// System package installations
|
||||
{
|
||||
path: path.join(homeDir, '.luanti'),
|
||||
type: 'System Package',
|
||||
description: 'System-wide Luanti installation'
|
||||
},
|
||||
{
|
||||
path: path.join(homeDir, '.minetest'),
|
||||
type: 'System Package',
|
||||
description: 'System-wide Minetest installation'
|
||||
},
|
||||
// AppImage or manual installations might use these
|
||||
{
|
||||
path: path.join(homeDir, '.local/share/minetest'),
|
||||
type: 'User Installation',
|
||||
description: 'User-local installation'
|
||||
}
|
||||
];
|
||||
|
||||
// Use the first one that exists, or default to .minetest
|
||||
for (const dir of possibleDirs) {
|
||||
if (fs.existsSync(dir)) {
|
||||
return dir;
|
||||
// Check which directories exist and contain expected Luanti files
|
||||
const validDirs = [];
|
||||
for (const candidate of candidateDirs) {
|
||||
if (fs.existsSync(candidate.path)) {
|
||||
// Check if it looks like a valid Luanti data directory
|
||||
const hasConfig = fs.existsSync(path.join(candidate.path, 'minetest.conf'));
|
||||
const hasWorlds = fs.existsSync(path.join(candidate.path, 'worlds'));
|
||||
const hasDebug = fs.existsSync(path.join(candidate.path, 'debug.txt'));
|
||||
|
||||
// Even if some files don't exist, the directory might be valid if it exists
|
||||
// (Luanti creates files on first run)
|
||||
candidate.confidence = (hasConfig ? 1 : 0) + (hasWorlds ? 1 : 0) + (hasDebug ? 0.5 : 0);
|
||||
candidate.exists = true;
|
||||
candidate.hasConfig = hasConfig;
|
||||
candidate.hasWorlds = hasWorlds;
|
||||
candidate.hasDebug = hasDebug;
|
||||
|
||||
validDirs.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by confidence (directories with more Luanti files first)
|
||||
validDirs.sort((a, b) => b.confidence - a.confidence);
|
||||
|
||||
return path.join(homeDir, '.minetest');
|
||||
return validDirs;
|
||||
}
|
||||
|
||||
async getGameDisplayName(gameId) {
|
||||
try {
|
||||
const games = await this.getInstalledGames();
|
||||
const game = games.find(g => g.name === gameId);
|
||||
return game ? game.title : gameId;
|
||||
} catch (error) {
|
||||
console.error('Error getting game display name:', error);
|
||||
return gameId;
|
||||
}
|
||||
}
|
||||
|
||||
setDataDirectory(dataDir) {
|
||||
@@ -113,12 +193,11 @@ class LuantiPaths {
|
||||
|
||||
async getInstalledGames() {
|
||||
const games = [];
|
||||
// For Flatpak and other sandboxed installations, only look in the configured data directory
|
||||
// System installations might also have access to additional directories, but we should
|
||||
// primarily focus on the configured data directory to match what Luanti actually sees
|
||||
const possibleGameDirs = [
|
||||
this.gamesDir, // User games directory
|
||||
'/usr/share/luanti/games', // System games directory
|
||||
'/usr/share/minetest/games', // Legacy system games directory
|
||||
path.join(process.env.HOME || '/root', '.minetest/games'), // Explicit user path
|
||||
path.join(process.env.HOME || '/root', '.luanti/games') // New user path
|
||||
this.gamesDir // Only the configured games directory
|
||||
];
|
||||
|
||||
for (const gameDir of possibleGameDirs) {
|
||||
|
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user