Skip to content
Draft
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
6 changes: 6 additions & 0 deletions src/LaunchConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ export interface LaunchConfiguration extends DebugProtocol.LaunchRequestArgument
*/
enableSourceMaps?: boolean;

/**
* If true, automatically continue stepping through generated/uninteresting code until a mapped source line is reached.
* @default false
*/
smartStep?: boolean;

/**
* If true, then any pkg path found in the device logs will be converted to a source location leveraging source maps if available
*
Expand Down
88 changes: 87 additions & 1 deletion src/debugSession/BrightScriptDebugSession.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { CompileError, DefaultFiles, rokuDeploy } from 'roku-deploy';
import type { AddProjectParams, ComponentLibraryConstructorParams } from '../managers/ProjectManager';
import { ComponentLibraryProject, Project } from '../managers/ProjectManager';
import { RendezvousTracker } from '../RendezvousTracker';
import { ClientToServerCustomEventName, isCustomRequestEvent, isProcessCrashEvent, LogOutputEvent } from './Events';
import { ClientToServerCustomEventName, isCustomRequestEvent, isProcessCrashEvent, LogOutputEvent, StoppedEventReason } from './Events';
import { EventEmitter } from 'eventemitter3';
import type { EvaluateContainer } from '../adapters/DebugProtocolAdapter';
import { VariableType } from '../debugProtocol/events/responses/VariablesResponse';
Expand Down Expand Up @@ -121,6 +121,9 @@ describe('BrightScriptDebugSession', () => {
registerSourceLocator: (a, b) => { },
setConsoleOutput: (a) => { },
evaluate: () => { },
stepOver: () => { },
stepInto: () => { },
stepOut: () => { },
syncBreakpoints: () => { },
getVariable: () => { },
getScopeVariables: (a) => { },
Expand Down Expand Up @@ -2866,6 +2869,89 @@ describe('BrightScriptDebugSession', () => {
});
});

describe('smartStep', () => {
beforeEach(() => {
session['entryBreakpointWasHandled'] = true;
session['launchConfiguration'].smartStep = true;
});

it('continues stepping when suspended on a generated line not covered by source map', async () => {
const activeThread = {
isSelected: true,
filePath: 'pkg:/source/main.brs',
lineNumber: 50,
threadId: 0
};
sinon.stub(session as any, 'setupSuspendedState').resolves([activeThread]);
sinon.stub(session.projectManager, 'getStagingFileInfo').resolves({
absolutePath: s`${stagingDir}/source/main.brs`
} as any);
sinon.stub(session.sourceMapManager, 'generatedLineIsMapped').resolves(false);
sinon.stub(session.sourceMapManager, 'getSourceMapPath').resolves(s`${stagingDir}/source/main.brs.map`);
sinon.stub(session.sourceMapManager, 'getSourceMap').resolves({} as any);
const stepOverStub = sinon.stub(rokuAdapter as any, 'stepOver').resolves();
const sendEventSpy = sinon.spy(session, 'sendEvent');

session['pendingSmartStep'] = { kind: 'next', threadId: 0, attempts: 0 };
await session['onSuspend']();

expect(stepOverStub.calledOnce).to.be.true;
expect(sendEventSpy.called).to.be.false;
});

it('emits a step stopped event when smart step lands on a mapped line', async () => {
const activeThread = {
isSelected: true,
filePath: 'pkg:/source/main.brs',
lineNumber: 4,
threadId: 0
};
sinon.stub(session as any, 'setupSuspendedState').resolves([activeThread]);
sinon.stub(session.projectManager, 'getStagingFileInfo').resolves({
absolutePath: s`${stagingDir}/source/main.brs`
} as any);
sinon.stub(session.sourceMapManager, 'generatedLineIsMapped').resolves(true);
const stepOverStub = sinon.stub(rokuAdapter as any, 'stepOver').resolves();
const sendEventSpy = sinon.spy(session, 'sendEvent');

session['pendingSmartStep'] = { kind: 'next', threadId: 0, attempts: 0 };
await session['onSuspend']();

expect(stepOverStub.called).to.be.false;
expect(sendEventSpy.calledOnce).to.be.true;
expect((sendEventSpy.getCall(0).args[0] as any).body.reason).to.equal(StoppedEventReason.step);
});

it('continues stepping when no source map exists but location is remapped by roku-debug', async () => {
const activeThread = {
isSelected: true,
filePath: 'pkg:/source/main.brs',
lineNumber: 7,
threadId: 0
};
sinon.stub(session as any, 'setupSuspendedState').resolves([activeThread]);
sinon.stub(session.projectManager, 'getStagingFileInfo').resolves({
absolutePath: s`${stagingDir}/source/main.brs`
} as any);
sinon.stub(session.sourceMapManager, 'generatedLineIsMapped').resolves(false);
sinon.stub(session.sourceMapManager, 'getSourceMapPath').resolves(s`${stagingDir}/source/main.brs.map`);
sinon.stub(session.sourceMapManager, 'getSourceMap').resolves(undefined);
sinon.stub(session.projectManager, 'getSourceLocation').resolves({
filePath: s`${rootDir}/source/main.bs`,
lineNumber: 7,
columnIndex: 0
});
const stepOverStub = sinon.stub(rokuAdapter as any, 'stepOver').resolves();
const sendEventSpy = sinon.spy(session, 'sendEvent');

session['pendingSmartStep'] = { kind: 'next', threadId: 0, attempts: 0 };
await session['onSuspend']();

expect(stepOverStub.calledOnce).to.be.true;
expect(sendEventSpy.called).to.be.false;
});
});

describe('stackTraceRequest', () => {
beforeEach(() => {
session['rokuAdapterDeferred'].resolve(session['rokuAdapter']);
Expand Down
106 changes: 103 additions & 3 deletions src/debugSession/BrightScriptDebugSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,8 @@ export class BrightScriptDebugSession extends LoggingDebugSession {
* A magic number to represent a fake thread that will be used for showing compile errors in the UI as if they were runtime crashes
*/
private COMPILE_ERROR_THREAD_ID = 7_777;
private pendingSmartStep: { kind: 'next' | 'stepIn' | 'stepOut'; threadId: number; attempts: number } | undefined;
private readonly maxSmartStepAttempts = 100;

