• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rokucommunity / roku-debug / 28666405759

03 Jul 2026 02:20PM UTC coverage: 72.794% (+0.1%) from 72.68%
28666405759

Pull #383

github

web-flow
Merge 8090f21a1 into 9fcde9964
Pull Request #383: Prevent orphaned debug adapter after client disconnect

3774 of 5413 branches covered (69.72%)

Branch coverage included in aggregate %.

67 of 69 new or added lines in 1 file covered. (97.1%)

5928 of 7915 relevant lines covered (74.9%)

47.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

66.71
/src/debugSession/BrightScriptDebugSession.ts
1
import * as fsExtra from 'fs-extra';
2✔
2
import { orderBy } from 'natural-orderby';
2✔
3
import * as path from 'path';
2✔
4
import * as semver from 'semver';
2✔
5
import { rokuDeploy, CompileError, isUpdateCheckRequiredError, isConnectionResetError, EcpNetworkAccessModeDisabledError } from 'roku-deploy';
2✔
6
import type { DeviceInfo, RokuDeploy, RokuDeployOptions } from 'roku-deploy';
7
import {
2✔
8
    BreakpointEvent,
9
    LoggingDebugSession,
10
    Logger as DapLogger,
11
    logger as dapLogger,
12
    CapabilitiesEvent,
13
    InitializedEvent,
14
    InvalidatedEvent,
15
    OutputEvent,
16
    ProgressEndEvent,
17
    ProgressStartEvent,
18
    ProgressUpdateEvent,
19
    Source,
20
    StackFrame,
21
    StoppedEvent,
22
    TerminatedEvent,
23
    Thread,
24
    Variable
25
} from '@vscode/debugadapter';
26
import type { SceneGraphCommandResponse } from '../SceneGraphDebugCommandController';
27
import { SceneGraphDebugCommandController } from '../SceneGraphDebugCommandController';
2✔
28
import type { DebugProtocol } from '@vscode/debugprotocol';
29
import { defer, util } from '../util';
2✔
30
import { fileUtils, standardizePath as s } from '../FileUtils';
2✔
31
import { ComponentLibraryServer } from '../ComponentLibraryServer';
2✔
32
import { ProjectManager, Project, ComponentLibraryProject } from '../managers/ProjectManager';
2✔
33
import type { EvaluateContainer, Thread as AdapterThread } from '../adapters/DebugProtocolAdapter';
34
import { DebugProtocolAdapter } from '../adapters/DebugProtocolAdapter';
2✔
35
import { TelnetAdapter } from '../adapters/TelnetAdapter';
2✔
36
import type { BSDebugDiagnostic } from '../CompileErrorProcessor';
37
import { RendezvousTracker } from '../RendezvousTracker';
2✔
38
import {
2✔
39
    LaunchStartEvent,
40
    LogOutputEvent,
41
    RendezvousEvent,
42
    DiagnosticsEvent,
43
    StoppedEventReason,
44
    ChanperfEvent,
45
    DebugServerLogOutputEvent,
46
    ChannelPublishedEvent,
47
    CustomRequestEvent,
48
    ClientToServerCustomEventName,
49
    ProfilingErrorEvent,
50
    ProfilingStartEvent,
51
    ProfilingStopEvent,
52
    ProfilingEnabledEvent as ProfilingEnableEvent,
53
    ProcessCrashEvent
54
} from './Events';
55
import type { ProcessCrashEventData } from './Events';
56
import type { LaunchConfiguration, ComponentLibraryConfiguration } from '../LaunchConfiguration';
57
import { FileManager } from '../managers/FileManager';
2✔
58
import { SourceMapManager } from '../managers/SourceMapManager';
2✔
59
import { LocationManager } from '../managers/LocationManager';
2✔
60
import type { AugmentedSourceBreakpoint } from '../managers/BreakpointManager';
61
import { BreakpointManager } from '../managers/BreakpointManager';
2✔
62
import type { LogMessage } from '../logging';
63
import { PerfettoManager } from '../PerfettoManager';
2✔
64
import { logger, FileLoggingManager, debugServerLogOutputEventTransport, LogLevelPriority } from '../logging';
2✔
65
import { VariableType } from '../debugProtocol/events/responses/VariablesResponse';
2✔
66
import { DiagnosticSeverity } from 'brighterscript';
2✔
67
import type { ExceptionBreakpoint } from '../debugProtocol/events/requests/SetExceptionBreakpointsRequest';
68
import { debounce } from 'debounce';
2✔
69
import { interfaces, components, events } from 'brighterscript/dist/roku-types';
2✔
70
import { globalCallables } from 'brighterscript/dist/globalCallables';
2✔
71
import { bscProjectWorkerPool } from '../bsc/threading/BscProjectWorkerPool';
2✔
72
import { populateVariableFromRegistryEcp } from './ecpRegistryUtils';
2✔
73
import { AppState, rokuECP } from '../RokuECP';
2✔
74
import { SocketConnectionInUseError } from '../Exceptions';
2✔
75

76
const diagnosticSource = 'roku-debug';
2✔
77

78
/**
79
 * Sort tiers for debug-console completions. Lower values sort first, so a variable's own members rank
80
 * above interface methods, then the file's scope functions, and finally the (large) set of globals.
81
 */
82
enum CompletionSortTier {
2✔
83
    Member = '1',
2✔
84
    Method = '2',
2✔
85
    ScopeFunction = '3',
2✔
86
    Global = '4'
2✔
87
}
88

