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

rokucommunity / roku-debug / 28881859181

07 Jul 2026 04:24PM UTC coverage: 72.802%. Remained the same
28881859181

push

github

web-flow
Improve ECP access mode error messages with step-by-step navigation (#386)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Christopher Dwyer-Perkins <chris@inverted-solutions.com>
Co-authored-by: Christopher Dwyer-Perkins <chrisdwyerperkins@gmail.com>

3776 of 5415 branches covered (69.73%)

Branch coverage included in aggregate %.

0 of 2 new or added lines in 1 file covered. (0.0%)

5930 of 7917 relevant lines covered (74.9%)

47.07 hits per line

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

66.76
/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();
205✔
92

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

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

100
        this.fileManager = new FileManager();
205✔
101
        this.sourceMapManager = new SourceMapManager();
205✔
102
        this.locationManager = new LocationManager(this.sourceMapManager);
205✔
103
        this.breakpointManager = new BreakpointManager(this.sourceMapManager, this.locationManager);
205✔
104
        //send newly-verified breakpoints to vscode
105
        this.breakpointManager.on('breakpoints-verified', (data) => this.onDeviceBreakpointsChanged('changed', data));
205✔
106
        this.projectManager = new ProjectManager({
205✔
107
            breakpointManager: this.breakpointManager,
108
            locationManager: this.locationManager
109
        });
110
        this.fileLoggingManager = new FileLoggingManager();
205✔
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.
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.
145
     */
146
    public sendEvent(event: DebugProtocol.Event): void {
147
        if (this.clientDisconnected) {
620✔
148
            return;
4✔
149
        }
150
        super.sendEvent(event);
616✔
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;
205✔
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.
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. 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
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]`);
205✔
329

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

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

337
    public fileManager: FileManager;
338

339
    public projectManager: ProjectManager;
340

341
    public fileLoggingManager: FileLoggingManager;
342

343
    private processErrorHandlersRegistered = false;
205✔
344
    private isCrashed = false;
205✔
345
    /** Set once the client (e.g. VS Code) disconnects, so we stop writing to a now-dead stream */
346
    private clientDisconnected = false;
205✔
347
    /** How long to wait for a graceful shutdown before forcibly exiting the process */
348
    private shutdownForceExitTimeout = 10_000;
205✔
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;
205✔
360

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

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

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

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

377
    private variables: Record<number, AugmentedVariable> = {};
205✔
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>();
205✔
385

386
    private rokuAdapter: DebugProtocolAdapter | TelnetAdapter;
387

388
    private perfettoManager: PerfettoManager;
389

390
    private rendezvousTracker: RendezvousTracker;
391

392
    public tempVarPrefix = '__rokudebug__';
205✔
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;
205✔
409

410
    private get enableDebugProtocol() {
411
        return this.launchConfiguration?.enableDebugProtocol;
75!
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[] = [];
205✔
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(
538✔
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();
8!
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();
8✔
601
        config.outDir ??= s`${config.cwd}/out`;
8✔
602
        config.stagingDir ??= s`${config.outDir}/.roku-deploy-staging`;
8!
603
        config.componentLibrariesPort ??= 8080;
8!
604
        config.packagePort ??= 80;
8!
605
        config.remotePort ??= 8060;
8!
606
        config.sceneGraphDebugCommandsPort ??= 8080;
8!
607
        config.controlPort ??= 8081;
8!
608
        config.brightScriptConsolePort ??= 8085;
8!
609
        config.stagingDir ??= config.stagingFolderPath;
8!
610
        config.emitChannelPublishedEvent ??= true;
8!
611
        config.rewriteDevicePathsInLogs ??= true;
8!
612
        config.autoResolveVirtualVariables ??= false;
8!
613
        config.enhanceREPLCompletions ??= true;
8!
614
        config.username ??= 'rokudev';
8!
615
        if (config.profiling?.tracing?.enable) {
8!
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') {
8!
623
            config.enableVariablesPanel = true;
8✔
624
        }
625
        config.deferScopeLoading ??= config.enableVariablesPanel === false;
8!
626
        return config;
8✔
627
    }
628

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

632
        try {
8✔
633
            this.resetSessionState();
8✔
634
            this.launchConfiguration = this.normalizeLaunchConfig(config);
8✔
635
            this.setupProcessErrorHandlers();
8✔
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));
8!
639

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

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

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

654
            // fetch device info if not supplied via launch config
655
            try {
8✔
656
                if (this.launchConfiguration.deviceInfo) {
8✔
657
                    this.deviceInfo = rokuDeploy.enhanceDeviceInfo(this.launchConfiguration.deviceInfo);
1✔
658
                } else {
659
                    this.deviceInfo = await rokuDeploy.getDeviceInfo({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, enhance: true, timeout: 4_000 });
7✔
660
                }
661
                if (this.deviceInfo.ecpSettingMode === 'limited') {
8!
NEW
662
                    return await this.shutdown(`Please ensure 'Settings' > 'System' > 'Advanced system settings' > 'Control by mobile apps' is set to "Enabled" or "Permissive" on your Roku device to allow the debugger to communicate properly. Current mode: Limited (device: ${this.launchConfiguration.host})`);
×
663
                }
664
            } catch (e) {
665
                if (e instanceof EcpNetworkAccessModeDisabledError) {
×
NEW
666
                    return this.shutdown(`Please ensure 'Settings' > 'System' > 'Advanced system settings' > 'Control by mobile apps' is set to "Enabled" or "Permissive" on your Roku device to allow the debugger to communicate properly. Current mode: Disabled (device: ${this.launchConfiguration.host})`);
×
667
                }
668
                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.`);
