Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 42 additions & 40 deletions src/components/preview_panel/PreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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);

Expand All @@ -72,50 +72,52 @@ 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;
};
// 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(() => {
Expand Down
44 changes: 44 additions & 0 deletions src/ipc/handlers/app_handlers.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Singleton proxyWorker is terminated when a second app starts, breaking the first app's preview

With the new concurrent app model, multiple apps can run simultaneously. However, proxyWorker is a single module-level variable. When executeApp is called for any app, it unconditionally terminates the existing proxy worker at lines 177-179. This means starting App B kills App A's proxy, silently breaking App A's preview iframe.

Root cause and impact

The proxy worker forwards requests from the preview iframe to each app's local dev server. It is started per-app in listenToProcess at src/ipc/handlers/app_handlers.ts:349 when a localhost URL is detected in stdout:

proxyWorker = await startProxy(urlMatch[1], { ... });

But proxyWorker is a single let variable (src/ipc/handlers/app_handlers.ts:156). When executeApp is called for the second app, lines 177-179 terminate the previous worker:

if (proxyWorker) {
  proxyWorker.terminate();
  proxyWorker = null;
}

Previously this was fine because only one app ran at a time. Now with concurrent apps, this terminates App A's proxy when App B starts, making App A's preview unreachable. The proxyUrl/originalUrl stored in RunningAppInfo at src/ipc/handlers/app_handlers.ts:352-355 will reference a dead proxy, and re-emitting these URLs on app switch (src/ipc/handlers/app_handlers.ts:1023-1029) will point the iframe at a terminated worker.

Impact: The core feature of this PR (keeping multiple apps running) is undermined — only the most recently started app will have a working preview.

(Refers to lines 177-179)

Prompt for agents
The proxyWorker variable in src/ipc/handlers/app_handlers.ts (line 156) is a single module-level variable, but now multiple apps run concurrently and each needs its own proxy worker. To fix this:

1. Remove the singleton `let proxyWorker: Worker | null = null;` at line 156.
2. Remove the termination of proxyWorker in executeApp (lines 177-179).
3. Store the proxy Worker reference inside `RunningAppInfo` (in src/ipc/utils/process_manager.ts), alongside the existing `proxyUrl` and `originalUrl` fields.
4. When starting a new proxy in `listenToProcess` (line 349), store the worker in the app's `RunningAppInfo` entry rather than the module-level variable.
5. When stopping an app via `stopAppByInfo` in src/ipc/utils/process_manager.ts, also terminate that app's proxy worker if it exists.
6. Consider whether the proxy worker for an already-running app should be re-used (it currently would only be started once since the URL match only fires on process stdout).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import {
removeAppIfCurrentProcess,
stopAppByInfo,
removeDockerVolumesForApp,
setCurrentlySelectedAppId,
updateAppLastViewed,
startAppGarbageCollection,
} from "../utils/process_manager";
import { getEnvVar } from "../utils/read_env";
import { readSettings } from "../../main/settings";
Expand Down Expand Up @@ -272,6 +275,7 @@ Details: ${details || "n/a"}
process: spawnedProcess,
processId: currentProcessId,
isDocker: false,
lastViewedAt: Date.now(),
});

listenToProcess({
Expand Down Expand Up @@ -344,6 +348,12 @@ function listenToProcess({
if (urlMatch) {
proxyWorker = await startProxy(urlMatch[1], {
onStarted: (proxyUrl) => {
// Store proxy URL in running app info for re-emission on app switch
const appInfo = runningApps.get(appId);
if (appInfo) {
appInfo.proxyUrl = proxyUrl;
appInfo.originalUrl = urlMatch[1];
}
safeSend(event.sender, "app:output", {
type: "stdout",
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${urlMatch[1]}]`,
Expand Down Expand Up @@ -577,6 +587,7 @@ ${errorOutput || "(empty)"}`,
processId: currentProcessId,
isDocker: true,
containerName,
lastViewedAt: Date.now(),
});

listenToProcess({
Expand Down Expand Up @@ -1007,6 +1018,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) {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Re-emitting a cached proxy URL for already-running apps can emit stale/dead URLs after another app start terminates the previous global proxy worker.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/ipc/handlers/app_handlers.ts, line 1023:

<comment>Re-emitting a cached proxy URL for already-running apps can emit stale/dead URLs after another app start terminates the previous global proxy worker.</comment>

<file context>
@@ -1012,6 +1018,15 @@ export function registerAppHandlers() {
         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",
</file context>
Fix with Cubic

safeSend(event.sender, "app:output", {
type: "stdout",
message: `[dyad-proxy-server]started=[${appInfo.proxyUrl}] original=[${appInfo.originalUrl}]`,
appId,
Comment on lines +1023 to +1027

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep proxy workers scoped per running app

This branch re-emits a cached proxy URL for already-running apps, but after enabling concurrent app processes that URL can be stale: executeApp still unconditionally terminates the singleton proxyWorker before starting another app (src/ipc/handlers/app_handlers.ts:177-179). In the flow “run A, run B, switch back to A”, app A stays in runningApps but its proxy worker has been killed, so this message restores a dead URL and the preview fails to load. Please track proxy workers per app (or stop terminating a global worker) before treating cached URLs as reusable.

Useful? React with 👍 / 👎.

});
}
return;
}

Expand Down Expand Up @@ -2004,6 +2024,30 @@ 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);
// Also update lastViewedAt if the app is running
updateAppLastViewed(appId);
} else {
logger.debug("No app selected for preview");
setCurrentlySelectedAppId(null);
}
});

// Handler for getting list of running apps
createTypedHandler(appContracts.getRunningApps, async () => {
const appIds = Array.from(runningApps.keys());
logger.debug(`Getting running apps: ${appIds.join(", ") || "none"}`);
return { appIds };
});

// Start the garbage collection for idle apps
startAppGarbageCollection();
}

function getCommand({
Expand Down
28 changes: 28 additions & 0 deletions src/ipc/types/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,13 @@ export const AppSearchResultSchema = z.object({
// App Contracts
// =============================================================================

/**
* Schema for running apps response.
*/
export const RunningAppsResponseSchema = z.object({
appIds: z.array(z.number()),
});

export const appContracts = {
createApp: defineContract({
channel: "create-app",
Expand Down Expand Up @@ -381,6 +388,27 @@ 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: AppIdParamsSchema.extend({
/** If null, no app is selected */
}).or(z.object({ appId: z.null() })),
output: z.void(),
}),

/**
* Gets the list of currently running app IDs.
*/
getRunningApps: defineContract({
channel: "get-running-apps",
input: z.void(),
output: RunningAppsResponseSchema,
}),
} as const;

// =============================================================================
Expand Down
Loading