89
export class BrightScriptDebugSession extends LoggingDebugSession {
2✔
90
    public constructor() {
91
        super();
203✔
92

93
        // this debugger uses one-based lines and columns
94
        this.setDebuggerLinesStartAt1(false);
203✔
95
        this.setDebuggerColumnsStartAt1(false);
203✔
96

97
        //give util a reference to this session to assist in logging across the entire module
98
        util._debugSession = this;
203✔
99

100
        this.fileManager = new FileManager();
203✔
101
        this.sourceMapManager = new SourceMapManager();
203✔
102
        this.locationManager = new LocationManager(this.sourceMapManager);
203✔
103
        this.breakpointManager = new BreakpointManager(this.sourceMapManager, this.locationManager);
203✔
104
        //send newly-verified breakpoints to vscode
105
        this.breakpointManager.on('breakpoints-verified', (data) => this.onDeviceBreakpointsChanged('changed', data));
203✔
106
        this.projectManager = new ProjectManager({
203✔
107
            breakpointManager: this.breakpointManager,
108
            locationManager: this.locationManager
109
        });
110
        this.fileLoggingManager = new FileLoggingManager();
203✔
111
    }
112

113
    public start(inStream: NodeJS.ReadableStream, outStream: NodeJS.WritableStream): void {
114
        super.start(inStream, outStream);
3✔
115

116
        //When the client's pipe goes away (e.g. VS Code closed), stop forwarding output. Otherwise the
117
        //still-running Roku app keeps streaming output, every write to the dead pipe fails, and each
118
        //failure re-triggers shutdown() in a tight loop that pegs the CPU and orphans this process (#486).
119
        const markClientGone = () => {
3✔
120
            this.clientDisconnected = true;
1✔
121
        };
122
        inStream?.on?.('close', markClientGone);
3✔
123
        inStream?.on?.('end', markClientGone);
3✔
124
        outStream?.on?.('error', markClientGone);
3✔
125

126
        // Set up DAP protocol logging as early as possible — immediately after start() so we capture
127
        // the initialize request and all early DAP traffic before launchRequest config is available.
128
        // The log file path is injected as ROKU_DAP_LOG_FILE by the extension's DebugAdapterDescriptorFactory,
129
        // which resolves the path from the `brightscript.debug.debugAdapterProtocolLogging` workspace setting
130
        // (or the equivalent launch.json property) before the debug adapter process is spawned.
131
        const dapLogFile = process.env.ROKU_DAP_LOG_FILE;
3✔
132
        if (dapLogFile) {
3✔
133
            // Use LogLevel.Error (not Verbose) as the console threshold so DAP messages are written
134
            // to the log file but are NOT forwarded to VS Code as OutputEvents, which would flood
135
            // the debug console and break the extension's output parsing.
136
            // Note: InternalLogger always writes ALL messages to the file stream regardless of level,
137
            // so the log file will still contain everything.
138
            dapLogger.setup(DapLogger.LogLevel.Error, dapLogFile);
1✔
139
        }
140
    }
141

142
    /**
143
     * Once the client has disconnected, drop outgoing events instead of writing to the dead stream.
144
     * Writing to a broken pipe re-triggers the base 'error' -> shutdown() handler in a tight loop (#486).
145
     */
146
    public sendEvent(event: DebugProtocol.Event): void {
147
        if (this.clientDisconnected) {
610✔
148
            return;
4✔
149
        }
150
        super.sendEvent(event);
606✔
151
    }
152

153
    public setupProcessErrorHandlers() {
154
        if (this.processErrorHandlersRegistered) {
16✔
155
            return;
1✔
156
        }
157
        this.processErrorHandlersRegistered = true;
15✔
158

159
        this._uncaughtExceptionHandler = (error) => this.handleProcessError('uncaughtException', error);
15✔
160
        this._unhandledRejectionHandler = (reason) => this.handleProcessError('unhandledRejection', reason);
15✔
161

162
        process.on('uncaughtException', this._uncaughtExceptionHandler);
15✔
163
        process.on('unhandledRejection', this._unhandledRejectionHandler);
15✔
164
    }
165

166
    private handlingProcessError = false;
203✔
167

168
    /**
169
     * True when an error indicates the client (e.g. VS Code) has gone away, such as a broken stdout pipe
170
     * (EPIPE). Writing to a dead pipe just produces more EPIPEs, so we must not try to report over it.
171
     */
172
    private isClientGoneError(error: unknown): boolean {
173
        const code = (error as NodeJS.ErrnoException)?.code;
21!
174
        const message = error instanceof Error ? error.message : String(error ?? '');
21!
175
        return code === 'EPIPE' || /\bEPIPE\b|write after end/i.test(message);
21✔
176
    }
177

178
    /**
179
     * Tear down the process error handlers and forcibly exit, so we never leave an orphaned adapter
180
     * spinning in the background after the client is gone or a graceful shutdown has hung. (#486)
181
     */
182
    private forceExit(code = 0): void {
1✔
183
        this.teardownProcessErrorHandlers();
1✔
184
        process.exit(code);
1✔
185
    }
186

187
    private handleProcessError(type: 'uncaughtException' | 'unhandledRejection', error: unknown) {
188
        //a broken client pipe (EPIPE) means the client (e.g. VS Code) is gone. Trying to report it over
189
        //the now-dead stream just produces more EPIPEs, which re-enter this handler in a tight loop and
190
        //peg the CPU (#486). Exit instead.
191
        if (this.isClientGoneError(error)) {
18✔
192
            this.clientDisconnected = true;
1✔
193
            this.forceExit();
1✔
194
            return;
1✔
195
        }
196
        //only handle the first error; re-entering here (e.g. from a failed write while reporting) would
197
        //spin the CPU and flood the logs (#486)
198
        if (this.handlingProcessError) {
17✔
199
            return;
2✔
200
        }
201
        this.handlingProcessError = true;
15✔
202

203
        const logger = this.logger.createLogger(`${type}`);
15✔
204
        const message = error instanceof Error ? error.message : String(error);
15✔
205
        const stack = error instanceof Error ? error.stack : undefined;
15✔
206
        logger.error(message, stack);
15✔
207

208
        let output: string;
209
        let debuggerVersion: string;
210
        let additionalInfo: ProcessCrashEventData['additionalInfo'];
211
        try {
15✔
212
            debuggerVersion = (fsExtra.readJsonSync(path.resolve(__dirname, '../../package.json')) as { version: string }).version;
15✔
213

214
            const clientName = this.initRequestArgs?.clientName ?? 'unknown';
14✔
215

216
            additionalInfo = {
14✔
217
                clientName: clientName,
218
                rokuDebugVersion: debuggerVersion,
219
                ecpMode: this.deviceInfo?.ecpSettingMode,
42!
220
                developerMode: this.deviceInfo?.developerEnabled,
42!
221
                firmware: this.deviceInfo ? `${this.deviceInfo?.softwareVersion}.${this.deviceInfo?.softwareBuild}` : undefined,
14!
222
                protocolVersion: this.deviceInfo?.brightscriptDebuggerVersion,
42!
223
                protocolEnabled: this.enableDebugProtocol
224
            };
225

226
            const lines = Object.entries(additionalInfo as Record<string, unknown>).map(([key, value]) => {
14✔
227
                // Insert a space before all uppercase letters preceded by a lowercase letter, then uppercase the first char
228
                const spacedString = key.replace(/([a-z])([A-Z])/g, '$1 $2');
98✔
229
                const formattedKey = spacedString.charAt(0).toUpperCase() + spacedString.slice(1);
98✔
230
                return `**${formattedKey}:** ${JSON.stringify(value)}`;
98✔
231
            });
232

233
            const issueBodyPrefix = [
14✔
234
                `**Error type:** ${type}`,
235
                `**Message:** ${message}`,
236
                ...lines,
237
                '',
238
                `**Steps to reproduce:**`,
239
                `<!-- Please describe what you were doing when this crash occurred -->`,
240
                '',
241
                '**Stack trace:**',
242
                '```',
243
                ''
244
            ].join('\n');
245
            const issueBodySuffix = '\n```';
14✔
246

247
            const issueTitle = encodeURIComponent(`[crash] ${type}: ${message}`);
14✔
248
            const baseUrl = 'https://github.com/RokuCommunity/roku-debug/issues/new';
14✔
249
            const maxUrlLength = 2000;
14✔
250
            const urlOverhead = `${baseUrl}?title=${issueTitle}&body=`.length;
14✔
251
            const bodyBudget = maxUrlLength - urlOverhead;
14✔
252
            const encodedPrefix = encodeURIComponent(issueBodyPrefix);
14✔
253
            const encodedSuffix = encodeURIComponent(issueBodySuffix);
14✔
254
            const stackBudget = bodyBudget - encodedPrefix.length - encodedSuffix.length;
14✔
255
            let truncatedStack: string;
256
            if (!stack) {
14✔
257
                truncatedStack = '(no stack trace)';
2✔
258
            } else if (encodeURIComponent(stack).length <= stackBudget) {
12✔
259
                truncatedStack = stack;
4✔
260
            } else {
261
                truncatedStack = decodeURIComponent(encodeURIComponent(stack).slice(0, stackBudget)) + '\n...(truncated)';
8✔
262
            }
263
            const issueUrl = `${baseUrl}?title=${issueTitle}&body=${encodedPrefix}${encodeURIComponent(truncatedStack)}${encodedSuffix}`;
11✔
264

265
            output = [
11✔
266
                '',
267
                '================================================================',
268
                '\tBRIGHTSCRIPT DEBUGGER INTERNAL ERROR',
269
                '\tThis is a crash in the debug adapter, not in your application.',
270
                '================================================================',
271
                `\tError type: ${type}`,
272
                `\tMessage: ${message}`,
273
                ...lines.map(l => `\t${l}`),
77✔
274
                '',
275
                '\tStack trace:',
276
                ...(stack ?? '(no stack trace)').split('\n').map(l => `\t${l}`),
84✔
277
                '',
278
                '\tPlease report this at:',
279
                `\t${issueUrl}`,
280
                '================================================================',
281
                ''
282
            ].join('\n');
283
        } catch (e) {
284
            output = JSON.stringify({
4✔
285
                name: e.name,
286
                message: e.message,
287
                stack: e.stack
288
            });
289
        }
290

291
        void this.sendLogOutput(output).catch(() => { /** best-effort */ });
15✔
292
        this.isCrashed = true;
15✔
293
        this.sendEvent(new ProcessCrashEvent({ type, message, stack, additionalInfo: additionalInfo ?? {} }));
15✔
294
        setTimeout(() => void this.shutdown(), 5000).unref();
15✔
295
    }
296

297
    public teardownProcessErrorHandlers() {
298
        if (this._uncaughtExceptionHandler) {
32✔
299
            process.removeListener('uncaughtException', this._uncaughtExceptionHandler);
15✔
300
            this._uncaughtExceptionHandler = undefined;
15✔
301
        }
302
        if (this._unhandledRejectionHandler) {
32✔
303
            process.removeListener('unhandledRejection', this._unhandledRejectionHandler);
15✔
304
            this._unhandledRejectionHandler = undefined;
15✔
305
        }
306
        this.processErrorHandlersRegistered = false;
32✔
307
    }
308

309
    private onDeviceBreakpointsChanged(eventName: 'changed' | 'new', data: { breakpoints: AugmentedSourceBreakpoint[] }) {
310
        this.logger.info('Sending verified device breakpoints to client', data);
3✔
311
        //send all verified breakpoints to the client
312
        for (const breakpoint of data.breakpoints) {
3✔
313
            const event: DebugProtocol.Breakpoint = {
3✔
314
                line: breakpoint.line,
315
                column: breakpoint.column,
316
                verified: breakpoint.verified,
317
                id: breakpoint.id,
318
                reason: breakpoint.reason,
319
                message: breakpoint.message,
320
                source: {
321
                    path: breakpoint.srcPath
322
                }
323
            };
324
            this.sendEvent(new BreakpointEvent(eventName, event));
3✔
325
        }
326
    }
327

328
    public logger = logger.createLogger(`[session]`);
203✔
329

330
    private readonly isWindowsPlatform = process.platform.startsWith('win');
203✔
331

332
    /**
333
     * A sequence used to help identify log statements for requests
334
     */
335
    private idCounter = 1;
203✔
336

337
    public fileManager: FileManager;
338

339
    public projectManager: ProjectManager;
340

341
    public fileLoggingManager: FileLoggingManager;
342

343
    private processErrorHandlersRegistered = false;
203✔
344
    private isCrashed = false;
203✔
345
    /** Set once the client (e.g. VS Code) disconnects, so we stop writing to a now-dead stream (#486) */
346
    private clientDisconnected = false;
203✔
347
    /** How long to wait for a graceful shutdown before forcibly exiting the process (#486) */
348
    private shutdownForceExitTimeout = 10_000;
203✔
349
    private _uncaughtExceptionHandler: ((error: Error) => void) | undefined;
350
    private _unhandledRejectionHandler: ((reason: unknown) => void) | undefined;
351

352
    public breakpointManager: BreakpointManager;
353

354
    public locationManager: LocationManager;
355

356
    public sourceMapManager: SourceMapManager;
357

358
    //set imports as class properties so they can be spied upon during testing
359
    public rokuDeploy = rokuDeploy as unknown as RokuDeploy;
203✔
360

361
    private componentLibraryServer = new ComponentLibraryServer();
203✔
362

363
    private rokuAdapterDeferred = defer<DebugProtocolAdapter | TelnetAdapter>();
203✔
364
    /**
365
     * A promise that is resolved whenever the app has started running for the first time
366
     */
367
    private firstRunDeferred = defer<void>();
203✔
368

369
    /**
370
     * Resolved whenever we're finished copying all the files to staging for all projects
371
     */
372
    private stagingDefered = defer<void>();
203✔
373

374
    private evaluateRefIdLookup: Record<string, number> = {};
203✔
375
    private evaluateRefIdCounter = 1;
203✔
376

377
    private variables: Record<number, AugmentedVariable> = {};
203✔
378

379
    /**
380
     * Caches the device lookups performed while resolving completion requests. Variables don't change
381
     * while the debugger is paused, so this avoids repeated round-trips for the same path. Cleared by
382
     * `clearState` whenever the debugger resumes or steps.
383
     */
384
    private completionParentVariableCache = new Map<string, AugmentedVariable>();
203✔
385

386
    private rokuAdapter: DebugProtocolAdapter | TelnetAdapter;
387

388
    private perfettoManager: PerfettoManager;
389

390
    private rendezvousTracker: RendezvousTracker;
391

392
    public tempVarPrefix = '__rokudebug__';
203✔
393

394
    /**
395
     * The progressId of the active launch progress bar, if any.
396
     * Cleared once the ProgressEndEvent is sent.
397
     */
398
    private launchProgressId: string | undefined;
399

400
    /**
401
     * The first encountered compile error, will be used to send to the client as a runtime error (nicer UI presentation)
402
     */
403
    private compileError: BSDebugDiagnostic;
404

405
    /**
406
     * 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
407
     */
408
    private COMPILE_ERROR_THREAD_ID = 7_777;
203✔
409

410
    private get enableDebugProtocol() {
411
        return this.launchConfiguration?.enableDebugProtocol;
71!
412
    }
413

414
    /**
415
     * Check if the Roku firmware supports Perfetto tracing (requires OS 15.2 or higher)
416
     */
417
    private get supportsPerfettoTracing() {
418
        this.logger.log('Checking if device supports Perfetto tracing', this.deviceInfo.softwareVersion);
13✔
419
        return semver.satisfies(this.deviceInfo?.softwareVersion ?? '0.0', '>= 15.2');
13!
420
    }
421

422
    /**
423
     * Get a promise that resolves when the roku adapter is ready to be used
424
     */
425
    private async getRokuAdapter() {
426
        await this.rokuAdapterDeferred.promise;
26✔
427
        await this.rokuAdapter.onReady();
26✔
428
        return this.rokuAdapter;
26✔
429
    }
430

431
    private launchConfiguration: LaunchConfiguration;
432
    private initRequestArgs: DebugProtocol.InitializeRequestArguments;
433

434
    private exceptionBreakpoints: ExceptionBreakpoint[] = [];
203✔
435

436
    /**
437
     * The 'initialize' request is the first request called by the frontend
438
     * to interrogate the features the debug adapter provides.
439
     */
440
    public initializeRequest(response: DebugProtocol.InitializeResponse, args: DebugProtocol.InitializeRequestArguments): void {
441
        this.initRequestArgs = args;
2✔
442
        this.logger.log('initializeRequest');
2✔
443

444
        response.body ||= {};
2✔
445

446
        // This debug adapter implements the configurationDoneRequest.
447
        response.body.supportsConfigurationDoneRequest = true;
2✔
448

449
        // The debug adapter supports the 'restart' request. In this case a client should not implement 'restart' by terminating and relaunching the adapter but by calling the RestartRequest.
450
        response.body.supportsRestartRequest = true;
2✔
451

452
        // make VS Code to use 'evaluate' when hovering over source
453
        response.body.supportsEvaluateForHovers = true;
2✔
454

455
        //NOTE: `supportsConditionalBreakpoints` and `supportsHitConditionalBreakpoints` are
456
        //sent later in the post-connect CapabilitiesEvent once we know which adapter is in use
457
        //(telnet always supports them via stop-statement rewrites; debug protocol requires v3.1.0+).
458
        //VS Code reads these caps per-render in the BREAKPOINTS view, so CapabilitiesEvent updates
459
        //take effect immediately - unlike `exceptionBreakpointFilters` / `breakpointModes` which
460
        //must be in the initialize response.
461

462
        //surface the filter list here so VS Code's BREAKPOINTS panel renders the checkboxes - the
463
        //panel only reads this list from the initialize response, not from later CapabilitiesEvents.
464
        //The `supportsExceptionFilterOptions` / `supportsExceptionOptions` booleans are deferred and
465
        //sent via CapabilitiesEvent once we know the connected device's protocol version.
466
        response.body.exceptionBreakpointFilters = [{
2✔
467
            filter: 'caught',
468
            supportsCondition: true,
469
            conditionDescription: '__brs_err__.rethrown = true',
470
            label: 'Caught Exceptions',
471
            description: `Breaks on all errors, even if they're caught later.`,
472
            default: false
473
        }, {
474
            filter: 'uncaught',
475
            supportsCondition: true,
476
            conditionDescription: '__brs_err__.rethrown = true',
477
            label: 'Uncaught Exceptions',
478
            description: 'Breaks only on errors that are not handled.',
479
            default: true
480
        }];
481

482
        response.body.supportsCompletionsRequest = true;
2✔
483
        response.body.completionTriggerCharacters = ['.', '(', '{', ',', ' '];
2✔
484

485
        this.sendResponse(response);
2✔
486

487
        //register the debug output log transport writer
488
        debugServerLogOutputEventTransport.setWriter((message: LogMessage) => {
2✔
489
            this.sendEvent(
542✔
490
                new DebugServerLogOutputEvent(
491
                    message.logger.formatMessage(message, false)
492
                )
493
            );
494
        });
495

496
        this.logger.log('initializeRequest finished');
2✔
497
    }
498

499
    protected async setExceptionBreakPointsRequest(response: DebugProtocol.SetExceptionBreakpointsResponse, args: DebugProtocol.SetExceptionBreakpointsArguments) {
500
        response.body ??= {};
9!
501
        try {
9✔
502

503
            let filterOptions: ExceptionBreakpoint[];
504
            if (args.filterOptions) {
9✔
505
                filterOptions = args.filterOptions.map(x => ({
2✔
506
                    filter: x.filterId as 'caught' | 'uncaught',
507
                    conditionExpression: x.condition
508
                }));
509
            } else if (args.filters) {
7✔
510
                filterOptions = args.filters.map(x => ({
9✔
511
                    filter: x as 'caught' | 'uncaught'
512
                }));
513
            }
514
            this.exceptionBreakpoints = filterOptions;
9✔
515

516
            //wait until the adapter object exists, but don't wait for the device to come online —
517
            //VS Code will not send configurationDone (and we cannot launch the channel) until we
518
            //respond to this request.
519
            await this.rokuAdapterDeferred.promise;
9✔
520

521
            if (this.rokuAdapter.supportsExceptionBreakpoints) {
9✔
522
                //the adapter queues these filters internally if the debug protocol client hasn't
523
                //connected yet, and replays them once it does
524
                await this.rokuAdapter.setExceptionBreakpoints(filterOptions);
8✔
525
                response.body.breakpoints = [
7✔
526
                    { verified: true },
527
                    { verified: true }
528
                ];
529
            } else {
530
                response.body.breakpoints = [
1✔
531
                    { verified: false },
532
                    { verified: false }
533
                ];
534
            }
535
        } catch (e) {
536
            //if error (or not supported)
537
            response.body.breakpoints = [
1✔
538
                { verified: false },
539
                { verified: false }
540
            ];
541
            this.logger.error('Failed to set exception breakpoints', e);
1✔
542
        } finally {
543
            this.sendResponse(response);
9✔
544
        }
545
    }
546

547

548
    protected async setTransientsToInvalid() {
549
        let brsErr = Object.values(this.variables).find((v) => v.name === '__brs_err__');
×
550
        if (brsErr && brsErr.type !== VariableType.Uninitialized) {
×
551
            // Assigning the variable to the function call results in it becoming unintialized
552
            await this.rokuAdapter.evaluate(`__brs_err__ = [].clear()`, brsErr.frameId);
×
553
        }
554
    }
555

556
    private async showPopupMessage<T extends string>(message: string, severity: 'error' | 'warn' | 'info', modal = false, actions?: T[]): Promise<T> {
4✔
557
        const response = await this.sendCustomRequest('showPopupMessage', { message: message, severity: severity, modal: modal, actions: actions });
4✔
558
        return response.selectedAction;
2✔
559
    }
560

561
    private static requestIdSequence = 0;
2✔
562

563
    private async sendCustomRequest<T = any, R = any>(name: string, data: T): Promise<R> {
564
        const requestId = BrightScriptDebugSession.requestIdSequence++;
3✔
565
        const responsePromise = new Promise<R>((resolve, reject) => {
3✔
566
            this.on(ClientToServerCustomEventName.customRequestEventResponse, (response) => {
3✔
567
                if (response.requestId === requestId) {
1!
568
                    if (response.error) {
1!
569
                        throw response.error;
×
570
                    } else {
571
                        resolve(response as R);
1✔
572
                    }
573
                }
574
            });
575
        });
576
        this.sendEvent(
3✔
577
            new CustomRequestEvent({
578
                requestId: requestId,
579
                name: name,
580
                ...data ?? {}
9!
581
            }));
582
        return responsePromise;
3✔
583
    }
584

585
    /**
586
      * Get the cwd from the launchConfiguration, or default to process.cwd()
587
      */
588
    private get cwd() {
589
        return this.launchConfiguration?.cwd ?? process.cwd();
6!
590
    }
591

592
    public deviceInfo: DeviceInfo;
593

594
    /**
595
     * Set defaults and standardize values for all of the LaunchConfiguration values
596
     * @param config
597
     * @returns
598
     */
599
    private normalizeLaunchConfig(config: LaunchConfiguration) {
600
        config.cwd ??= process.cwd();
6✔
601
        config.outDir ??= s`${config.cwd}/out`;
6✔
602
        config.stagingDir ??= s`${config.outDir}/.roku-deploy-staging`;
6!
603
        config.componentLibrariesPort ??= 8080;
6!
604
        config.packagePort ??= 80;
6!
605
        config.remotePort ??= 8060;
6!
606
        config.sceneGraphDebugCommandsPort ??= 8080;
6!
607
        config.controlPort ??= 8081;
6!
608
        config.brightScriptConsolePort ??= 8085;
6!
609
        config.stagingDir ??= config.stagingFolderPath;
6!
610
        config.emitChannelPublishedEvent ??= true;
6!
611
        config.rewriteDevicePathsInLogs ??= true;
6!
612
        config.autoResolveVirtualVariables ??= false;
6!
613
        config.enhanceREPLCompletions ??= true;
6!
614
        config.username ??= 'rokudev';
6!
615
        if (config.profiling?.tracing?.enable) {
6!
616
            config.profiling.tracing.dir ??= s`${config.cwd}/traces/`;
×
617
            // eslint-disable-next-line no-template-curly-in-string
618
            config.profiling.tracing.filename ??= '${appTitle}_${timestamp}.perfetto-trace';
×
619
        }
620

621
        // migrate the old `enableVariablesPanel` setting to the new `deferScopeLoading` setting
622
        if (typeof config.enableVariablesPanel !== 'boolean') {
6!
623
            config.enableVariablesPanel = true;
6✔
624
        }
625
        config.deferScopeLoading ??= config.enableVariablesPanel === false;
6!
626
        return config;
6✔
627
    }
628

629
    public async launchRequest(response: DebugProtocol.LaunchResponse, config: LaunchConfiguration) {
630
        const logEnd = this.logger.timeStart('log', '[launchRequest] launch');
6✔
631

632
        try {
6✔
633
            this.resetSessionState();
6✔
634
            this.launchConfiguration = this.normalizeLaunchConfig(config);
6✔
635
            this.setupProcessErrorHandlers();
6✔
636

637
            //prebake some threads for our ProjectManager to use later on (1 for the main project, and 1 for every complib)
638
            bscProjectWorkerPool.preload(1 + (this.launchConfiguration?.componentLibraries?.length ?? 0));
6!
639

640
            //set the logLevel provided by the launch config
641
            if (this.launchConfiguration.logLevel) {
6!
642
                logger.logLevel = this.launchConfiguration.logLevel;
×
643
            }
644

645
            this.sendLaunchProgress('start', 'Finding device on network');
6✔
646

647
            //do a DNS lookup for the host to fix issues with roku rejecting ECP
648
            try {
6✔
649
                this.launchConfiguration.host = await util.dnsLookup(this.launchConfiguration.host);
6✔
650
            } catch (e) {
651
                return this.shutdown(`Could not resolve ip address for host '${this.launchConfiguration.host}'`);
×
652
            }
653

654
            // fetches the device info and parses the xml data to JSON object
655
            try {
6✔
656
                this.deviceInfo = await rokuDeploy.getDeviceInfo({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, enhance: true, timeout: 4_000 });
6✔
657
                if (this.deviceInfo.ecpSettingMode === 'limited') {
6!
658
                    return await this.shutdown(`ECP access is limited on this Roku. Please change it to 'permissive' or 'enabled' and try again. (device: ${this.launchConfiguration.host})`);
×
659
                }
660
            } catch (e) {
661
                if (e instanceof EcpNetworkAccessModeDisabledError) {
×
662
                    return this.shutdown(`ECP access is disabled on this Roku. Please change it to 'permissive' or 'enabled' and try again. (device: ${this.launchConfiguration.host})`);
×
663
                }
664
                return this.shutdown(`Unable to connect to roku at '${this.launchConfiguration.host}'. Verify the IP address is correct and that the device is powered on and connected to same network as this computer.`);
×
665
            }
666

667
            if (this.deviceInfo && !this.deviceInfo.developerEnabled) {
6!
668
                return await this.shutdown(`Developer mode is not enabled for host '${this.launchConfiguration.host}'.`);
×
669
            }
670

671
            // everything is ready, send the response to the launch request so the UI can update and configuration can begin
672
            this.sendResponse(response);
6✔
673

674
            //initialize all file logging (rokuDevice, debugger, etc)
675
            this.fileLoggingManager.activate(this.launchConfiguration?.fileLogging, this.cwd);
6!
676

677
            this.projectManager.launchConfiguration = this.launchConfiguration;
6✔
678
            this.breakpointManager.launchConfiguration = this.launchConfiguration;
6✔
679

680
            this.sendEvent(new LaunchStartEvent(this.launchConfiguration));
6✔
681

682
            this.logger.log('[launchRequest] Packaging and deploying to roku');
6✔
683
            const packageEnd = this.logger.timeStart('log', 'Packaging');
6✔
684
            this.sendLaunchProgress('update', `Packaging Project${(this.launchConfiguration?.componentLibraries?.length ?? 0) > 0 ? 's' : ''}`);
6!
685
            //build the main project and all component libraries at the same time
686
            await Promise.all([
6✔
687
                this.prepareMainProject(),
688
                this.prepareComponentLibraries(this.launchConfiguration.componentLibraries)
689
            ]);
690

691
            //all of the projects have been successfully staged.
692
            this.stagingDefered.tryResolve();
6✔
693

694
            //if the client supports it, let it process (inspect/modify) each project's staging dir before we package them
695
            if (this.launchConfiguration.clientCapabilities?.supportsProcessStagingDir) {
6!
696
                await this.sendCustomRequest('processStagingDir', {
×
697
                    projects: this.projectManager.getProjectStagingInfo()
698
                });
699
            }
700

701
            packageEnd();
6✔
702

703
            if (this.enableDebugProtocol) {
6!
704
                util.log(`Connecting to Roku via the BrightScript debug protocol at ${this.launchConfiguration.host}:${this.launchConfiguration.controlPort}`);
×
705
            } else {
706
                util.log(`Connecting to Roku via telnet at ${this.launchConfiguration.host}:${this.launchConfiguration.brightScriptConsolePort}`);
6✔
707
            }
708

709
            //activate rendezvous tracking (if enabled). Log the error and move on if it crashes, this shouldn't bring down the session.
710
            try {
6✔
711
                const rendezvousEnd = this.logger.timeStart('log', 'Rendezvous tracking');
6✔
712
                await this.initRendezvousTracking();
6✔
713
                rendezvousEnd();
6✔
714
            } catch (e) {
715
                this.logger.error('Failed to initialize rendezvous tracking', e);
×
716
            }
717

718
            this.sendLaunchProgress('update', 'Connecting to debug server');
6✔
719
            const connectAdapterEnd = this.logger.timeStart('log', 'Connect adapter');
6✔
720
            this.createRokuAdapter(this.rendezvousTracker);
6✔
721
            await this.connectRokuAdapter();
6✔
722
            connectAdapterEnd();
6✔
723

724
            // Capabilities that depend on the adapter or device version. The exception-breakpoint
725
            // FILTER LIST was surfaced in initializeRequest (VS Code only reads it from there).
726
            // Everything below is read per-action in VS Code, so a CapabilitiesEvent update takes
727
            // effect dynamically.
728
            const supportsExceptionBreakpoints = this.rokuAdapter.supportsExceptionBreakpoints;
6✔
729
            this.sendEvent(new CapabilitiesEvent({
6✔
730
                supportsLogPoints: !this.enableDebugProtocol,
731
                supportsExceptionFilterOptions: supportsExceptionBreakpoints,
732
                supportsExceptionOptions: supportsExceptionBreakpoints,
733
                supportsConditionalBreakpoints: this.rokuAdapter.supportsConditionalBreakpoints,
734
                supportsHitConditionalBreakpoints: this.rokuAdapter.supportsHitConditionalBreakpoints
735
            }));
736

737
            this.sendLaunchProgress('update', 'Configuring breakpoints');
6✔
738

739
            util.log('Done initializing');
6✔
740

741
            // notify VS Code that the adapter is ready to receive configuration (breakpoints, etc.)
742
            // VS Code will respond with setBreakpoints, setExceptionBreakpoints, then configurationDone
743
            this.sendEvent(new InitializedEvent());
6✔
744

745
            await this.initializeProfiling();
6✔
746

747
        } catch (e) {
748
            //if the message is anything other than compile errors, we want to display the error
749
            if (!(e instanceof CompileError)) {
×
750
                util.log('Encountered an issue during the launch process');
×
751
                util.log((e as Error)?.stack);
×
752

753
                //send any compile errors to the client
754
                await this.rokuAdapter?.sendErrors();
×
755

756
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
757
                await this.shutdown(message as string, true);
×
758
            } else {
759
                this.sendLaunchProgress('end', 'Aborted (compile error)');
×
760
            }
761
        }
762
        logEnd();
6✔
763
    }
764

765
    protected async configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse, args: DebugProtocol.ConfigurationDoneArguments) {
766
        this.logger.log('configurationDoneRequest');
3✔
767
        super.configurationDoneRequest(response, args);
3✔
768

769
        let error: Error;
770
        try {
3✔
771
            await this.runAutomaticSceneGraphCommands(this.launchConfiguration.autoRunSgDebugCommands);
3✔
772

773
            //press the home button to ensure we're at the home screen
774
            await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
3✔
775

776
            //pass the log level down thought the adapter to the RendezvousTracker and ChanperfTracker
777
            this.rokuAdapter.setConsoleOutput(this.launchConfiguration.consoleOutput);
3✔
778

779
            //pass along the console output
780
            if (this.launchConfiguration.consoleOutput === 'full') {
3!
781
                this.rokuAdapter.on('console-output', (data) => {
×
782
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
×
783
                });
784
            } else {
785
                this.rokuAdapter.on('unhandled-console-output', (data) => {
3✔
786
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
×
787
                });
788
            }
789

790
            this.rokuAdapter.on('device-unresponsive', async (data: { lastCommand: string }) => {
3✔
791
                const stopDebuggerAction = 'Stop Debugger';
×
792
                const message = `Roku device ${this.launchConfiguration.host} is not responding and may not recover.` +
×
793
                    (data.lastCommand ? `\n\nActive command:\n"${util.truncate(data.lastCommand, 30)}"` : '');
×
794
                this.logger.log(message, data);
×
795
                const response = await this.showPopupMessage(message, 'warn', false, [stopDebuggerAction]);
×
796
                if (response === stopDebuggerAction) {
×
797
                    await this.shutdown();
×
798
                }
799
            });
800

801
            // Send chanperf events to the extension
802
            this.rokuAdapter.on('chanperf', (output) => {
3✔
803
                this.sendEvent(new ChanperfEvent(output));
×
804
            });
805

806
            //listen for a closed connection (shut down when received)
807
            this.rokuAdapter.on('close', (reason = '') => {
3!
808
                if (reason === 'compileErrors') {
×
809
                    error = new Error('compileErrors');
×
810
                } else {
811
                    error = new Error('Unable to connect to Roku. Is another device already connected?');
×
812
                }
813
            });
814

815
            // handle any compile errors
816
            this.rokuAdapter.on('diagnostics', (diagnostics: BSDebugDiagnostic[]) => {
3✔
817
                this.handleDiagnostics(diagnostics).catch(e => this.logger.error('Failed to handle diagnostics', e));
×
818
            });
819

820
            // close disconnect if required when the app is exited
821
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
822
            this.rokuAdapter.on('app-exit', async () => {
3✔
823
                this.resetSessionState();
×
824

825
                if (this.launchConfiguration.stopDebuggerOnAppExit) {
×
826
                    let message = `App exit event detected and launchConfiguration.stopDebuggerOnAppExit is true`;
×
827
                    message += ' - shutting down debug session';
×
828

829
                    this.logger.log('on app-exit', message);
×
830
                    this.sendEvent(new LogOutputEvent(message));
×
831
                    await this.shutdown();
×
832
                } else {
833
                    const message = 'App exit detected; but launchConfiguration.stopDebuggerOnAppExit is set to false, so keeping debug session running.';
×
834
                    this.logger.log('[configurationDoneRequest]', message);
×
835
                    this.sendEvent(new LogOutputEvent(message));
×
836
                    this.rokuAdapter.once('connected').then(async () => {
×
837
                        await this.rokuAdapter.setExceptionBreakpoints(this.exceptionBreakpoints);
×
838
                    }).catch(e => this.logger.error('Failed to set exception breakpoints after reconnect', e));
×
839
                }
840
            });
841
            //profiling supports connecting to the socket BEFORE a channel is published, so go ahead and connect now
842
            await this.tryProfilingConnectOnStart();
3✔
843

844
            //all setBreakpoints requests have arrived by this point (configurationDone is the DAP signal
845
            //that the client has finished sending configuration). Inject the STOPs and seal zips now.
846
            await Promise.all([
3✔
847
                this.packageMainProject(),
848
                this.packageAndHostComponentLibraries(this.launchConfiguration.componentLibraries, this.launchConfiguration.componentLibrariesPort)
849
            ]);
850

851
            this.sendLaunchProgress('update', 'Uploading to Roku');
3✔
852
            await this.publish();
3✔
853

854
            //hack for certain roku devices that lock up when this event is emitted (no idea why!).
855
            if (this.launchConfiguration.emitChannelPublishedEvent) {
2!
856
                this.sendEvent(new ChannelPublishedEvent(
2✔
857
                    this.launchConfiguration
858
                ));
859
            }
860

861
            //tell the adapter adapter that the channel has been launched.
862
            this.sendLaunchProgress('update', 'Waiting on application');
2✔
863
            await this.rokuAdapter.activate();
2✔
864
            if (this.rokuAdapter.isDestroyed) {
2!
865
                throw new Error('Debug session encountered an error');
×
866
            }
867
            if (!error) {
2!
868
                if (this.rokuAdapter.connected) {
2!
869
                    this.logger.info('Host connection was established before the main public process was completed');
2✔
870
                    this.logger.log(`deployed to Roku@${this.launchConfiguration.host}`);
2✔
871
                } else {
872
                    this.logger.info('Main public process was completed but we are still waiting for a connection to the host');
×
873
                    this.rokuAdapter.on('connected', (status) => {
×
874
                        if (status) {
×
875
                            this.logger.log(`deployed to Roku@${this.launchConfiguration.host}`);
×
876
                        }
877
                    });
878
                }
879
            } else {
880
                throw error;
×
881
            }
882

883
            //at this point, the project has been deployed. If we need to use a deep link, launch it now.
884
            if (this.launchConfiguration.deepLinkUrl && !this.enableDebugProtocol) {
2!
885
                //wait until the first entry breakpoint has been hit
886
                await this.firstRunDeferred.promise;
×
887
                //if we are at a breakpoint, continue
888
                await this.rokuAdapter.continue();
×
889
                //kill the app on the roku
890
                // await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
891
                //convert a hostname to an ip address
892
                const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl);
×
893
                //send the deep link http request
894
                await util.httpPost(deepLinkUrl);
×
895
            }
896

897
        } catch (e) {
898
            //if the message is anything other than compile errors, we want to display the error
899
            if (!(e instanceof CompileError)) {
1!
900
                util.log('Encountered an issue during the publish process');
×
901
                util.log((e as Error)?.stack);
×
902

903
                //send any compile errors to the client
904
                await this.rokuAdapter?.sendErrors();
×
905

906
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
907
                await this.shutdown(message as string, true);
×
908
            } else {
909
                this.sendLaunchProgress('end', 'Aborted (compile error)');
1✔
910
            }
911
        }
