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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Reference in New Issue
Block a user