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

@@ -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();
});
});
}