912
    }
913

914
    /**
915
     * Activate all required functionality for profiling
916
     */
917
    private async initializeProfiling() {
918

919
        // Initialize PerfettoManager
920
        this.perfettoManager = new PerfettoManager({
15✔
921
            host: this.launchConfiguration.host,
922
            rootDir: this.launchConfiguration.rootDir,
923
            remotePort: this.launchConfiguration.remotePort,
924
            ...this.launchConfiguration.profiling?.tracing
45✔
925
        });
926

927
        //send certain profiling events back to the client
928
        this.perfettoManager.on('enable', (event) => {
15✔
929
            this.sendEvent(new ProfilingEnableEvent({
×
930
                types: event.types
931
            }));
932
        });
933
        this.perfettoManager.on('start', (event) => {
15✔
934
            this.sendEvent(new ProfilingStartEvent({
×
935
                type: event.type
936
            }));
937
        });
938
        this.perfettoManager.on('stop', (event) => {
15✔
939
            this.sendEvent(new ProfilingStopEvent({
×
940
                type: event.type,
941
                result: event.result
942
            }));
943
        });
944
        this.perfettoManager.on('error', (event) => {
15✔
945
            this.sendEvent(new ProfilingErrorEvent({
×
946
                error: event.error
947
            }));
948
        });
949

950
        //tracing is explicitly enabled. Turn it on
951
        if (this.launchConfiguration.profiling?.tracing?.enable && this.supportsPerfettoTracing) {
15✔
952
            this.logger.info('Enabling perfetto tracing because it is supported by the device and enabled in the launch configuration');
2✔
953
            try {
2✔
954
                await this.perfettoManager.enableTracing();
2✔
955
            } catch (e) {
956
                this.logger.error('Failed to enable perfetto tracing', e);
1✔
957
            }
958

959
            //tracing is expicitly DISabled. turn it off
960
        } else if (this.launchConfiguration.profiling?.tracing?.enable === false && this.supportsPerfettoTracing) {
13✔
961
            this.logger.info('Disabling perfetto tracing because it is disabled in the launch configuration');
2✔
962
            //TODO implement a way to disable perfetto tracing on the device
963

964
            //tracing was requested but the device firmware does not meet the minimum requirement
965
        } else if (this.launchConfiguration.profiling?.tracing?.enable && !this.supportsPerfettoTracing) {
11✔
966
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
2!
967
            const message = `Perfetto profiling is not available: device firmware ${firmwareVersion} is below the minimum required version (15.2). The Perfetto profiling buttons will not be available during this session.`;
2✔
968
            this.logger.warn(message);
2✔
969
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
2✔
970

971
            //profiling.tracing.enabled is set to `undefined`, which means we should do nothing
972
        } else {
973
            this.logger.info('Skipping perfetto initalization because `profiling.tracing.enable` is not defined in the launch configuration');
9✔
974
        }
975
    }
976

977
    /**
978
     * If profiling was marked "connectOnStart", try connecting right away
979
     */
980
    private async tryProfilingConnectOnStart() {
981
        if (this.launchConfiguration.profiling?.tracing?.connectOnStart && this.supportsPerfettoTracing) {
8✔
982
            try {
2✔
983
                await this.perfettoManager.startTracing();
2✔
984
            } catch (e) {
985
                this.logger.error('Failed to start perfetto tracing on start', e);
1✔
986
            }
987
        } else if (this.launchConfiguration.profiling?.tracing?.connectOnStart && !this.supportsPerfettoTracing) {
6✔
988
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
1!
989
            const message = `Perfetto profiling is not available: device firmware ${firmwareVersion} is below the minimum required version (15.2). Tracing will not start automatically.`;
1✔
990
            this.logger.warn(message);
1✔
991
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
1✔
992
        }
993
    }
994

995
    /**
996
     * Clear certain properties that need reset whenever a debug session is restarted (via vscode or launched from the Roku home screen)
997
     */
998
    private resetSessionState() {
999
        // launchRequest gets invoked by our restart session flow.
1000
        // We need to clear/reset some state to avoid issues.
1001
        this.entryBreakpointWasHandled = false;
7✔
1002
        //reset all per-session breakpoint state (diff baseline + cached parsed ASTs) since a restart
1003
        //re-stages the project
1004
        this.breakpointManager.reset();
7✔
1005
    }
1006

1007
    /**
1008
     * Activate rendezvous tracking (IF enabled in the LaunchConfig)
1009
     */
1010
    public async initRendezvousTracking() {
1011
        const timeout = 5000;
3✔
1012
        let initCompleted = false;
3✔
1013
        await Promise.race([
3✔
1014
            util.sleep(timeout),
1015
            this._initRendezvousTracking().finally(() => {
1016
                initCompleted = true;
3✔
1017
            })
1018
        ]);
1019

1020
        if (initCompleted === false) {
3!
1021
            this.showPopupMessage(`Rendezvous tracking timed out after ${timeout}ms. Consider setting "rendezvousTracking": false in launch.json`, 'warn').catch((error) => {
×
1022
                this.logger.error('Error showing popup message', { error });
×
1023
            });
1024
        }
1025
    }
1026

1027
    private async _initRendezvousTracking() {
1028
        this.rendezvousTracker = new RendezvousTracker(this.deviceInfo, this.launchConfiguration);
3✔
1029

1030
        //pass the debug functions used to locate the client files and lines thought the adapter to the RendezvousTracker
1031
        this.rendezvousTracker.registerSourceLocator(async (debuggerPath: string, lineNumber: number) => {
3✔
1032
            return this.projectManager.getSourceLocation(debuggerPath, lineNumber);
×
1033
        });
1034

1035
        // Send rendezvous events to the debug protocol client
1036
        this.rendezvousTracker.on('rendezvous', (output) => {
3✔
1037
            this.sendEvent(new RendezvousEvent(output));
1✔
1038
        });
1039

1040
        //clear the history so the user doesn't have leftover rendezvous data from a previous session
1041
        this.rendezvousTracker.clearHistory();
3✔
1042

1043
        //if rendezvous tracking is enabled, then enable it on the device
1044
        if (this.launchConfiguration.rendezvousTracking !== false) {
3✔
1045
            // start ECP rendezvous tracking (if possible)
1046
            await this.rendezvousTracker.activate();
2✔
1047
        }
1048
    }
1049

1050
    /**
1051
     * Anytime a roku adapter emits diagnostics, this method is called to handle it.
1052
     */
1053
    private async handleDiagnostics(diagnostics: BSDebugDiagnostic[]) {
1054
        // Roku device and sourcemap work with 1-based line numbers, VSCode expects 0-based lines.
1055
        for (let diagnostic of diagnostics) {
2✔
1056
            diagnostic.source = diagnosticSource;
2✔
1057
            let sourceLocation = await this.projectManager.getSourceLocation(diagnostic.path, diagnostic.range.start.line + 1);
2✔
1058
            if (sourceLocation) {
2✔
1059
                diagnostic.path = sourceLocation.filePath;
1✔
1060
                diagnostic.range.start.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1✔
1061
                diagnostic.range.end.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1✔
1062
            } else {
1063
                // TODO: may need to add a custom event if the source location could not be found by the ProjectManager
1064
                diagnostic.path = fileUtils.removeLeadingSlash(util.removeFileScheme(diagnostic.path));
1✔
1065
            }
1066
        }
1067

1068
        //find the first compile error (i.e. first DiagnosticSeverity.Error) if there is one
1069
        this.compileError = diagnostics.find(x => x.severity === DiagnosticSeverity.Error);
2✔
1070
        if (this.compileError) {
2✔
1071
            this.sendLaunchProgress('end', 'Aborted (compile error)');
1✔
1072
            this.sendEvent(new StoppedEvent(
1✔
1073
                StoppedEventReason.exception,
1074
                this.COMPILE_ERROR_THREAD_ID,
1075
                `CompileError: ${this.compileError.message}`
1076
            ));
1077
        }
1078

1079
        this.sendEvent(new DiagnosticsEvent(diagnostics));
2✔
1080
    }
1081

1082
    private publishTimeout = 60_000;
203✔
1083

1084
    private async publish() {
1085
        const uploadingEnd = this.logger.timeStart('log', 'Uploading zip');
2✔
1086
        let packageIsPublished = false;
2✔
1087

1088
        //delete any currently installed dev channel (if enabled to do so)
1089
        try {
2✔
1090
            if (this.launchConfiguration.deleteDevChannelBeforeInstall === true) {
2!
1091
                await this.rokuDeploy.deleteInstalledChannel({
×
1092
                    ...this.launchConfiguration
1093
                } as any as RokuDeployOptions);
1094
            }
1095
        } catch (e) {
1096
            const statusCode = e?.results?.response?.statusCode;
×
1097
            const message = e.message as string;
×
1098
            if (statusCode === 401) {
×
1099
                await this.shutdown(message, true);
×
1100
                throw e;
×
1101
            }
1102
            this.logger.warn('Failed to delete the dev channel...probably not a big deal', e);
×
1103
        }
1104

1105
        const isConnected = this.rokuAdapter.once('app-ready');
2✔
1106
        const options: RokuDeployOptions = {
2✔
1107
            ...this.launchConfiguration,
1108
            //typing fix
1109
            logLevel: LogLevelPriority[this.logger.logLevel],
1110
            // enable the debug protocol if true
1111
            remoteDebug: this.enableDebugProtocol,
1112
            //necessary for capturing compile errors from the protocol (has no effect on telnet)
1113
            remoteDebugConnectEarly: false,
1114
            //we don't want to fail if there were compile errors...we'll let our compile error processor handle that
1115
            failOnCompileError: true,
1116
            //pass any upload form overrides the client may have configured
1117
            packageUploadOverrides: this.launchConfiguration.packageUploadOverrides
1118
        };
1119
        //if packagePath is specified, use that info instead of outDir and outFile
1120
        if (this.launchConfiguration.packagePath) {
2✔
1121
            options.outDir = path.dirname(this.launchConfiguration.packagePath);
1✔
1122
            options.outFile = path.basename(this.launchConfiguration.packagePath);
1✔
1123
        }
1124

1125
        //publish the package to the target Roku
1126
        const publishPromise = this.rokuDeploy.publish(options).then(() => {
2✔
1127
            packageIsPublished = true;
2✔
1128
        }).catch(async (e) => {
1129
            const statusCode = e?.results?.response?.statusCode;
×
1130
            const message = e.message as string;
×
1131
            if ((statusCode && statusCode !== 200) || isUpdateCheckRequiredError(e) || isConnectionResetError(e)) {
×
1132
                await this.shutdown(message, true);
×
1133
                throw e;
×
1134
            }
1135
            this.logger.error(e);
×
1136
        });
1137

1138
        await publishPromise;
2✔
1139

1140
        uploadingEnd();
2✔
1141

1142
        //the channel has been deployed. Wait for the adapter to finish connecting.
1143
        //if it hasn't connected after 60 seconds, abort the launch.
1144
        let didTimeOut = false;
2✔
1145
        await Promise.race([
2✔
1146
            isConnected,
1147
            util.sleep(this.publishTimeout).then(() => {
1148
                didTimeOut = true;
2✔
1149
            })
1150
        ]);
1151
        this.logger.log('Finished racing promises');
2✔
1152
        if (didTimeOut) {
2✔
1153
            this.logger.warn('Timed out waiting for roku to connect');
1✔
1154
        }
1155
        //if the adapter is still not connected, then it will probably never connect. Abort.
1156
        if (packageIsPublished && !this.rokuAdapter.connected) {
2✔
1157
            return this.shutdown('Debug session cancelled: failed to connect to debug protocol control port.');
1✔
1158
        }
1159
    }
1160

1161
    private pendingSendLogPromise = Promise.resolve();
203✔
1162

1163
    /**
1164
     * Send log output to the "client" (i.e. vscode)
1165
     * @param logOutput
1166
     */
1167
    private sendLogOutput(logOutput: string) {
1168
        if (this.isCrashed) {
38!
1169
            return Promise.resolve();
×
1170
        }
1171
        this.fileLoggingManager.writeRokuDeviceLog(logOutput);
38✔
1172

1173
        this.pendingSendLogPromise = this.pendingSendLogPromise.then(async () => {
38✔
1174
            logOutput = await this.convertBacktracePaths(logOutput);
38✔
1175

1176
            const lines = logOutput.split(/\r?\n/g);
38✔
1177
            for (let i = 0; i < lines.length; i++) {
38✔
1178
                let line = lines[i];
264✔
1179
                if (i < lines.length - 1) {
264✔
1180
                    line += '\n';
226✔
1181
                }
1182

1183
                if (this.launchConfiguration.rewriteDevicePathsInLogs) {
264✔
1184
                    let potentialPaths = this.getPotentialPkgPaths(line);
91✔
1185
                    for (let potentialPath of potentialPaths) {
91✔
1186
                        let originalLocation = await this.projectManager.getSourceLocation(potentialPath.path, potentialPath.lineNumber, potentialPath.columnNumber);
28✔
1187
                        if (originalLocation) {
28✔
1188
                            let replacement: string;
1189
                            replacement = originalLocation.filePath.replaceAll(' ', '%20');
26✔
1190
                            if (replacement !== originalLocation.filePath) {
26✔
1191
                                if (this.isWindowsPlatform) {
6✔
1192
                                    replacement = `vscode://file/${replacement}`;
3✔
1193
                                } else {
1194
                                    replacement = `file://${replacement}`;
3✔
1195
                                }
1196
                            }
1197
                            replacement += `:${originalLocation.lineNumber}`;
26✔
1198
                            if (potentialPath.columnNumber !== undefined) {
26✔
1199
                                replacement += `:${originalLocation.columnIndex + 1}`;
10✔
1200
                            }
1201

1202
                            line = line.replaceAll(potentialPath.fullMatch, replacement);
26✔
1203
                        }
1204
                    }
1205
                }
1206
                this.sendEvent(new OutputEvent(line, 'stdout'));
264✔
1207
                this.sendEvent(new LogOutputEvent(line));
264✔
1208
            }
1209
        });
1210
        return this.pendingSendLogPromise;
38✔
1211
    }
1212

1213
    /**
1214
     * Extracts potential package paths from a given line of text.
1215
     *
1216
     * This method uses a regular expression to find matches in the provided line
1217
     * and returns an array of objects containing details about each match.
1218
     *
1219
     * @param input - The line of text to search for potential package paths.
1220
     * @returns An array of objects, each containing:
1221
     *   - `fullMatch`: The full matched string.
1222
     *   - `path`: The extracted path from the match.
1223
     *   - `lineNumber`: The line number extracted from the match.
1224
     *   - `columnNumber`: The column number extracted from the match, or `undefined` if not found.
1225
     */
1226
    private getPotentialPkgPaths(input: string): Array<{ fullMatch: string; path: string; lineNumber: number; columnNumber: number }> {
1227
        // https://regex101.com/r/ixpQiq/1
1228
        let matches = input.matchAll(/((?:\.\.\.|[A-Za-z_0-9]*pkg\:\/)[A-Za-z_0-9 \/\.]+\.[A-Za-z_0-9 \/]+)(?:(?:\:)(\d+)(?:\:(\d+))?|\((\d+)(?:\:(\d+))?\))/ig);
91✔
1229
        let paths: ReturnType<BrightScriptDebugSession['getPotentialPkgPaths']> = [];
91✔
1230
        if (matches) {
91!
1231
            for (let match of matches) {
91✔
1232
                let fullMatch = match[0];
28✔
1233
                let path = match[1];
28✔
1234
                let lineNumber = parseInt(match[2] ?? match[4]);
28✔
1235
                let columnNumber = parseInt(match[3] ?? match[5]);
28✔
1236
                if (isNaN(columnNumber)) {
28✔
1237
                    columnNumber = undefined;
17✔
1238
                }
1239
                paths.push({
28✔
1240
                    fullMatch: fullMatch,
1241
                    path: path,
1242
                    lineNumber: lineNumber,
1243
                    columnNumber: columnNumber
1244
                });
1245
            }
1246
        }
1247
        return paths;
91✔
1248
    }
1249

1250
    /**
1251
     * Converts the filename property in backtrace objects in the given input string to source paths if found
1252
     */
1253
    private async convertBacktracePaths(input: string) {
1254
        if (!this.launchConfiguration.rewriteDevicePathsInLogs) {
38✔
1255
            return input;
8✔
1256
        }
1257
        // Why does this not work? It should work, but it doesn't. I'm not sure why.
1258
        // let matches = input.matchAll(this.deviceBacktraceObjectRegex);
1259

1260
        // https://regex101.com/r/y1koaV/2
1261
        let deviceBacktraceObjectRegex = /{\s+filename:\s+"([A-Za-z0-9_\.\/\: ]+)"\s+function\:\s+".+"\s+(line_number\:\s+(\d+))\s+}/gi;
30✔
1262
        let matches = [];
30✔
1263
        let match = deviceBacktraceObjectRegex.exec(input);
30✔
1264
        while (match) {
30✔
1265
            matches.push(match);
5✔
1266
            match = deviceBacktraceObjectRegex.exec(input);
5✔
1267
        }
1268

1269
        if (matches) {
30!
1270
            for (let match of matches) {
30✔
1271
                let fullMatch = match[0] as string;
5✔
1272
                let filePath = match[1] as string;
5✔
1273
                let fullLineNumber = match[2] as string;
5✔
1274
                let lineNumber = parseInt(match[3] as string);
5✔
1275
                let originalLocation = await this.projectManager.getSourceLocation(filePath, lineNumber);
5✔
1276
                if (originalLocation) {
5✔
1277
                    let fileReplacement: string;
1278
                    fileReplacement = originalLocation.filePath.replaceAll(' ', '%20');
4✔
1279
                    if (fileReplacement !== originalLocation.filePath) {
4✔
1280
                        if (this.isWindowsPlatform) {
2✔
1281
                            fileReplacement = `vscode://file/${fileReplacement}`;
1✔
1282
                        } else {
1283
                            fileReplacement = `file://${fileReplacement}`;
1✔
1284
                        }
1285
                    }
1286
                    fileReplacement += `:${originalLocation.lineNumber}`;
4✔
1287

1288
                    let lineNumberReplacement = fullLineNumber.replace(lineNumber.toString(), originalLocation.lineNumber.toString());
4✔
1289

1290
                    // replace the full backtrace object with the an updated version so we don't modify other parts of the log output that might contain the same file path
1291
                    let completeReplacement = fullMatch.replace(filePath, fileReplacement);
4✔
1292
                    completeReplacement = completeReplacement.replace(fullLineNumber, lineNumberReplacement);
4✔
1293
                    input = input.replaceAll(fullMatch, completeReplacement);
4✔
1294
                }
1295

1296
            }
1297
        }
1298

1299
        return input;
30✔
1300
    }
