fix(debug): stopped native fallback for missing delve

- Kept missing language-specific adapters from falling through to native debuggers.
- Resolved nested launch roots before session-local binaries and PATH, including explicit adapters and go.work workspaces.
- Added actionable install/configuration errors and deterministic regression coverage.

Fixes #5037
This commit is contained in:
can1357
2026-07-10 14:04:32 +02:00
parent 520f6f9bcc
commit e8add61016
8 changed files with 465 additions and 82 deletions
@@ -2,7 +2,14 @@ import { afterEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { getAdapterConfigs, resolveAdapter, selectLaunchAdapter } from "../../src/dap/config";
import * as piUtils from "@oh-my-pi/pi-utils";
import {
getAdapterConfigs,
type LaunchAdapterSelection,
resolveAdapter,
selectLaunchAdapter,
} from "../../src/dap/config";
import type { DapResolvedAdapter } from "../../src/dap/types";
import { injectPluginDirRoots } from "../../src/discovery/helpers";
const tempDirs: string[] = [];
@@ -15,6 +22,45 @@ async function makeTempDir(prefix: string): Promise<string> {
return cwd;
}
interface NestedGoProgram {
moduleRoot: string;
program: string;
}
async function writeExecutable(filePath: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n");
await fs.chmod(filePath, 0o755);
}
async function writeDlvOverride(cwd: string, command: string): Promise<void> {
await fs.writeFile(path.join(cwd, "dap.json"), JSON.stringify({ adapters: { dlv: { command } } }));
}
async function setupMissingDlvProject(cwd: string): Promise<string> {
const missingCommand = path.join(cwd, "tools", "missing-dlv");
await fs.writeFile(path.join(cwd, "go.mod"), "module example.com/app\n\ngo 1.22\n");
await writeExecutable(path.join(cwd, "bin", "gdb"));
await writeDlvOverride(cwd, missingCommand);
return missingCommand;
}
async function setupNestedGoProgram(cwd: string): Promise<NestedGoProgram> {
const moduleRoot = path.join(cwd, "services", "api");
const program = path.join(moduleRoot, "main.go");
await fs.mkdir(moduleRoot, { recursive: true });
await fs.writeFile(path.join(moduleRoot, "go.mod"), "module example.com/api\n\ngo 1.22\n");
await fs.writeFile(program, "package main\n\nfunc main() {}\n");
return { moduleRoot, program };
}
function requireSelectedAdapter(selection: LaunchAdapterSelection): DapResolvedAdapter {
if (selection.kind !== "adapter") {
throw new Error(`Expected an available adapter, received '${selection.kind}'`);
}
return selection.adapter;
}
afterEach(async () => {
vi.restoreAllMocks();
if (ORIGINAL_OMP_PLUGIN_DIR === undefined) {
@@ -63,8 +109,8 @@ describe("DAP adapter configuration", () => {
expect(adapter?.launchDefaults).toEqual({ request: "launch", mainClass: "" });
expect(adapter?.attachDefaults).toEqual({ request: "attach", host: "127.0.0.1" });
const selected = selectLaunchAdapter(path.join("src", "Main.java"), cwd);
expect(selected?.name).toBe("custom-jvm");
const selected = requireSelectedAdapter(selectLaunchAdapter(path.join("src", "Main.java"), cwd));
expect(selected.name).toBe("custom-jvm");
});
it("merges partial user overrides over built-in adapters", async () => {
@@ -116,9 +162,9 @@ describe("DAP adapter configuration", () => {
].join("\n"),
);
const selected = selectLaunchAdapter("Main.kt", cwd);
expect(selected?.name).toBe("yaml-kotlin");
expect(selected?.launchDefaults).toEqual({ request: "launch", projectRoot: "." });
const selected = requireSelectedAdapter(selectLaunchAdapter("Main.kt", cwd));
expect(selected.name).toBe("yaml-kotlin");
expect(selected.launchDefaults).toEqual({ request: "launch", projectRoot: "." });
});
it("resolves relative adapter commands from the debug cwd", async () => {
@@ -195,4 +241,100 @@ describe("DAP adapter configuration", () => {
expect(config["missing-command"]).toBeUndefined();
expect(config.valid?.command).toBe("bun");
});
it("reports missing dlv for Go source instead of falling back to a native debugger", async () => {
const cwd = await makeTempDir("omp-dap-go-source-missing-");
const missingCommand = await setupMissingDlvProject(cwd);
const program = path.join(cwd, "main.go");
await fs.writeFile(program, "package main\n\nfunc main() {}\n");
const selection = selectLaunchAdapter(program, cwd);
expect(selection).toEqual({ kind: "unavailable", adapterName: "dlv", command: missingCommand });
});
it("reports missing dlv for Go package directories instead of selecting a native debugger", async () => {
const cwd = await makeTempDir("omp-dap-go-directory-missing-");
const missingCommand = await setupMissingDlvProject(cwd);
const program = path.join(cwd, "cmd", "server");
await fs.mkdir(program, { recursive: true });
const selection = selectLaunchAdapter(program, cwd, undefined, "directory");
expect(selection).toEqual({ kind: "unavailable", adapterName: "dlv", command: missingCommand });
});
it("prefers a nested module adapter over cwd and PATH for inferred launches", async () => {
const cwd = await makeTempDir("omp-dap-go-nested-local-");
const { moduleRoot, program } = await setupNestedGoProgram(cwd);
const nestedDlv = path.join(moduleRoot, "bin", "dlv");
await writeExecutable(nestedDlv);
await fs.writeFile(path.join(cwd, "go.mod"), "module example.com/repo\n\ngo 1.22\n");
await writeExecutable(path.join(cwd, "bin", "dlv"));
const whichSpy = vi.spyOn(piUtils, "$which").mockReturnValue(path.join(cwd, "global", "dlv"));
const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd));
expect(selected.resolvedCommand).toBe(nestedDlv);
expect(whichSpy).not.toHaveBeenCalled();
});
it("uses a nested module adapter when dlv is requested explicitly", async () => {
const cwd = await makeTempDir("omp-dap-go-nested-explicit-");
const { moduleRoot, program } = await setupNestedGoProgram(cwd);
const nestedDlv = path.join(moduleRoot, "bin", "dlv");
await writeExecutable(nestedDlv);
const whichSpy = vi.spyOn(piUtils, "$which").mockReturnValue(path.join(cwd, "global", "dlv"));
const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd, "dlv"));
expect(selected.resolvedCommand).toBe(nestedDlv);
expect(whichSpy).not.toHaveBeenCalled();
});
it("prefers the session cwd adapter over PATH after a nested-root miss", async () => {
const cwd = await makeTempDir("omp-dap-go-nested-cwd-");
const { program } = await setupNestedGoProgram(cwd);
const cwdDlv = path.join(cwd, "bin", "dlv");
await fs.writeFile(path.join(cwd, "go.mod"), "module example.com/repo\n\ngo 1.22\n");
await writeExecutable(cwdDlv);
const whichSpy = vi.spyOn(piUtils, "$which").mockReturnValue(path.join(cwd, "global", "dlv"));
const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd));
expect(selected.resolvedCommand).toBe(cwdDlv);
expect(whichSpy).not.toHaveBeenCalled();
});
it("resolves a local dlv for Go workspaces rooted by go.work", async () => {
const cwd = await makeTempDir("omp-dap-go-work-");
const program = path.join(cwd, "cmd", "worker");
const localDlv = path.join(cwd, "bin", "dlv");
await fs.writeFile(path.join(cwd, "go.work"), "go 1.22\n\nuse ./cmd/worker\n");
await fs.mkdir(program, { recursive: true });
await writeExecutable(localDlv);
const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd, undefined, "directory"));
expect(selected.resolvedCommand).toBe(localDlv);
});
it("re-resolves an adapter installed after an earlier miss", async () => {
const cwd = await makeTempDir("omp-dap-go-fresh-");
const program = path.join(cwd, "main.go");
const command = path.join(cwd, "tools", process.platform === "win32" ? "dlv.cmd" : "dlv");
await fs.writeFile(path.join(cwd, "go.mod"), "module example.com/cache\n\ngo 1.22\n");
await fs.writeFile(program, "package main\n\nfunc main() {}\n");
await writeDlvOverride(cwd, command);
expect(selectLaunchAdapter(program, cwd)).toEqual({
kind: "unavailable",
adapterName: "dlv",
command,
});
await writeExecutable(command);
const selected = requireSelectedAdapter(selectLaunchAdapter(program, cwd));
expect(selected.resolvedCommand).toBe(command);
});
});
@@ -489,7 +489,10 @@ describe("DAP launch failure handling", () => {
describe("DebugTool launch validation", () => {
it("rejects directory programs when the selected adapter cannot debug a directory", async () => {
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(TEST_ADAPTER);
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
kind: "adapter",
adapter: TEST_ADAPTER,
});
try {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-program-"));
try {
@@ -523,7 +526,10 @@ describe("DebugTool launch validation", () => {
launchDefaults: { request: "launch", mode: "debug", stopOnEntry: true },
acceptsDirectoryProgram: true,
};
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(dlvAdapter);
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
kind: "adapter",
adapter: dlvAdapter,
});
const sessionLaunchSpy = spyOn(dapModule.dapSessionManager, "launch").mockImplementation(async opts => {
throw Object.assign(new Error("captured launch"), { capturedOptions: opts });
});
@@ -603,7 +609,10 @@ describe("DebugTool launch validation", () => {
launchDefaults: { request: "launch", mode: "debug", stopOnEntry: true },
acceptsDirectoryProgram: true,
};
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(dlvAdapter);
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
kind: "adapter",
adapter: dlvAdapter,
});
const sessionLaunchSpy = spyOn(dapModule.dapSessionManager, "launch").mockImplementation(async opts => {
throw Object.assign(new Error("captured launch"), { capturedOptions: opts });
});
@@ -635,7 +644,11 @@ describe("DebugTool launch validation", () => {
});
it("throws targeted 'python not found in PATH' when adapter:'debugpy' is unresolvable for launch", async () => {
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(null);
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
kind: "unavailable",
adapterName: "debugpy",
command: "python",
});
try {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-debugpy-"));
try {
@@ -685,8 +698,93 @@ describe("DebugTool launch validation", () => {
}
});
it("shows the Delve install command when the canonical dlv adapter is unavailable", async () => {
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
kind: "unavailable",
adapterName: "dlv",
command: "dlv",
});
try {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-dlv-hint-"));
try {
await fs.writeFile(path.join(cwd, "main.go"), "package main\n\nfunc main() {}\n");
const session: ToolSession = {
cwd,
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated({ "debug.enabled": true }),
};
const tool = new DebugTool(session);
await expect(tool.execute("call", { action: "launch", program: "main.go" })).rejects.toThrow(
/go install github\.com\/go-delve\/delve\/cmd\/dlv@latest/,
);
} finally {
await removeWithRetries(cwd);
}
} finally {
launchSpy.mockRestore();
}
});
it("points to DAP configuration when a custom adapter command is unavailable", async () => {
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
kind: "unavailable",
adapterName: "dlv",
command: "./bin/missing-dlv",
});
try {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-dlv-config-"));
try {
await fs.writeFile(path.join(cwd, "main.go"), "package main\n\nfunc main() {}\n");
const session: ToolSession = {
cwd,
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated({ "debug.enabled": true }),
};
const tool = new DebugTool(session);
await expect(tool.execute("call", { action: "launch", program: "main.go" })).rejects.toThrow(
/configured command '\.\/bin\/missing-dlv' did not resolve.*DAP adapter config/,
);
} finally {
await removeWithRetries(cwd);
}
} finally {
launchSpy.mockRestore();
}
});
it("shows the rdbg install command for explicit Ruby attach", async () => {
const attachSpy = spyOn(dapModule, "selectAttachAdapter").mockReturnValue(null);
try {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-rdbg-attach-"));
try {
const session: ToolSession = {
cwd,
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated({ "debug.enabled": true }),
};
const tool = new DebugTool(session);
await expect(tool.execute("call", { action: "attach", pid: 1234, adapter: "rdbg" })).rejects.toThrow(
/gem install debug/,
);
} finally {
await removeWithRetries(cwd);
}
} finally {
attachSpy.mockRestore();
}
});
it("falls back to the generic 'No debugger adapter' error when adapter is unspecified", async () => {
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(null);
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({ kind: "none" });
try {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-noadapter-"));
try {