Skip to content
Merged
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
12 changes: 12 additions & 0 deletions extensions/vscode/src/dap/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ type BreakpointSyncLogRecord = {
error?: string;
};

const BREAKPOINT_SYNC_TEST_LOG_ENV = 'SOROBAN_DEBUG_BREAKPOINT_SYNC_TEST_LOG';

export class SorobanDebugSession extends DebugSession {
private static readonly FIRST_CONTINUE_STOP_REASON: 'breakpoint' = 'breakpoint';
private logManager: LogManager | undefined;
Expand Down Expand Up @@ -865,6 +867,7 @@ export class SorobanDebugSession extends DebugSession {
LogPhase.DAP,
`BREAKPOINT_SYNC ${JSON.stringify(setRecord)}`
);
this.emitBreakpointSyncTestLog(setRecord);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.set(
Expand All @@ -890,6 +893,15 @@ export class SorobanDebugSession extends DebugSession {
return errors;
}

private emitBreakpointSyncTestLog(record: BreakpointSyncLogRecord): void {
if (process.env[BREAKPOINT_SYNC_TEST_LOG_ENV] !== '1' || record.action !== 'set' || !record.success) {
return;
}

// Temporary stderr log for e2e regression coverage of heuristic breakpoint sync.
process.stderr.write(`BREAKPOINT_SYNC_TEST ${JSON.stringify(record)}\n`);
}

private async refreshState(): Promise<void> {
if (!this.debuggerProcess) {
return;
Expand Down
108 changes: 105 additions & 3 deletions extensions/vscode/src/test/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type DebugMessage = {
response?: { type: string; [key: string]: unknown };
};

const BREAKPOINT_SYNC_TEST_LOG_ENV = "SOROBAN_DEBUG_BREAKPOINT_SYNC_TEST_LOG";

async function startMockDebuggerServer(options: {
evaluateDelayMs: number;
}): Promise<{ port: number; close: () => Promise<void> }> {
Expand Down Expand Up @@ -163,6 +165,36 @@ async function wait(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}

async function waitForProcessLog(
getOutput: () => string,
pattern: RegExp,
timeoutMs = 5_000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = getOutput();
if (pattern.test(output)) {
return output;
}
await wait(25);
}

throw new Error(`Timed out waiting for process log matching ${pattern}`);
}

async function assertProcessLogAbsent(
getOutput: () => string,
pattern: RegExp,
waitMs = 500,
): Promise<void> {
await wait(waitMs);
assert.doesNotMatch(
getOutput(),
pattern,
`Did not expect process log matching ${pattern}`,
);
}

async function main(): Promise<void> {
if (!(await isLoopbackAvailable())) {
console.warn(
Expand Down Expand Up @@ -756,7 +788,7 @@ async function main(): Promise<void> {
const exportedFunctions = await debuggerProcess.getContractFunctions();
const resolvedBreakpoints = resolveSourceBreakpoints(
sourcePath,
[10],
[14],
exportedFunctions,
);
assert.equal(
Expand All @@ -771,10 +803,36 @@ async function main(): Promise<void> {
"Expected heuristic mapping to still set a function breakpoint",
);

const nonExportedBreakpoints = resolveSourceBreakpoints(
sourcePath,
[10],
exportedFunctions,
);
assert.equal(
nonExportedBreakpoints[0].verified,
false,
"Expected non-exported function mapping to be unverified",
);
assert.equal(
nonExportedBreakpoints[0].functionName,
"helper",
"Expected non-exported function line to map to helper",
);
assert.equal(
nonExportedBreakpoints[0].reasonCode,
"HEURISTIC_NOT_EXPORTED",
"Expected non-exported function reason code",
);
assert.equal(
nonExportedBreakpoints[0].setBreakpoint,
false,
"Expected non-exported function mapping to skip runtime breakpoint install",
);

// Test HEURISTIC_NO_FUNCTION behavior for lines outside any function
const noFunctionBreakpoints = resolveSourceBreakpoints(
sourcePath,
[1, 2, 13], // Lines outside any function in lib.rs
[1, 2, 12, 16], // Lines outside any function in lib.rs
exportedFunctions,
);

Expand Down Expand Up @@ -934,9 +992,15 @@ async function runDapHappyPathE2E(
fixtures: { contractPath: string; sourcePath: string; binaryPath: string },
): Promise<void> {
const proc = spawn(process.execPath, [debugAdapterPath], {
env: { ...process.env, [BREAKPOINT_SYNC_TEST_LOG_ENV]: "1" },
stdio: ["pipe", "pipe", "pipe"],
});
const client = new DapClient(proc);
let stderrOutput = "";
proc.stderr.setEncoding("utf8");
proc.stderr.on("data", (chunk: string) => {
stderrOutput += chunk;
});

try {
const init = await client.request("initialize", {
Expand Down Expand Up @@ -974,7 +1038,7 @@ async function runDapHappyPathE2E(

const setBps = await client.request("setBreakpoints", {
source: { path: fixtures.sourcePath },
breakpoints: [{ line: 10 }],
breakpoints: [{ line: 14 }],
});
assert.equal(
setBps.success,
Expand All @@ -986,6 +1050,44 @@ async function runDapHappyPathE2E(
false,
"Expected heuristic source mapping to be unverified",
);
const breakpointSyncLog = await waitForProcessLog(
() => stderrOutput,
/BREAKPOINT_SYNC_TEST .*"action":"set".*"functionName":"echo".*"success":true/,
);
assert.match(
breakpointSyncLog,
/BREAKPOINT_SYNC_TEST/,
"Expected adapter test log to confirm runtime breakpoint installation",
);

const privateBps = await client.request("setBreakpoints", {
source: { path: fixtures.sourcePath },
breakpoints: [{ line: 10 }],
});
assert.equal(
privateBps.success,
true,
`setBreakpoints for non-exported function failed: ${privateBps.message || ""}`,
);
assert.equal(
privateBps.body?.breakpoints?.[0]?.verified,
false,
"Expected non-exported function source mapping to be unverified",
);
assert.equal(
privateBps.body?.breakpoints?.[0]?.reasonCode,
"HEURISTIC_NOT_EXPORTED",
"Expected non-exported reason code on source breakpoint response",
);
assert.match(
String(privateBps.body?.breakpoints?.[0]?.message || ""),
/HEURISTIC_NOT_EXPORTED/,
"Expected non-exported breakpoint message to include reason code",
);
await assertProcessLogAbsent(
() => stderrOutput,
/BREAKPOINT_SYNC_TEST .*"action":"set".*"functionName":"helper".*"success":true/,
);

const configDone = await client.request("configurationDone", {});
assert.equal(
Expand Down
108 changes: 105 additions & 3 deletions extensions/vscode/src/test/suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ type TestFixtures = {
binaryPath: string;
};

const BREAKPOINT_SYNC_TEST_LOG_ENV = "SOROBAN_DEBUG_BREAKPOINT_SYNC_TEST_LOG";

async function startMockDebuggerServer(options: {
evaluateDelayMs: number;
}): Promise<{ port: number; close: () => Promise<void> }> {
Expand Down Expand Up @@ -164,6 +166,36 @@ async function wait(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}

async function waitForProcessLog(
getOutput: () => string,
pattern: RegExp,
timeoutMs = 5_000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = getOutput();
if (pattern.test(output)) {
return output;
}
await wait(25);
}

throw new Error(`Timed out waiting for process log matching ${pattern}`);
}

async function assertProcessLogAbsent(
getOutput: () => string,
pattern: RegExp,
waitMs = 500,
): Promise<void> {
await wait(waitMs);
assert.doesNotMatch(
getOutput(),
pattern,
`Did not expect process log matching ${pattern}`,
);
}

function resolveFixtures(): TestFixtures {
const extensionRoot = process.cwd();
const repoRoot = path.resolve(extensionRoot, "..", "..");
Expand Down Expand Up @@ -546,7 +578,7 @@ export async function runSmokeSuite(): Promise<void> {
const exportedFunctions = await debuggerProcess.getContractFunctions();
const resolvedBreakpoints = resolveSourceBreakpoints(
fixtures.sourcePath,
[10],
[14],
exportedFunctions,
);
assert.equal(
Expand All @@ -561,10 +593,36 @@ export async function runSmokeSuite(): Promise<void> {
"Expected heuristic mapping to still set a function breakpoint",
);

const nonExportedBreakpoints = resolveSourceBreakpoints(
fixtures.sourcePath,
[10],
exportedFunctions,
);
assert.equal(
nonExportedBreakpoints[0].verified,
false,
"Expected non-exported function mapping to be unverified",
);
assert.equal(
nonExportedBreakpoints[0].functionName,
"helper",
"Expected non-exported function line to map to helper",
);
assert.equal(
nonExportedBreakpoints[0].reasonCode,
"HEURISTIC_NOT_EXPORTED",
"Expected non-exported function reason code",
);
assert.equal(
nonExportedBreakpoints[0].setBreakpoint,
false,
"Expected non-exported function mapping to skip runtime breakpoint install",
);

// Test HEURISTIC_NO_FUNCTION behavior for lines outside any function
const noFunctionBreakpoints = resolveSourceBreakpoints(
fixtures.sourcePath,
[1, 2, 13], // Lines outside any function in lib.rs
[1, 2, 12, 16], // Lines outside any function in lib.rs
exportedFunctions,
);

Expand Down Expand Up @@ -740,9 +798,15 @@ async function runDapHappyPathE2E(
fixtures: Pick<TestFixtures, "contractPath" | "sourcePath" | "binaryPath">,
): Promise<void> {
const proc = spawn(process.execPath, [debugAdapterPath], {
env: { ...process.env, [BREAKPOINT_SYNC_TEST_LOG_ENV]: "1" },
stdio: ["pipe", "pipe", "pipe"],
});
const client = new DapClient(proc);
let stderrOutput = "";
proc.stderr.setEncoding("utf8");
proc.stderr.on("data", (chunk: string) => {
stderrOutput += chunk;
});

try {
const init = await client.request("initialize", {
Expand Down Expand Up @@ -780,7 +844,7 @@ async function runDapHappyPathE2E(

const setBps = await client.request("setBreakpoints", {
source: { path: fixtures.sourcePath },
breakpoints: [{ line: 10 }],
breakpoints: [{ line: 14 }],
});
assert.equal(
setBps.success,
Expand All @@ -802,6 +866,44 @@ async function runDapHappyPathE2E(
/HEURISTIC_NO_DWARF/,
"Expected breakpoint message to include heuristic reason code",
);
const breakpointSyncLog = await waitForProcessLog(
() => stderrOutput,
/BREAKPOINT_SYNC_TEST .*"action":"set".*"functionName":"echo".*"success":true/,
);
assert.match(
breakpointSyncLog,
/BREAKPOINT_SYNC_TEST/,
"Expected adapter test log to confirm runtime breakpoint installation",
);

const privateBps = await client.request("setBreakpoints", {
source: { path: fixtures.sourcePath },
breakpoints: [{ line: 10 }],
});
assert.equal(
privateBps.success,
true,
`setBreakpoints for non-exported function failed: ${privateBps.message || ""}`,
);
assert.equal(
privateBps.body?.breakpoints?.[0]?.verified,
false,
"Expected non-exported function source mapping to be unverified",
);
assert.equal(
privateBps.body?.breakpoints?.[0]?.reasonCode,
"HEURISTIC_NOT_EXPORTED",
"Expected non-exported reason code on source breakpoint response",
);
assert.match(
String(privateBps.body?.breakpoints?.[0]?.message || ""),
/HEURISTIC_NOT_EXPORTED/,
"Expected non-exported breakpoint message to include reason code",
);
await assertProcessLogAbsent(
() => stderrOutput,
/BREAKPOINT_SYNC_TEST .*"action":"set".*"functionName":"helper".*"success":true/,
);

const configDone = await client.request("configurationDone", {});
assert.equal(
Expand Down
6 changes: 5 additions & 1 deletion tests/fixtures/contracts/echo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ pub struct Echo;

#[contractimpl]
impl Echo {
pub fn echo(_env: Env, v: Val) -> Val {
fn helper(v: Val) -> Val {
v
}

pub fn echo(_env: Env, v: Val) -> Val {
Self::helper(v)
}
}
Loading