1301

1302
    private async runAutomaticSceneGraphCommands(commands: string[]) {
1303
        if (commands) {
1!
1304
            let connection = new SceneGraphDebugCommandController(this.launchConfiguration.host, this.launchConfiguration.sceneGraphDebugCommandsPort);
×
1305

1306
            try {
×
1307
                await connection.connect();
×
1308
                for (let command of this.launchConfiguration.autoRunSgDebugCommands) {
×
1309
                    let response: SceneGraphCommandResponse;
1310
                    switch (command) {
×
1311
                        case 'chanperf':
1312
                            util.log('Enabling Chanperf Tracking');
×
1313
                            response = await connection.chanperf({ interval: 1 });
×
1314
                            if (!response.error) {
×
1315
                                util.log(response.result.rawResponse);
×
1316
                            }
1317
                            break;
×
1318

1319
                        case 'fpsdisplay':
1320
                            util.log('Enabling FPS Display');
×
1321
                            response = await connection.fpsDisplay('on');
×
1322
                            if (!response.error) {
×
1323
                                util.log(response.result.data as string);
×
1324
                            }
1325
                            break;
×
1326

1327
                        case 'logrendezvous':
1328
                            util.log('Enabling Rendezvous Logging:');
×
1329
                            response = await connection.logrendezvous('on');
×
1330
                            if (!response.error) {
×
1331
                                util.log(response.result.rawResponse);
×
1332
                            }
1333
                            break;
×
1334

1335
                        default:
1336
                            util.log(`Running custom SceneGraph debug command on port 8080 '${command}':`);
×
1337
                            response = await connection.exec(command);
×
1338
                            if (!response.error) {
×
1339
                                util.log(response.result.rawResponse);
×
1340
                            }
1341
                            break;
×
1342
                    }
1343
                }
1344
                await connection.end();
×
1345
            } catch (error) {
1346
                util.log(`Error connecting to port 8080: ${error.message}`);
×
1347
            }
1348
        }
1349
    }
1350

1351
    /**
1352
     * Stage, insert breakpoints, and package the main project
1353
     */
1354
    public async prepareMainProject() {
1355
        //add the main project
1356
        this.projectManager.mainProject = new Project({
2✔
1357
            rootDir: this.launchConfiguration.rootDir,
1358
            files: this.launchConfiguration.files,
1359
            outDir: this.launchConfiguration.outDir,
1360
            sourceDirs: this.launchConfiguration.sourceDirs,
1361
            bsConst: this.launchConfiguration.bsConst,
1362
            injectRaleTrackerTask: this.launchConfiguration.injectRaleTrackerTask,
1363
            raleTrackerTaskFileLocation: this.launchConfiguration.raleTrackerTaskFileLocation,
1364
            injectRdbOnDeviceComponent: this.launchConfiguration.injectRdbOnDeviceComponent,
1365
            rdbFilesBasePath: this.launchConfiguration.rdbFilesBasePath,
1366
            stagingDir: this.launchConfiguration.stagingDir,
1367
            packagePath: this.launchConfiguration.packagePath,
1368
            enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1369
        });
1370

1371
        util.log('Moving selected files to staging area');
2✔
1372
        await this.projectManager.mainProject.stage();
2✔
1373

1374
        //add the entry breakpoint if stopOnEntry is true
1375
        await this.handleEntryBreakpoint();
2✔
1376
    }
1377

1378
    /**
1379
     * Inject breakpoint STOP statements into the staged main project and create the zip package.
1380
     * Runs after the DAP `InitializedEvent` so client-side `setBreakpoints` requests have landed
1381
     * before any STOPs are written to the staged .brs files (telnet path) and before the zip is sealed.
1382
     */
1383
    private async packageMainProject() {
1384
        //add breakpoint lines to source files and then publish
1385
        util.log('Adding stop statements for active breakpoints');
2✔
1386

1387
        //validate breakpoints for all debugger types (and write `stop` statements for telnet — decided internally)
1388
        await this.breakpointManager.validateAndWriteBreakpointsForProject(this.projectManager.mainProject);
2✔
1389

1390
        if (this.launchConfiguration.packageTask) {
2✔
1391
            util.log(`Executing task '${this.launchConfiguration.packageTask}' to assemble the app`);
1✔
1392
            await this.sendCustomRequest('executeTask', { task: this.launchConfiguration.packageTask });
1✔
1393

1394
            const options = {
1✔
1395
                ...this.launchConfiguration
1396
            } as any as RokuDeployOptions;
1397
            //if packagePath is specified, use that info instead of outDir and outFile
1398
            if (this.launchConfiguration.packagePath) {
1!
1399
                options.outDir = path.dirname(this.launchConfiguration.packagePath);
1✔
1400
                options.outFile = path.basename(this.launchConfiguration.packagePath);
1✔
1401
            }
1402
            const packagePath = this.launchConfiguration.packagePath ?? rokuDeploy.getOutputZipFilePath(options);
1!
1403

1404
            if (!fsExtra.pathExistsSync(packagePath as string)) {
1!
1405
                return this.shutdown(`Cancelling debug session. Package does not exist at '${packagePath}'`);
×
1406
            }
1407
        } else {
1408
            //create zip package from staging folder
1409
            util.log('Creating zip archive from project sources');
1✔
1410
            await this.projectManager.mainProject.zipPackage({ retainStagingFolder: true });
1✔
1411
        }
1412
    }
1413

1414
    /**
1415
     * Accepts custom events and requests from the extension
1416
     * @param command name of the command to execute
1417
     */
1418
    protected async customRequest(command: string, response: DebugProtocol.Response, args: any) {
1419
        if (command === 'rendezvous.clearHistory') {
×
1420
            this.rokuAdapter.clearRendezvousHistory();
×
1421
        } else if (command === 'chanperf.clearHistory') {
×
1422
            this.rokuAdapter.clearChanperfHistory();
×
1423

1424
        } else if (command === 'customRequestEventResponse') {
×
1425
            this.emit('customRequestEventResponse', args);
×
1426

1427
        } else if (command === 'popupMessageEventResponse') {
×
1428
            this.emit('popupMessageEventResponse', args);
×
1429

1430
        } else if (command === 'captureHeapSnapshot') {
×
1431
            this.perfettoManager.captureHeapSnapshot().catch((e) => this.logger.error('Failed to capture heap snapshot', e));
×
1432

1433
        } else if (command === 'startPerfettoTracing') {
×
1434
            try {
×
1435
                await this.perfettoManager.startTracing();
×
1436
            } catch (e) {
1437
                response.success = false;
×
1438
                response.body = { message: e?.message || String(e) };
×
1439
            }
1440

1441
        } else if (command === 'stopPerfettoTracing') {
×
1442
            try {
×
1443
                await this.perfettoManager.stopTracing();
×
1444
            } catch (e) {
1445
                response.success = false;
×
1446
                response.body = { message: e?.message || String(e) };
×
1447
            }
1448

1449
        }
1450
        this.sendResponse(response);
×
1451
    }
1452

1453
    /**
1454
     * Stores the path to the staging folder for each component library
1455
     */
1456
    protected async prepareComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1457
        if (!componentLibraries || componentLibraries.length === 0) {
11✔
1458
            return;
2✔
1459
        }
1460
        let componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
9✔
1461
        //make sure this folder exists (and is empty)
1462
        await fsExtra.ensureDir(componentLibrariesOutDir);
9✔
1463
        await fsExtra.emptyDir(componentLibrariesOutDir);
9✔
1464

1465
        //create a ComponentLibraryProject for each component library
1466
        for (let libraryIndex = 0; libraryIndex < componentLibraries.length; libraryIndex++) {
9✔
1467
            let componentLibrary = componentLibraries[libraryIndex];
19✔
1468

1469
            this.projectManager.componentLibraryProjects.push(
19✔
1470
                new ComponentLibraryProject({
1471
                    rootDir: componentLibrary.rootDir,
1472
                    files: componentLibrary.files,
1473
                    outDir: componentLibrariesOutDir,
1474
                    outFile: componentLibrary.outFile,
1475
                    sourceDirs: componentLibrary.sourceDirs,
1476
                    bsConst: componentLibrary.bsConst,
1477
                    install: componentLibrary.install,
1478
                    enablePostfix: componentLibrary.enablePostfix,
1479
                    injectRaleTrackerTask: componentLibrary.injectRaleTrackerTask,
1480
                    raleTrackerTaskFileLocation: componentLibrary.raleTrackerTaskFileLocation,
1481
                    libraryIndex: libraryIndex,
1482
                    enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1483
                })
1484
            );
1485
        }
1486

1487
        //stage all of the libraries in parallel
1488
        await Promise.all(
9✔
1489
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.stage())
19✔
1490
        );
1491
    }
1492

1493
    /**
1494
     * Inject breakpoint STOPs into the staged complibs, seal their zips, install installable ones,
1495
     * and start static file hosting. Runs in `configurationDoneRequest` (after `InitializedEvent`)
1496
     * so client-side `setBreakpoints` requests have landed before the staged .brs files are written
1497
     * and the zips are sealed.
1498
     */
1499
    protected async packageAndHostComponentLibraries(componentLibraries: ComponentLibraryConfiguration[], port: number) {
1500
        if (!componentLibraries || componentLibraries.length === 0) {
10✔
1501
            return;
2✔
1502
        }
1503
        const componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
8✔
1504

1505
        // Add breakpoint lines to the staging files and before publishing
1506
        util.log('Adding stop statements for active breakpoints in Component Libraries');
8✔
1507

1508
        //validate breakpoints (and write STOPs for telnet), postfix, and zip each complib in parallel — each targets distinct files
1509
        const packagePromises = this.projectManager.componentLibraryProjects.map(async (compLibProject) => {
8✔
1510
            await this.breakpointManager.validateAndWriteBreakpointsForProject(compLibProject);
18✔
1511
            await compLibProject.postfixFiles();
18✔
1512
            await compLibProject.zipPackage({ retainStagingFolder: true });
18✔
1513
        });
1514

1515
        const needToDeleteComplibs = this.projectManager.componentLibraryProjects.some(x => x.install);
9✔
1516
        if (needToDeleteComplibs) {
8✔
1517
            await rokuDeploy.deleteAllComponentLibraries({
7✔
1518
                host: this.launchConfiguration.host,
1519
                password: this.launchConfiguration.password,
1520
                username: this.launchConfiguration.username || 'rokudev'
14✔
1521
            });
1522
        }
1523

1524
        for (let i = 0; i < this.projectManager.componentLibraryProjects.length; i++) {
8✔
1525
            const compLibProject = this.projectManager.componentLibraryProjects[i];
18✔
1526

1527
            if (compLibProject.install === true) {
18✔
1528
                //wait for this complib to finish being packaged
1529
                await packagePromises[i];
12✔
1530

1531
                if (componentLibraries[i].packageTask) {
12✔
1532
                    await this.sendCustomRequest('executeTask', { task: componentLibraries[i].packageTask });
2✔
1533
                }
1534

1535
                const options: RokuDeployOptions = {
12✔
1536
                    host: this.launchConfiguration.host,
1537
                    password: this.launchConfiguration.password,
1538
                    username: this.launchConfiguration.username || 'rokudev',
24✔
1539
                    logLevel: LogLevelPriority[this.logger.logLevel],
1540
                    failOnCompileError: true,
1541
                    outDir: compLibProject.outDir,
1542
                    outFile: compLibProject.outFile,
1543
                    appType: 'dcl',
1544
                    packageUploadOverrides: componentLibraries[i].packageUploadOverrides || {}
22✔
1545
                };
1546

1547
                if (componentLibraries[i].packagePath) {
12✔
1548
                    options.outDir = path.dirname(componentLibraries[i].packagePath);
2✔
1549
                    options.outFile = path.basename(componentLibraries[i].packagePath);
2✔
1550
                }
1551

1552
                try {
12✔
1553
                    await rokuDeploy.publish(options);
12✔
1554
                } catch (error) {
1555
                    this.logger.error(`Error installing component library ${i}`, error);
4✔
1556
                }
1557
            }
1558
        }
1559

1560
        const hostingPromise = this.componentLibraryServer.startStaticFileHosting(componentLibrariesOutDir, port, (message: string) => {
8✔
1561
            util.log(message);
×
1562
        });
1563

1564
        //wait for all complib packaging to finish and the file hosting to start
1565
        await Promise.all([
8✔
1566
            ...packagePromises,
1567
            hostingPromise
1568
        ]);
1569
    }
1570

1571
    protected sourceRequest(response: DebugProtocol.SourceResponse, args: DebugProtocol.SourceArguments) {
1572
        this.logger.log('sourceRequest');
×
1573
        let old = this.sendResponse;
×
1574
        this.sendResponse = function sendResponse(...args) {
×
1575
            old.apply(this, args);
×
1576
            this.sendResponse = old;
×
1577
        };
1578
        super.sourceRequest(response, args);
×
1579
    }
1580

1581
    /**
1582
     * Called every time a breakpoint is created, modified, or deleted, for each file. This receives the entire list of breakpoints every time.
1583
     */
1584
    public async setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments) {
1585
        this.logger.log('setBreakpointsRequest', args);
6✔
1586
        let sanitizedBreakpoints = this.breakpointManager.replaceBreakpoints(args.source.path, args.breakpoints);
6✔
1587
        //sort the breakpoints
1588
        let sortedAndFilteredBreakpoints = orderBy(sanitizedBreakpoints, [x => x.line, x => x.column]);
6✔
1589

1590
        response.body = {
6✔
1591
            breakpoints: sortedAndFilteredBreakpoints
1592
        };
1593
        this.sendResponse(response);
6✔
1594

1595
        //ensure we've staged all the files
1596
        await this.stagingDefered.promise;
6✔
1597

1598
        await this.rokuAdapter?.syncBreakpoints();
6!
1599
    }
1600

1601
    protected exceptionInfoRequest(response: DebugProtocol.ExceptionInfoResponse, args: DebugProtocol.ExceptionInfoArguments) {
1602
        this.logger.log('exceptionInfoRequest');
×
1603
    }
1604

1605
    protected async threadsRequest(response: DebugProtocol.ThreadsResponse) {
1606
        this.logger.log('threadsRequest');
4✔
1607

1608
        let threads = [];
4✔
1609

1610
        //This is a bit of a hack. If there's a compile error, send a thread to represent it so we can show the compile error like a runtime exception
1611
        if (this.compileError) {
4!
1612
            threads.push(new Thread(this.COMPILE_ERROR_THREAD_ID, 'Compile Error'));
×
1613
        } else {
1614
            //wait for the roku adapter to load
1615
            await this.getRokuAdapter();
4✔
1616

1617
            //only send the threads request if we are at the debugger prompt
1618
            if (this.rokuAdapter.isAtDebuggerPrompt) {
4✔
1619
                let rokuThreads = await this.rokuAdapter.getThreads();
3✔
1620

1621
                for (let thread of rokuThreads) {
3✔
1622
                    const threadName = this.getThreadName(thread as AdapterThread);
4✔
1623
                    threads.push(
4✔
1624
                        new Thread(thread.threadId, threadName)
1625
                    );
1626
                }
1627

1628
                if (threads.length === 0) {
3!
1629
                    threads = [{
×
1630
                        id: 1001,
1631
                        name: 'unable to retrieve threads: not stopped',
1632
                        isFake: true
1633
                    }];
1634
                }
1635

1636
            } else {
1637
                this.logger.log('Skipped getting threads because the RokuAdapter is not accepting input at this time.');
1✔
1638
            }
1639

1640
        }
1641

1642
        response.body = {
4✔
1643
            threads: threads
1644
        };
1645

1646
        this.sendResponse(response);
4✔
1647
    }
1648

1649
    /**
1650
     * Get the thread name to display in the UI based on the thread info we have.
1651
     * This is what displays in the `call stack` region in vscode
1652
     * @param thread
1653
     * @returns
1654
     */
1655
    private getThreadName(thread: AdapterThread) {
1656
        let threadName = '';
24✔
1657
        if (thread.type || thread.name || thread.osThreadId) {
24✔
1658
            //build the name from only the parts that are present, so missing values don't leak into the name
1659
            const parts: string[] = [];
13✔
1660
            if (thread.type) {
13✔
1661
                parts.push(`[${thread.type}]`);
10✔
1662
            }
1663
            if (thread.name) {
13✔
1664
                parts.push(thread.name);
9✔
1665
            }
1666
            if (thread.osThreadId) {
13✔
1667
                parts.push(thread.osThreadId);
9✔
1668
            }
1669
            threadName = parts.join(' ');
13✔
1670
        }
1671
        //remove any extraneous whitespace to deal with missing values
1672
        threadName = threadName.replace(/\s+/g, ' ').trim();
24✔
1673

1674
        if (threadName === '') {
24✔
1675
            threadName = `Thread ${thread.threadId}`;
11✔
1676
        }
1677

1678
        if (thread.isDetached) {
24✔
1679
            threadName += ' [detached]';
4✔
1680
        }
1681

1682
        //remove any extraneous whitespace to deal with missing values
1683
        threadName = threadName.replace(/\s+/g, ' ').trim();
24✔
1684

1685
        return threadName;
24✔
1686
    }
1687

1688
    protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) {
1689
        try {
3✔
1690
            this.logger.log('stackTraceRequest');
3✔
1691
            let frames: DebugProtocol.StackFrame[] = [];
3✔
1692

1693
            //this is a bit of a hack. If there's a compile error, send a full stack frame so we can show the compile error like a runtime crash
1694
            if (this.compileError) {
3!
1695
                frames.push(new StackFrame(
×
1696
                    0,
1697
                    'Compile Error',
1698
                    new Source(path.basename(this.compileError.path), this.compileError.path),
1699
                    // range is 0-based; toClientLine/toClientColumn handle client coordinate conversion
1700
                    this.toClientLine(this.compileError.range.start.line),
1701
                    this.toClientColumn(this.compileError.range.start.character)
1702
                ));
1703
            } else if (args.threadId === 1001) {
3!
1704
                frames.push(new StackFrame(
×
1705
                    0,
1706
                    'ERROR: threads would not stop',
1707
                    new Source('main.brs', s`${this.launchConfiguration.stagingDir}/manifest`),
1708
                    this.toClientLine(0),
1709
                    this.toClientColumn(0)
1710
                ));
1711
                this.showPopupMessage('Unable to suspend threads. Debugger is in an unstable state, please press Continue to resume debugging', 'warn').catch((error) => {
×
1712
                    this.logger.error('Error showing popup message', { error });
×
1713
                });
1714
            } else {
1715
                //ensure the rokuAdapter is loaded
1716
                await this.getRokuAdapter();
3✔
1717

1718
                if (this.rokuAdapter.isAtDebuggerPrompt) {
3!
1719
                    let stackTrace = await this.rokuAdapter.getStackTrace(args.threadId);
3✔
1720
                    if (stackTrace.length === 0) {
3✔
1721
                        // Thread is detached or encountered an error requesting Stack Trace — show a non-interactive label so VS Code can display
1722
                        // the thread without letting the user navigate to a source location
1723
                        const frame = new StackFrame(0, '[unavailable]');
2✔
1724
                        frame.presentationHint = 'label';
2✔
1725
                        frames.push(frame);
2✔
1726
                    } else {
1727
                        for (let debugFrame of stackTrace) {
1✔
1728
                            let sourceLocation = await this.projectManager.getSourceLocation(debugFrame.filePath, debugFrame.lineNumber);
3✔
1729

1730
                            //the stacktrace returns function identifiers in all lower case. Try to get the actual case
1731
                            //load the contents of the file and get the correct casing for the function identifier
1732
                            try {
3✔
1733
                                let functionName = this.fileManager.getCorrectFunctionNameCase(sourceLocation?.filePath, debugFrame.functionIdentifier);
3!
1734
                                if (functionName) {
3!
1735

1736
                                    //search for original function name if this is an anonymous function.
1737
                                    //anonymous function names are prefixed with $ in the stack trace (i.e. $anon_1 or $functionname_40002)
1738
                                    if (functionName.startsWith('$')) {
3!
1739
                                        functionName = this.fileManager.getFunctionNameAtPosition(
×
1740
                                            sourceLocation.filePath,
1741
                                            sourceLocation.lineNumber - 1,
1742
                                            functionName
1743
                                        );
1744
                                    }
1745
                                    debugFrame.functionIdentifier = functionName;
3✔
1746
                                }
1747
                            } catch (error) {
1748
                                this.logger.error('Error correcting function identifier case', { error, sourceLocation, debugFrame });
×
1749
                            }
1750
                            const filePath = sourceLocation?.filePath ?? debugFrame.filePath;
3!
1751

1752
                            const frame: DebugProtocol.StackFrame = new StackFrame(
3✔
1753
                                debugFrame.frameId,
1754
                                `${debugFrame.functionIdentifier}`,
1755
                                new Source(path.basename(filePath), filePath),
1756
                                // lineNumber is 1-based from Roku; toClientLine expects 0-based
1757
                                this.toClientLine((sourceLocation?.lineNumber ?? debugFrame.lineNumber) - 1),
18!
1758
                                this.toClientColumn(0)
1759
                            );
1760
                            if (!sourceLocation) {
3!
1761
                                frame.presentationHint = 'subtle';
×
1762
                            }
1763
                            frames.push(frame);
3✔
1764
                        }
1765
                    }
1766
                } else {
1767
                    this.logger.log('Skipped calculating stacktrace because the RokuAdapter is not accepting input at this time');
×
1768
                }
1769
            }
1770
            response.body = {
3✔
1771
                stackFrames: frames,
1772
                totalFrames: frames.length
1773
            };
1774
            this.sendResponse(response);
3✔
1775
        } catch (error) {
1776
            this.logger.error('Error getting stacktrace', { error, args });
×
1777
        }
1778
    }
