Improve check error performance by off-loading to worker thread w/ incremental compilation (#575)

This commit is contained in:
Will Chen
2025-07-07 12:47:33 -07:00
committed by GitHub
parent 97b5c29f11
commit bc38f9b2d7
22 changed files with 695 additions and 396 deletions

View File

@@ -40,7 +40,7 @@ import { Worker } from "worker_threads";
import { createFromTemplate } from "./createFromTemplate";
import { gitCommit } from "../utils/git_utils";
import { safeSend } from "../utils/safe_sender";
import { normalizePath } from "../processors/normalizePath";
import { normalizePath } from "../../../shared/normalizePath";
async function copyDir(
source: string,

View File

@@ -22,10 +22,7 @@ import { getDyadAppPath } from "../../paths/paths";
import { readSettings } from "../../main/settings";
import type { ChatResponseEnd, ChatStreamParams } from "../ipc_types";
import { extractCodebase, readFileWithCache } from "../../utils/codebase";
import {
getDyadAddDependencyTags,
processFullResponseActions,
} from "../processors/response_processor";
import { processFullResponseActions } from "../processors/response_processor";
import { streamTestResponse } from "./testing_chat_handlers";
import { getTestResponse } from "./testing_chat_handlers";
import { getModelClient, ModelClient } from "../utils/get_model_client";
@@ -51,7 +48,13 @@ import { safeSend } from "../utils/safe_sender";
import { cleanFullResponse } from "../utils/cleanFullResponse";
import { generateProblemReport } from "../processors/tsc";
import { createProblemFixPrompt } from "@/shared/problem_prompt";
import { AsyncVirtualFileSystem } from "@/utils/VirtualFilesystem";
import { AsyncVirtualFileSystem } from "../../../shared/VirtualFilesystem";
import {
getDyadAddDependencyTags,
getDyadWriteTags,
getDyadDeleteTags,
getDyadRenameTags,
} from "../utils/dyad_tag_parser";
import { fileExists } from "../utils/file_utils";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
@@ -708,7 +711,14 @@ ${problemReport.problems
readFile: (fileName: string) => readFileWithCache(fileName),
},
);
virtualFileSystem.applyResponseChanges(fullResponse);
const writeTags = getDyadWriteTags(fullResponse);
const renameTags = getDyadRenameTags(fullResponse);
const deletePaths = getDyadDeleteTags(fullResponse);
virtualFileSystem.applyResponseChanges({
deletePaths,
renameTags,
writeTags,
});
const { formattedOutput: codebaseInfo, files } =
await extractCodebase({

View File

@@ -9,16 +9,16 @@ import { messages, chats } from "../../db/schema";
import { desc, eq, and } from "drizzle-orm";
import path from "node:path"; // Import path for basename
// Import tag parsers
import { processFullResponseActions } from "../processors/response_processor";
import {
getDyadAddDependencyTags,
getDyadChatSummaryTag,
getDyadWriteTags,
getDyadRenameTags,
getDyadDeleteTags,
getDyadExecuteSqlTags,
getDyadRenameTags,
getDyadWriteTags,
getDyadAddDependencyTags,
getDyadChatSummaryTag,
getDyadCommandTags,
processFullResponseActions,
} from "../processors/response_processor";
} from "../utils/dyad_tag_parser";
import log from "electron-log";
import { isServerFunction } from "../../supabase_admin/supabase_utils";
import {

View File

@@ -1,4 +1,6 @@
import { ipcMain, session } from "electron";
import fs from "node:fs/promises";
import { getTypeScriptCachePath } from "@/paths/paths";
export const registerSessionHandlers = () => {
ipcMain.handle("clear-session-data", async (_event) => {
@@ -8,5 +10,12 @@ export const registerSessionHandlers = () => {
storages: ["cookies", "localstorage"],
});
console.info(`[IPC] All session data cleared for default session`);
// Clear custom cache data (like tsbuildinfo)
try {
await fs.rm(getTypeScriptCachePath(), { recursive: true, force: true });
} catch {
// Directory might not exist
}
});
};

View File

@@ -1,4 +1,6 @@
import { z } from "zod";
import type { ProblemReport, Problem } from "../../shared/tsc_types";
export type { ProblemReport, Problem };
export interface AppOutput {
type: "stdout" | "stderr" | "info" | "client-error";
@@ -246,15 +248,3 @@ export interface AppUpgrade {
manualUpgradeUrl: string;
isNeeded: boolean;
}
export interface Problem {
file: string;
line: number;
column: number;
message: string;
code: number;
}
export interface ProblemReport {
problems: Problem[];
}

View File

@@ -1,10 +0,0 @@
/**
* Normalize the path to use forward slashes instead of backslashes.
* This is important to prevent weird Git issues, particularly on Windows.
* @param path Source path.
* @returns Normalized path.
*/
export function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}

View File

@@ -15,149 +15,21 @@ import {
executeSupabaseSql,
} from "../../supabase_admin/supabase_management_client";
import { isServerFunction } from "../../supabase_admin/supabase_utils";
import { SqlQuery, UserSettings } from "../../lib/schemas";
import { UserSettings } from "../../lib/schemas";
import { gitCommit } from "../utils/git_utils";
import { readSettings } from "@/main/settings";
import { writeMigrationFile } from "../utils/file_utils";
import { normalizePath } from "./normalizePath";
import {
getDyadWriteTags,
getDyadRenameTags,
getDyadDeleteTags,
getDyadAddDependencyTags,
getDyadExecuteSqlTags,
} from "../utils/dyad_tag_parser";
const readFile = fs.promises.readFile;
const logger = log.scope("response_processor");
export function getDyadWriteTags(fullResponse: string): {
path: string;
content: string;
description?: string;
}[] {
const dyadWriteRegex = /<dyad-write([^>]*)>([\s\S]*?)<\/dyad-write>/gi;
const pathRegex = /path="([^"]+)"/;
const descriptionRegex = /description="([^"]+)"/;
let match;
const tags: { path: string; content: string; description?: string }[] = [];
while ((match = dyadWriteRegex.exec(fullResponse)) !== null) {
const attributesString = match[1];
let content = match[2].trim();
const pathMatch = pathRegex.exec(attributesString);
const descriptionMatch = descriptionRegex.exec(attributesString);
if (pathMatch && pathMatch[1]) {
const path = pathMatch[1];
const description = descriptionMatch?.[1];
const contentLines = content.split("\n");
if (contentLines[0]?.startsWith("```")) {
contentLines.shift();
}
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
contentLines.pop();
}
content = contentLines.join("\n");
tags.push({ path: normalizePath(path), content, description });
} else {
logger.warn(
"Found <dyad-write> tag without a valid 'path' attribute:",
match[0],
);
}
}
return tags;
}
export function getDyadRenameTags(fullResponse: string): {
from: string;
to: string;
}[] {
const dyadRenameRegex =
/<dyad-rename from="([^"]+)" to="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-rename>/g;
let match;
const tags: { from: string; to: string }[] = [];
while ((match = dyadRenameRegex.exec(fullResponse)) !== null) {
tags.push({
from: normalizePath(match[1]),
to: normalizePath(match[2]),
});
}
return tags;
}
export function getDyadDeleteTags(fullResponse: string): string[] {
const dyadDeleteRegex =
/<dyad-delete path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-delete>/g;
let match;
const paths: string[] = [];
while ((match = dyadDeleteRegex.exec(fullResponse)) !== null) {
paths.push(normalizePath(match[1]));
}
return paths;
}
export function getDyadAddDependencyTags(fullResponse: string): string[] {
const dyadAddDependencyRegex =
/<dyad-add-dependency packages="([^"]+)">[^<]*<\/dyad-add-dependency>/g;
let match;
const packages: string[] = [];
while ((match = dyadAddDependencyRegex.exec(fullResponse)) !== null) {
packages.push(...match[1].split(" "));
}
return packages;
}
export function getDyadChatSummaryTag(fullResponse: string): string | null {
const dyadChatSummaryRegex =
/<dyad-chat-summary>([\s\S]*?)<\/dyad-chat-summary>/g;
const match = dyadChatSummaryRegex.exec(fullResponse);
if (match && match[1]) {
return match[1].trim();
}
return null;
}
export function getDyadExecuteSqlTags(fullResponse: string): SqlQuery[] {
const dyadExecuteSqlRegex =
/<dyad-execute-sql([^>]*)>([\s\S]*?)<\/dyad-execute-sql>/g;
const descriptionRegex = /description="([^"]+)"/;
let match;
const queries: { content: string; description?: string }[] = [];
while ((match = dyadExecuteSqlRegex.exec(fullResponse)) !== null) {
const attributesString = match[1] || "";
let content = match[2].trim();
const descriptionMatch = descriptionRegex.exec(attributesString);
const description = descriptionMatch?.[1];
// Handle markdown code blocks if present
const contentLines = content.split("\n");
if (contentLines[0]?.startsWith("```")) {
contentLines.shift();
}
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
contentLines.pop();
}
content = contentLines.join("\n");
queries.push({ content, description });
}
return queries;
}
export function getDyadCommandTags(fullResponse: string): string[] {
const dyadCommandRegex =
/<dyad-command type="([^"]+)"[^>]*><\/dyad-command>/g;
let match;
const commands: string[] = [];
while ((match = dyadCommandRegex.exec(fullResponse)) !== null) {
commands.push(match[1]);
}
return commands;
}
interface Output {
message: string;
error: unknown;

View File

@@ -1,29 +1,19 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { Worker } from "node:worker_threads";
import { ProblemReport } from "../ipc_types";
import { Problem } from "../ipc_types";
import { normalizePath } from "./normalizePath";
import { SyncVirtualFileSystem } from "../../utils/VirtualFilesystem";
import log from "electron-log";
import { WorkerInput, WorkerOutput } from "../../../shared/tsc_types";
import {
getDyadDeleteTags,
getDyadRenameTags,
getDyadWriteTags,
} from "../utils/dyad_tag_parser";
import { getTypeScriptCachePath } from "@/paths/paths";
const logger = log.scope("tsc");
function loadLocalTypeScript(appPath: string): typeof import("typescript") {
try {
// Try to load TypeScript from the project's node_modules
const requirePath = require.resolve("typescript", { paths: [appPath] });
logger.info(`Loading TypeScript from ${requirePath} for app ${appPath}`);
const ts = require(requirePath);
return ts;
} catch (error) {
throw new Error(
`Failed to load TypeScript from ${appPath} because of ${error}`,
);
}
}
export async function generateProblemReport({
fullResponse,
appPath,
@@ -31,181 +21,61 @@ export async function generateProblemReport({
fullResponse: string;
appPath: string;
}): Promise<ProblemReport> {
// Load the local TypeScript version from the app's node_modules
const ts = loadLocalTypeScript(appPath);
return new Promise((resolve, reject) => {
// Determine the worker script path
const workerPath = path.join(__dirname, "tsc_worker.js");
// Create virtual file system with TypeScript system delegate and apply changes from response
const vfs = new SyncVirtualFileSystem(appPath, {
fileExists: (fileName: string) => ts.sys.fileExists(fileName),
readFile: (fileName: string) => ts.sys.readFile(fileName),
});
vfs.applyResponseChanges(fullResponse);
logger.info(`Starting TSC worker for app ${appPath}`);
// Find TypeScript config - throw error if not found
const tsconfigPath = findTypeScriptConfig(appPath);
// Create the worker
const worker = new Worker(workerPath);
// Create TypeScript program with virtual file system
const result = await runTypeScriptCheck(ts, appPath, tsconfigPath, vfs);
return result;
}
// Handle worker messages
worker.on("message", (output: WorkerOutput) => {
worker.terminate();
function findTypeScriptConfig(appPath: string): string {
const possibleConfigs = [
// For vite applications, we want to check tsconfig.app.json, since it's the
// most important one (client-side app).
// The tsconfig.json in vite apps is a project reference and doesn't
// actually check anything unless you do "--build" which requires a complex
// programmatic approach
"tsconfig.app.json",
// For Next.js applications, it typically has a single tsconfig.json file
"tsconfig.json",
];
for (const config of possibleConfigs) {
const configPath = path.join(appPath, config);
if (fs.existsSync(configPath)) {
return configPath;
}
}
throw new Error(
`No TypeScript configuration file found in ${appPath}. Expected one of: ${possibleConfigs.join(", ")}`,
);
}
async function runTypeScriptCheck(
ts: typeof import("typescript"),
appPath: string,
tsconfigPath: string,
vfs: SyncVirtualFileSystem,
): Promise<ProblemReport> {
return runSingleProject(ts, appPath, tsconfigPath, vfs);
}
async function runSingleProject(
ts: typeof import("typescript"),
appPath: string,
tsconfigPath: string,
vfs: SyncVirtualFileSystem,
): Promise<ProblemReport> {
// Use the idiomatic way to parse TypeScript config
const parsedCommandLine = ts.getParsedCommandLineOfConfigFile(
tsconfigPath,
undefined, // No additional options
{
// Custom system object that can handle our virtual files
...ts.sys,
fileExists: (fileName: string) => vfs.fileExists(fileName),
readFile: (fileName: string) => vfs.readFile(fileName),
onUnRecoverableConfigFileDiagnostic: (
diagnostic: import("typescript").Diagnostic,
) => {
throw new Error(
`TypeScript config error: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
);
},
},
);
if (!parsedCommandLine) {
throw new Error(`Failed to parse TypeScript config: ${tsconfigPath}`);
}
let rootNames = parsedCommandLine.fileNames;
// Add any virtual files that aren't already included
const virtualTsFiles = vfs
.getVirtualFiles()
.map((file) => path.resolve(appPath, file.path))
.filter(isTypeScriptFile);
// Remove deleted files from rootNames
const deletedFiles = vfs
.getDeletedFiles()
.map((file) => path.resolve(appPath, file));
rootNames = rootNames.filter((fileName) => {
const resolvedPath = path.resolve(fileName);
return !deletedFiles.includes(resolvedPath);
});
for (const virtualFile of virtualTsFiles) {
if (!rootNames.includes(virtualFile)) {
rootNames.push(virtualFile);
}
}
// Create custom compiler host
const host = createVirtualCompilerHost(
ts,
appPath,
vfs,
parsedCommandLine.options,
);
// Create TypeScript program - this is the idiomatic way
const program = ts.createProgram(rootNames, parsedCommandLine.options, host);
// Get diagnostics
const diagnostics = [
...program.getSyntacticDiagnostics(),
...program.getSemanticDiagnostics(),
...program.getGlobalDiagnostics(),
];
// Convert diagnostics to our format
const problems: Problem[] = [];
for (const diagnostic of diagnostics) {
if (!diagnostic.file) continue;
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(
diagnostic.start!,
);
const message = ts.flattenDiagnosticMessageText(
diagnostic.messageText,
"\n",
);
if (diagnostic.category !== ts.DiagnosticCategory.Error) {
continue;
}
problems.push({
file: normalizePath(path.relative(appPath, diagnostic.file.fileName)),
line: line + 1, // Convert to 1-based
column: character + 1, // Convert to 1-based
message,
code: diagnostic.code,
if (output.success && output.data) {
logger.info(`TSC worker completed successfully for app ${appPath}`);
resolve(output.data);
} else {
logger.error(`TSC worker failed for app ${appPath}: ${output.error}`);
reject(new Error(output.error || "Unknown worker error"));
}
});
}
return {
problems,
};
}
function createVirtualCompilerHost(
ts: typeof import("typescript"),
appPath: string,
vfs: SyncVirtualFileSystem,
compilerOptions: import("typescript").CompilerOptions,
): import("typescript").CompilerHost {
const host = ts.createCompilerHost(compilerOptions);
// Override file reading to use virtual files
host.readFile = (fileName: string) => {
return vfs.readFile(fileName);
};
// Override file existence check
host.fileExists = (fileName: string) => {
return vfs.fileExists(fileName);
};
return host;
}
function isTypeScriptFile(fileName: string): boolean {
const ext = path.extname(fileName).toLowerCase();
return [".ts", ".tsx", ".js", ".jsx"].includes(ext);
// Handle worker errors
worker.on("error", (error) => {
logger.error(`TSC worker error for app ${appPath}:`, error);
worker.terminate();
reject(error);
});
// Handle worker exit
worker.on("exit", (code) => {
if (code !== 0) {
logger.error(`TSC worker exited with code ${code} for app ${appPath}`);
reject(new Error(`Worker exited with code ${code}`));
}
});
const writeTags = getDyadWriteTags(fullResponse);
const renameTags = getDyadRenameTags(fullResponse);
const deletePaths = getDyadDeleteTags(fullResponse);
const virtualChanges = {
deletePaths,
renameTags,
writeTags,
};
// Send input to worker
const input: WorkerInput = {
virtualChanges,
appPath,
tsBuildInfoCacheDir: getTypeScriptCachePath(),
};
logger.info(`Sending input to TSC worker for app ${appPath}`);
worker.postMessage(input);
});
}

View File

@@ -0,0 +1,139 @@
import { normalizePath } from "../../../shared/normalizePath";
import log from "electron-log";
import { SqlQuery } from "../../lib/schemas";
const logger = log.scope("dyad_tag_parser");
export function getDyadWriteTags(fullResponse: string): {
path: string;
content: string;
description?: string;
}[] {
const dyadWriteRegex = /<dyad-write([^>]*)>([\s\S]*?)<\/dyad-write>/gi;
const pathRegex = /path="([^"]+)"/;
const descriptionRegex = /description="([^"]+)"/;
let match;
const tags: { path: string; content: string; description?: string }[] = [];
while ((match = dyadWriteRegex.exec(fullResponse)) !== null) {
const attributesString = match[1];
let content = match[2].trim();
const pathMatch = pathRegex.exec(attributesString);
const descriptionMatch = descriptionRegex.exec(attributesString);
if (pathMatch && pathMatch[1]) {
const path = pathMatch[1];
const description = descriptionMatch?.[1];
const contentLines = content.split("\n");
if (contentLines[0]?.startsWith("```")) {
contentLines.shift();
}
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
contentLines.pop();
}
content = contentLines.join("\n");
tags.push({ path: normalizePath(path), content, description });
} else {
logger.warn(
"Found <dyad-write> tag without a valid 'path' attribute:",
match[0],
);
}
}
return tags;
}
export function getDyadRenameTags(fullResponse: string): {
from: string;
to: string;
}[] {
const dyadRenameRegex =
/<dyad-rename from="([^"]+)" to="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-rename>/g;
let match;
const tags: { from: string; to: string }[] = [];
while ((match = dyadRenameRegex.exec(fullResponse)) !== null) {
tags.push({
from: normalizePath(match[1]),
to: normalizePath(match[2]),
});
}
return tags;
}
export function getDyadDeleteTags(fullResponse: string): string[] {
const dyadDeleteRegex =
/<dyad-delete path="([^"]+)"[^>]*>([\s\S]*?)<\/dyad-delete>/g;
let match;
const paths: string[] = [];
while ((match = dyadDeleteRegex.exec(fullResponse)) !== null) {
paths.push(normalizePath(match[1]));
}
return paths;
}
export function getDyadAddDependencyTags(fullResponse: string): string[] {
const dyadAddDependencyRegex =
/<dyad-add-dependency packages="([^"]+)">[^<]*<\/dyad-add-dependency>/g;
let match;
const packages: string[] = [];
while ((match = dyadAddDependencyRegex.exec(fullResponse)) !== null) {
packages.push(...match[1].split(" "));
}
return packages;
}
export function getDyadChatSummaryTag(fullResponse: string): string | null {
const dyadChatSummaryRegex =
/<dyad-chat-summary>([\s\S]*?)<\/dyad-chat-summary>/g;
const match = dyadChatSummaryRegex.exec(fullResponse);
if (match && match[1]) {
return match[1].trim();
}
return null;
}
export function getDyadExecuteSqlTags(fullResponse: string): SqlQuery[] {
const dyadExecuteSqlRegex =
/<dyad-execute-sql([^>]*)>([\s\S]*?)<\/dyad-execute-sql>/g;
const descriptionRegex = /description="([^"]+)"/;
let match;
const queries: { content: string; description?: string }[] = [];
while ((match = dyadExecuteSqlRegex.exec(fullResponse)) !== null) {
const attributesString = match[1] || "";
let content = match[2].trim();
const descriptionMatch = descriptionRegex.exec(attributesString);
const description = descriptionMatch?.[1];
// Handle markdown code blocks if present
const contentLines = content.split("\n");
if (contentLines[0]?.startsWith("```")) {
contentLines.shift();
}
if (contentLines[contentLines.length - 1]?.startsWith("```")) {
contentLines.pop();
}
content = contentLines.join("\n");
queries.push({ content, description });
}
return queries;
}
export function getDyadCommandTags(fullResponse: string): string[] {
const dyadCommandRegex =
/<dyad-command type="([^"]+)"[^>]*><\/dyad-command>/g;
let match;
const commands: string[] = [];
while ((match = dyadCommandRegex.exec(fullResponse)) !== null) {
commands.push(match[1]);
}
return commands;
}