Unverified 提交 daa8bce5 authored 作者: wwwillchen-bot's avatar wwwillchen-bot 提交者: GitHub

feat: allow multiple apps to run concurrently with garbage collection (#2825)

## Summary - Allow multiple Dyad apps to run simultaneously instead of stopping the previous app when switching - Add garbage collection that stops apps idle for 10+ minutes (unless currently selected) - Track `lastViewedAt` timestamp on running apps to determine eligibility for GC - Clean up all running apps when the Dyad application quits ## Test plan 1. Create two test apps in Dyad 2. Run the first app and verify it starts 3. Switch to the second app - verify the first app keeps running (check processes) 4. Wait 10+ minutes without viewing the first app - verify it gets garbage collected 5. Close Dyad completely - verify all running app processes are stopped Closes #2819 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2825" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarWill Chen <willchen90@gmail.com> Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com> Co-authored-by: 's avatarclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
上级 6b0d6ef8
...@@ -11,7 +11,7 @@ import { PreviewIframe } from "./PreviewIframe"; ...@@ -11,7 +11,7 @@ import { PreviewIframe } from "./PreviewIframe";
import { Problems } from "./Problems"; import { Problems } from "./Problems";
import { ConfigurePanel } from "./ConfigurePanel"; import { ConfigurePanel } from "./ConfigurePanel";
import { ChevronDown, ChevronUp, Logs } from "lucide-react"; import { ChevronDown, ChevronUp, Logs } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels"; import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
import { Console } from "./Console"; import { Console } from "./Console";
import { useRunApp } from "@/hooks/useRunApp"; import { useRunApp } from "@/hooks/useRunApp";
...@@ -20,6 +20,7 @@ import { SecurityPanel } from "./SecurityPanel"; ...@@ -20,6 +20,7 @@ import { SecurityPanel } from "./SecurityPanel";
import { PlanPanel } from "./PlanPanel"; import { PlanPanel } from "./PlanPanel";
import { useSupabase } from "@/hooks/useSupabase"; import { useSupabase } from "@/hooks/useSupabase";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ipc } from "@/ipc/types";
interface ConsoleHeaderProps { interface ConsoleHeaderProps {
isOpen: boolean; isOpen: boolean;
...@@ -61,9 +62,8 @@ export function PreviewPanel() { ...@@ -61,9 +62,8 @@ export function PreviewPanel() {
const [previewMode] = useAtom(previewModeAtom); const [previewMode] = useAtom(previewModeAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom); const selectedAppId = useAtomValue(selectedAppIdAtom);
const [isConsoleOpen, setIsConsoleOpen] = useState(false); const [isConsoleOpen, setIsConsoleOpen] = useState(false);
const { runApp, stopApp, loading, app } = useRunApp(); const { runApp, loading, app } = useRunApp();
const { loadEdgeLogs } = useSupabase(); const { loadEdgeLogs } = useSupabase();
const runningAppIdRef = useRef<number | null>(null);
const key = useAtomValue(previewPanelKeyAtom); const key = useAtomValue(previewPanelKeyAtom);
const consoleEntries = useAtomValue(appConsoleEntriesAtom); const consoleEntries = useAtomValue(appConsoleEntriesAtom);
...@@ -72,50 +72,54 @@ export function PreviewPanel() { ...@@ -72,50 +72,54 @@ export function PreviewPanel() {
? consoleEntries[consoleEntries.length - 1]?.message ? consoleEntries[consoleEntries.length - 1]?.message
: undefined; : undefined;
// Notify backend about app selection changes (for garbage collection tracking)
const notifyAppSelected = useCallback(async (appId: number | null) => {
try {
await ipc.app.selectAppForPreview({ appId });
} catch (error) {
console.error("Failed to notify app selection:", error);
}
}, []);
useEffect(() => { useEffect(() => {
const previousAppId = runningAppIdRef.current; let cancelled = false;
// Check if the selected app ID has changed const handleAppSelection = async () => {
if (selectedAppId !== previousAppId) { // Notify backend which app is currently selected (for GC tracking)
// Stop the previously running app, if any await notifyAppSelected(selectedAppId);
if (previousAppId !== null) {
console.debug("Stopping previous app", previousAppId); // If the effect was cleaned up while awaiting, don't proceed
stopApp(previousAppId); if (cancelled) return;
// We don't necessarily nullify the ref here immediately,
// let the start of the next app update it or unmount handle it.
}
// Start the new app if an ID is selected // Start the app if it's selected
// The backend will handle the case where the app is already running
if (selectedAppId !== null) { if (selectedAppId !== null) {
console.debug("Starting new app", selectedAppId); console.debug(
runApp(selectedAppId); // Consider adding error handling for the promise if needed "Running app (will start if not already running)",
runningAppIdRef.current = selectedAppId; // Update ref to the new running app ID selectedAppId,
} else { );
// If selectedAppId is null, ensure no app is marked as running runApp(selectedAppId);
runningAppIdRef.current = null;
} }
} };
handleAppSelection();
// Cleanup function: This runs when the component unmounts OR before the effect runs again.
// We only want to stop the app on actual unmount. The logic above handles stopping
// when the appId changes. So, we capture the running appId at the time the effect renders.
const appToStopOnUnmount = runningAppIdRef.current;
return () => { return () => {
if (appToStopOnUnmount !== null) { cancelled = true;
const currentRunningApp = runningAppIdRef.current; // Notify backend that no app is being previewed so GC can reclaim idle apps
if (currentRunningApp !== null) { notifyAppSelected(null);
console.debug(
"Component unmounting or selectedAppId changing, stopping app",
currentRunningApp,
);
stopApp(currentRunningApp);
runningAppIdRef.current = null; // Clear ref on stop
}
}
}; };
// Dependencies: run effect when selectedAppId changes. // Note: We no longer stop apps when switching. The backend garbage collector
// runApp/stopApp are stable due to useCallback. // will stop apps that haven't been viewed in 10 minutes.
}, [selectedAppId, runApp, stopApp]); // Apps are only stopped explicitly when:
// 1. User manually stops them
// 2. App is deleted
// 3. Garbage collector determines they've been idle too long
}, [selectedAppId, runApp, notifyAppSelected]);
// Note: We no longer stop all apps on unmount. The garbage collector
// will handle cleanup of idle apps, and users may want apps to keep
// running in the background.
// Load edge logs if app has Supabase project configured // Load edge logs if app has Supabase project configured
useEffect(() => { useEffect(() => {
......
...@@ -148,7 +148,7 @@ export function useRunApp() { ...@@ -148,7 +148,7 @@ export function useRunApp() {
const logEntry = { const logEntry = {
level: "info" as const, level: "info" as const,
type: "server" as const, type: "server" as const,
message: "Trying to restart app...", message: "Connecting to app...",
appId, appId,
timestamp: Date.now(), timestamp: Date.now(),
}; };
......
...@@ -22,6 +22,8 @@ import { ...@@ -22,6 +22,8 @@ import {
removeAppIfCurrentProcess, removeAppIfCurrentProcess,
stopAppByInfo, stopAppByInfo,
removeDockerVolumesForApp, removeDockerVolumesForApp,
setCurrentlySelectedAppId,
startAppGarbageCollection,
} from "../utils/process_manager"; } from "../utils/process_manager";
import { getEnvVar } from "../utils/read_env"; import { getEnvVar } from "../utils/read_env";
import { readSettings } from "../../main/settings"; import { readSettings } from "../../main/settings";
...@@ -39,7 +41,6 @@ import { ...@@ -39,7 +41,6 @@ import {
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
import { getLanguageModelProviders } from "../shared/language_model_helpers"; import { getLanguageModelProviders } from "../shared/language_model_helpers";
import { startProxy } from "../utils/start_proxy_server"; import { startProxy } from "../utils/start_proxy_server";
import { Worker } from "worker_threads";
import { createFromTemplate } from "./createFromTemplate"; import { createFromTemplate } from "./createFromTemplate";
import { import {
gitCommit, gitCommit,
...@@ -150,8 +151,6 @@ async function copyDir( ...@@ -150,8 +151,6 @@ async function copyDir(
}); });
} }
let proxyWorker: Worker | null = null;
// Needed, otherwise electron in MacOS/Linux will not be able // Needed, otherwise electron in MacOS/Linux will not be able
// to find node/pnpm. // to find node/pnpm.
fixPath(); fixPath();
...@@ -171,10 +170,6 @@ async function executeApp({ ...@@ -171,10 +170,6 @@ async function executeApp({
installCommand?: string | null; installCommand?: string | null;
startCommand?: string | null; startCommand?: string | null;
}): Promise<void> { }): Promise<void> {
if (proxyWorker) {
proxyWorker.terminate();
proxyWorker = null;
}
const settings = readSettings(); const settings = readSettings();
const runtimeMode = settings.runtimeMode2 ?? "host"; const runtimeMode = settings.runtimeMode2 ?? "host";
...@@ -272,6 +267,7 @@ Details: ${details || "n/a"} ...@@ -272,6 +267,7 @@ Details: ${details || "n/a"}
process: spawnedProcess, process: spawnedProcess,
processId: currentProcessId, processId: currentProcessId,
isDocker: false, isDocker: false,
lastViewedAt: Date.now(),
}); });
listenToProcess({ listenToProcess({
...@@ -342,15 +338,54 @@ function listenToProcess({ ...@@ -342,15 +338,54 @@ function listenToProcess({
const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/); const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/);
if (urlMatch) { if (urlMatch) {
proxyWorker = await startProxy(urlMatch[1], { const originalUrl = urlMatch[1];
const appInfo = runningApps.get(appId);
if (!appInfo) {
return;
}
// Reuse the existing proxy worker for this app if it already targets this URL.
if (
appInfo.proxyWorker &&
appInfo.originalUrl === originalUrl &&
appInfo.proxyUrl
) {
safeSend(event.sender, "app:output", {
type: "stdout",
message: `[dyad-proxy-server]started=[${appInfo.proxyUrl}] original=[${originalUrl}]`,
appId,
});
return;
}
if (appInfo.proxyWorker) {
await appInfo.proxyWorker.terminate();
appInfo.proxyWorker = undefined;
}
const proxyWorker = await startProxy(originalUrl, {
onStarted: (proxyUrl) => { onStarted: (proxyUrl) => {
// Store proxy URL in running app info for re-emission on app switch
const latestAppInfo = runningApps.get(appId);
if (latestAppInfo) {
latestAppInfo.proxyUrl = proxyUrl;
latestAppInfo.originalUrl = originalUrl;
}
safeSend(event.sender, "app:output", { safeSend(event.sender, "app:output", {
type: "stdout", type: "stdout",
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${urlMatch[1]}]`, message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${originalUrl}]`,
appId, appId,
}); });
}, },
}); });
const latestAppInfo = runningApps.get(appId);
if (latestAppInfo) {
latestAppInfo.proxyWorker = proxyWorker;
latestAppInfo.originalUrl = originalUrl;
} else {
await proxyWorker.terminate();
}
} }
} }
}); });
...@@ -577,6 +612,7 @@ ${errorOutput || "(empty)"}`, ...@@ -577,6 +612,7 @@ ${errorOutput || "(empty)"}`,
processId: currentProcessId, processId: currentProcessId,
isDocker: true, isDocker: true,
containerName, containerName,
lastViewedAt: Date.now(),
}); });
listenToProcess({ listenToProcess({
...@@ -1007,6 +1043,15 @@ export function registerAppHandlers() { ...@@ -1007,6 +1043,15 @@ export function registerAppHandlers() {
// Check if app is already running // Check if app is already running
if (runningApps.has(appId)) { if (runningApps.has(appId)) {
logger.debug(`App ${appId} is already running.`); logger.debug(`App ${appId} is already running.`);
// Re-emit the proxy URL so the frontend can restore the preview
const appInfo = runningApps.get(appId);
if (appInfo?.proxyUrl && appInfo?.originalUrl) {
safeSend(event.sender, "app:output", {
type: "stdout",
message: `[dyad-proxy-server]started=[${appInfo.proxyUrl}] original=[${appInfo.originalUrl}]`,
appId,
});
}
return; return;
} }
...@@ -2004,6 +2049,21 @@ export function registerAppHandlers() { ...@@ -2004,6 +2049,21 @@ export function registerAppHandlers() {
} }
}); });
}); });
// Handler for selecting an app for preview (updates lastViewedAt to prevent GC)
createTypedHandler(appContracts.selectAppForPreview, async (_, params) => {
const { appId } = params;
if (appId !== null) {
logger.debug(`App ${appId} selected for preview`);
setCurrentlySelectedAppId(appId);
} else {
logger.debug("No app selected for preview");
setCurrentlySelectedAppId(null);
}
});
// Start the garbage collection for idle apps
startAppGarbageCollection();
} }
function getCommand({ function getCommand({
......
...@@ -381,6 +381,16 @@ export const appContracts = { ...@@ -381,6 +381,16 @@ export const appContracts = {
input: UpdateAppCommandsParamsSchema, input: UpdateAppCommandsParamsSchema,
output: z.void(), output: z.void(),
}), }),
/**
* Notifies the backend that an app has been selected/viewed in the preview panel.
* This updates the lastViewedAt timestamp to prevent garbage collection.
*/
selectAppForPreview: defineContract({
channel: "select-app-for-preview",
input: z.object({ appId: z.number().nullable() }),
output: z.void(),
}),
} as const; } as const;
// ============================================================================= // =============================================================================
......
import { ChildProcess, spawn } from "node:child_process"; import { ChildProcess, spawn } from "node:child_process";
import treeKill from "tree-kill"; import treeKill from "tree-kill";
import log from "electron-log";
import type { Worker } from "node:worker_threads";
import { withLock } from "./lock_utils";
const logger = log.scope("process_manager");
// Define a type for the value stored in runningApps // Define a type for the value stored in runningApps
export interface RunningAppInfo { export interface RunningAppInfo {
...@@ -7,6 +12,14 @@ export interface RunningAppInfo { ...@@ -7,6 +12,14 @@ export interface RunningAppInfo {
processId: number; processId: number;
isDocker: boolean; isDocker: boolean;
containerName?: string; containerName?: string;
/** Timestamp of when this app was last viewed/selected in the preview panel */
lastViewedAt: number;
/** Proxy URL for the running app, set when the proxy server starts */
proxyUrl?: string;
/** Original localhost URL for the running app */
originalUrl?: string;
/** Proxy worker dedicated to this running app */
proxyWorker?: Worker;
} }
// Store running app processes // Store running app processes
...@@ -37,7 +50,7 @@ export function killProcess(process: ChildProcess): Promise<void> { ...@@ -37,7 +50,7 @@ export function killProcess(process: ChildProcess): Promise<void> {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
// Add timeout to prevent hanging // Add timeout to prevent hanging
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
console.warn( logger.warn(
`Timeout waiting for process (PID: ${process.pid}) to close. Force killing may be needed.`, `Timeout waiting for process (PID: ${process.pid}) to close. Force killing may be needed.`,
); );
resolve(); resolve();
...@@ -45,7 +58,7 @@ export function killProcess(process: ChildProcess): Promise<void> { ...@@ -45,7 +58,7 @@ export function killProcess(process: ChildProcess): Promise<void> {
process.on("close", (code, signal) => { process.on("close", (code, signal) => {
clearTimeout(timeout); clearTimeout(timeout);
console.log( logger.info(
`Received 'close' event for process (PID: ${process.pid}) with code ${code}, signal ${signal}.`, `Received 'close' event for process (PID: ${process.pid}) with code ${code}, signal ${signal}.`,
); );
resolve(); resolve();
...@@ -54,7 +67,7 @@ export function killProcess(process: ChildProcess): Promise<void> { ...@@ -54,7 +67,7 @@ export function killProcess(process: ChildProcess): Promise<void> {
// Handle potential errors during kill/close sequence // Handle potential errors during kill/close sequence
process.on("error", (err) => { process.on("error", (err) => {
clearTimeout(timeout); clearTimeout(timeout);
console.error( logger.error(
`Error during stop sequence for process (PID: ${process.pid}): ${err.message}`, `Error during stop sequence for process (PID: ${process.pid}): ${err.message}`,
); );
resolve(); resolve();
...@@ -63,22 +76,20 @@ export function killProcess(process: ChildProcess): Promise<void> { ...@@ -63,22 +76,20 @@ export function killProcess(process: ChildProcess): Promise<void> {
// Ensure PID exists before attempting to kill // Ensure PID exists before attempting to kill
if (process.pid) { if (process.pid) {
// Use tree-kill to terminate the entire process tree // Use tree-kill to terminate the entire process tree
console.log( logger.info(
`Attempting to tree-kill process tree starting at PID ${process.pid}.`, `Attempting to tree-kill process tree starting at PID ${process.pid}.`,
); );
treeKill(process.pid, "SIGTERM", (err: Error | undefined) => { treeKill(process.pid, "SIGTERM", (err: Error | undefined) => {
if (err) { if (err) {
console.warn( logger.warn(`tree-kill error for PID ${process.pid}: ${err.message}`);
`tree-kill error for PID ${process.pid}: ${err.message}`,
);
} else { } else {
console.log( logger.info(
`tree-kill signal sent successfully to PID ${process.pid}.`, `tree-kill signal sent successfully to PID ${process.pid}.`,
); );
} }
}); });
} else { } else {
console.warn(`Cannot tree-kill process: PID is undefined.`); logger.warn(`Cannot tree-kill process: PID is undefined.`);
} }
}); });
} }
...@@ -117,6 +128,11 @@ export async function stopAppByInfo( ...@@ -117,6 +128,11 @@ export async function stopAppByInfo(
appId: number, appId: number,
appInfo: RunningAppInfo, appInfo: RunningAppInfo,
): Promise<void> { ): Promise<void> {
if (appInfo.proxyWorker) {
await appInfo.proxyWorker.terminate();
appInfo.proxyWorker = undefined;
}
if (appInfo.isDocker) { if (appInfo.isDocker) {
const containerName = appInfo.containerName || `dyad-app-${appId}`; const containerName = appInfo.containerName || `dyad-app-${appId}`;
await stopDockerContainer(containerName); await stopDockerContainer(containerName);
...@@ -137,13 +153,210 @@ export function removeAppIfCurrentProcess( ...@@ -137,13 +153,210 @@ export function removeAppIfCurrentProcess(
): void { ): void {
const currentAppInfo = runningApps.get(appId); const currentAppInfo = runningApps.get(appId);
if (currentAppInfo && currentAppInfo.process === process) { if (currentAppInfo && currentAppInfo.process === process) {
if (currentAppInfo.proxyWorker) {
void currentAppInfo.proxyWorker.terminate();
currentAppInfo.proxyWorker = undefined;
}
runningApps.delete(appId); runningApps.delete(appId);
console.log( logger.info(
`Removed app ${appId} (processId ${currentAppInfo.processId}) from running map. Current size: ${runningApps.size}`, `Removed app ${appId} (processId ${currentAppInfo.processId}) from running map. Current size: ${runningApps.size}`,
); );
} else { } else {
console.log( logger.info(
`App ${appId} process was already removed or replaced in running map. Ignoring.`, `App ${appId} process was already removed or replaced in running map. Ignoring.`,
); );
} }
} }
/**
* Updates the lastViewedAt timestamp for an app.
* This is called when a user views/selects an app in the preview panel.
* @param appId The app ID to update
*/
export function updateAppLastViewed(appId: number): void {
const appInfo = runningApps.get(appId);
if (appInfo) {
appInfo.lastViewedAt = Date.now();
logger.info(`Updated lastViewedAt for app ${appId}`);
}
}
// Garbage collection interval in milliseconds (check every 1 minute)
const GC_CHECK_INTERVAL_MS = 60 * 1000;
// Time in milliseconds after which an idle app is eligible for garbage collection (10 minutes)
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
// Track the currently selected app ID to avoid garbage collecting it
let currentlySelectedAppId: number | null = null;
/**
* Sets the currently selected app ID. The selected app will never be garbage collected.
* @param appId The app ID that is currently selected, or null if none
*/
export function setCurrentlySelectedAppId(appId: number | null): void {
// Update lastViewedAt for the previously selected app so the idle timer
// starts from when the user actually stopped viewing it
if (currentlySelectedAppId !== null && currentlySelectedAppId !== appId) {
updateAppLastViewed(currentlySelectedAppId);
}
currentlySelectedAppId = appId;
if (appId !== null) {
updateAppLastViewed(appId);
}
}
/**
* Gets the currently selected app ID.
*/
export function getCurrentlySelectedAppId(): number | null {
return currentlySelectedAppId;
}
/**
* Garbage collects idle apps that haven't been viewed in the last 10 minutes
* and are not the currently selected app.
*/
export async function garbageCollectIdleApps(): Promise<void> {
const now = Date.now();
const appsToStop: number[] = [];
for (const [appId, appInfo] of runningApps.entries()) {
// Never garbage collect the currently selected app
if (appId === currentlySelectedAppId) {
continue;
}
// Check if the app has been idle for more than 10 minutes
const idleTime = now - appInfo.lastViewedAt;
if (idleTime >= IDLE_TIMEOUT_MS) {
logger.info(
`App ${appId} has been idle for ${Math.round(idleTime / 1000 / 60)} minutes. Marking for garbage collection.`,
);
appsToStop.push(appId);
}
}
// Stop idle apps (acquire per-app lock to avoid racing with runApp/stopApp/restartApp)
for (const appId of appsToStop) {
try {
await withLock(appId, async () => {
// Re-check: the user may have selected this app while we were stopping others
if (appId === currentlySelectedAppId) {
logger.info(
`Skipping GC for app ${appId}: it became the selected app during this GC cycle`,
);
return;
}
const appInfo = runningApps.get(appId);
if (!appInfo) return;
// Re-check idle time under lock in case the app was viewed/restarted
const recheckIdle = Date.now() - appInfo.lastViewedAt;
if (recheckIdle < IDLE_TIMEOUT_MS) {
logger.info(
`Skipping GC for app ${appId}: idle time refreshed during lock wait`,
);
return;
}
logger.info(`Garbage collecting idle app ${appId}`);
await stopAppByInfo(appId, appInfo);
});
} catch (error) {
logger.error(`Failed to garbage collect app ${appId}:`, error);
}
}
if (appsToStop.length > 0) {
logger.info(
`Garbage collection complete. Stopped ${appsToStop.length} idle app(s). Running apps: ${runningApps.size}`,
);
}
}
// Start the garbage collection timer
let gcTimeoutId: ReturnType<typeof setTimeout> | null = null;
/**
* Starts the garbage collection timer to periodically clean up idle apps.
* Uses recursive setTimeout instead of setInterval to prevent overlapping
* executions when garbageCollectIdleApps takes longer than the interval.
*/
export function startAppGarbageCollection(): void {
if (gcTimeoutId !== null) {
logger.info("App garbage collection already running");
return;
}
logger.info(
`Starting app garbage collection (interval: ${GC_CHECK_INTERVAL_MS / 1000}s, idle timeout: ${IDLE_TIMEOUT_MS / 1000 / 60} minutes)`,
);
const runGarbageCollection = () => {
garbageCollectIdleApps()
.catch((error) => {
logger.error("Error during app garbage collection:", error);
})
.finally(() => {
// Only schedule next run if not stopped
if (gcTimeoutId !== null) {
gcTimeoutId = setTimeout(runGarbageCollection, GC_CHECK_INTERVAL_MS);
}
});
};
gcTimeoutId = setTimeout(runGarbageCollection, GC_CHECK_INTERVAL_MS);
}
/**
* Stops the garbage collection timer.
*/
export function stopAppGarbageCollection(): void {
if (gcTimeoutId !== null) {
clearTimeout(gcTimeoutId);
gcTimeoutId = null;
logger.info("Stopped app garbage collection");
}
}
/**
* Synchronously sends kill signals to all running apps without awaiting completion.
* Used during app quit when Electron's EventEmitter does not await async handlers.
*/
export function stopAllAppsSync(): void {
const appIds = Array.from(runningApps.keys());
logger.info(`Synchronously stopping ${appIds.length} running app(s) on quit`);
for (const appId of appIds) {
const appInfo = runningApps.get(appId);
if (!appInfo) continue;
if (appInfo.proxyWorker) {
void appInfo.proxyWorker.terminate();
appInfo.proxyWorker = undefined;
}
if (appInfo.isDocker) {
const containerName = appInfo.containerName || `dyad-app-${appId}`;
// Fire-and-forget: spawn docker stop without awaiting
const stop = spawn("docker", ["stop", containerName], {
stdio: "ignore",
});
stop.on("error", (err) => {
logger.warn(
`Failed to stop docker container for app ${appId} (${containerName}): ${err.message}`,
);
});
logger.info(`Sent docker stop for app ${appId} (${containerName})`);
} else if (appInfo.process.pid) {
// treeKill sends SIGTERM synchronously
treeKill(appInfo.process.pid, "SIGTERM", (err: Error | undefined) => {
if (err) {
logger.warn(
`tree-kill error for app ${appId} (PID ${appInfo.process.pid}): ${err.message}`,
);
}
});
logger.info(`Sent SIGTERM to app ${appId} (PID ${appInfo.process.pid})`);
}
runningApps.delete(appId);
}
}
...@@ -28,6 +28,10 @@ import { ...@@ -28,6 +28,10 @@ import {
startPerformanceMonitoring, startPerformanceMonitoring,
stopPerformanceMonitoring, stopPerformanceMonitoring,
} from "./utils/performance_monitor"; } from "./utils/performance_monitor";
import {
stopAllAppsSync,
stopAppGarbageCollection,
} from "./ipc/utils/process_manager";
import { cleanupOldAiMessagesJson } from "./pro/main/ipc/handlers/local_agent/ai_messages_cleanup"; import { cleanupOldAiMessagesJson } from "./pro/main/ipc/handlers/local_agent/ai_messages_cleanup";
import fs from "fs"; import fs from "fs";
import { gitAddSafeDirectory } from "./ipc/utils/git_utils"; import { gitAddSafeDirectory } from "./ipc/utils/git_utils";
...@@ -609,10 +613,19 @@ app.on("window-all-closed", () => { ...@@ -609,10 +613,19 @@ app.on("window-all-closed", () => {
} }
}); });
// Only set isRunning to false when the app is properly quit by the user // Only set isRunning to false when the app is properly quit by the user.
// IMPORTANT: This handler must be synchronous because Electron's EventEmitter
// does not await async callbacks — the returned Promise would be silently ignored.
app.on("will-quit", () => { app.on("will-quit", () => {
logger.info("App is quitting, setting isRunning to false"); logger.info("App is quitting, setting isRunning to false");
// Stop the garbage collection timer
stopAppGarbageCollection();
// Synchronously send kill signals to all running apps (fire-and-forget).
// We cannot use async/await here because Electron won't wait for it.
stopAllAppsSync();
// Stop performance monitoring and capture final metrics // Stop performance monitoring and capture final metrics
stopPerformanceMonitoring(); stopPerformanceMonitoring();
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论