1779

1780
    protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) {
1781
        const logger = this.logger.createLogger(`scopesRequest ${this.idCounter}`);
1✔
1782
        logger.info('begin', { args });
1✔
1783
        try {
1✔
1784
            const scopes = new Array<DebugProtocol.Scope>();
1✔
1785

1786
            // create the locals scope
1787
            let v = this.getOrCreateLocalsScope(args.frameId);
1✔
1788

1789
            let localScope: DebugProtocol.Scope = {
1✔
1790
                name: 'Local',
1791
                variablesReference: v.variablesReference,
1792
                // Flag the locals scope as expensive if the client asked that it be loaded lazily
1793
                expensive: this.launchConfiguration.deferScopeLoading,
1794
                presentationHint: 'locals'
1795
            };
1796

1797
            const frame = this.rokuAdapter.getStackFrameById(args.frameId);
1✔
1798
            if (frame) {
×
1799
                const scopeRange = await this.projectManager.getScopeRange(frame.filePath, { line: frame.lineNumber - 1, character: 0 });
×
1800

1801
                if (scopeRange) {
×
1802
                    localScope.line = this.toClientLine(scopeRange.start.line - 1);
×
1803
                    localScope.column = this.toClientColumn(scopeRange.start.column);
×
1804
                    localScope.endLine = this.toClientLine(scopeRange.end.line - 1);
×
1805
                    localScope.endColumn = this.toClientColumn(scopeRange.end.column);
×
1806
                }
1807
            }
1808

1809
            scopes.push(localScope);
×
1810

1811
            // create the registry scope
1812
            let registryRefId = this.getEvaluateRefId('$$registry', Infinity);
×
1813
            scopes.push(<DebugProtocol.Scope>{
×
1814
                name: 'Registry',
1815
                variablesReference: registryRefId,
1816
                expensive: true
1817
            });
1818

1819
            this.variables[registryRefId] = {
×
1820
                variablesReference: registryRefId,
1821
                name: 'Registry',
1822
                value: '',
1823
                type: '$$Registry',
1824
                isScope: true,
1825
                childVariables: []
1826
            };
1827

1828
            response.body = {
×
1829
                scopes: scopes
1830
            };
1831
            logger.debug('send response', { response });
×
1832
            this.sendResponse(response);
×
1833
            logger.info('end');
×
1834
        } catch (error) {
1835
            logger.error('Error getting scopes', { error, args });
1✔
1836
        }
1837
    }
1838

1839
    /**
1840
     * Get the locals scope container for a frame, creating an (unpopulated) one if it doesn't exist yet.
1841
     * The child variables are filled in lazily by `populateScopeVariables`.
1842
     */
1843
    private getOrCreateLocalsScope(frameId: number): AugmentedVariable {
1844
        const refId = this.getEvaluateRefId('$$locals', frameId);
5✔
1845
        if (!this.variables[refId]) {
5✔
1846
            this.variables[refId] = {
1✔
1847
                variablesReference: refId,
1848
                name: 'Locals',
1849
                value: '',
1850
                type: '$$Locals',
1851
                frameId: frameId,
1852
                isScope: true,
1853
                childVariables: []
1854
            };
1855
        }
1856
        return this.variables[refId];
5✔
1857
    }
1858

1859
    protected async continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments) {
1860
        //if we have a compile error, we should shut down
1861
        if (this.compileError) {
×
1862
            this.sendResponse(response);
×
1863
            await this.shutdown();
×
1864
            return;
×
1865
        }
1866

1867
        this.logger.log('continueRequest');
×
1868
        await this.setTransientsToInvalid(); // call before clearState
×
1869
        this.clearState();
×
1870

1871
        // The debug session ends after the next line. Do not put new work after this line.
1872
        await this.rokuAdapter.continue();
×
1873
        this.sendResponse(response);
×
1874
    }
1875

1876
    protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
1877
        this.logger.log('pauseRequest');
×
1878

1879
        //if we have a compile error, we should shut down
1880
        if (this.compileError) {
×
1881
            this.sendResponse(response);
×
1882
            await this.shutdown();
×
1883
            return;
×
1884
        }
1885

1886
        await this.rokuAdapter.pause();
×
1887
        this.sendResponse(response);
×
1888
    }
1889

1890
    protected reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse, args: DebugProtocol.ReverseContinueArguments) {
1891
        this.logger.log('reverseContinueRequest');
×
1892
        this.sendResponse(response);
×
1893
    }
1894

1895
    /**
1896
     * Clicked the "Step Over" button
1897
     * @param response
1898
     * @param args
1899
     */
1900
    protected async nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) {
1901
        this.logger.log('[nextRequest] begin');
×
1902

1903
        //if we have a compile error, we should shut down
1904
        if (this.compileError) {
×
1905
            this.sendResponse(response);
×
1906
            await this.shutdown();
×
1907
            return;
×
1908
        }
1909

1910
        await this.setTransientsToInvalid(); // call before clearState
×
1911
        this.clearState();
×
1912

1913
        // The debug session ends after the next line. Do not put new work after this line.
1914
        try {
×
1915
            await this.rokuAdapter.stepOver(args.threadId);
×
1916
            this.logger.info('[nextRequest] end');
×
1917
        } catch (error) {
1918
            this.logger.error(`[nextRequest] Error running '${BrightScriptDebugSession.prototype.nextRequest.name}()'`, error);
×
1919
        }
1920
        this.sendResponse(response);
×
1921
    }
1922

1923
    protected async stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
1924
        this.logger.log('[stepInRequest]');
×
1925

1926
        //if we have a compile error, we should shut down
1927
        if (this.compileError) {
×
1928
            this.sendResponse(response);
×
1929
            await this.shutdown();
×
1930
            return;
×
1931
        }
1932

1933
        await this.setTransientsToInvalid(); // call before clearState
×
1934
        this.clearState();
×
1935
        // The debug session ends after the next line. Do not put new work after this line.
1936
        await this.rokuAdapter.stepInto(args.threadId);
×
1937
        this.sendResponse(response);
×
1938
        this.logger.info('[stepInRequest] end');
×
1939
    }
1940

1941
    protected async stepOutRequest(response: DebugProtocol.StepOutResponse, args: DebugProtocol.StepOutArguments) {
1942
        this.logger.log('[stepOutRequest] begin');
×
1943

1944
        //if we have a compile error, we should shut down
1945
        if (this.compileError) {
×
1946
            this.sendResponse(response);
×
1947
            await this.shutdown();
×
1948
            return;
×
1949
        }
1950

1951
        await this.setTransientsToInvalid(); // call before clearState
×
1952
        this.clearState();
×
1953

1954
        // The debug session ends after the next line. Do not put new work after this line.
1955
        await this.rokuAdapter.stepOut(args.threadId);
×
1956
        this.sendResponse(response);
×
1957
        this.logger.info('[stepOutRequest] end');
×
1958
    }
1959

1960
    protected stepBackRequest(response: DebugProtocol.StepBackResponse, args: DebugProtocol.StepBackArguments) {
1961
        this.logger.log('[stepBackRequest] begin');
×
1962
        this.sendResponse(response);
×
1963
        this.logger.info('[stepBackRequest] end');
×
1964
    }
1965

1966
    public async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) {
1967
        const logger = this.logger.createLogger('[variablesRequest]');
4✔
1968
        let sendInvalidatedEvent = false;
4✔
1969
        let frameId: number = null;
4✔
1970
        try {
4✔
1971
            logger.log('begin', { args });
4✔
1972

1973
            //ensure the rokuAdapter is loaded
1974
            await this.getRokuAdapter();
4✔
1975

1976
            let updatedVariables: AugmentedVariable[] = [];
4✔
1977
            //wait for any `evaluate` commands to finish so we have a higher likely hood of being at a debugger prompt
1978
            await this.evaluateRequestPromise;
4✔
1979
            if (this.rokuAdapter?.isAtDebuggerPrompt !== true) {
4!
1980
                logger.log('Skipped getting variables because the RokuAdapter is not accepting input at this time');
×
1981
                response.success = false;
×
1982
                response.message = 'Debug session is not paused';
×
1983
                return this.sendResponse(response);
×
1984
            }
1985

1986
            //find the variable with this reference
1987
            let v = this.variables[args.variablesReference];
4✔
1988
            if (!v) {
4!
1989
                response.success = false;
×
1990
                response.message = `Variable reference has expired`;
×
1991
                return this.sendResponse(response);
×
1992
            }
1993
            logger.log('variable', v);
4✔
1994

1995
            // Populate scope level values if needed
1996
            if (v.isScope) {
4✔
1997
                await this.populateScopeVariables(v, args);
2✔
1998
            }
1999

2000
            //query for child vars if we haven't done it yet or DAP is asking to resolve a lazy variable
2001
            if (v.childVariables.length === 0 || v.isResolved) {
4!
2002
                let tempVar: AugmentedVariable;
2003
                if (!v.isResolved) {
×
2004
                    // Evaluate the variable
2005
                    try {
×
2006
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: v.evaluateName, frameId: v.frameId }, util.getVariablePath(v.evaluateName));
×
2007
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, v.frameId);
×
2008
                        tempVar = await this.getVariableFromResult(result, v.frameId);
×
2009
                        tempVar.frameId = v.frameId;
×
2010
                        // Determine if the variable has changed
2011
                        sendInvalidatedEvent = v.type !== tempVar.type || v.indexedVariables !== tempVar.indexedVariables;
×
2012
                    } catch (error) {
2013
                        logger.error('Error getting variables', error);
×
2014
                        tempVar = new Variable('Error', `❌ Error: ${error.message}`);
×
2015
                        tempVar.type = '';
×
2016
                        tempVar.childVariables = [];
×
2017
                        sendInvalidatedEvent = true;
×
2018
                        response.success = false;
×
2019
                        response.message = error.message;
×
2020
                    }
2021

2022
                    // Merge the resulting updates together
2023
                    v.childVariables = tempVar.childVariables;
×
2024
                    v.value = tempVar.value;
×
2025
                    v.type = tempVar.type;
×
2026
                    v.indexedVariables = tempVar.indexedVariables;
×
2027
                    v.namedVariables = tempVar.namedVariables;
×
2028
                }
2029
                frameId = v.frameId;
×
2030

2031
                if (v?.presentationHint?.lazy || v.isResolved) {
×
2032
                    // If this was a lazy variable we need to respond with the updated variable and not the children
2033
                    if (v.isResolved && v.childVariables.length > 0) {
×
2034
                        updatedVariables = v.childVariables;
×
2035
                    } else {
2036
                        updatedVariables = [v];
×
2037
                    }
2038
                    v.isResolved = true;
×
2039
                } else {
2040
                    updatedVariables = v.childVariables;
×
2041
                }
2042

2043
                // If the variable has no children, set the reference to 0
2044
                // so it does not look expandable in the Ui
2045
                if (v.childVariables.length === 0) {
×
2046
                    v.variablesReference = 0;
×
2047
                }
2048

2049
                // If the variable was resolve in the past we may not have fetched a new temp var
2050
                tempVar ??= v;
×
2051
                if (v?.presentationHint) {
×
2052
                    v.presentationHint.lazy = tempVar.presentationHint?.lazy;
×
2053
                } else {
2054
                    v.presentationHint = tempVar.presentationHint;
×
2055
                }
2056

2057
            } else {
2058
                updatedVariables = v.childVariables;
4✔
2059
            }
2060

2061
            // Only send the updated variables if we are not going to trigger an invalidated event.
2062
            // This is to prevent the UI from updating twice and makes the experience much smoother to the end user.
2063
            response.body = {
4✔
2064
                variables: this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2065
                // TODO: Re-enable this when we can send the correct variables based on the initial inspect context
2066
                // variables: sendInvalidatedEvent ? [] : this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2067
            };
2068
        } catch (error) {
2069
            logger.error('Error during variablesRequest', error, { args });
×
2070
            response.success = false;
×
2071
            response.message = error?.message ?? 'Error during variablesRequest';
×
2072
        } finally {
2073
            logger.info('end', { response });
4✔
2074
        }
2075
        this.sendResponse(response);
4✔
2076
        if (sendInvalidatedEvent) {
4!
2077
            this.debounceSendInvalidatedEvent(null, frameId);
×
2078
        }
2079
    }
2080

2081
    private debounceSendInvalidatedEvent = debounce((threadId: number, frameId: number) => {
203✔
2082
        this.sendInvalidatedEvent(threadId, frameId);
×
2083
    }, 50);
2084

2085

2086
    private filterVariablesUpdates(updatedVariables: Array<AugmentedVariable>, args: DebugProtocol.VariablesArguments, v: DebugProtocol.Variable): Array<AugmentedVariable> {
2087
        if (!updatedVariables || !v) {
4!
2088
            return [];
×
2089
        }
2090

2091
        let start = args.start ?? 0;
4!
2092

2093
        //if the variable is an array, send only the requested range
2094
        if (Array.isArray(updatedVariables) && args.filter === 'indexed') {
4!
2095
            //only send the variable range requested by the debugger
2096
            if (!args.count) {
×
2097
                updatedVariables = updatedVariables.slice(0, v.indexedVariables);
×
2098
            } else {
2099
                updatedVariables = updatedVariables.slice(start, start + args.count);
×
2100
            }
2101
        }
2102

2103
        if (Array.isArray(updatedVariables) && args.filter === 'named') {
4!
2104
            // We currently do not support named variable paging so we always send all named variables
2105
            updatedVariables = updatedVariables.slice(v.indexedVariables);
4✔
2106
        }
2107

2108
        let filteredUpdatedVariables = this.launchConfiguration.showHiddenVariables !== true ? updatedVariables.filter(
4✔
2109
            (child: AugmentedVariable) => !child.name.startsWith(this.tempVarPrefix)) : updatedVariables;
6✔
2110

2111
        if (this.launchConfiguration.showHiddenVariables !== true) {
4✔
2112
            filteredUpdatedVariables = filteredUpdatedVariables.filter((child: AugmentedVariable) => {
2✔
2113
                //A transient variable that we show when there is a value
2114
                if (child.name === '__brs_err__' && child.type !== VariableType.Uninitialized) {
4!
2115
                    return true;
×
2116
                } else if (util.isTransientVariable(child.name)) {
4!
2117
                    return false;
×
2118
                } else {
2119
                    return true;
4✔
2120
                }
2121
            });
2122
        }
2123

2124
        return filteredUpdatedVariables;
4✔
2125
    }
2126

2127
    /**
2128
     * Takes a scope variable and populates its child variables based on the scope type and the current adapter type.
2129
     * @param v scope variable to populate
2130
     * @param args
2131
     */
2132
    private async populateScopeVariables(v: AugmentedVariable, args: DebugProtocol.VariablesArguments) {
2133
        if (v.childVariables.length > 0) {
4✔
2134
            // Already populated
2135
            return;
3✔
2136
        }
2137

2138
        let tempVar: AugmentedVariable;
2139
        try {
1✔
2140
            if (v.type === '$$Locals') {
1!
2141
                if (this.rokuAdapter.isDebugProtocolAdapter()) {
1!
2142
                    let result = await this.rokuAdapter.getLocalVariables(v.frameId);
1✔
2143
                    tempVar = await this.getVariableFromResult(result, v.frameId);
1✔
2144
                } else if (this.rokuAdapter.isTelnetAdapter()) {
×
2145
                    // NOTE: Legacy telnet support
2146
                    let variables: AugmentedVariable[] = [];
×
2147
                    const varNames = await this.rokuAdapter.getScopeVariables();
×
2148

2149
                    // Fetch each variable individually
2150
                    for (const varName of varNames) {
×
2151
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: varName, frameId: -1 }, util.getVariablePath(varName));
×
2152
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, -1);
×
2153
                        let tempLocalsVar = await this.getVariableFromResult(result, -1);
×
2154
                        variables.push(tempLocalsVar);
×
2155
                    }
2156
                    tempVar = {
×
2157
                        ...v,
2158
                        childVariables: variables,
2159
                        namedVariables: variables.length,
2160
                        indexedVariables: 0
2161
                    };
2162
                }
2163

2164
                // Merge the resulting updates together onto the original variable
2165
                v.childVariables = tempVar.childVariables;
1✔
2166
                v.namedVariables = tempVar.namedVariables;
1✔
2167
                v.indexedVariables = tempVar.indexedVariables;
1✔
2168
            } else if (v.type === '$$Registry') {
×
2169
                // This is a special scope variable used to load registry data via an ECP call
2170
                // Send the registry ECP call for the `dev` app as side loaded apps are always `dev`
2171
                await populateVariableFromRegistryEcp({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, appId: 'dev' }, v, this.variables, this.getEvaluateRefId.bind(this));
×
2172
            }
2173
        } catch (error) {
2174
            logger.error(`Error getting variables for scope ${v.type}`, error);
×
2175
            tempVar = {
×
2176
                name: '',
2177
                value: `❌ Error: ${error.message}`,
2178
                variablesReference: 0,
2179
                childVariables: []
2180
            };
2181
            v.childVariables = [tempVar];
×
2182
            v.namedVariables = 1;
×
2183
            v.indexedVariables = 0;
×
2184
        }
2185

2186
        // Mark the scope as resolved so we don't re-fetch the variables
2187
        v.isResolved = true;
1✔
2188

2189
        // If the scope has no children, add a single child to indicate there are no values
2190
        if (v.childVariables.length === 0) {
1!
2191
            tempVar = {
×
2192
                name: '',
2193
                value: `No values for scope '${v.name}'`,
2194
                variablesReference: 0,
2195
                childVariables: []
2196
            };
2197
            v.childVariables = [tempVar];
×
2198
            v.namedVariables = 1;
×
2199
            v.indexedVariables = 0;
×
2200
        }
2201
    }
2202

2203
    private evaluateRequestPromise = Promise.resolve();
203✔
2204
    private evaluateVarIndexByFrameId = new Map<number, number>();
203✔
2205

2206
    private getNextVarIndex(frameId: number): number {
2207
        if (!this.evaluateVarIndexByFrameId.has(frameId)) {
6✔
2208
            this.evaluateVarIndexByFrameId.set(frameId, 0);
5✔
2209
        }
2210
        let value = this.evaluateVarIndexByFrameId.get(frameId);
6✔
2211
        this.evaluateVarIndexByFrameId.set(frameId, value + 1);
6✔
2212
        return value;
6✔
2213
    }
2214

