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";
import { Problems } from "./Problems";
import { ConfigurePanel } from "./ConfigurePanel";
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 { Console } from "./Console";
import { useRunApp } from "@/hooks/useRunApp";
......@@ -20,6 +20,7 @@ import { SecurityPanel } from "./SecurityPanel";
import { PlanPanel } from "./PlanPanel";
import { useSupabase } from "@/hooks/useSupabase";
import { useTranslation } from "react-i18next";
import { ipc } from "@/ipc/types";
interface ConsoleHeaderProps {
isOpen: boolean;
......@@ -61,9 +62,8 @@ export function PreviewPanel() {
const [previewMode] = useAtom(previewModeAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom);
const [isConsoleOpen, setIsConsoleOpen] = useState(false);
const { runApp, stopApp, loading, app } = useRunApp();
const { runApp, loading, app } = useRunApp();
const { loadEdgeLogs } = useSupabase();
const runningAppIdRef = useRef<number | null>(null);
const key = useAtomValue(previewPanelKeyAtom);
const consoleEntries = useAtomValue(appConsoleEntriesAtom);
......@@ -72,50 +72,54 @@ export function PreviewPanel() {
? consoleEntries[consoleEntries.length - 1]?.message
: 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(() => {
const previousAppId = runningAppIdRef.current;
// Check if the selected app ID has changed
if (selectedAppId !== previousAppId) {
// Stop the previously running app, if any
if (previousAppId !== null) {
console.debug("Stopping previous app", previousAppId);
stopApp(previousAppId);
// We don't necessarily nullify the ref here immediately,
// let the start of the next app update it or unmount handle it.
}
let cancelled = false;
const handleAppSelection = async () => {
// Notify backend which app is currently selected (for GC tracking)
await notifyAppSelected(selectedAppId);
// If the effect was cleaned up while awaiting, don't proceed
if (cancelled) return;
// 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) {
console.debug("Starting new app", selectedAppId);
runApp(selectedAppId); // Consider adding error handling for the promise if needed
runningAppIdRef.current = selectedAppId; // Update ref to the new running app ID
} else {
// If selectedAppId is null, ensure no app is marked as running
runningAppIdRef.current = null;
console.debug(
"Running app (will start if not already running)",
selectedAppId,
);
runApp(selectedAppId);
}
}
};
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 () => {
if (appToStopOnUnmount !== null) {
const currentRunningApp = runningAppIdRef.current;
if (currentRunningApp !== null) {
console.debug(
"Component unmounting or selectedAppId changing, stopping app",
currentRunningApp,
);
stopApp(currentRunningApp);
runningAppIdRef.current = null; // Clear ref on stop
}
}
cancelled = true;
// Notify backend that no app is being previewed so GC can reclaim idle apps
notifyAppSelected(null);
};
// Dependencies: run effect when selectedAppId changes.
// runApp/stopApp are stable due to useCallback.
}, [selectedAppId, runApp, stopApp]);
// Note: We no longer stop apps when switching. The backend garbage collector
// will stop apps that haven't been viewed in 10 minutes.
// 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
useEffect(() => {
......
......@@ -148,7 +148,7 @@ export function useRunApp() {
const logEntry = {
level: "info" as const,
type: "server" as const,
message: "Trying to restart app...",
message: "Connecting to app...",
appId,
timestamp: Date.now(),
};
......
......@@ -22,6 +22,8 @@ import {
removeAppIfCurrentProcess,
stopAppByInfo,
removeDockerVolumesForApp,
setCurrentlySelectedAppId,
startAppGarbageCollection,
} from "../utils/process_manager";
import { getEnvVar } from "../utils/read_env";
import { readSettings } from "../../main/settings";
......@@ -39,7 +41,6 @@ import {
import { createLoggedHandler } from "./safe_handle";
import { getLanguageModelProviders } from "../shared/language_model_helpers";
import { startProxy } from "../utils/start_proxy_server";
import { Worker } from "worker_threads";
import { createFromTemplate } from "./createFromTemplate";
import {
gitCommit,
......@@ -150,8 +151,6 @@ async function copyDir(
});
}
let proxyWorker: Worker | null = null;
// Needed, otherwise electron in MacOS/Linux will not be able
// to find node/pnpm.
fixPath();
......@@ -171,10 +170,6 @@ async function executeApp({
installCommand?: string | null;
startCommand?: string | null;
}): Promise<void> {
if (proxyWorker) {
proxyWorker.terminate();
proxyWorker = null;
}
const settings = readSettings();
const runtimeMode = settings.runtimeMode2 ?? "host";
......@@ -272,6 +267,7 @@ Details: ${details || "n/a"}
process: spawnedProcess,
processId: currentProcessId,
isDocker: false,
lastViewedAt: Date.now(),
});
listenToProcess({
......@@ -342,15 +338,54 @@ function listenToProcess({
const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/);
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) => {
// 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", {
type: "stdout",
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${urlMatch[1]}]`,
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${originalUrl}]`,
appId,
});
},
});
const latestAppInfo = runningApps.get(appId);
if (latestAppInfo) {
latestAppInfo.proxyWorker = proxyWorker;
latestAppInfo.originalUrl = originalUrl;
} else {
await proxyWorker.terminate();
}
}
}
});
......@@ -577,6 +612,7 @@ ${errorOutput || "(empty)"}`,
processId: currentProcessId,
isDocker: true,
containerName,
lastViewedAt: Date.now(),
});
listenToProcess({
......@@ -1007,6 +1043,15 @@ export function registerAppHandlers() {
// Check if app is already running
if (runningApps.has(appId)) {
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;
}
......@@ -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({
......
......@@ -381,6 +381,16 @@ export const appContracts = {
input: UpdateAppCommandsParamsSchema,
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;
// =============================================================================
......
......@@ -28,6 +28,10 @@ import {
startPerformanceMonitoring,
stopPerformanceMonitoring,
} 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 fs from "fs";
import { gitAddSafeDirectory } from "./ipc/utils/git_utils";
......@@ -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", () => {
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
stopPerformanceMonitoring();
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论