×
669
            }
670

671
            if (this.deviceInfo && !this.deviceInfo.developerEnabled) {
8!
672
                return await this.shutdown(`Developer mode is not enabled for host '${this.launchConfiguration.host}'.`);
×
673
            }
674

675
            // everything is ready, send the response to the launch request so the UI can update and configuration can begin
676
            this.sendResponse(response);
8✔
677

678
            //initialize all file logging (rokuDevice, debugger, etc)
679
            this.fileLoggingManager.activate(this.launchConfiguration?.fileLogging, this.cwd);
8!
680

681
            this.projectManager.launchConfiguration = this.launchConfiguration;
8✔
682
            this.breakpointManager.launchConfiguration = this.launchConfiguration;
8✔
683

684
            this.sendEvent(new LaunchStartEvent(this.launchConfiguration));
8✔
685

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

695
            //all of the projects have been successfully staged.
696
            this.stagingDefered.tryResolve();
8✔
697

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

705
            packageEnd();
8✔
706

707
            if (this.enableDebugProtocol) {
8!
708
                util.log(`Connecting to Roku via the BrightScript debug protocol at ${this.launchConfiguration.host}:${this.launchConfiguration.controlPort}`);
×
709
            } else {
710
                util.log(`Connecting to Roku via telnet at ${this.launchConfiguration.host}:${this.launchConfiguration.brightScriptConsolePort}`);
8✔
711
            }
712

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

722
            this.sendLaunchProgress('update', 'Connecting to debug server');
8✔
723
            const connectAdapterEnd = this.logger.timeStart('log', 'Connect adapter');
8✔
724
            this.createRokuAdapter(this.rendezvousTracker);
8✔
725
            await this.connectRokuAdapter();
8✔
726
            connectAdapterEnd();
8✔
727

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

741
            this.sendLaunchProgress('update', 'Configuring breakpoints');
8✔
742

743
            util.log('Done initializing');
8✔
744

745
            // notify VS Code that the adapter is ready to receive configuration (breakpoints, etc.)
746
            // VS Code will respond with setBreakpoints, setExceptionBreakpoints, then configurationDone
747
            this.sendEvent(new InitializedEvent());
8✔
748

749
            await this.initializeProfiling();
8✔
750

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

757
                //send any compile errors to the client
758
                await this.rokuAdapter?.sendErrors();
×
759

760
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
761
                await this.shutdown(message as string, true);
×
762
            } else {
763
                this.sendLaunchProgress('end', 'Aborted (compile error)');
×
764
            }
765
        }
766
        logEnd();
8✔
767
    }
768