2215
    public async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) {
2216
        //ensure the rokuAdapter is loaded
2217
        await this.getRokuAdapter();
15✔
2218

2219
        let deferred = defer<void>();
15✔
2220
        if (args.context === 'repl' && this.rokuAdapter.isTelnetAdapter() && args.expression.trim().startsWith('>')) {
15!
2221
            this.clearState();
×
2222
            this.rokuAdapter.clearCache();
×
2223
            const expression = args.expression.replace(/^\s*>\s*/, '');
×
2224
            this.logger.log('Sending raw telnet command...I sure hope you know what you\'re doing', { expression });
×
2225
            this.rokuAdapter.requestPipeline.client.write(`${expression}\r\n`);
×
2226
            this.sendResponse(response);
×
2227
            return deferred.promise;
×
2228
        }
2229

2230
        try {
15✔
2231
            this.evaluateRequestPromise = this.evaluateRequestPromise.then(() => {
15✔
2232
                return deferred.promise;
15✔
2233
            });
2234

2235
            //fix vscode hover bug that excludes closing quotemark sometimes.
2236
            if (args.context === 'hover') {
15✔
2237
                args.expression = util.ensureClosingQuote(args.expression);
4✔
2238
            }
2239

2240
            if (!this.rokuAdapter.isAtDebuggerPrompt) {
15✔
2241
                let message = 'Skipped evaluate request because RokuAdapter is not accepting requests at this time';
1✔
2242
                if (args.context === 'repl') {
1!
2243
                    this.sendEvent(new OutputEvent(message, 'stderr'));
1✔
2244
                    response.body = {
1✔
2245
                        result: 'invalid',
2246
                        variablesReference: 0
2247
                    };
2248
                } else {
2249
                    throw new Error(message);
×
2250
                }
2251

2252
                //is at debugger prompt
2253
            } else if (args.expression.trim()) {
14!
2254
                // We trim and check that the expression is not an empty string so that we do not send empty expressions to the Roku
2255
                // This happens mostly when hovering over leading whitespace in the editor
2256

2257
                let { evalArgs, variablePath } = await this.evaluateExpressionToTempVar(args, util.getVariablePath(args.expression));
14✔
2258

2259
                //if we found a variable path (e.g. ['a', 'b', 'c']) then do a variable lookup because it's faster and more widely supported than `evaluate`
2260
                if (variablePath) {
14✔
2261
                    let refId = this.getEvaluateRefId(evalArgs.expression, evalArgs.frameId);
11✔
2262
                    let v: AugmentedVariable;
2263
                    //if we already looked this item up, return it
2264
                    if (this.variables[refId]) {
11✔
2265
                        v = this.variables[refId];
1✔
2266
                    } else {
2267
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, evalArgs.frameId);
10✔
2268
                        if (!result) {
10!
2269
                            throw new Error('Error: unable to evaluate expression');
×
2270
                        }
2271

2272
                        v = await this.getVariableFromResult(result, evalArgs.frameId);
10✔
2273
                        //TODO - testing something, remove later
2274
                        // eslint-disable-next-line camelcase
2275
                        v.request_seq = response.request_seq;
10✔
2276
                        v.frameId = evalArgs.frameId;
10✔
2277
                    }
2278
                    response.body = {
11✔
2279
                        result: v.value,
2280
                        type: v.type,
2281
                        variablesReference: v.variablesReference,
2282
                        namedVariables: v.namedVariables || 0,
21✔
2283
                        indexedVariables: v.indexedVariables || 0
21✔
2284
                    };
2285

2286
                    //run an `evaluate` call
2287
                } else {
2288
                    let commandResults = await this.rokuAdapter.evaluate(evalArgs.expression, evalArgs.frameId);
3✔
2289

2290
                    commandResults.message = util.trimDebugPrompt(commandResults.message);
3✔
2291
                    if (args.context === 'repl') {
3!
2292
                        // Clear variable cache since this action could have side-effects
2293
                        // Only do this for REPL requests as hovers and watches should not clear the cache
2294
                        this.clearState();
3✔
2295
                        this.sendInvalidatedEvent(null, evalArgs.frameId);
3✔
2296
                    }
2297

2298
                    // If the adapter captured output (probably only telnet), log the results
2299
                    if (typeof commandResults.message === 'string') {
3✔
2300
                        this.logger.debug('evaluateRequest', { commandResults });
1✔
2301
                        if (args.context === 'repl') {
1!
2302
                            // If the command was a repl command, send the output to the debug console for the developer as well
2303
                            // We limit this to repl only so you don't get extra logs when hovering over variables ro running watches
2304
                            this.sendEvent(new OutputEvent(commandResults.message, commandResults.type === 'error' ? 'stderr' : 'stdio'));
1!
2305
                        }
2306
                    }
2307

2308
                    if (this.enableDebugProtocol || (typeof commandResults.message !== 'string')) {
3✔
2309
                        response.body = {
2✔
2310
                            result: 'invalid',
2311
                            variablesReference: 0
2312
                        };
2313
                    } else {
2314
                        response.body = {
1✔
2315
                            result: commandResults.message === '\r\n' ? 'invalid' : commandResults.message,
1!
2316
                            variablesReference: 0
2317
                        };
2318
                    }
2319
                }
2320
            }
2321
        } catch (error) {
2322
            this.logger.error('Error during variables request', error);
×
2323
            response.success = false;
×
2324
            response.message = error?.message ?? error;
×
2325
        }
2326
        try {
15✔
2327
            this.sendResponse(response);
15✔
2328
        } catch { }
2329
        deferred.resolve();
15✔
2330
    }
2331

2332
    private async evaluateExpressionToTempVar(args: DebugProtocol.EvaluateArguments, variablePath: string[]): Promise<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }> {
2333
        let returnVal = { evalArgs: args, variablePath };
19✔
2334
        if (!variablePath && util.isAssignableExpression(args.expression)) {
19✔
2335
            let varIndex = this.getNextVarIndex(args.frameId);
6✔
2336
            let arrayVarName = this.tempVarPrefix + 'eval';
6✔
2337
            let command = '';
6✔
2338
            if (varIndex === 0) {
6✔
2339
                await this.rokuAdapter.evaluate(`if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`, args.frameId);
5✔
2340
            }
2341
            let statement = `${arrayVarName}[${varIndex}] = ${args.expression}`;
6✔
2342
            returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
6✔
2343
            command += statement;
6✔
2344
            let commandResults = await this.rokuAdapter.evaluate(command, args.frameId);
6✔
2345
            if (commandResults.type === 'error') {
6!
2346
                throw new Error(commandResults.message);
×
2347
            }
2348
            returnVal.variablePath = [arrayVarName, varIndex.toString()];
6✔
2349
        }
2350
        return returnVal;
19✔
2351
    }
2352

2353
    private async bulkEvaluateExpressionToTempVar(frameId: number, argsArray: Array<DebugProtocol.EvaluateArguments>, variablePathArray: Array<string[]>): Promise<{ evaluations: Array<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }>; bulkVarName: string }> {
2354
        let results = {
×
2355
            evaluations: [],
2356
            bulkVarName: ''
2357
        };
2358
        let storedVariables = [];
×
2359
        let command = '';
×
2360
        for (let i = 0; i < argsArray.length; i++) {
×
2361
            let args = argsArray[i];
×
2362
            let variablePath = variablePathArray[i];
×
2363
            let returnVal = { evalArgs: args, variablePath };
×
2364
            if (!variablePath && util.isAssignableExpression(args.expression)) {
×
2365
                let varIndex = this.getNextVarIndex(frameId);
×
2366
                let arrayVarName = this.tempVarPrefix + 'eval';
×
2367
                if (varIndex === 0) {
×
2368
                    command += `if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`;
×
2369
                }
2370
                let statement = `${arrayVarName}[${varIndex}] = ${args.expression}\n`;
×
2371
                returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
×
2372
                command += statement;
×
2373

2374
                storedVariables.push(`${arrayVarName}[${varIndex}]`);
×
2375
                returnVal.variablePath = [arrayVarName, varIndex.toString()];
×
2376
            }
2377

2378
            results.evaluations[i] = returnVal;
×
2379
        }
2380

2381
        if (command) {
×
2382

2383
            // create a bulk container for the command results
2384
            let varIndex = this.getNextVarIndex(frameId);
×
2385
            let arrayVarName = this.tempVarPrefix + 'eval';
×
2386
            let bulkContainerStatement = `${arrayVarName}[${varIndex}] = [\n`;
×
2387
            for (let storedVariable of storedVariables) {
×
2388
                bulkContainerStatement += `${storedVariable},\n`;
×
2389
            }
2390
            bulkContainerStatement += `]`;
×
2391

2392
            command += bulkContainerStatement;
×
2393

2394
            results.bulkVarName = `${arrayVarName}[${varIndex}]`;
×
2395

2396
            let commandResults = await this.rokuAdapter.evaluate(command, frameId);
×
2397
            if (commandResults.type === 'error') {
×
2398
                throw new Error(commandResults.message);
×
2399
            }
2400
        }
2401

2402
        return results;
×
2403
    }
2404

2405
    protected async completionsRequest(response: DebugProtocol.CompletionsResponse, args: DebugProtocol.CompletionsArguments, request?: DebugProtocol.Request) {
2406
        this.logger.log('completionsRequest', args, request);
20✔
2407
        // this.sendEvent(new LogOutputEvent(`completionsRequest: ${args.text}`));
2408
        // this.sendEvent(new OutputEvent(`completionsRequest: ${args.text}\n`, 'stderr'));
2409

2410
        try {
20✔
2411
            let supplyLocalScopeCompletions = false;
20✔
2412

2413
            let closestCompletionDetails = this.getClosestCompletionDetails(args);
20✔
2414

2415
            if (!closestCompletionDetails) {
20!
2416
                // If the cursor is not at the end of the line, then we should not supply completions at this time
2417
                response.body = {
×
2418
                    targets: []
2419
                };
2420
                return this.sendResponse(response);
×
2421
            }
2422
            let completions = new Map<string, DebugProtocol.CompletionItem>();
20✔
2423

2424
            let parentVariablePath = closestCompletionDetails.parentVariablePath;
20✔
2425
            // When set, the user is typing a string key (ex: `m["fo`) and completions should insert the
2426
            // key wrapped to close the access (ex: `firstName"]`) rather than appending a bare label. The
2427
            // value is the closing text to append (empty when a closing bracket is already present).
2428
            const stringKeyClosing = closestCompletionDetails.stringKeyClosing;
20✔
2429

2430
            // The span of input each completion replaces. The client requests completions once (at the first
2431
            // character) and then filters the list as the user keeps typing, so without an explicit range that
2432
            // incremental filtering is anchored incorrectly.
2433
            const replaceRange = this.getCompletionReplaceRange(args);
20✔
2434
            // Whether the character immediately before the replaced span is a `.` (ie. the user is doing dot
2435
            // member access). A key that can't be dot-accessed (ex: `my key`) is rewritten as bracket access,
2436
            // which has to consume that `.` so `m.` becomes `m["my key"]` rather than `m.["my key"]`.
2437
            const lines = args.text.split('\n');
20✔
2438
            const targetLine = lines[this.toDebuggerLine(args.line, 0)] ?? '';
20!
2439
            const precededByDot = targetLine[replaceRange.start - 1] === '.';
20✔
2440

2441
            // Get the completions if the variable path was valid
2442
            if (parentVariablePath) {
20!
2443

2444
                // If the parent variable path is an empty string, then we are looking up the local scope variables and global functions
2445
                if (parentVariablePath.length === 1 && parentVariablePath[0] === '') {
20✔
2446
                    supplyLocalScopeCompletions = true;
4✔
2447
                }
2448

2449
                // Look up the parent variable (in-memory first, then the device), scoped to the current frame.
2450
                let parentVariable = await this.resolveCompletionParentVariable(parentVariablePath, args.frameId);
20✔
2451

2452
                // provide completions for the parent variable if one was found
2453
                if (parentVariable) {
20✔
2454
                    // arrays and lists are integer-indexed; their `[N]` elements aren't valid `.` or `["..."]`
2455
                    // completions (you can't write `arr.[0]` or `arr["0"]`), so don't offer them as members.
2456
                    // Only the interface methods below (Count, Push, ...) apply to these containers.
2457
                    const isIntegerIndexed = parentVariable.type === VariableType.Array ||
19✔
2458
                        parentVariable.type === VariableType.List ||
2459
                        parentVariable.type === 'roXMLList' ||
2460
                        parentVariable.type === 'roByteArray';
2461

2462
                    const possibleFieldsAndMethods = isIntegerIndexed
19✔
2463
                        ? []
2464
                        // Filter out virtual variables and the empty-named placeholder used for empty scopes
2465
                        : parentVariable.childVariables.filter((child) => child.name && child.presentationHint?.kind !== 'virtual');
21!
2466

2467
                    for (let v of possibleFieldsAndMethods) {
19✔
2468
                        // Default completion type should be variable
2469
                        let completionType: DebugProtocol.CompletionItemType = 'variable';
21✔
2470
                        if (!supplyLocalScopeCompletions) {
21✔
2471
                            // We are not supplying local scope completions, so we need to determine the completion type relative to the parent variable
2472
                            if (parentVariable.type === 'roSGNode' || parentVariable.type === VariableType.AssociativeArray || parentVariable.type === VariableType.Object) {
17!
2473
                                completionType = 'field';
17✔
2474
                            }
2475

2476
                            switch (v.type) {
17!
2477
                                case VariableType.Function:
2478
                                case VariableType.Subroutine:
2479
                                    completionType = 'method';
×
2480
                                    break;
×
2481
                                default:
2482
                                    break;
17✔
2483
                            }
2484
                        }
2485

2486
                        const completionItem: DebugProtocol.CompletionItem = {
21✔
2487
                            label: v.name,
2488
                            type: completionType,
2489
                            //rank a variable's own members/locals above everything else
2490
                            sortText: `${CompletionSortTier.Member}${v.name}`
2491
                        };
2492
                        if (stringKeyClosing !== undefined) {
21✔
2493
                            // Insert the key and close the access, ex: `firstName"]` (the replacement range is applied
2494
                            // below). A `"` inside the key is escaped as `""` so the inserted string literal stays valid
2495
                            // (ex: a key of `a"b` is inserted as `a""b`).
2496
                            completionItem.text = `${v.name.replace(/"/g, '""')}${stringKeyClosing}`;
4✔
2497
                        } else if (!supplyLocalScopeCompletions && precededByDot && !/^[a-z_][a-z0-9_]*$/i.test(v.name)) {
17✔
2498
                            // The key can't be dot-accessed (ex: it has a space or a quote), so rewrite the access as
2499
                            // bracket notation and consume the `.` before the cursor: `m.` -> `m["my key"]`. A `"` in
2500
                            // the key is escaped as `""` so the inserted string literal stays valid.
2501
                            completionItem.text = `["${v.name.replace(/"/g, '""')}"]`;
3✔
2502
                            completionItem.start = replaceRange.start - 1;
3✔
2503
                            completionItem.length = replaceRange.length + 1;
3✔
2504
                        }
2505
                        completions.set(`${completionType}-${v.name}`, completionItem);
21✔
2506
                    }
2507

2508
                    // Interface methods aren't valid string keys, so skip them when completing a string key
2509
                    if (stringKeyClosing === undefined) {
19✔
2510
                        let parentComponentType = this.debuggerVarTypeToRoType(parentVariable.type).toLowerCase();
15✔
2511
                        //assemble a list of all methods on the parent component
2512
                        const methods = [
15✔
2513
                            //if the parent variable is an actual interface (if applicable) Ex: `ifString` or `ifArray`
2514
                            ...interfaces[parentComponentType as 'ifappinfo']?.methods ?? [],
90!
2515
                            //interfaces from component of this name (if applicable) Ex: `roSGNode` or `roDateTime`
2516
                            ...components[parentComponentType as 'roappinfo']?.interfaces.map((i) => interfaces[i.name.toLowerCase() as 'ifappinfo']?.methods) ?? [],
29!
2517
                            // Add parent event function completions (if applicable) Ex: `roSGNodeEvent` or `roDeviceInfoEvent`
2518
                            ...events[parentComponentType as 'roappmemorymonitorevent']?.methods ?? []
90!
2519
                        ].flat();
2520

2521
                        // Based on the results of interface, component, and event looks up, add all the methods to the completions
2522
                        for (const method of methods) {
15✔
2523
                            completions.set(`method-${method.name}`, {
185✔
2524
                                label: method.name,
2525
                                type: 'method',
2526
                                detail: method.description ?? '',
555!
2527
                                sortText: `${CompletionSortTier.Method}${method.name}`
2528
                            });
2529
                        }
2530
                    }
2531

2532
                    // Add the global functions to the completions results
2533
                    if (supplyLocalScopeCompletions) {
19✔
2534
                        for (let globalCallable of globalCallables) {
4✔
2535
                            completions.set(`function-${globalCallable.name.toLocaleLowerCase()}`, {
308✔
2536
                                label: globalCallable.name,
2537
                                type: 'function',
2538
                                detail: globalCallable.shortDescription ?? globalCallable.documentation ?? '',
1,848!
2539
                                sortText: `${CompletionSortTier.Global}${globalCallable.name}`
2540
                            });
2541
                        }
2542

2543
                        const frame = this.rokuAdapter.getStackFrameById(args.frameId);
4✔
2544

2545
                        try {
4✔
2546
                            let scopeFunctions = await this.projectManager.getScopeFunctionsForFile(frame.filePath as string);
4✔
2547
                            for (let scopeFunction of scopeFunctions) {
4✔
2548
                                if (!completions.has(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`)) {
1!
2549
                                    completions.set(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`, {
1✔
2550
                                        label: scopeFunction.name,
2551
                                        type: scopeFunction.completionItemKind,
2552
                                        sortText: `${CompletionSortTier.ScopeFunction}${scopeFunction.name}`
2553
                                    });
2554
                                }
2555
                            }
2556
                        } catch (e) {
2557
                            this.logger.warn('Could not build list of scope functions for file', e);
×
2558
                        }
2559
                    }
2560
                }
2561
            }
2562

2563
            // Apply the default replacement span to every completion that didn't already set its own (bracket
2564
            // rewrites above use an extended range that also consumes the preceding `.`).
2565
            for (const target of completions.values()) {
20✔
2566
                if (target.start === undefined) {
499✔
2567
                    target.start = replaceRange.start;
496✔
2568
                    target.length = replaceRange.length;
496✔
2569
                }
2570
            }
2571

2572
            response.body = {
20✔
2573
                targets: [...completions.values()]
2574
            };
2575
        } catch (error) {
2576
            // this.sendEvent(new LogOutputEvent(`text: ${args.text} | ${error}`));
2577
            // this.sendEvent(new OutputEvent(`text: ${args.text} | ${error}\n`, 'stderr'));
2578
            this.logger.error('Error during completionsRequest', error, { args });
×
2579
        }
2580
        this.sendResponse(response);
20✔
2581
    }
2582

2583
    /**
2584
     * Gets the closest completion details the incoming completion request.
2585
     */
