Unverified 提交 4d7fa151 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

perf: batch and throttle IPC app output to prevent log flooding (#3035)

## Summary - Buffer stdout/stderr messages from child processes and flush them every 100ms as a single batched IPC event (`app:output-batch`), reducing IPC traffic, array allocations, and React re-renders when apps emit high-volume logs - Keep `input-requested` messages on the immediate `app:output` channel for responsive UX - Renderer processes batched events with a single `setConsoleEntries` state update instead of one per message ## Test plan - [ ] Run an app that emits high-volume logs (e.g., `console.log` in a loop) and verify the UI remains responsive - [ ] Verify app console still shows all log output correctly - [ ] Verify interactive prompts (y/n) still appear immediately - [ ] Verify proxy URL detection and preview panel still work - [ ] Verify HMR updates still trigger iframe refresh 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3035" 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 avatarClaude Opus 4.6 (1M context) <noreply@anthropic.com>
上级 300f0ac2
...@@ -101,6 +101,18 @@ queryClient.invalidateQueries({ queryKey: queryKeys.apps.all }); ...@@ -101,6 +101,18 @@ queryClient.invalidateQueries({ queryKey: queryKeys.apps.all });
**Adding new keys:** Add entries to the appropriate domain in `queryKeys.ts`. Follow the existing pattern with `all` for the base key and factory functions using object parameters for parameterized keys. **Adding new keys:** Add entries to the appropriate domain in `queryKeys.ts`. Follow the existing pattern with `all` for the base key and factory functions using object parameters for parameterized keys.
## High-volume event batching
When an IPC event can fire at very high frequency (e.g., stdout/stderr from child processes), **batch messages and flush on a timer** instead of sending each message individually. This prevents IPC channel saturation, excessive array allocations in the renderer, and unnecessary React re-renders.
**Pattern** (see `app_handlers.ts` `enqueueAppOutput`/`flushAllAppOutputs`):
- Buffer outgoing events in a `Map<WebContents, Payload[]>`.
- Start a `setTimeout` on first enqueue; flush all buffered messages as a single batch event (e.g., `app:output-batch`) when the timer fires (100ms default).
- Flush immediately on process exit so no messages are lost.
- Keep latency-sensitive events (e.g., `input-requested`) on an immediate, unbatched channel.
- On the renderer side, process the entire batch array in a single state update (`setConsoleEntries(prev => [...prev, ...newEntries])`) instead of one update per message.
## Streaming chunk optimizations ## Streaming chunk optimizations
The `chat:response:chunk` event supports two modes: The `chat:response:chunk` event supports two modes:
......
...@@ -70,12 +70,22 @@ export function useAppOutputSubscription() { ...@@ -70,12 +70,22 @@ export function useAppOutputSubscription() {
console.error("Failed to respond to app input:", error); console.error("Failed to respond to app input:", error);
} }
}); });
return; // Don't add to regular output return null; // Don't add to regular output
} }
// Add to console entries // Handle HMR updates
// Use "server" type for stdout/stderr to match the backend log store if (
// (app_handlers.ts stores these as type: "server") output.message.includes("hmr update") &&
output.message.includes("[vite]")
) {
onHotModuleReload();
}
// Process proxy server output
processProxyServerOutput(output);
// Only send client-error logs to central store
// Server logs (stdout/stderr) are already stored in the main process
const logEntry = { const logEntry = {
level: level:
output.type === "stderr" || output.type === "client-error" output.type === "stderr" || output.type === "client-error"
...@@ -87,39 +97,52 @@ export function useAppOutputSubscription() { ...@@ -87,39 +97,52 @@ export function useAppOutputSubscription() {
timestamp: output.timestamp ?? Date.now(), timestamp: output.timestamp ?? Date.now(),
}; };
// Only send client-error logs to central store
// Server logs (stdout/stderr) are already stored in the main process
if (output.type === "client-error") { if (output.type === "client-error") {
ipc.misc.addLog(logEntry); ipc.misc.addLog(logEntry);
} }
// Also update UI state return logEntry;
setConsoleEntries((prev) => [...prev, logEntry]);
// Process proxy server output
processProxyServerOutput(output);
}, },
[setConsoleEntries, processProxyServerOutput], [processProxyServerOutput, onHotModuleReload],
); );
// Subscribe to app output events from main process // Subscribe to immediate app output events (input-requested)
useEffect(() => { useEffect(() => {
const unsubscribe = ipc.events.misc.onAppOutput((output) => { const unsubscribe = ipc.events.misc.onAppOutput((output) => {
// Only process events for the currently selected app
if (appId !== null && output.appId === appId) { if (appId !== null && output.appId === appId) {
// Handle HMR updates const entry = processAppOutput(output);
if ( if (entry) {
output.message.includes("hmr update") && setConsoleEntries((prev) => [...prev, entry]);
output.message.includes("[vite]") }
) { }
onHotModuleReload(); });
return unsubscribe;
}, [appId, processAppOutput, setConsoleEntries]);
// Subscribe to batched app output events (stdout/stderr)
useEffect(() => {
const unsubscribe = ipc.events.misc.onAppOutputBatch((outputs) => {
const newEntries: ReturnType<typeof processAppOutput>[] = [];
for (const output of outputs) {
if (appId !== null && output.appId === appId) {
const entry = processAppOutput(output);
if (entry) {
newEntries.push(entry);
}
} }
processAppOutput(output); }
if (newEntries.length > 0) {
setConsoleEntries((prev) => [
...prev,
...(newEntries as NonNullable<(typeof newEntries)[number]>[]),
]);
} }
}); });
return unsubscribe; return unsubscribe;
}, [appId, processAppOutput, onHotModuleReload]); }, [appId, processAppOutput, setConsoleEntries]);
} }
export function useRunApp() { export function useRunApp() {
......
...@@ -50,6 +50,7 @@ import { ...@@ -50,6 +50,7 @@ import {
gitRenameBranch, gitRenameBranch,
} from "../utils/git_utils"; } from "../utils/git_utils";
import { safeSend } from "../utils/safe_sender"; import { safeSend } from "../utils/safe_sender";
import type { AppOutput } from "../types/misc";
import { normalizePath } from "../../../shared/normalizePath"; import { normalizePath } from "../../../shared/normalizePath";
import { import {
isServerFunction, isServerFunction,
...@@ -279,6 +280,43 @@ Details: ${details || "n/a"} ...@@ -279,6 +280,43 @@ Details: ${details || "n/a"}
}); });
} }
// =============================================================================
// App Output Batcher
// =============================================================================
// Batches stdout/stderr IPC messages to avoid flooding the renderer when apps
// emit high-volume logs. Messages are buffered and flushed every 100ms.
const APP_OUTPUT_FLUSH_INTERVAL_MS = 100;
const pendingOutputs = new Map<Electron.WebContents, AppOutput[]>();
let flushTimer: ReturnType<typeof setTimeout> | null = null;
function enqueueAppOutput(
sender: Electron.WebContents,
output: AppOutput,
): void {
let queue = pendingOutputs.get(sender);
if (!queue) {
queue = [];
pendingOutputs.set(sender, queue);
}
queue.push(output);
if (!flushTimer) {
flushTimer = setTimeout(flushAllAppOutputs, APP_OUTPUT_FLUSH_INTERVAL_MS);
}
}
function flushAllAppOutputs(): void {
flushTimer = null;
for (const [sender, outputs] of pendingOutputs) {
if (outputs.length > 0) {
safeSend(sender, "app:output-batch", outputs);
}
}
pendingOutputs.clear();
}
function listenToProcess({ function listenToProcess({
process: spawnedProcess, process: spawnedProcess,
appId, appId,
...@@ -323,15 +361,15 @@ function listenToProcess({ ...@@ -323,15 +361,15 @@ function listenToProcess({
const inputRequestPattern = /\s*›\s*\([yY]\/[nN]\)\s*$/; const inputRequestPattern = /\s*›\s*\([yY]\/[nN]\)\s*$/;
const isInputRequest = inputRequestPattern.test(message); const isInputRequest = inputRequestPattern.test(message);
if (isInputRequest) { if (isInputRequest) {
// Send special input-requested event for interactive prompts // Send input-requested immediately (not batched) for responsive UX
safeSend(event.sender, "app:output", { safeSend(event.sender, "app:output", {
type: "input-requested", type: "input-requested",
message, message,
appId, appId,
}); });
} else { } else {
// Normal stdout handling // Batch normal stdout for efficient IPC
safeSend(event.sender, "app:output", { enqueueAppOutput(event.sender, {
type: "stdout", type: "stdout",
message, message,
appId, appId,
...@@ -351,7 +389,7 @@ function listenToProcess({ ...@@ -351,7 +389,7 @@ function listenToProcess({
appInfo.originalUrl === originalUrl && appInfo.originalUrl === originalUrl &&
appInfo.proxyUrl appInfo.proxyUrl
) { ) {
safeSend(event.sender, "app:output", { enqueueAppOutput(event.sender, {
type: "stdout", type: "stdout",
message: `[dyad-proxy-server]started=[${appInfo.proxyUrl}] original=[${originalUrl}]`, message: `[dyad-proxy-server]started=[${appInfo.proxyUrl}] original=[${originalUrl}]`,
appId, appId,
...@@ -372,7 +410,7 @@ function listenToProcess({ ...@@ -372,7 +410,7 @@ function listenToProcess({
latestAppInfo.proxyUrl = proxyUrl; latestAppInfo.proxyUrl = proxyUrl;
latestAppInfo.originalUrl = originalUrl; latestAppInfo.originalUrl = originalUrl;
} }
safeSend(event.sender, "app:output", { enqueueAppOutput(event.sender, {
type: "stdout", type: "stdout",
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${originalUrl}]`, message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${originalUrl}]`,
appId, appId,
...@@ -406,7 +444,7 @@ function listenToProcess({ ...@@ -406,7 +444,7 @@ function listenToProcess({
appId, appId,
}); });
safeSend(event.sender, "app:output", { enqueueAppOutput(event.sender, {
type: "stderr", type: "stderr",
message, message,
appId, appId,
...@@ -418,6 +456,8 @@ function listenToProcess({ ...@@ -418,6 +456,8 @@ function listenToProcess({
logger.log( logger.log(
`App ${appId} (PID: ${spawnedProcess.pid}) process closed with code ${code}, signal ${signal}.`, `App ${appId} (PID: ${spawnedProcess.pid}) process closed with code ${code}, signal ${signal}.`,
); );
// Flush any remaining batched output before signaling process exit
flushAllAppOutputs();
removeAppIfCurrentProcess(appId, spawnedProcess); removeAppIfCurrentProcess(appId, spawnedProcess);
}); });
......
...@@ -383,6 +383,11 @@ export const miscEvents = { ...@@ -383,6 +383,11 @@ export const miscEvents = {
payload: AppOutputSchema, payload: AppOutputSchema,
}), }),
appOutputBatch: defineEvent({
channel: "app:output-batch",
payload: z.array(AppOutputSchema),
}),
deepLinkReceived: defineEvent({ deepLinkReceived: defineEvent({
channel: "deep-link-received", channel: "deep-link-received",
payload: DeepLinkDataSchema, payload: DeepLinkDataSchema,
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论