private get enableDebugProtocol() {
return this.launchConfiguration?.enableDebugProtocol;
Expand Down Expand Up @@ -529,6 +531,7 @@ export class BrightScriptDebugSession extends LoggingDebugSession {
config.stagingDir ??= config.stagingFolderPath;
config.emitChannelPublishedEvent ??= true;
config.rewriteDevicePathsInLogs ??= true;
config.smartStep ??= false;
config.autoResolveVirtualVariables ??= false;
config.enhanceREPLCompletions ??= true;
config.username ??= 'rokudev';
Expand Down Expand Up @@ -900,6 +903,7 @@ export class BrightScriptDebugSession extends LoggingDebugSession {
// launchRequest gets invoked by our restart session flow.
// We need to clear/reset some state to avoid issues.
this.entryBreakpointWasHandled = false;
this.pendingSmartStep = undefined;
this.breakpointManager.clearBreakpointLastState();
}

Expand Down Expand Up @@ -1725,6 +1729,7 @@ export class BrightScriptDebugSession extends LoggingDebugSession {
}

this.logger.log('continueRequest');
this.pendingSmartStep = undefined;
await this.setTransientsToInvalid(); // call before clearState
this.clearState();

Expand All @@ -1735,6 +1740,7 @@ export class BrightScriptDebugSession extends LoggingDebugSession {

protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
this.logger.log('pauseRequest');
this.pendingSmartStep = undefined;

//if we have a compile error, we should shut down
if (this.compileError) {
Expand Down Expand Up @@ -1769,12 +1775,14 @@ export class BrightScriptDebugSession extends LoggingDebugSession {

await this.setTransientsToInvalid(); // call before clearState
this.clearState();
this.beginSmartStep('next', args.threadId);

// The debug session ends after the next line. Do not put new work after this line.
try {
await this.rokuAdapter.stepOver(args.threadId);
this.logger.info('[nextRequest] end');
} catch (error) {
this.pendingSmartStep = undefined;
this.logger.error(`[nextRequest] Error running '${BrightScriptDebugSession.prototype.nextRequest.name}()'`, error);
}
this.sendResponse(response);
Expand All @@ -1792,8 +1800,14 @@ export class BrightScriptDebugSession extends LoggingDebugSession {

await this.setTransientsToInvalid(); // call before clearState
this.clearState();
this.beginSmartStep('stepIn', args.threadId);
// The debug session ends after the next line. Do not put new work after this line.
await this.rokuAdapter.stepInto(args.threadId);
try {
await this.rokuAdapter.stepInto(args.threadId);
} catch (error) {
this.pendingSmartStep = undefined;
throw error;
}
this.sendResponse(response);
this.logger.info('[stepInRequest] end');
}
Expand All @@ -1810,9 +1824,15 @@ export class BrightScriptDebugSession extends LoggingDebugSession {

await this.setTransientsToInvalid(); // call before clearState
this.clearState();
this.beginSmartStep('stepOut', args.threadId);

// The debug session ends after the next line. Do not put new work after this line.
await this.rokuAdapter.stepOut(args.threadId);
try {
await this.rokuAdapter.stepOut(args.threadId);
} catch (error) {
this.pendingSmartStep = undefined;
throw error;
}
this.sendResponse(response);
this.logger.info('[stepOutRequest] end');
}
Expand Down Expand Up @@ -2664,8 +2684,15 @@ export class BrightScriptDebugSession extends LoggingDebugSession {
}
}

if (await this.trySmartStep(activeThread)) {
return;
}

const stoppedEventReason = this.pendingSmartStep ? StoppedEventReason.step : StoppedEventReason.breakpoint;
this.pendingSmartStep = undefined;

const event: StoppedEvent = new StoppedEvent(
StoppedEventReason.breakpoint,
stoppedEventReason,
//Not sure why, but sometimes there is no active thread. Just pick thread 0 to prevent the app from totally crashing
activeThread?.threadId ?? 0,
'' //exception text
Expand All @@ -2675,6 +2702,79 @@ export class BrightScriptDebugSession extends LoggingDebugSession {
this.sendEvent(event);
}

private beginSmartStep(kind: 'next' | 'stepIn' | 'stepOut', threadId: number) {
if (this.launchConfiguration?.smartStep) {
this.pendingSmartStep = {
kind: kind,
threadId: threadId,
attempts: 0
};
} else {
this.pendingSmartStep = undefined;
}
}

private async trySmartStep(activeThread: { filePath: string; lineNumber: number; threadId: number }) {
if (!this.pendingSmartStep || !this.launchConfiguration?.smartStep || !activeThread) {
return false;
}

if (!await this.shouldSmartStepThroughLine(activeThread)) {
return false;
}

if (this.pendingSmartStep.attempts >= this.maxSmartStepAttempts) {
this.logger.warn(`Smart step reached max attempts (${this.maxSmartStepAttempts}). Stopping on current line.`);
this.pendingSmartStep = undefined;
return false;
}

this.pendingSmartStep.attempts++;

await this.setTransientsToInvalid(); // call before clearState
this.clearState();

try {
const threadId = this.pendingSmartStep.threadId ?? activeThread.threadId;
if (this.pendingSmartStep.kind === 'next') {
await this.rokuAdapter.stepOver(threadId);
} else if (this.pendingSmartStep.kind === 'stepIn') {
await this.rokuAdapter.stepInto(threadId);
} else {
await this.rokuAdapter.stepOut(threadId);
}
return true;
} catch (error) {
this.logger.error('Error running smart step', error);
this.pendingSmartStep = undefined;
return false;
}
}

private async shouldSmartStepThroughLine(activeThread: { filePath: string; lineNumber: number; threadId: number }) {
const stagingFileInfo = await this.projectManager.getStagingFileInfo(activeThread.filePath);
if (!stagingFileInfo) {
return false;
}

const hasMappedGeneratedLine = await this.sourceMapManager.generatedLineIsMapped(stagingFileInfo.absolutePath, activeThread.lineNumber);
if (hasMappedGeneratedLine) {
return false;
}

const sourceMapPath = await this.sourceMapManager.getSourceMapPath(stagingFileInfo.absolutePath);
const hasSourceMap = !!(await this.sourceMapManager.getSourceMap(sourceMapPath));
if (hasSourceMap) {
return true;
}

const sourceLocation = await this.projectManager.getSourceLocation(activeThread.filePath, activeThread.lineNumber);
if (!sourceLocation?.filePath) {
return false;
}
return fileUtils.standardizePath(sourceLocation.filePath).toLowerCase() !== fileUtils.standardizePath(stagingFileInfo.absolutePath).toLowerCase();
}

private async setupSuspendedState() {
//clear the index for storing evalutated expressions
this.evaluateVarIndexByFrameId.clear();
Expand Down
25 changes: 25 additions & 0 deletions src/managers/SourceMapManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,29 @@ describe('SourceMapManager', () => {
expect(location).to.be.undefined;
});
});

describe('generatedLineIsMapped', () => {
it('returns true only for generated lines with mappings', async () => {
const brsPath = s`${tmpPath}/staging/components/foo.brs`;
const mapPath = `${brsPath}.map`;
fsExtra.ensureDirSync(path.dirname(mapPath));

const { SourceMapGenerator } = await import('source-map');
const gen = new SourceMapGenerator({ file: 'foo.brs' });
gen.addMapping({
generated: { line: 1, column: 0 },
original: { line: 5, column: 0 },
source: '../../src/components/foo.brs'
});
fsExtra.writeFileSync(mapPath, gen.toString());

expect(await manager.generatedLineIsMapped(brsPath, 1)).to.be.true;
expect(await manager.generatedLineIsMapped(brsPath, 2)).to.be.false;
});

it('returns false when no source map exists', async () => {
const brsPath = s`${tmpPath}/staging/source/main.brs`;
expect(await manager.generatedLineIsMapped(brsPath, 1)).to.be.false;
});
});
});
29 changes: 29 additions & 0 deletions src/managers/SourceMapManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class SourceMapManager {
* So take that into consideration when deciding to use falsey checking
*/
private cache = {} as Record<string, RawSourceMap | null>;
private generatedMappedLinesCache: Record<string, Set<number>> = {};

/**
* Does a source map exist at the specified path?
Expand Down Expand Up @@ -71,6 +72,8 @@ export class SourceMapManager {
//casings to be safe across platforms.
delete this.sourceMapConsumerCache[sourceMapPath];
delete this.sourceMapConsumerCache[key];
delete this.generatedMappedLinesCache[sourceMapPath];
delete this.generatedMappedLinesCache[key];
//standardize the source map paths
const mapDir = path.dirname(sourceMapPath);
// Resolve sourceRoot relative to the map file's directory (handles relative sourceRoot correctly)
Expand Down Expand Up @@ -179,6 +182,32 @@ export class SourceMapManager {
return consumer;
}

/**
* Returns true when the generated line has at least one explicit mapping in the source map.
*/
public async generatedLineIsMapped(filePath: string, generatedLineNumber: number) {
const sourceMapPath = await this.getSourceMapPath(filePath);
const parsedSourceMap = await this.getSourceMap(sourceMapPath);
if (!parsedSourceMap) {
return false;
}

const cacheKey = s`${sourceMapPath.toLowerCase()}`;
let mappedLines = this.generatedMappedLinesCache[cacheKey];
if (!mappedLines) {
const consumer = await this.getSourceMapConsumer(sourceMapPath, parsedSourceMap);
mappedLines = new Set<number>();
consumer.eachMapping((mapping) => {
if (mapping?.source && mapping.generatedLine) {
mappedLines.add(mapping.generatedLine);
}
}, undefined, SourceMapConsumer.GENERATED_ORDER);
this.generatedMappedLinesCache[cacheKey] = mappedLines;
}

return mappedLines.has(generatedLineNumber);
}

/**
* Given a source location, find the generated location using source maps
*/
Expand Down