2586
    private getClosestCompletionDetails(args: DebugProtocol.CompletionsArguments): { parentVariablePath: string[]; stringKeyClosing?: string } {
2587
        const incomingText = args.text;
69✔
2588
        const lines = incomingText.split('\n');
69✔
2589
        let lineNumber = this.toDebuggerLine(args.line, 0);
69✔
2590
        let column = this.toDebuggerColumn(args.column);
69✔
2591

2592
        const targetLine = lines[lineNumber] ?? '';
69!
2593

2594
        const cursorIndex = column - 1;
69✔
2595
        const variableChars = /[a-z0-9_\.]/i;
69✔
2596

2597
        // If the character immediately to the right of the cursor is a variable character, then we are
2598
        // in the middle of a token and should not supply completions yet.
2599
        if (cursorIndex + 1 < targetLine.length && variableChars.test(targetLine[cursorIndex + 1])) {
69✔
2600
            return undefined;
2✔
2601
        }
2602

2603
        // Determine where the expression we want to complete ends, and whether we are completing the
2604
        // members of that expression. A trailing `.` or being inside an unclosed string-key bracket
2605
        // (ex: `m["fo`) are both treated as member access on the parent expression.
2606
        let endColumn = column;
67✔
2607
        let isMemberAccess = false;
67✔
2608
        //when set (including ''), the user is completing a string key; the value is the text to append to
2609
        //close the access (ex: `"]`), empty when a closing bracket is already present
2610
        let stringKeyClosing: string;
2611

2612
        const openBracket = this.findUnclosedOpener(targetLine, column);
67✔
2613
        if (openBracket?.char === '[') {
67✔
2614
            // find the opening quote (skipping any whitespace after the `[`)
2615
            let quoteIndex = openBracket.index + 1;
12✔
2616
            while (targetLine[quoteIndex] === ' ' || targetLine[quoteIndex] === '\t') {
12✔
2617
                quoteIndex++;
×
2618
            }
2619
            const quote = targetLine[quoteIndex];
12✔
2620
            if (quote === '"' || quote === `'`) {
12✔
2621
                // The user is typing a string key, so complete the keys of the expression before the `[`
2622
                endColumn = openBracket.index;
8✔
2623
                isMemberAccess = true;
8✔
2624
                // close the string and bracket only when there is nothing meaningful after the cursor
2625
                stringKeyClosing = targetLine.slice(column).trim() === '' ? `${quote}]` : '';
8✔
2626
            }
2627
        }
2628

2629
        // Walk backwards from `endColumn` to find the start of the variable path, stepping over balanced
2630
        // `[...]` index access so paths like `arr[0].name` are captured as a whole.
2631
        let startIndex = endColumn - 1;
67✔
2632
        let bracketDepth = 0;
67✔
2633
        while (startIndex >= 0) {
67✔
2634
            const char = targetLine[startIndex];
438✔
2635
            if (char === ']') {
438✔
2636
                bracketDepth++;
10✔
2637
            } else if (char === '[') {
428✔
2638
                if (bracketDepth === 0) {
12✔
2639
                    // An unbalanced `[` means we hit the start of an index/key being typed; stop here.
2640
                    break;
2✔
2641
                }
2642
                bracketDepth--;
10✔
2643
            } else if (bracketDepth === 0 && (char === undefined || !variableChars.test(char))) {
416✔
2644
                break;
23✔
2645
            }
2646
            startIndex--;
413✔
2647
        }
2648

2649
        const variablePathString = targetLine.slice(startIndex + 1, endColumn);
67✔
2650

2651
        // Attempted dot access on something unexpected.
2652
        // Example: `getPerson().name` where `getPerson()` is not a valid variable path,
2653
        // which leaves `.name` as the variable path string.
2654
        if (variablePathString.startsWith('.')) {
67✔
2655
            return undefined;
2✔
2656
        }
2657

2658
        if (variablePathString.endsWith('.')) {
65✔
2659
            isMemberAccess = true;
25✔
2660
        }
2661

2662
        // Get the variable path from the text
2663
        let variablePath: string[];
2664
        if (!variablePathString.trim()) {
65✔
2665
            // The text was empty so assume via '' that we are looking up the local scope variables and global functions
2666
            variablePath = [''];
10✔
2667
        } else if (variablePathString.endsWith('.')) {
55✔
2668
            // supplied text ends with a period, so strip it off to create a valid variable path
2669
            variablePath = util.getVariablePath(variablePathString.slice(0, -1));
25✔
2670
        } else {
2671
            variablePath = util.getVariablePath(variablePathString);
30✔
2672
        }
2673

2674
        // the target string is not a valid variable path
2675
        if (!variablePath) {
65✔
2676
            return undefined;
2✔
2677
        }
2678

2679
        // For member access we complete the members of the full expression. Otherwise we complete the
2680
        // siblings of the final (partial) segment, so drop it to get the parent.
2681
        let parentVariablePath = isMemberAccess ? variablePath : variablePath.slice(0, variablePath.length - 1);
63✔
2682

2683
        // An empty parent path means we are looking up the local scope variables and global functions
2684
        if (parentVariablePath.length === 0) {
63✔
2685
            parentVariablePath = [''];
17✔
2686
        }
2687

2688
        const result: { parentVariablePath: string[]; stringKeyClosing?: string } = { parentVariablePath: parentVariablePath };
63✔
2689
        // Only attach the string-key context when we actually resolved a parent object to complete keys on
2690
        if (stringKeyClosing !== undefined && !(parentVariablePath.length === 1 && parentVariablePath[0] === '')) {
63✔
2691
            result.stringKeyClosing = stringKeyClosing;
8✔
2692
        }
2693
        return result;
63✔
2694
    }
2695

2696
    /**
2697
     * Compute the span of input text that a completion replaces: the run of identifier characters
2698
     * immediately before the cursor. This lets the client filter the list correctly as the user keeps
2699
     * typing past the first character.
2700
     *
2701
     * `start` is a 0-based offset into the line, NOT a client column. Per the Debug Adapter Protocol,
2702
     * `CompletionItem.start` is measured in UTF-16 code units and the client maps it to a position
2703
     * itself, so it must not be run through `toClientColumn` (unlike stack-frame, breakpoint, and scope
2704
     * positions). Our debugger column base is already 0-based, so the internal offset is sent as-is.
2705
     */
2706
    private getCompletionReplaceRange(args: DebugProtocol.CompletionsArguments): { start: number; length: number } {
2707
        const lines = args.text.split('\n');
20✔
2708
        const lineNumber = this.toDebuggerLine(args.line, 0);
20✔
2709
        const cursorOffset = this.toDebuggerColumn(args.column);
20✔
2710
        const targetLine = lines[lineNumber] ?? '';
20!
2711

2712
        const identifierChars = /[a-z0-9_]/i;
20✔
2713
        let wordStart = cursorOffset;
20✔
2714
        while (wordStart > 0 && identifierChars.test(targetLine[wordStart - 1])) {
20✔
2715
            wordStart--;
7✔
2716
        }
2717
        return {
20✔
2718
            start: wordStart,
2719
            length: cursorOffset - wordStart
2720
        };
2721
    }
2722

2723
    /**
2724
     * Scan backwards from `column` to find the nearest opening bracket (`(`, `[`, or `{`) that has not
2725
     * been closed before the cursor. Returns the opener's index and character, or undefined if none.
2726
     */
2727
    private findUnclosedOpener(line: string, column: number): { index: number; char: string } {
2728
        let depth = 0;
67✔
2729
        for (let i = column - 1; i >= 0; i--) {
67✔
2730
            const char = line[i];
604✔
2731
            if (char === ')' || char === ']' || char === '}') {
604✔
2732
                depth++;
12✔
2733
            } else if (char === '(' || char === '[' || char === '{') {
592✔
2734
                if (depth === 0) {
34✔
2735
                    return { index: i, char: char };
22✔
2736
                }
2737
                depth--;
12✔
2738
            }
2739
        }
2740
        return undefined;
45✔
2741
    }
2742

2743
    /**
2744
     * Resolve the parent variable for a completion request. Prefers the in-memory locals for the frame,
2745
     * then falls back to a device lookup. Device lookups are cached for the duration of the paused state
2746
     * (cleared by `clearState`) so repeated completion requests on the same path don't hammer the device.
2747
     */
2748
    private async resolveCompletionParentVariable(parentVariablePath: string[], frameId: number): Promise<AugmentedVariable> {
2749
        // For local-scope completions, make sure the frame's locals are fetched on demand. Otherwise they
2750
        // would only appear once the user expands the Variables panel (which is what triggers the fetch).
2751
        const isLocalScope = parentVariablePath.length === 1 && parentVariablePath[0] === '';
20✔
2752
        if (isLocalScope) {
20✔
2753
            const localsScope = this.getOrCreateLocalsScope(frameId);
4✔
2754
            if (!localsScope.isResolved) {
4!
2755
                try {
4✔
2756
                    await this.populateScopeVariables(localsScope, { variablesReference: localsScope.variablesReference } as DebugProtocol.VariablesArguments);
4✔
2757
                } catch (error) {
2758
                    this.logger.debug('Could not populate locals for completions', error, { frameId });
×
2759
                }
2760
            }
2761
        }
2762

2763
        const inMemory = this.findFrameVariableByPath(parentVariablePath, frameId);
20✔
2764
        if (inMemory && inMemory.childVariables.length > 0) {
20✔
2765
            return inMemory;
14✔
2766
        }
2767

2768
        // Rebuild a valid accessor expression for the device lookup. Joining with `.` is wrong for indexed
2769
        // segments (ex: `m.services[0]` would become the invalid `m.services.0` and the index gets dropped).
2770
        const expression = this.buildVariableExpression(parentVariablePath);
6✔
2771

2772
        const cacheKey = `${frameId}:${expression}`;
6✔
2773
        if (this.completionParentVariableCache.has(cacheKey)) {
6✔
2774
            return this.completionParentVariableCache.get(cacheKey);
1✔
2775
        }
2776

2777
        let parentVariable: AugmentedVariable;
2778
        try {
5✔
2779
            let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: expression, frameId: frameId }, parentVariablePath);
5✔
2780
            let result = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
5✔
2781
            parentVariable = await this.getVariableFromResult(result, frameId);
4✔
2782
        } catch (error) {
2783
            // A failed lookup is expected while the user is still typing an incomplete expression, so keep it quiet.
2784
            this.logger.debug('Could not resolve parent variable for completions', error, { parentVariablePath });
1✔
2785
            parentVariable = undefined;
1✔
2786
        }
2787

2788
        this.completionParentVariableCache.set(cacheKey, parentVariable);
5✔
2789
        return parentVariable;
5✔
2790
    }
2791

2792
    /**
2793
     * Rebuild a valid BrightScript accessor expression from a resolved variable path. String-literal keys
2794
     * arrive already quoted from `getVariablePath` and are emitted as `["key"]` so they stay case-sensitive
2795
     * on the device (Roku AAs can be set case-sensitive); numeric segments use `[index]`, and identifiers
2796
     * use dot access. This keeps array indices and string keys correct through the device lookup.
2797
     */
2798
    private buildVariableExpression(segments: string[]): string {
2799
        return segments.reduce((expression, segment, index) => {
14✔
2800
            if (index === 0) {
27✔
2801
                return segment;
14✔
2802
            }
2803
            //already-quoted string key (preserve the quotes so the device matches it case-sensitively).
2804
            //A lone `"` is not a quoted literal (the shortest is `""`), so require at least 2 chars.
2805
            if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
13✔
2806
                return `${expression}[${segment}]`;
2✔
2807
            }
2808
            if (/^[0-9]+$/.test(segment)) {
11✔
2809
                return `${expression}[${segment}]`;
3✔
2810
            }
2811
            if (/^[a-z_][a-z0-9_]*$/i.test(segment)) {
8✔
2812
                return `${expression}.${segment}`;
5✔
2813
            }
2814
            return `${expression}["${segment.replace(/"/g, '""')}"]`;
3✔
2815
        }, '');
2816
    }
2817

2818
    /**
2819
     * Normalize a variable path segment or variable name for matching: drop surrounding string-key quotes
2820
     * and lower-case it. BrightScript variables and dotted access are case-insensitive, and the device
2821
     * reports names lower-cased, so this lets the in-memory lookup find the parent regardless of the casing
2822
     * the user typed (ex: `topRef` matching the cached `topref`).
2823
     */
2824
    private normalizeVariableName(name: string): string {
2825
        let value = name ?? '';
46!
2826
        if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
46✔
2827
            value = value.slice(1, -1).replace(/""/g, '"');
2✔
2828
        }
2829
        return value.toLowerCase();
46✔
2830
    }
2831

2832
    /**
2833
     * Resolve a variable path against the current frame's local scope. The first path segment is matched
2834
     * against the frame's locals (not the global pool of every materialized variable), then we walk down
2835
     * the child variables. The empty path (`['']`) resolves to the locals scope container itself.
2836
     */
2837
    private findFrameVariableByPath(path: string[], frameId: number): AugmentedVariable {
2838
        const localsContainer = this.variables[this.getEvaluateRefId('$$locals', frameId)];
20✔
2839
        if (path.length === 1 && path[0] === '') {
20✔
2840
            return localsContainer;
4✔
2841
        }
2842
        return this.findVariableByPath(localsContainer?.childVariables ?? [], path, frameId);
16✔
2843
    }
2844

2845
    private findVariableByPath(variables: AugmentedVariable[], path: string[], frameId: number) {
2846
        let current: AugmentedVariable = null;
22✔
2847
        for (const name of path) {
22✔
2848
            const normalizedName = this.normalizeVariableName(name);
26✔
2849
            // Find the object matching the current name in the data (case-insensitive, per BrightScript)
2850
            current = (Array.isArray(variables) ? variables : current?.childVariables)?.find(obj => {
26!
2851
                return this.normalizeVariableName(obj.name) === normalizedName && obj.frameId === frameId;
20✔
2852
            });
2853

2854
            // If no match is found, return null
2855
            if (!current) {
26✔
2856
                return null;
7✔
2857
            }
2858

2859
            // Move to the children for the next iteration
2860
            variables = current.childVariables;
19✔
2861
        }
2862
        return current;
15✔
2863
    }
2864

2865
    private debuggerVarTypeToRoType(type: string): string {
2866
        switch (type) {
15!
2867
            case VariableType.Function:
2868
            case VariableType.Subroutine:
2869
                return 'roFunction';
×
2870
            case VariableType.AssociativeArray:
2871
                return 'roAssociativeArray';
11✔
2872
            case VariableType.List:
2873
                return 'roList';
×
2874
            case VariableType.Array:
2875
                return 'roArray';
1✔
2876
            case VariableType.Boolean:
2877
                return 'roBoolean';
×
2878
            case VariableType.Double:
2879
                return 'roDouble';
×
2880
            case VariableType.Float:
2881
                return 'roFloat';
×
2882
            case VariableType.Integer:
2883
                return 'roInteger';
×
2884
            case VariableType.LongInteger:
2885
                return 'roLongInteger';
×
2886
            case VariableType.String:
2887
                return 'roString';
×
2888
            default:
2889
                return type;
3✔
2890
        }
2891
    }
2892

2893
    /**
2894
     * Called when the host stops debugging
2895
     * @param response
2896
     * @param args
2897
     */
2898
    protected async disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments, request?: DebugProtocol.Request) {
2899
        //return to the home screen — best effort. The device may already be powered off or unreachable
2900
        //at disconnect time; without a guard pressHomeButton rejects (EHOSTDOWN / ECONNREFUSED / etc)
2901
        //and because @vscode/debugadapter dispatches this method without awaiting the returned Promise,
2902
        //that rejection escapes as an unhandledRejection and crashes the DAP process.
2903
        //See https://github.com/rokucommunity/vscode-brightscript-language/issues/807
2904
        //    https://github.com/rokucommunity/roku-debug/issues/332
2905
        if (!this.enableDebugProtocol) {
2!
2906
            try {
2✔
2907
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
2✔
2908
            } catch (e) {
2909
                this.logger.warn('Failed to press home button during disconnect; device may be unreachable', e);
2✔
2910
            }
2911
        }
2912
        this.sendResponse(response);
2✔
2913
        await this.shutdown();
2✔
2914
    }
2915

2916
    private createRokuAdapter(rendezvousTracker: RendezvousTracker) {
2917
        if (this.enableDebugProtocol) {
1!
2918
            this.rokuAdapter = new DebugProtocolAdapter(this.launchConfiguration, this.projectManager, this.breakpointManager, rendezvousTracker, this.deviceInfo);
×
2919
        } else {
2920
            this.rokuAdapter = new TelnetAdapter(this.launchConfiguration, rendezvousTracker);
1✔
2921
        }
2922
    }
2923

2924
    protected async restartRequest(response: DebugProtocol.RestartResponse, args: DebugProtocol.RestartArguments, request?: DebugProtocol.Request) {
2925
        this.logger.log('[restartRequest] begin');
×
2926
        if (this.rokuAdapter) {
×
2927
            if (!this.enableDebugProtocol) {
×
2928
                this.rokuAdapter.removeAllListeners();
×
2929
            }
2930
            await this.rokuAdapter.destroy();
×
2931
            await this.ensureAppIsInactive();
×
2932
            this.rokuAdapterDeferred = defer();
×
2933
            this.stagingDefered.tryResolve();
×
2934
            this.stagingDefered = defer();
×
2935
        }
2936
        await this.launchRequest(response, args.arguments as LaunchConfiguration);
×
2937
    }
2938

2939
    private exitAppTimeout = 5000;
203✔
2940
    private async ensureAppIsInactive() {
2941
        const startTime = Date.now();
×
2942

2943
        while (true) {
×
2944
            if (Date.now() - startTime > this.exitAppTimeout) {
×
2945
                return;
×
2946
            }
2947

2948
            try {
×
2949
                let appStateResult = await rokuECP.getAppState({
×
2950
                    host: this.launchConfiguration.host,
2951
                    remotePort: this.launchConfiguration.remotePort,
2952
                    appId: 'dev',
2953
                    requestOptions: { timeout: 300 }
2954
                });
2955

2956
                const state = appStateResult.state;
×
2957

2958
                if (state === AppState.active || state === AppState.background) {
×
2959
                    // Suspends or terminates an app that is running:
2960
                    // If the app supports Instant Resume and is running in the foreground, sending this command suspends the app (the app runs in the background).
2961
                    // If the app supports Instant Resume and is running in the background or the app does not support Instant Resume and is running, sending this command terminates the app.
2962
                    // This means that we might need to send this command twice to terminate the app.
2963
                    await rokuECP.exitApp({
×
2964
                        host: this.launchConfiguration.host,
2965
                        remotePort: this.launchConfiguration.remotePort,
2966
                        appId: 'dev',
2967
                        requestOptions: { timeout: 300 }
2968
                    });
2969
                } else if (state === AppState.inactive) {
×
2970
                    return;
×
2971
                }
2972
            } catch (e) {
2973
                this.logger.error('Error attempting to exit application', e);
×
2974
            }
2975

2976
            await util.sleep(200);
×
2977
        }
2978
    }
2979

2980
    /**
2981
     * Used to track whether the entry breakpoint has already been handled
2982
     */
2983
    private entryBreakpointWasHandled = false;
203✔
2984

2985
    /**
2986
     * Registers the main events for the RokuAdapter
2987
     */
2988
    private async connectRokuAdapter() {
2989
        this.rokuAdapter.on('start', () => {
×
2990
            this.sendLaunchProgress('end', 'Complete');
×
2991
            if (!this.firstRunDeferred.isCompleted) {
×
2992
                this.firstRunDeferred.resolve();
×
2993
            }
2994
        });
2995

2996
        this.rokuAdapter.on('launch-status', (message) => {
×
2997
            this.sendLaunchProgress('update', message);
×
2998
        });
2999

3000
        //when the debugger suspends (pauses for debugger input)
3001
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
3002
        this.rokuAdapter.on('suspend', async () => {
×
3003
            await this.onSuspend();
×
3004
        });
3005

3006
        //anytime the adapter encounters an exception on the roku,
3007
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
3008
        this.rokuAdapter.on('runtime-error', async (exception) => {
×
3009
            await this.getRokuAdapter();
×
3010
            const threads = await this.setupSuspendedState();
×
3011
            let threadId = threads[0]?.threadId;
×
3012
            this.sendEvent(new StoppedEvent(StoppedEventReason.exception, threadId, exception.message));
×
3013
        });
3014

3015
        // If the roku says it can't continue, we are no longer able to debug, so kill the debug session
3016
        this.rokuAdapter.on('cannot-continue', () => {
×
3017
            this.shutdown().catch(e => this.logger.error(e));
×
3018
        });
3019

3020
        //make the connection
3021
        await this.rokuAdapter.connect();
×
3022
        this.rokuAdapterDeferred.resolve(this.rokuAdapter);
×
3023
        return this.rokuAdapter;
×
3024
    }
3025

3026
    private async onSuspend() {
3027
        const threads = await this.setupSuspendedState();
1✔
3028
        const activeThread = threads.find(x => x.isSelected);
1✔
3029

3030
        //if !stopOnEntry, and we haven't encountered a suspend yet, THIS is the entry breakpoint. auto-continue
3031
        if (!this.entryBreakpointWasHandled && !this.launchConfiguration.stopOnEntry) {
1!
3032
            this.entryBreakpointWasHandled = true;
1✔
3033
            //if there's a user-defined breakpoint at this exact position, it needs to be handled like a regular breakpoint (i.e. suspend). So only auto-continue if there's no breakpoint here
3034
            if (activeThread && !await this.breakpointManager.lineHasBreakpoint(this.projectManager.getAllProjects(), activeThread.filePath, activeThread.lineNumber - 1)) {
1!
3035
                this.logger.info('Encountered entry breakpoint and `stopOnEntry` is disabled. Continuing...');
×
3036
                return this.rokuAdapter.continue();
×
3037
            }
3038
        }
3039

3040
        const event: StoppedEvent = new StoppedEvent(
1✔
3041
            StoppedEventReason.breakpoint,
3042
            //Not sure why, but sometimes there is no active thread. Just pick thread 0 to prevent the app from totally crashing
3043
            activeThread?.threadId ?? 0,
6!
3044
            '' //exception text
3045
        );
3046
        // Socket debugger will always stop all threads and supports multi thread inspection.
3047
        (event.body as any).allThreadsStopped = this.enableDebugProtocol;
1✔
3048
        this.sendEvent(event);
1✔
3049
    }
3050

