1.0.14
This commit is contained in:
1 parent
8f3a7eb435
commit
610548ed66
17 files changed
+343
-105
No files matched your search
@@ -155,6 +155,7 @@ export async function createEnvironment(env: Omit<Environment, 'id' | 'createdAt
|
||||
tlsCa: env.tlsCa || null,
|
||||
tlsCert: env.tlsCert || null,
|
||||
tlsKey: encrypt(env.tlsKey) || null,
|
||||
tlsSkipVerify: env.tlsSkipVerify ?? false,
|
||||
icon: env.icon || 'globe',
|
||||
socketPath: env.socketPath || '/var/run/docker.sock',
|
||||
collectActivity: env.collectActivity !== false,
|
||||
@@ -1149,7 +1150,11 @@ export async function updateAuthSettings(data: Partial<AuthSettingsData>): Promi
|
||||
if (data.defaultProvider !== undefined) updateData.defaultProvider = data.defaultProvider;
|
||||
if (data.sessionTimeout !== undefined) updateData.sessionTimeout = data.sessionTimeout;
|
||||
|
||||
await db.update(authSettings).set(updateData).where(eq(authSettings.id, 1));
|
||||
// Get existing row's id (may not be 1 after db reset/migration)
|
||||
const existing = await db.select({ id: authSettings.id }).from(authSettings).limit(1);
|
||||
if (existing[0]) {
|
||||
await db.update(authSettings).set(updateData).where(eq(authSettings.id, existing[0].id));
|
||||
}
|
||||
return getAuthSettings();
|
||||
}
|
||||
|
||||
|
||||
@@ -3097,17 +3097,42 @@ export async function runContainerWithStreaming(options: {
|
||||
// Wait for container to fully exit before fetching stdout
|
||||
// The stderr stream may close before the container finishes writing to stdout
|
||||
// Use a timeout to prevent hanging if something goes wrong (container should already be exited)
|
||||
const waitPromise = dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Container wait timeout after 10s')), 10000)
|
||||
);
|
||||
await Promise.race([waitPromise, timeoutPromise]).catch((err) => {
|
||||
let exitCode: number | undefined;
|
||||
try {
|
||||
const waitPromise = dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId);
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Container wait timeout after 10s')), 10000)
|
||||
);
|
||||
const waitResult = await Promise.race([waitPromise, timeoutPromise]);
|
||||
const waitData = await waitResult.json() as { StatusCode?: number };
|
||||
exitCode = waitData.StatusCode;
|
||||
console.log(`[runContainerWithStreaming] Container exited with code: ${exitCode}`);
|
||||
} catch (err) {
|
||||
// Log but don't fail - container might already be gone or stderr stream was reliable
|
||||
console.warn(`[runContainerWithStreaming] Wait warning: ${err.message}`);
|
||||
});
|
||||
console.warn(`[runContainerWithStreaming] Wait warning: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Container has exited. Now fetch stdout reliably (no race condition).
|
||||
const stdout = await fetchContainerStdout(containerId, config, options.envId);
|
||||
|
||||
// If stdout is empty and exit code is non-zero, fetch stderr for debugging
|
||||
if (stdout.length === 0 && exitCode !== 0) {
|
||||
try {
|
||||
const stderrResponse = await dockerFetch(
|
||||
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=false`,
|
||||
{},
|
||||
options.envId
|
||||
);
|
||||
const stderrBuffer = Buffer.from(await stderrResponse.arrayBuffer());
|
||||
const stderrResult = processStreamFrames(stderrBuffer, undefined, undefined);
|
||||
if (stderrResult.stderr) {
|
||||
console.error(`[runContainerWithStreaming] Container stderr: ${stderrResult.stderr.substring(0, 1000)}`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore stderr fetch errors
|
||||
}
|
||||
}
|
||||
|
||||
return stdout;
|
||||
} finally {
|
||||
// Always cleanup container
|
||||
|
||||
@@ -1097,13 +1097,17 @@ export async function deployGitStackWithProgress(
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Record the stack source
|
||||
// Record the stack source with resolved compose path for consistency
|
||||
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
|
||||
const resolvedComposePath = join(stackDir, basename(gitStack.composePath));
|
||||
|
||||
await upsertStackSource({
|
||||
stackName: gitStack.stackName,
|
||||
environmentId: gitStack.environmentId,
|
||||
sourceType: 'git',
|
||||
gitRepositoryId: gitStack.repositoryId,
|
||||
gitStackId: stackId
|
||||
gitStackId: stackId,
|
||||
composePath: resolvedComposePath
|
||||
});
|
||||
|
||||
onProgress({ status: 'complete', message: `Successfully deployed ${gitStack.stackName}` });
|
||||
|
||||
@@ -76,6 +76,10 @@ const SCANNER_CACHE_DIR = 'scanner-cache';
|
||||
// Track running scanner instances to detect concurrent scans
|
||||
const runningScanners = new Map<string, number>(); // key: "grype" or "trivy", value: count
|
||||
|
||||
// Track in-progress scans per image to prevent duplicate scans
|
||||
// Key: "{scannerType}:{imageName}", Value: Promise that resolves to the scan result
|
||||
const inProgressScans = new Map<string, Promise<string>>();
|
||||
|
||||
// Default CLI arguments for scanners (image name is substituted for {image})
|
||||
export const DEFAULT_GRYPE_ARGS = '-o json -v {image}';
|
||||
export const DEFAULT_TRIVY_ARGS = 'image --format json {image}';
|
||||
@@ -433,6 +437,38 @@ async function runScannerContainer(
|
||||
cmd: string[],
|
||||
envId?: number,
|
||||
onOutput?: (line: string) => void
|
||||
): Promise<string> {
|
||||
// Check if a scan for this exact image is already in progress
|
||||
// This prevents duplicate scans when multiple containers use the same image
|
||||
const scanKey = `${scannerType}:${imageName}:${envId ?? 'local'}`;
|
||||
const existingScan = inProgressScans.get(scanKey);
|
||||
if (existingScan) {
|
||||
console.log(`[Scanner] Reusing in-progress ${scannerType} scan for: ${imageName}`);
|
||||
return existingScan;
|
||||
}
|
||||
|
||||
// Create the actual scan promise
|
||||
const scanPromise = runScannerContainerImpl(scannerImage, scannerType, imageName, cmd, envId, onOutput);
|
||||
|
||||
// Register it so concurrent requests can reuse it
|
||||
inProgressScans.set(scanKey, scanPromise);
|
||||
|
||||
try {
|
||||
return await scanPromise;
|
||||
} finally {
|
||||
// Clean up the tracking entry when done
|
||||
inProgressScans.delete(scanKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Internal implementation of scanner container run
|
||||
async function runScannerContainerImpl(
|
||||
scannerImage: string,
|
||||
scannerType: 'grype' | 'trivy',
|
||||
imageName: string,
|
||||
cmd: string[],
|
||||
envId?: number,
|
||||
onOutput?: (line: string) => void
|
||||
): Promise<string> {
|
||||
console.log(`[Scanner] Starting ${scannerType} scan for image: ${imageName}, envId: ${envId ?? 'local'}`);
|
||||
|
||||
@@ -451,17 +487,25 @@ async function runScannerContainer(
|
||||
|
||||
// Detect the host Docker socket path based on connection type
|
||||
// For local socket environments, detect the actual host socket path (handles rootless Docker)
|
||||
// For remote environments (hawser/direct), scanner runs remotely and uses standard path
|
||||
// For remote environments (hawser/direct with host), scanner runs remotely and uses standard path
|
||||
const env = envId ? await getEnvironment(envId) : undefined;
|
||||
const connectionType = env?.connectionType;
|
||||
|
||||
// Determine if this is a local socket environment:
|
||||
// - connectionType === 'socket' (explicit)
|
||||
// - connectionType is null/undefined (default behavior)
|
||||
// - connectionType === 'direct' but no host specified (legacy local environments)
|
||||
const isLocalSocket = !connectionType ||
|
||||
connectionType === 'socket' ||
|
||||
(connectionType === 'direct' && !env?.host);
|
||||
|
||||
let hostSocketPath: string;
|
||||
let containerUser: string | undefined;
|
||||
|
||||
if (!connectionType || connectionType === 'socket') {
|
||||
if (isLocalSocket) {
|
||||
// Local socket environment - detect host socket path (handles rootless Docker)
|
||||
hostSocketPath = getHostDockerSocket();
|
||||
console.log(`[Scanner] Local socket scan - detected host Docker socket: ${hostSocketPath}`);
|
||||
console.log(`[Scanner] Local socket scan (${connectionType || 'default'}) - detected host Docker socket: ${hostSocketPath}`);
|
||||
|
||||
// For user-specific Docker sockets, run scanner as that user
|
||||
// e.g., /run/user/1000/docker.sock -> run as UID 1000
|
||||
@@ -471,10 +515,10 @@ async function runScannerContainer(
|
||||
console.log(`[Scanner] Rootless Docker detected (UID ${containerUser})`);
|
||||
}
|
||||
} else {
|
||||
// Remote environment (direct/hawser-standard/hawser-edge)
|
||||
// Remote environment (direct with host/hawser-standard/hawser-edge)
|
||||
// Scanner runs on remote host, uses remote host's standard Docker socket
|
||||
hostSocketPath = '/var/run/docker.sock';
|
||||
console.log(`[Scanner] Remote scan (${connectionType}) - using standard socket path: ${hostSocketPath}`);
|
||||
console.log(`[Scanner] Remote scan (${connectionType}, host: ${env?.host}) - using standard socket path: ${hostSocketPath}`);
|
||||
}
|
||||
|
||||
// Determine cache storage strategy based on environment
|
||||
|
||||
@@ -74,7 +74,12 @@ export async function runImagePrune(
|
||||
|
||||
// Extract space reclaimed and images removed from result
|
||||
const spaceReclaimed = result?.SpaceReclaimed || 0;
|
||||
const imagesRemoved = result?.ImagesDeleted?.length || 0;
|
||||
// Count unique images by filtering Untagged entries that are not digest references
|
||||
// Docker returns multiple entries per image: Untagged (tag), Untagged (digest @sha256:), Deleted (layers)
|
||||
// We only count tag-based Untagged entries to get actual image count
|
||||
const imagesRemoved = result?.ImagesDeleted
|
||||
?.filter((img: any) => img.Untagged && !img.Untagged.includes('@sha256:'))
|
||||
.length || 0;
|
||||
|
||||
// Format space for human-readable output
|
||||
const formatBytes = (bytes: number): string => {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
getAutoUpdateSetting
|
||||
} from './db';
|
||||
import { unregisterSchedule } from './scheduler';
|
||||
import { deleteGitStackFiles } from './git';
|
||||
import { deleteGitStackFiles, parseEnvFileContent } from './git';
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
import { rewriteComposeVolumePaths, getHostDataDir } from './host-path';
|
||||
|
||||
@@ -1225,18 +1225,35 @@ async function executeComposeCommand(
|
||||
|
||||
switch (env.connectionType) {
|
||||
case 'hawser-standard':
|
||||
case 'hawser-edge':
|
||||
case 'hawser-edge': {
|
||||
// For Hawser deployments, we need to read the .env file and send variables via envVars
|
||||
// because Docker Compose on the remote host may not auto-read the .env file reliably.
|
||||
// Local deployments use --env-file flag, but Hawser needs variables injected via shell env.
|
||||
let hawserEnvVars = envVars;
|
||||
if (envPath && existsSync(envPath)) {
|
||||
try {
|
||||
const envFileContent = await Bun.file(envPath).text();
|
||||
const envFileVars = parseEnvFileContent(envFileContent, stackName);
|
||||
// Merge: envFileVars (lowest) < envVars (DB overrides)
|
||||
// secretVars are handled separately in executeComposeViaHawser
|
||||
hawserEnvVars = { ...envFileVars, ...(envVars || {}) };
|
||||
console.log(`[Stack:${stackName}] Read ${Object.keys(envFileVars).length} vars from .env file for Hawser injection`);
|
||||
} catch (err) {
|
||||
console.warn(`[Stack:${stackName}] Failed to read .env file at ${envPath}:`, err);
|
||||
}
|
||||
}
|
||||
return executeComposeViaHawser(
|
||||
operation,
|
||||
stackName,
|
||||
composeContent,
|
||||
envId!,
|
||||
envVars,
|
||||
hawserEnvVars,
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes,
|
||||
stackFiles
|
||||
);
|
||||
}
|
||||
|
||||
case 'direct': {
|
||||
const port = env.port || 2375;
|
||||
@@ -1490,6 +1507,8 @@ export interface RequireComposeResult {
|
||||
success: boolean;
|
||||
content?: string;
|
||||
secretVars?: Record<string, string>;
|
||||
/** Non-secret variables from database (needed for compose interpolation) */
|
||||
nonSecretVars?: Record<string, string>;
|
||||
needsFileLocation?: boolean;
|
||||
error?: string;
|
||||
/** Directory containing the compose file (for working directory) */
|
||||
@@ -1534,6 +1553,10 @@ async function requireComposeFile(
|
||||
// These are NEVER written to disk
|
||||
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
|
||||
|
||||
// Get NON-SECRET variables from database (needed for compose interpolation)
|
||||
// For git stacks without .env files, these are the only source of env vars
|
||||
const nonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
|
||||
|
||||
// Determine env file path for --env-file flag
|
||||
// For stacks with custom composePath (adopted/external), derive envPath from same directory
|
||||
// For internal stacks, use the default data directory
|
||||
@@ -1558,11 +1581,13 @@ async function requireComposeFile(
|
||||
}
|
||||
|
||||
// Docker Compose reads non-secrets from the .env file via --env-file.
|
||||
// Only secrets need to be injected via shell environment.
|
||||
// Secrets and non-secrets from DB need to be injected via shell environment
|
||||
// for stacks without .env files (e.g., git stacks with manual env vars).
|
||||
return {
|
||||
success: true,
|
||||
content: composeResult.content!,
|
||||
secretVars,
|
||||
nonSecretVars,
|
||||
stackDir: composeResult.stackDir,
|
||||
composePath: composeResult.composePath ?? undefined,
|
||||
envPath: envFilePath ?? undefined
|
||||
@@ -1588,7 +1613,7 @@ export async function startStack(
|
||||
'up',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
undefined,
|
||||
result.nonSecretVars,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
@@ -1612,7 +1637,7 @@ export async function stopStack(
|
||||
'stop',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
undefined,
|
||||
result.nonSecretVars,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
@@ -1636,7 +1661,7 @@ export async function restartStack(
|
||||
'restart',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
undefined,
|
||||
result.nonSecretVars,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
@@ -1661,7 +1686,7 @@ export async function downStack(
|
||||
'down',
|
||||
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
undefined,
|
||||
result.nonSecretVars,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
@@ -2026,7 +2051,7 @@ export async function pullStackImages(
|
||||
'pull',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
undefined,
|
||||
result.nonSecretVars,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ const lastPollTime: Map<number, number> = new Map();
|
||||
// Recent event cache for deduplication (key: timeNano-containerId-action)
|
||||
const recentEvents: Map<string, number> = new Map();
|
||||
const DEDUP_WINDOW_MS = 5000; // 5 second window for deduplication
|
||||
const CACHE_CLEANUP_INTERVAL_MS = 30000; // Clean up cache every 30 seconds
|
||||
const CACHE_CLEANUP_INTERVAL_MS = 5000; // Clean up cache every 5 seconds (match dedup window)
|
||||
const MAX_DEDUP_CACHE_SIZE = 500; // Hard limit to prevent unbounded growth
|
||||
|
||||
let cacheCleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let isShuttingDown = false;
|
||||
@@ -126,14 +127,25 @@ interface DockerEvent {
|
||||
|
||||
/**
|
||||
* Clean up old entries from the deduplication cache
|
||||
* Also enforces max size limit with LRU eviction
|
||||
*/
|
||||
function cleanupRecentEvents() {
|
||||
const now = Date.now();
|
||||
// First pass: remove expired entries
|
||||
for (const [key, timestamp] of recentEvents.entries()) {
|
||||
if (now - timestamp > DEDUP_WINDOW_MS) {
|
||||
recentEvents.delete(key);
|
||||
}
|
||||
}
|
||||
// Second pass: enforce max size with LRU eviction if still too large
|
||||
if (recentEvents.size > MAX_DEDUP_CACHE_SIZE) {
|
||||
const entries = Array.from(recentEvents.entries())
|
||||
.sort((a, b) => a[1] - b[1]); // Sort by timestamp (oldest first)
|
||||
const toRemove = entries.slice(0, entries.length - MAX_DEDUP_CACHE_SIZE);
|
||||
for (const [key] of toRemove) {
|
||||
recentEvents.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,9 +286,11 @@ async function pollEnvironmentEvents(envId: number, envName: string) {
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
// Cancel the stream first to ensure proper cleanup, then release lock
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Reader already released
|
||||
// Reader already released or stream closed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +376,8 @@ async function startEnvironmentCollector(envId: number, envName: string) {
|
||||
} finally {
|
||||
if (reader) {
|
||||
try {
|
||||
// Cancel the stream first to ensure proper cleanup, then release lock
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Reader already released or stream closed - ignore
|
||||
@@ -376,6 +392,8 @@ async function startEnvironmentCollector(envId: number, envName: string) {
|
||||
} catch (error: any) {
|
||||
if (reader) {
|
||||
try {
|
||||
// Cancel the stream first to ensure proper cleanup, then release lock
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Reader already released or stream closed - ignore
|
||||
@@ -624,7 +642,17 @@ async function start(): Promise<void> {
|
||||
|
||||
// Start periodic cache cleanup
|
||||
cacheCleanupInterval = setInterval(cleanupRecentEvents, CACHE_CLEANUP_INTERVAL_MS);
|
||||
console.log('[EventSubprocess] Started deduplication cache cleanup (every 30s)');
|
||||
console.log('[EventSubprocess] Started deduplication cache cleanup (every 5s)');
|
||||
|
||||
// Start memory diagnostics logging (every 5 minutes)
|
||||
setInterval(() => {
|
||||
const mem = process.memoryUsage();
|
||||
console.log(
|
||||
`[EventSubprocess] Memory: heap=${Math.round(mem.heapUsed / 1024 / 1024)}MB, ` +
|
||||
`rss=${Math.round(mem.rss / 1024 / 1024)}MB, ` +
|
||||
`dedup=${recentEvents.size}, collectors=${collectors.size}, pollers=${pollIntervals.size}`
|
||||
);
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// Listen for commands from main process
|
||||
process.on('message', (message: MainProcessCommand) => {
|
||||
|
||||
@@ -20,12 +20,20 @@ const ENV_DISK_TIMEOUT = 20000; // 20 seconds timeout per environment for disk c
|
||||
|
||||
/**
|
||||
* Timeout wrapper - returns fallback if promise takes too long
|
||||
* IMPORTANT: Properly clears the timeout to prevent memory leaks
|
||||
*/
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>(resolve => setTimeout(() => resolve(fallback), ms))
|
||||
]);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const timeoutPromise = new Promise<T>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve(fallback), ms);
|
||||
});
|
||||
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track last disk warning sent per environment to avoid spamming
|
||||
@@ -35,6 +43,8 @@ const DISK_WARNING_COOLDOWN = 3600000; // 1 hour between warnings
|
||||
let collectInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let diskCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let isShuttingDown = false;
|
||||
let collectionCycleCount = 0;
|
||||
const MEMORY_LOG_INTERVAL = 10; // Log memory every 10 cycles (~5 minutes at 30s interval)
|
||||
|
||||
/**
|
||||
* Send message to main process
|
||||
@@ -170,6 +180,15 @@ async function collectMetrics() {
|
||||
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics failed: ${reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Periodic memory logging for diagnostics
|
||||
collectionCycleCount++;
|
||||
if (collectionCycleCount % MEMORY_LOG_INTERVAL === 0) {
|
||||
const memUsage = process.memoryUsage();
|
||||
const heapMB = Math.round(memUsage.heapUsed / 1024 / 1024);
|
||||
const rssMB = Math.round(memUsage.rss / 1024 / 1024);
|
||||
console.log(`[MetricsSubprocess] Memory: heap=${heapMB}MB, rss=${rssMB}MB (cycle ${collectionCycleCount})`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[MetricsSubprocess] Metrics collection error: ${message}`);
|
||||
@@ -432,6 +451,15 @@ async function start(): Promise<void> {
|
||||
console.log('[MetricsSubprocess] Disk space monitoring disabled (SKIP_DF_COLLECTION=true)');
|
||||
}
|
||||
|
||||
// Start memory diagnostics logging (every 5 minutes)
|
||||
setInterval(() => {
|
||||
const mem = process.memoryUsage();
|
||||
console.log(
|
||||
`[MetricsSubprocess] Memory: heap=${Math.round(mem.heapUsed / 1024 / 1024)}MB, ` +
|
||||
`rss=${Math.round(mem.rss / 1024 / 1024)}MB`
|
||||
);
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// Listen for commands from main process
|
||||
process.on('message', (message: MainProcessCommand) => {
|
||||
handleCommand(message);
|
||||
|
||||
Reference in new issue
Block a user