769
    protected async configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse, args: DebugProtocol.ConfigurationDoneArguments) {
770
        this.logger.log('configurationDoneRequest');
3✔
771
        super.configurationDoneRequest(response, args);
3✔
772

773
        let error: Error;
774
        try {
3✔
775
            await this.runAutomaticSceneGraphCommands(this.launchConfiguration.autoRunSgDebugCommands);
3✔
776

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

780
            //pass the log level down thought the adapter to the RendezvousTracker and ChanperfTracker
781
            this.rokuAdapter.setConsoleOutput(this.launchConfiguration.consoleOutput);
3✔
782

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

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

805
            // Send chanperf events to the extension
806
            this.rokuAdapter.on('chanperf', (output) => {
3✔
807
                this.sendEvent(new ChanperfEvent(output));
×
808
            });
809

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

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

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

829
                if (this.launchConfiguration.stopDebuggerOnAppExit) {
×
830
                    let message = `App exit event detected and launchConfiguration.stopDebuggerOnAppExit is true`;
×
831
                    message += ' - shutting down debug session';
×
832

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

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

855
            this.sendLaunchProgress('update', 'Uploading to Roku');
3✔
856
            await this.publish();
3✔
857

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

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

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

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

907
                //send any compile errors to the client
908
                await this.rokuAdapter?.sendErrors();
×
909

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

918
    /**
919
     * Activate all required functionality for profiling
920
     */
921
    private async initializeProfiling() {
922

923
        // Initialize PerfettoManager
924
        this.perfettoManager = new PerfettoManager({
17✔
925
            host: this.launchConfiguration.host,
926
            rootDir: this.launchConfiguration.rootDir,
927
            remotePort: this.launchConfiguration.remotePort,
928
            ...this.launchConfiguration.profiling?.tracing
51✔
929
        });
930

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

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

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

968
            //tracing was requested but the device firmware does not meet the minimum requirement
969
        } else if (this.launchConfiguration.profiling?.tracing?.enable && !this.supportsPerfettoTracing) {
13✔
970
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
2!
971
            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✔
972
            this.logger.warn(message);
2✔
973
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
2✔
974

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

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

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

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

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

1031
    private async _initRendezvousTracking() {
1032
        this.rendezvousTracker = new RendezvousTracker(this.deviceInfo, this.launchConfiguration);
3✔
1033

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

1039
        // Send rendezvous events to the debug protocol client
1040
        this.rendezvousTracker.on('rendezvous', (output) => {
3✔
1041
            this.sendEvent(new RendezvousEvent(output));
1✔
1042
        });
1043

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

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

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

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

1083
        this.sendEvent(new DiagnosticsEvent(diagnostics));
2✔
1084
    }
1085

1086
    private publishTimeout = 60_000;
205✔
1087

1088
    private async publish() {
1089
        const uploadingEnd = this.logger.timeStart('log', 'Uploading zip');
2✔
1090
        let packageIsPublished = false;
2✔
1091

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

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

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

1142
        await publishPromise;
2✔
1143

1144
        uploadingEnd();
2✔
1145

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

1165
    private pendingSendLogPromise = Promise.resolve();
205✔
1166

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

1177
        this.pendingSendLogPromise = this.pendingSendLogPromise.then(async () => {
38✔
1178
            logOutput = await this.convertBacktracePaths(logOutput);
38✔
1179

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

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

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

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

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

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

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

1292
                    let lineNumberReplacement = fullLineNumber.replace(lineNumber.toString(), originalLocation.lineNumber.toString());
4✔
1293

1294
                    // 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
1295
                    let completeReplacement = fullMatch.replace(filePath, fileReplacement);
4✔
1296
                    completeReplacement = completeReplacement.replace(fullLineNumber, lineNumberReplacement);
4✔
1297
                    input = input.replaceAll(fullMatch, completeReplacement);
4✔
1298
                }
1299

1300
            }
1301
        }
1302

1303
        return input;
30✔
1304
    }
1305

1306
    private async runAutomaticSceneGraphCommands(commands: string[]) {
1307
        if (commands) {
1!
1308
            let connection = new SceneGraphDebugCommandController(this.launchConfiguration.host, this.launchConfiguration.sceneGraphDebugCommandsPort);
×
1309

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

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

1331
                        case 'logrendezvous':
1332
                            util.log('Enabling Rendezvous Logging:');
×
1333
                            response = await connection.logrendezvous('on');
×
1334
                            if (!response.error) {
×
1335
                                util.log(response.result.rawResponse);
×
1336
                            }
1337
                            break;
×
1338

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

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

1375
        util.log('Moving selected files to staging area');
2✔
1376
        await this.projectManager.mainProject.stage();
2✔
1377

1378
        //add the entry breakpoint if stopOnEntry is true
1379
        await this.handleEntryBreakpoint();
2✔
1380
    }
1381

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

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

1394
        if (this.launchConfiguration.packageTask) {
2✔
1395
            util.log(`Executing task '${this.launchConfiguration.packageTask}' to assemble the app`);
1✔
1396
            await this.sendCustomRequest('executeTask', { task: this.launchConfiguration.packageTask });
1✔
1397

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

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

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

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

1431
        } else if (command === 'popupMessageEventResponse') {
×
1432
            this.emit('popupMessageEventResponse', args);
×
1433

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

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

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

1453
        }
1454
        this.sendResponse(response);
×
1455
    }
1456

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

1469
        //create a ComponentLibraryProject for each component library
1470
        for (let libraryIndex = 0; libraryIndex < componentLibraries.length; libraryIndex++) {
9✔
1471
            let componentLibrary = componentLibraries[libraryIndex];
19✔
1472

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

1491
        //stage all of the libraries in parallel
1492
        await Promise.all(
9✔
1493
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.stage())
19✔
1494
        );
1495
    }
1496

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

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

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

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

1528
        for (let i = 0; i < this.projectManager.componentLibraryProjects.length; i++) {
8✔
1529
            const compLibProject = this.projectManager.componentLibraryProjects[i];
18✔
1530

1531
            if (compLibProject.install === true) {
18✔
1532
                //wait for this complib to finish being packaged
1533
                await packagePromises[i];
12✔
1534

1535
                if (componentLibraries[i].packageTask) {
12✔
1536
                    await this.sendCustomRequest('executeTask', { task: componentLibraries[i].packageTask });
2✔
1537
                }
1538

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

1551
                if (componentLibraries[i].packagePath) {
12✔
1552
                    options.outDir = path.dirname(componentLibraries[i].packagePath);
2✔
1553
                    options.outFile = path.basename(componentLibraries[i].packagePath);
2✔
1554
                }
1555

1556
                try {
12✔
1557
                    await rokuDeploy.publish(options);
12✔
1558
                } catch (error) {
1559
                    this.logger.error(`Error installing component library ${i}`, error);
4✔
1560
                }
1561
            }
1562
        }
1563

1564
        const hostingPromise = this.componentLibraryServer.startStaticFileHosting(componentLibrariesOutDir, port, (message: string) => {
8✔
1565
            util.log(message);
×
1566
        });
1567

1568
        //wait for all complib packaging to finish and the file hosting to start
1569
        await Promise.all([
8✔
1570
            ...packagePromises,
1571
            hostingPromise
1572
        ]);
1573
    }
1574

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

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

1594
        response.body = {
6✔
1595
            breakpoints: sortedAndFilteredBreakpoints
1596
        };
1597
        this.sendResponse(response);
6✔
1598

1599
        //ensure we've staged all the files
1600
        await this.stagingDefered.promise;
6✔
1601

1602
        await this.rokuAdapter?.syncBreakpoints();
6!
1603
    }
1604

1605
    protected exceptionInfoRequest(response: DebugProtocol.ExceptionInfoResponse, args: DebugProtocol.ExceptionInfoArguments) {
1606
        this.logger.log('exceptionInfoRequest');
×
1607
    }
1608

1609
    protected async threadsRequest(response: DebugProtocol.ThreadsResponse) {
1610
        this.logger.log('threadsRequest');
4✔
1611

1612
        let threads = [];
4✔
1613

1614
        //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
1615
        if (this.compileError) {
4!
1616
            threads.push(new Thread(this.COMPILE_ERROR_THREAD_ID, 'Compile Error'));
×
1617
        } else {
1618
            //wait for the roku adapter to load
1619
            await this.getRokuAdapter();
4✔
1620

1621
            //only send the threads request if we are at the debugger prompt
1622
            if (this.rokuAdapter.isAtDebuggerPrompt) {
4✔
1623
                let rokuThreads = await this.rokuAdapter.getThreads();
3✔
1624

1625
                for (let thread of rokuThreads) {
3✔
1626
                    const threadName = this.getThreadName(thread as AdapterThread);
4✔
1627
                    threads.push(
4✔
1628
                        new Thread(thread.threadId, threadName)
1629
                    );
1630
                }
1631

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

1640
            } else {
1641
                this.logger.log('Skipped getting threads because the RokuAdapter is not accepting input at this time.');
1✔
1642
            }
1643

1644
        }
1645

1646
        response.body = {
4✔
1647
            threads: threads
1648
        };
1649

1650
        this.sendResponse(response);
4✔
1651
    }
1652

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

1678
        if (threadName === '') {
24✔
1679
            threadName = `Thread ${thread.threadId}`;
11✔
1680
        }
1681

1682
        if (thread.isDetached) {
24✔
1683
            threadName += ' [detached]';
4✔
1684
        }
1685

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

1689
        return threadName;
24✔
1690
    }
1691

1692
    protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) {
1693
        try {
3✔
1694
            this.logger.log('stackTraceRequest');
3✔
1695
            let frames: DebugProtocol.StackFrame[] = [];
3✔
1696

1697
            //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
1698
            if (this.compileError) {
3!
1699
                frames.push(new StackFrame(
×
1700
                    0,
1701
                    'Compile Error',
1702
                    new Source(path.basename(this.compileError.path), this.compileError.path),
1703
                    // range is 0-based; toClientLine/toClientColumn handle client coordinate conversion
1704
                    this.toClientLine(this.compileError.range.start.line),
1705
                    this.toClientColumn(this.compileError.range.start.character)
1706
                ));
1707
            } else if (args.threadId === 1001) {
3!
1708
                frames.push(new StackFrame(
×
1709
                    0,
1710
                    'ERROR: threads would not stop',
1711
                    new Source('main.brs', s`${this.launchConfiguration.stagingDir}/manifest`),
1712
                    this.toClientLine(0),
1713
                    this.toClientColumn(0)
1714
                ));
1715
                this.showPopupMessage('Unable to suspend threads. Debugger is in an unstable state, please press Continue to resume debugging', 'warn').catch((error) => {
×
1716
                    this.logger.error('Error showing popup message', { error });
×
1717
                });
1718
            } else {
1719
                //ensure the rokuAdapter is loaded
1720
                await this.getRokuAdapter();
3✔
1721

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

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

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

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

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

1790
            // create the locals scope
1791
            let v = this.getOrCreateLocalsScope(args.frameId);
1✔
1792

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

1801
            const frame = this.rokuAdapter.getStackFrameById(args.frameId);
1✔
1802
            if (frame) {
×
1803
                const scopeRange = await this.projectManager.getScopeRange(frame.filePath, { line: frame.lineNumber - 1, character: 0 });
×
1804

1805
                if (scopeRange) {
×
1806
                    localScope.line = this.toClientLine(scopeRange.start.line - 1);
×
1807
                    localScope.column = this.toClientColumn(scopeRange.start.column);
×
1808
                    localScope.endLine = this.toClientLine(scopeRange.end.line - 1);
×
1809
                    localScope.endColumn = this.toClientColumn(scopeRange.end.column);
×
1810
                }
1811
            }
1812

1813
            scopes.push(localScope);
×
1814

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

1823
            this.variables[registryRefId] = {
×
1824
                variablesReference: registryRefId,
1825
                name: 'Registry',
1826
                value: '',
1827
                type: '$$Registry',
1828
                isScope: true,
1829
                childVariables: []
1830
            };
1831

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

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

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

1871
        this.logger.log('continueRequest');
×
1872
        await this.setTransientsToInvalid(); // call before clearState
×
1873
        this.clearState();
×
1874

1875
        // The debug session ends after the next line. Do not put new work after this line.
1876
        await this.rokuAdapter.continue();
×
1877
        this.sendResponse(response);
×
1878
    }
1879

1880
    protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
1881
        this.logger.log('pauseRequest');
×
1882

1883
        //if we have a compile error, we should shut down
1884
        if (this.compileError) {
×
1885
            this.sendResponse(response);
×
1886
            await this.shutdown();
×
1887
            return;
×
1888
        }
1889

1890
        await this.rokuAdapter.pause();
×
1891
        this.sendResponse(response);
×
1892
    }
1893

1894
    protected reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse, args: DebugProtocol.ReverseContinueArguments) {
1895
        this.logger.log('reverseContinueRequest');
×
1896
        this.sendResponse(response);
×
1897
    }
1898

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

1907
        //if we have a compile error, we should shut down
1908
        if (this.compileError) {
×
1909
            this.sendResponse(response);
×
1910
            await this.shutdown();
×
1911
            return;
×
1912
        }
1913

1914
        await this.setTransientsToInvalid(); // call before clearState
×
1915
        this.clearState();
×
1916

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

1927
    protected async stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
1928
        this.logger.log('[stepInRequest]');
×
1929

1930
        //if we have a compile error, we should shut down
1931
        if (this.compileError) {
×
1932
            this.sendResponse(response);
×
1933
            await this.shutdown();
×
1934
            return;
×
1935
        }
1936

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

1945
    protected async stepOutRequest(response: DebugProtocol.StepOutResponse, args: DebugProtocol.StepOutArguments) {
1946
        this.logger.log('[stepOutRequest] begin');
×
1947

1948
        //if we have a compile error, we should shut down
1949
        if (this.compileError) {
×
1950
            this.sendResponse(response);
×
1951
            await this.shutdown();
×
1952
            return;
×
1953
        }
1954

1955
        await this.setTransientsToInvalid(); // call before clearState
×
1956
        this.clearState();
×
1957

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

1964
    protected stepBackRequest(response: DebugProtocol.StepBackResponse, args: DebugProtocol.StepBackArguments) {
1965
        this.logger.log('[stepBackRequest] begin');
×
1966
        this.sendResponse(response);
×
1967
        this.logger.info('[stepBackRequest] end');
×
1968
    }
1969

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

1977
            //ensure the rokuAdapter is loaded
1978
            await this.getRokuAdapter();
4✔
1979

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

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

1999
            // Populate scope level values if needed
2000
            if (v.isScope) {
4✔
2001
                await this.populateScopeVariables(v, args);
2✔
2002
            }
2003

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

2026
                    // Merge the resulting updates together
2027
                    v.childVariables = tempVar.childVariables;
×
2028
                    v.value = tempVar.value;
×
2029
                    v.type = tempVar.type;
×
2030
                    v.indexedVariables = tempVar.indexedVariables;
×
2031
                    v.namedVariables = tempVar.namedVariables;
×
2032
                }
2033
                frameId = v.frameId;
×
2034

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

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

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

2061
            } else {
2062
                updatedVariables = v.childVariables;
4✔
2063
            }
2064

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

2085
    private debounceSendInvalidatedEvent = debounce((threadId: number, frameId: number) => {
205✔
2086
        this.sendInvalidatedEvent(threadId, frameId);
×
2087
    }, 50);
2088

2089

2090
    private filterVariablesUpdates(updatedVariables: Array<AugmentedVariable>, args: DebugProtocol.VariablesArguments, v: DebugProtocol.Variable): Array<AugmentedVariable> {
2091
        if (!updatedVariables || !v) {
4!
2092
            return [];
×
2093
        }
2094

2095
        let start = args.start ?? 0;
4!
2096

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

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

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

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

2128
        return filteredUpdatedVariables;
4✔
2129
    }
2130

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

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

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

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

2190
        // Mark the scope as resolved so we don't re-fetch the variables
2191
        v.isResolved = true;
1✔
2192

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

2207
    private evaluateRequestPromise = Promise.resolve();
205✔
2208
    private evaluateVarIndexByFrameId = new Map<number, number>();
205✔
2209

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

2219
    public async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) {
2220
        //ensure the rokuAdapter is loaded
2221
        await this.getRokuAdapter();
15✔
2222

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

2234
        try {
15✔
2235
            this.evaluateRequestPromise = this.evaluateRequestPromise.then(() => {
15✔
2236
                return deferred.promise;
15✔
2237
            });
2238

2239
            //fix vscode hover bug that excludes closing quotemark sometimes.
2240
            if (args.context === 'hover') {
15✔
2241
                args.expression = util.ensureClosingQuote(args.expression);
4✔
2242
            }
2243

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

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

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

2263
                //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`
2264
                if (variablePath) {
14✔
2265
                    let refId = this.getEvaluateRefId(evalArgs.expression, evalArgs.frameId);
11✔
2266
                    let v: AugmentedVariable;
2267
                    //if we already looked this item up, return it
2268
                    if (this.variables[refId]) {
11✔
2269
                        v = this.variables[refId];
1✔
2270
                    } else {
2271
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, evalArgs.frameId);
10✔
2272
                        if (!result) {
10!
2273
                            throw new Error('Error: unable to evaluate expression');
×
2274
                        }
2275

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

2290
                    //run an `evaluate` call
2291
                } else {
2292
                    let commandResults = await this.rokuAdapter.evaluate(evalArgs.expression, evalArgs.frameId);
3✔
2293

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

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

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

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

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

2378
                storedVariables.push(`${arrayVarName}[${varIndex}]`);
×
2379
                returnVal.variablePath = [arrayVarName, varIndex.toString()];
×
2380
            }
2381

2382
            results.evaluations[i] = returnVal;
×
2383
        }
2384

2385
        if (command) {
×
2386

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

2396
            command += bulkContainerStatement;
×
2397

2398
            results.bulkVarName = `${arrayVarName}[${varIndex}]`;
×
2399

2400
            let commandResults = await this.rokuAdapter.evaluate(command, frameId);
×
2401
            if (commandResults.type === 'error') {
×
2402
                throw new Error(commandResults.message);
×
2403
            }
2404
        }
2405

2406
        return results;
×
2407
    }
2408

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

2414
        try {
20✔
2415
            let supplyLocalScopeCompletions = false;
20✔
2416

2417
            let closestCompletionDetails = this.getClosestCompletionDetails(args);
20✔
2418

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

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

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

2445
            // Get the completions if the variable path was valid
2446
            if (parentVariablePath) {
20!
2447

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

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

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

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

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

2480
                            switch (v.type) {
17!
2481
                                case VariableType.Function:
2482
                                case VariableType.Subroutine:
2483
                                    completionType = 'method';
×
2484
                                    break;
×
2485
                                default:
2486
                                    break;
17✔
2487
                            }
2488
                        }
2489

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

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

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

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

2547
                        const frame = this.rokuAdapter.getStackFrameById(args.frameId);
4✔
2548

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

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

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

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

2596
        const targetLine = lines[lineNumber] ?? '';
69!
2597

2598
        const cursorIndex = column - 1;
69✔
2599
        const variableChars = /[a-z0-9_\.]/i;
69✔
2600

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

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

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

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

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

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

2662
        if (variablePathString.endsWith('.')) {
65✔
2663
            isMemberAccess = true;
25✔
2664
        }
2665

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

2678
        // the target string is not a valid variable path
2679
        if (!variablePath) {
65✔
2680
            return undefined;
2✔
2681
        }
2682

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

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

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

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

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

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

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

2767
        const inMemory = this.findFrameVariableByPath(parentVariablePath, frameId);
20✔
2768
        if (inMemory && inMemory.childVariables.length > 0) {
20✔
2769
            return inMemory;
14✔
2770
        }
2771

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

2776
        const cacheKey = `${frameId}:${expression}`;
6✔
2777
        if (this.completionParentVariableCache.has(cacheKey)) {
6✔
2778
            return this.completionParentVariableCache.get(cacheKey);
1✔
2779
        }
2780

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

2792
        this.completionParentVariableCache.set(cacheKey, parentVariable);
5✔
2793
        return parentVariable;
5✔
2794
    }
2795

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

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

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

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

2858
            // If no match is found, return null
2859
            if (!current) {
26✔
2860
                return null;
7✔
2861
            }
2862

2863
            // Move to the children for the next iteration
2864
            variables = current.childVariables;
19✔
2865
        }
2866
        return current;
15✔
2867
    }
2868

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

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

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

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

2943
    private exitAppTimeout = 5000;
205✔
2944
    private async ensureAppIsInactive() {
2945
        const startTime = Date.now();
×
2946

2947
        while (true) {
×
2948
            if (Date.now() - startTime > this.exitAppTimeout) {
×
2949
                return;
×
2950
            }
2951

2952
            try {
×
2953
                let appStateResult = await rokuECP.getAppState({
×
2954
                    host: this.launchConfiguration.host,
2955
                    remotePort: this.launchConfiguration.remotePort,
2956
                    appId: 'dev',
2957
                    requestOptions: { timeout: 300 }
2958
                });
2959

2960
                const state = appStateResult.state;
×
2961

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

2980
            await util.sleep(200);
×
2981
        }
2982
    }
2983

2984
    /**
2985
     * Used to track whether the entry breakpoint has already been handled
2986
     */
2987
    private entryBreakpointWasHandled = false;
205✔
2988

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

3000
        this.rokuAdapter.on('launch-status', (message) => {
×
3001
            this.sendLaunchProgress('update', message);
×
3002
        });
3003

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

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

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

3024
        //make the connection
3025
        await this.rokuAdapter.connect();
×
3026
        this.rokuAdapterDeferred.resolve(this.rokuAdapter);
×
3027
        return this.rokuAdapter;
×
3028
    }
3029

3030
    private async onSuspend() {
3031
        const threads = await this.setupSuspendedState();
1✔
3032
        const activeThread = threads.find(x => x.isSelected);
1✔
3033

3034
        //if !stopOnEntry, and we haven't encountered a suspend yet, THIS is the entry breakpoint. auto-continue
3035
        if (!this.entryBreakpointWasHandled && !this.launchConfiguration.stopOnEntry) {
1!
3036
            this.entryBreakpointWasHandled = true;
1✔
3037
            //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
3038
            if (activeThread && !await this.breakpointManager.lineHasBreakpoint(this.projectManager.getAllProjects(), activeThread.filePath, activeThread.lineNumber - 1)) {
1!
3039
                this.logger.info('Encountered entry breakpoint and `stopOnEntry` is disabled. Continuing...');
×
3040
                return this.rokuAdapter.continue();
×
3041
            }
3042
        }
3043

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

3055
    private async setupSuspendedState() {
3056
        //clear the index for storing evalutated expressions
3057
        this.evaluateVarIndexByFrameId.clear();
8✔
3058

3059
        const threads = await this.rokuAdapter.getThreads();
8✔
3060

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

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

3093
        //sync breakpoints
3094
        await this.rokuAdapter?.syncBreakpoints();
8!
3095

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

3098
        this.clearState();
8✔
3099
        return threads;
8✔
3100
    }
3101

3102
    private async getVariableFromResult(result: EvaluateContainer, frameId: number, maxDepth = 1) {
15✔
3103
        let v: AugmentedVariable;
3104

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

3128
                if (result.keyType) {
16✔
3129
                    let value = `${result.value ?? result.type}`;
5!
3130
                    let indexedVariables = result.indexedVariables;
5✔
3131
                    let namedVariables = result.namedVariables;
5✔
3132

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

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

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

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

3196
            if (result.children && maxDepth > 0) {
25✔
3197
                if (!v.childVariables) {
7!
3198
                    v.childVariables = [];
7✔
3199
                }
3200

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

3212
                    let variablePathArray = childrenToEvaluate.map(x => {
×
3213
                        return util.getVariablePath(x.child.evaluateName);
×
3214
                    });
3215

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

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

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

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

3273
    private clearState() {
3274
        //erase all cached variables
3275
        this.variables = {};
12✔
3276
        this.completionParentVariableCache.clear();
12✔
3277
    }
3278

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

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

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

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

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

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

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

3380
    private shutdownPromise: Promise<void> | undefined = undefined;
205✔
3381

3382
    /**
3383
     * 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
3384
     * the same promise on subsequent calls
3385
     */
3386
    public async shutdown(errorMessage?: string, modal = false): Promise<void> {
16✔
3387
        if (this.shutdownPromise === undefined) {
16!
3388
            this.logger.log('[shutdown] Beginning shutdown sequence', errorMessage);
16✔
3389
            //Backstop: if the graceful shutdown hangs (e.g. pressHomeButton against an unreachable
3390
            //device), force-exit anyway so we never leave an orphaned adapter running forever
3391
            const forceExitTimer = setTimeout(() => {
16✔
3392
                this.logger.error('[shutdown] graceful shutdown timed out; forcing exit');
×
3393
                this.forceExit();
×
3394
            }, this.shutdownForceExitTimeout);
3395
            forceExitTimer.unref?.();
16!
3396
            this.shutdownPromise = this._shutdown(errorMessage, modal).finally(() => {
16✔
3397
                clearTimeout(forceExitTimer);
16✔
3398
            });
3399
        } else {
3400
            this.logger.log('[shutdown] Tried to call `.shutdown()` again. Returning the same promise');
×
3401
        }
3402
        return this.shutdownPromise;
16✔
3403
    }
3404

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

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

3427
        try {
16✔
3428
            await this.perfettoManager?.dispose?.();
16!
3429
        } catch (e) {
3430
            this.logger.error('Error disposing perfetto manager', e);
×
3431
        }
3432

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

3448
        try {
16✔
3449
            this.projectManager?.dispose?.();
16!
3450
        } catch (e) {
3451
            this.logger.error(e);
×
3452
        }
3453

3454
        try {
16✔
3455
            this.componentLibraryServer?.stop();
16!
3456
        } catch (e) {
3457
            this.logger.error(e);
×
3458
        }
3459

3460
        try {
16✔
3461
            await this.rendezvousTracker?.destroy?.();
16!
3462
        } catch (e) {
3463
            this.logger.error(e);
×
3464
        }
3465

3466
        try {
16✔
3467
            await this.sourceMapManager?.destroy?.();
16!
3468
        } catch (e) {
3469
            this.logger.error(e);
×
3470
        }
3471

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

3490
        try {
16✔
3491
            this.logger.log('Send terminated event');
16✔
3492
            this.sendEvent(new TerminatedEvent());
16✔
3493

3494
            //shut down the process
3495
            this.logger.log('super.shutdown()');
16✔
3496
            super.shutdown();
16✔
3497
            this.logger.log('shutdown complete');
16✔
3498
        } catch (e) {
3499
            this.logger.error(e);
×
3500
        }
3501

3502
        try {
16✔
3503
            this.teardownProcessErrorHandlers();
16✔
3504
        } catch (e) {
3505
            this.logger.error(e);
×
3506
        }
3507
    }
3508
}
3509

3510
export interface AugmentedVariable extends DebugProtocol.Variable {
3511
    childVariables?: AugmentedVariable[];
3512
    // eslint-disable-next-line camelcase
3513
    request_seq?: number;
3514
    frameId?: number;
3515
    /**
3516
     * only used for lazy variables
3517
     */
3518
    isResolved?: boolean;
3519
    /**
3520
     * used to indicate that this variable is a scope variable
3521
     * and may require special handling
3522
     */
3523
    isScope?: boolean;
3524
}
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