3051
    private async setupSuspendedState() {
3052
        //clear the index for storing evalutated expressions
3053
        this.evaluateVarIndexByFrameId.clear();
8✔
3054

3055
        const threads = await this.rokuAdapter.getThreads();
8✔
3056

3057
        //TODO remove this once Roku fixes their threads off-by-one line number issues
3058
        //look up the correct line numbers for each thread from the StackTrace
3059
        await Promise.all(
8✔
3060
            threads.map(async (thread) => {
3061
                const stackTrace = await this.rokuAdapter.getStackTrace(thread.threadId);
9✔
3062
                const stackTraceLineNumber = stackTrace[0]?.lineNumber;
9✔
3063
                const stackTraceFilePath = stackTrace[0]?.filePath;
9✔
3064
                // Only apply the line correction when we actually have valid data — never clobber
3065
                // thread.filePath with undefined, which would crash getSourceLocation downstream.
3066
                if (stackTraceLineNumber !== undefined && stackTraceLineNumber !== thread.lineNumber) {
9✔
3067
                    this.logger.warn(`Thread ${thread.threadId} reported incorrect line (${thread.lineNumber}). Using line from stack trace instead (${stackTraceLineNumber})`, thread, stackTrace);
2✔
3068
                    thread.lineNumber = stackTraceLineNumber;
2✔
3069
                    thread.filePath = stackTraceFilePath ?? thread.filePath;
2!
3070
                }
3071
            })
3072
        );
3073

3074
        outer: for (const bp of this.breakpointManager.failedDeletions) {
8✔
3075
            for (const thread of threads) {
4✔
3076
                let sourceLocation = await this.projectManager.getSourceLocation(thread.filePath, thread.lineNumber);
4✔
3077
                // This stop was due to a breakpoint that we tried to delete, but couldn't.
3078
                // Now that we are stopped, we can delete it. We won't stop here again unless you re-add the breakpoint. You're welcome.
3079
                if (sourceLocation && (bp.srcPath === sourceLocation.filePath) && (bp.line === sourceLocation.lineNumber)) {
4✔
3080
                    this.showPopupMessage(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops.`, 'info').catch((error) => {
1✔
3081
                        this.logger.error('Error showing popup message', { error });
×
3082
                    });
3083
                    this.logger.warn(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops`, bp, thread, sourceLocation);
1✔
3084
                    break outer;
1✔
3085
                }
3086
            }
3087
        }
3088

3089
        //sync breakpoints
3090
        await this.rokuAdapter?.syncBreakpoints();
8!
3091

3092
        this.logger.info('received "suspend" event from adapter');
8✔
3093

3094
        this.clearState();
8✔
3095
        return threads;
8✔
3096
    }
3097

3098
    private async getVariableFromResult(result: EvaluateContainer, frameId: number, maxDepth = 1) {
15✔
3099
        let v: AugmentedVariable;
3100

3101
        if (result) {
25!
3102
            if (this.rokuAdapter.isDebugProtocolAdapter()) {
25✔
3103
                let refId = this.getEvaluateRefId(result.evaluateName, frameId);
16✔
3104
                if (result.isCustom && !result.presentationHint?.lazy && result.evaluateNow) {
16!
3105
                    try {
×
3106
                        // We should not wait to resolve this variable later. Fetch, store, and merge the results right away.
3107
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: result.evaluateName, frameId: frameId }, util.getVariablePath(result.evaluateName));
×
3108
                        let newResult = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
×
3109
                        this.mergeEvaluateContainers(result, newResult);
×
3110
                    } catch (error) {
3111
                        logger.error('Error getting variables', error);
×
3112
                        this.mergeEvaluateContainers(result, {
×
3113
                            name: result.name,
3114
                            evaluateName: result.evaluateName,
3115
                            children: [],
3116
                            value: `❌ Error: ${error.message}`,
3117
                            type: '',
3118
                            highLevelType: undefined,
3119
                            keyType: undefined
3120
                        });
3121
                    }
3122
                }
3123

3124
                if (result.keyType) {
16✔
3125
                    let value = `${result.value ?? result.type}`;
5!
3126
                    let indexedVariables = result.indexedVariables;
5✔
3127
                    let namedVariables = result.namedVariables;
5✔
3128

3129
                    if (indexedVariables === undefined || namedVariables === undefined) {
5!
3130
                        // If either indexed or named variables are undefined, we should tell the debugger to ask for everything
3131
                        // by supplying undefined values for both
3132
                        indexedVariables = undefined;
×
3133
                        namedVariables = undefined;
×
3134
                    }
3135

3136
                    // check to see if this is an dictionary or a list
3137
                    if (result.keyType === 'Integer') {
5!
3138
                        // list type
3139
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
×
3140
                    } else if (result.keyType === 'String') {
5!
3141
                        // dictionary type
3142
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
5✔
3143
                    }
3144
                    v.type = result.type;
5✔
3145
                } else {
3146

3147
                    let value: string;
3148
                    if (result.type === VariableType.Invalid) {
11!
3149
                        value = result.value ?? 'Invalid';
×
3150
                    } else if (result.type === VariableType.Uninitialized) {
11!
3151
                        value = 'Uninitialized';
×
3152
                    } else {
3153
                        value = `${result.value}`;
11✔
3154
                    }
3155
                    // If the variable is lazy we must assign a refId to inform the system
3156
                    // to request this variable again in the future for value resolution
3157
                    v = new Variable(result.name, value, result?.presentationHint?.lazy ? refId : 0);
11!
3158
                }
3159
                this.variables[refId] = v;
16✔
3160
            } else if (this.rokuAdapter.isTelnetAdapter()) {
9!
3161
                if (result.highLevelType === 'primative' || result.highLevelType === 'uninitialized') {
9✔
3162
                    v = new Variable(result.name, `${result.value}`);
7✔
3163
                } else if (result.highLevelType === 'array') {
2✔
3164
                    let refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3165
                    v = new Variable(result.name, result.type, refId, result.children?.length ?? 0, 0);
1!
3166
                    this.variables[refId] = v;
1✔
3167
                } else if (result.highLevelType === 'object') {
1!
3168
                    let refId: number;
3169
                    //handle collections
3170
                    if (this.rokuAdapter.isScrapableContainObject(result.type)) {
1!
3171
                        refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3172
                    }
3173
                    v = new Variable(result.name, result.type, refId, 0, result.children?.length ?? 0);
1!
3174
                    this.variables[refId] = v;
1✔
3175
                } else if (result.highLevelType === 'function') {
×
3176
                    v = new Variable(result.name, `${result.value}`);
×
3177
                } else {
3178
                    //all other cases, but mostly for HighLevelType.unknown
3179
                    v = new Variable(result.name, `${result.value}`);
×
3180
                }
3181
            }
3182

3183
            v.type = result.type;
25✔
3184
            v.evaluateName = result.evaluateName;
25✔
3185
            v.frameId = frameId;
25✔
3186
            v.type = result.type;
25✔
3187
            v.presentationHint = result.presentationHint ? { kind: result.presentationHint?.kind, lazy: result.presentationHint?.lazy } : undefined;
25!
3188
            if (util.isTransientVariable(v.name)) {
25!
3189
                v.presentationHint = { kind: 'virtual' };
×
3190
            }
3191

3192
            if (result.children && maxDepth > 0) {
25✔
3193
                if (!v.childVariables) {
7!
3194
                    v.childVariables = [];
7✔
3195
                }
3196

3197
                // Create a mapping of the children to their index so we can evaluate them in bulk
3198
                let indexMappedChildren = result.children.map((child, index) => {
7✔
3199
                    let remapped = { child: child, index: index, evaluate: !!(child.isCustom && !child.presentationHint?.lazy && child.evaluateNow) };
10!
3200
                    return remapped;
10✔
3201
                });
3202
                if (this.enableDebugProtocol) {
7!
3203
                    let childrenToEvaluate = indexMappedChildren.filter(x => x.evaluate);
×
3204
                    let evaluateArgsArray = childrenToEvaluate.map(x => {
×
3205
                        return { expression: x.child.evaluateName, frameId: frameId };
×
3206
                    });
3207

3208
                    let variablePathArray = childrenToEvaluate.map(x => {
×
3209
                        return util.getVariablePath(x.child.evaluateName);
×
3210
                    });
3211

3212
                    try {
×
3213
                        let bulkEvaluations = await this.bulkEvaluateExpressionToTempVar(frameId, evaluateArgsArray, variablePathArray);
×
3214
                        if (bulkEvaluations.bulkVarName) {
×
3215
                            let newResults = await this.rokuAdapter.getVariable(bulkEvaluations.bulkVarName, frameId);
×
3216
                            childrenToEvaluate.map((mappedChild, index) => {
×
3217
                                let newResult = newResults.children[index];
×
3218
                                this.mergeEvaluateContainers(mappedChild.child, newResult);
×
3219
                                mappedChild.child.evaluateNow = false;
×
3220
                                return mappedChild;
×
3221
                            });
3222
                        }
3223
                    } catch (error) {
3224
                        this.logger.error('Error getting bulk variables, will fall back to var by var lookups', error);
×
3225
                    }
3226
                }
3227
                // If bulk evaluations failed, there is fall back logic in `getVariableFromResult` to do individual evaluations
3228
                v.childVariables = await Promise.all(indexMappedChildren.map(async (mappedChild) => {
7✔
3229
                    return this.getVariableFromResult(mappedChild.child, frameId, maxDepth - 1);
10✔
3230
                }));
3231
            } else {
3232
                v.childVariables = [];
18✔
3233
            }
3234

3235
            // if the var is an array and debugProtocol is enabled, include the array size
3236
            if (this.enableDebugProtocol && v.type === VariableType.Array) {
25!
3237
                if (isNaN(result.indexedVariables as number)) {
×
3238
                    v.value = v.type;
×
3239
                } else {
3240
                    v.value = `${v.type}(${result.indexedVariables})`;
×
3241
                }
3242
            }
3243
        }
3244
        return v;
25✔
3245
    }
3246

3247
    /**
3248
     * Helper function to merge the results of an evaluate call into an existing EvaluateContainer
3249
     * Used primarily for custom variables
3250
     */
3251
    private mergeEvaluateContainers(original: EvaluateContainer, updated: EvaluateContainer) {
3252
        original.children = updated.children;
×
3253
        original.value = updated.value;
×
3254
        original.type = updated.type;
×
3255
        original.highLevelType = updated.highLevelType;
×
3256
        original.keyType = updated.keyType;
×
3257
        original.indexedVariables = updated.indexedVariables;
×
3258
        original.namedVariables = updated.namedVariables;
×
3259
    }
3260

3261
    private getEvaluateRefId(expression: string, frameId: number) {
3262
        let evaluateRefId = `${expression}-${frameId}`;
77✔
3263
        if (!this.evaluateRefIdLookup[evaluateRefId]) {
77✔
3264
            this.evaluateRefIdLookup[evaluateRefId] = this.evaluateRefIdCounter++;
45✔
3265
        }
3266
        return this.evaluateRefIdLookup[evaluateRefId];
77✔
3267
    }
3268

3269
    private clearState() {
3270
        //erase all cached variables
3271
        this.variables = {};
12✔
3272
        this.completionParentVariableCache.clear();
12✔
3273
    }
3274

3275
    /**
3276
     * Sends a launch progress event to the client if the client supports progress reporting.
3277
     * - `'start'`: begins a new progress bar with the given message. Assigns a new progressId.
3278
     * - `'update'`: updates the message on the active progress bar.
3279
     * - `'end'`: dismisses the active progress bar with an optional final message.
3280
     */
3281
    private sendLaunchProgress(type: 'start' | 'update' | 'end', message?: string) {
3282
        if (!this.initRequestArgs?.supportsProgressReporting) {
61✔
3283
            return;
30✔
3284
        }
3285
        if (type === 'start') {
31✔
3286
            this.launchProgressId = `rokudebug-launch-${this.idCounter++}`;
10✔
3287
            this.sendEvent(new ProgressStartEvent(this.launchProgressId, 'Launching', `${message}...`));
10✔
3288
        } else if (this.launchProgressId) {
21✔
3289
            if (type === 'update') {
18✔
3290
                this.sendEvent(new ProgressUpdateEvent(this.launchProgressId, `${message}...`));
13✔
3291
            } else {
3292
                const lastId = this.launchProgressId;
5✔
3293
                this.sendEvent(new ProgressUpdateEvent(lastId, message));
5✔
3294
                setTimeout(() => {
5✔
3295
                    this.sendEvent(new ProgressEndEvent(lastId, message));
5✔
3296
                }, 1000); // add a slight delay before ending the progress to improve UX
3297
                this.launchProgressId = undefined;
5✔
3298
            }
3299
        }
3300
    }
3301

3302
    /**
3303
     * Tells the client to re-request all variables because we've invalidated them
3304
     * @param threadId
3305
     * @param stackFrameId
3306
     */
3307
    private sendInvalidatedEvent(threadId?: number, stackFrameId?: number) {
3308
        //if the client supports this request, send it
3309
        if (this.initRequestArgs.supportsInvalidatedEvent) {
3!
3310
            this.sendEvent(new InvalidatedEvent(['variables'], threadId, stackFrameId));
×
3311
        }
3312
    }
3313

3314
    /**
3315
     * If `stopOnEntry` is enabled, register the entry breakpoint.
3316
     */
3317
    public async handleEntryBreakpoint() {
3318
        if (!this.enableDebugProtocol) {
4!
3319
            this.entryBreakpointWasHandled = true;
4✔
3320
            if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) {
4✔
3321
                await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir);
1✔
3322
            }
3323
        }
3324
    }
3325

3326
    /**
3327
     * Converts a debugger line number to a client line number.
3328
     *
3329
     * @param debuggerLine - The line number from the debugger as zero based.
3330
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `debuggerLine` is not provided.
3331
     * @returns The corresponding client line number.
3332
     */
3333
    private toClientLine(debuggerLine: number, defaultDebuggerLine?: number) {
3334
        return this.convertDebuggerLineToClient(debuggerLine ?? defaultDebuggerLine);
3!
3335
    }
3336

3337
    /**
3338
     * Converts a debugger column number to a client column number.
3339
     *
3340
     * @param debuggerLine - The column number from the debugger as zero based.
3341
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `debuggerLine` is not provided.
3342
     * @returns The corresponding client column number.
3343
     */
3344
    private toClientColumn(debuggerLine: number, defaultDebuggerLine?: number) {
3345
        return this.convertDebuggerColumnToClient(debuggerLine ?? defaultDebuggerLine);
3!
3346
    }
3347

3348
    /**
3349
     * Converts a client line number to a debugger line number.
3350
     *
3351
     * @param clientLine - The line number from the client.
3352
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `clientLine` is not provided.
3353
     * @returns The corresponding debugger line number as zero based.
3354
     */
3355
    private toDebuggerLine(clientLine: number, defaultDebuggerLine?: number) {
3356
        if (typeof clientLine === 'number') {
109✔
3357
            return this.convertClientLineToDebugger(clientLine);
2✔
3358
        }
3359
        return defaultDebuggerLine;
107✔
3360
    }
3361

3362
    /**
3363
     * Converts a client column number to a debugger column number.
3364
     *
3365
     * @param clientLine - The column number from the client.
3366
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `clientLine` is not provided.
3367
     * @returns The corresponding debugger column number as zero based.
3368
     */
3369
    private toDebuggerColumn(clientLine: number, defaultDebuggerLine?: number) {
3370
        if (typeof clientLine === 'number') {
89!
3371
            return this.convertClientColumnToDebugger(clientLine);
89✔
3372
        }
3373
        return defaultDebuggerLine;
×
3374
    }
3375

3376
    private shutdownPromise: Promise<void> | undefined = undefined;
203✔
3377

3378
    /**
3379
     * Called when the debugger is terminated. Feel free to call this as frequently as you want; we'll only run the shutdown process the first time, and return
3380
     * the same promise on subsequent calls
3381
     */
3382
    public async shutdown(errorMessage?: string, modal = false): Promise<void> {
16✔
3383
        if (this.shutdownPromise === undefined) {
16!
3384
            this.logger.log('[shutdown] Beginning shutdown sequence', errorMessage);
16✔
3385
            //Backstop: if the graceful shutdown hangs (e.g. pressHomeButton against an unreachable
3386
            //device), force-exit anyway so we never leave an orphaned adapter running forever (#486)
3387
            const forceExitTimer = setTimeout(() => {
16✔
NEW
3388
                this.logger.error('[shutdown] graceful shutdown timed out; forcing exit');
×
NEW
3389
                this.forceExit();
×
3390
            }, this.shutdownForceExitTimeout);
3391
            forceExitTimer.unref?.();
16!
3392
            this.shutdownPromise = this._shutdown(errorMessage, modal).finally(() => {
16✔
3393
                clearTimeout(forceExitTimer);
16✔
3394
            });
3395
        } else {
3396
            this.logger.log('[shutdown] Tried to call `.shutdown()` again. Returning the same promise');
×
3397
        }
3398
        return this.shutdownPromise;
16✔
3399
    }
3400

3401
    private async _shutdown(errorMessage?: string, modal = false): Promise<void> {
×
3402
        // Ensure any active launch progress bar is dismissed before showing error messages or the terminated event.
3403
        this.sendLaunchProgress('end', 'Complete');
16✔
3404

3405
        //send the message FIRST before anything else. This improves the chances that the message will be displayed to the user
3406
        try {
16✔
3407
            if (errorMessage) {
16!
3408
                this.logger.error(errorMessage);
×
3409
                this.showPopupMessage(errorMessage, 'error', modal).catch((error) => {
×
3410
                    this.logger.error('Error showing popup message', { error });
×
3411
                });
3412
            }
3413
        } catch (e) {
3414
            this.logger.error(e);
×
3415
        }
3416
        // stop perfetto tracing if it's running
3417
        try {
16✔
3418
            await this.perfettoManager.stopTracing();
16✔
3419
        } catch (e) {
3420
            this.logger.error('Error stopping perfetto tracing', e);
16✔
3421
        }
3422

3423
        try {
16✔
3424
            await this.perfettoManager?.dispose?.();
16!
3425
        } catch (e) {
3426
            this.logger.error('Error disposing perfetto manager', e);
×
3427
        }
3428

3429
        //close the debugger connection
3430
        try {
16✔
3431
            this.logger.log('Destroy rokuAdapter');
16✔
3432
            await this.rokuAdapter?.destroy?.();
16!
3433
            //press the home button to return to the home screen
3434
            try {
16✔
3435
                this.logger.log('Press home button');
16✔
3436
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
16✔
3437
            } catch (e) {
3438
                this.logger.error(e);
×
3439
            }
3440
        } catch (e) {
3441
            this.logger.error(e);
×
3442
        }
3443

3444
        try {
16✔
3445
            this.projectManager?.dispose?.();
16!
3446
        } catch (e) {
3447
            this.logger.error(e);
×
3448
        }
3449

3450
        try {
16✔
3451
            this.componentLibraryServer?.stop();
16!
3452
        } catch (e) {
3453
            this.logger.error(e);
×
3454
        }
3455

3456
        try {
16✔
3457
            await this.rendezvousTracker?.destroy?.();
16!
3458
        } catch (e) {
3459
            this.logger.error(e);
×
3460
        }
3461

3462
        try {
16✔
3463
            await this.sourceMapManager?.destroy?.();
16!
3464
        } catch (e) {
3465
            this.logger.error(e);
×
3466
        }
3467

3468
        try {
16✔
3469
            //if configured, delete the staging directory
3470
            if (!this.launchConfiguration.retainStagingFolder) {
16!
3471
                const stagingDirs = this.projectManager?.getStagingDirs() ?? [];
16!
3472
                this.logger.info('deleting staging folders', stagingDirs);
16✔
3473
                for (let stagingDir of stagingDirs) {
16✔
3474
                    try {
2✔
3475
                        fsExtra.removeSync(stagingDir);
2✔
3476
                    } catch (e) {
3477
                        this.logger.error(e);
×
3478
                        util.log(`Error removing staging directory '${stagingDir}': ${JSON.stringify(e)}`);
×
3479
                    }
3480
                }
3481
            }
3482
        } catch (e) {
3483
            this.logger.error(e);
×
3484
        }
3485

3486
        try {
16✔
3487
            this.logger.log('Send terminated event');
16✔
3488
            this.sendEvent(new TerminatedEvent());
16✔
3489

3490
            //shut down the process
3491
            this.logger.log('super.shutdown()');
16✔
3492
            super.shutdown();
16✔
3493
            this.logger.log('shutdown complete');
16✔
3494
        } catch (e) {
3495
            this.logger.error(e);
×
3496
        }
3497

3498
        try {
16✔
3499
            this.teardownProcessErrorHandlers();
16✔
3500
        } catch (e) {
3501
            this.logger.error(e);
×
3502
        }
3503
    }
3504
}
3505

3506
export interface AugmentedVariable extends DebugProtocol.Variable {
3507
    childVariables?: AugmentedVariable[];
3508
    // eslint-disable-next-line camelcase
3509
    request_seq?: number;
3510
    frameId?: number;
3511
    /**
3512
     * only used for lazy variables
3513
     */
3514
    isResolved?: boolean;
3515
    /**
3516
     * used to indicate that this variable is a scope variable
3517
     * and may require special handling
3518
     */
3519
    isScope?: boolean;
3520
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc