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

rokucommunity / roku-debug / 29762980867

20 Jul 2026 05:15PM UTC coverage: 72.892% (+0.2%) from 72.668%
29762980867

Pull #323

github

web-flow
Merge eb38d8798 into 74921e7f1
Pull Request #323: Fix `libary` statement complib postfixes

3860 of 5537 branches covered (69.71%)

Branch coverage included in aggregate %.

81 of 84 new or added lines in 2 files covered. (96.43%)

6003 of 7994 relevant lines covered (75.09%)

47.41 hits per line

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

67.05
/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();
215✔
92

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

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

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

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

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

337
    public fileManager: FileManager;
338

339
    public projectManager: ProjectManager;
340

341
    public fileLoggingManager: FileLoggingManager;
342

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

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

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

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

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

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

386
    private rokuAdapter: DebugProtocolAdapter | TelnetAdapter;
387

388
    private perfettoManager: PerfettoManager;
389

390
    private rendezvousTracker: RendezvousTracker;
391

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

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

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

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

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

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

647
            //do a DNS lookup for the host to fix issues with roku rejecting ECP
648
            try {
9✔
649
                this.launchConfiguration.host = await util.dnsLookup(this.launchConfiguration.host);
9✔
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 {
9✔
656
                if (this.launchConfiguration.deviceInfo) {
9✔
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 });
8✔
660
                }
661
                if (this.deviceInfo.ecpSettingMode === 'limited') {
9!
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) {
×
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) {
9!
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);
9✔
677

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

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

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

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

695
            //all of the projects have been successfully staged.
696
            this.stagingDefered.tryResolve();
9✔
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) {
9!
700
                await this.sendCustomRequest('processStagingDir', {
×
701
                    projects: this.projectManager.getProjectStagingInfo()
702
                });
703
            }
704

705
            packageEnd();
9✔
706

707
            if (this.enableDebugProtocol) {
9!
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}`);
9✔
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 {
9✔
715
                const rendezvousEnd = this.logger.timeStart('log', 'Rendezvous tracking');
9✔
716
                await this.initRendezvousTracking();
9✔
717
                rendezvousEnd();
9✔
718
            } catch (e) {
719
                this.logger.error('Failed to initialize rendezvous tracking', e);
×
720
            }
721

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

726
            // Manipulating complibs on-device autolaunches the dev app and often triggers compile errors,
727
            // so if we have at least one installable complib, delete the dev app and any complibs to avoid all that.
728
            if (this.launchConfiguration.componentLibraries?.some(x => x.install)) {
9!
NEW
729
                this.sendLaunchProgress('update', 'Removing existing dev app and component libraries');
×
NEW
730
                await rokuDeploy.deleteAllSideloadedPlugins({
×
731
                    host: this.launchConfiguration.host,
732
                    password: this.launchConfiguration.password
733
                });
734
            }
735

736
            await this.connectRokuAdapter();
9✔
737
            connectAdapterEnd();
9✔
738

739
            // Capabilities that depend on the adapter or device version. The exception-breakpoint
740
            // FILTER LIST was surfaced in initializeRequest (VS Code only reads it from there).
741
            // Everything below is read per-action in VS Code, so a CapabilitiesEvent update takes
742
            // effect dynamically.
743
            const supportsExceptionBreakpoints = this.rokuAdapter.supportsExceptionBreakpoints;
9✔
744
            this.sendEvent(new CapabilitiesEvent({
9✔
745
                supportsLogPoints: !this.enableDebugProtocol,
746
                supportsExceptionFilterOptions: supportsExceptionBreakpoints,
747
                supportsExceptionOptions: supportsExceptionBreakpoints,
748
                supportsConditionalBreakpoints: this.rokuAdapter.supportsConditionalBreakpoints,
749
                supportsHitConditionalBreakpoints: this.rokuAdapter.supportsHitConditionalBreakpoints
750
            }));
751

752
            this.sendLaunchProgress('update', 'Configuring breakpoints');
9✔
753

754
            util.log('Done initializing');
9✔
755

756
            // notify VS Code that the adapter is ready to receive configuration (breakpoints, etc.)
757
            // VS Code will respond with setBreakpoints, setExceptionBreakpoints, then configurationDone
758
            this.sendEvent(new InitializedEvent());
9✔
759

760
            await this.initializeProfiling();
9✔
761

762
        } catch (e) {
763
            //if the message is anything other than compile errors, we want to display the error
764
            if (!(e instanceof CompileError)) {
×
765
                util.log('Encountered an issue during the launch process');
×
766
                util.log((e as Error)?.stack);
×
767

768
                //send any compile errors to the client
769
                await this.rokuAdapter?.sendErrors();
×
770

771
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
772
                await this.shutdown(message as string, true);
×
773
            } else {
774
                this.sendLaunchProgress('end', 'Aborted (compile error)');
×
775
            }
776
        }
777
        logEnd();
9✔
778
    }
779

780
    protected async configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse, args: DebugProtocol.ConfigurationDoneArguments) {
781
        this.logger.log('configurationDoneRequest');
4✔
782
        super.configurationDoneRequest(response, args);
4✔
783

784
        let error: Error;
785
        try {
4✔
786
            await this.runAutomaticSceneGraphCommands(this.launchConfiguration.autoRunSgDebugCommands);
4✔
787

788
            //press the home button to ensure we're at the home screen
789
            await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
4✔
790

791
            //pass the log level down thought the adapter to the RendezvousTracker and ChanperfTracker
792
            this.rokuAdapter.setConsoleOutput(this.launchConfiguration.consoleOutput);
4✔
793

794
            //pass along the console output
795
            if (this.launchConfiguration.consoleOutput === 'full') {
4!
796
                this.rokuAdapter.on('console-output', (data) => {
×
797
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
×
798
                });
799
            } else {
800
                this.rokuAdapter.on('unhandled-console-output', (data) => {
4✔
801
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
×
802
                });
803
            }
804

805
            this.rokuAdapter.on('device-unresponsive', async (data: { lastCommand: string }) => {
4✔
806
                const stopDebuggerAction = 'Stop Debugger';
×
807
                const message = `Roku device ${this.launchConfiguration.host} is not responding and may not recover.` +
×
808
                    (data.lastCommand ? `\n\nActive command:\n"${util.truncate(data.lastCommand, 30)}"` : '');
×
809
                this.logger.log(message, data);
×
810
                const response = await this.showPopupMessage(message, 'warn', false, [stopDebuggerAction]);
×
811
                if (response === stopDebuggerAction) {
×
812
                    await this.shutdown();
×
813
                }
814
            });
815

816
            // Send chanperf events to the extension
817
            this.rokuAdapter.on('chanperf', (output) => {
4✔
818
                this.sendEvent(new ChanperfEvent(output));
×
819
            });
820

821
            //listen for a closed connection (shut down when received)
822
            this.rokuAdapter.on('close', (reason = '') => {
4!
823
                if (reason === 'compileErrors') {
×
824
                    error = new Error('compileErrors');
×
825
                } else {
826
                    error = new Error('Unable to connect to Roku. Is another device already connected?');
×
827
                }
828
            });
829

830
            // handle any compile errors
831
            this.rokuAdapter.on('diagnostics', (diagnostics: BSDebugDiagnostic[]) => {
4✔
832
                this.handleDiagnostics(diagnostics).catch(e => this.logger.error('Failed to handle diagnostics', e));
×
833
            });
834

835
            // close disconnect if required when the app is exited
836
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
837
            this.rokuAdapter.on('app-exit', async () => {
4✔
838
                this.resetSessionState();
×
839

840
                if (this.launchConfiguration.stopDebuggerOnAppExit) {
×
841
                    let message = `App exit event detected and launchConfiguration.stopDebuggerOnAppExit is true`;
×
842
                    message += ' - shutting down debug session';
×
843

844
                    this.logger.log('on app-exit', message);
×
845
                    this.sendEvent(new LogOutputEvent(message));
×
846
                    await this.shutdown();
×
847
                } else {
848
                    const message = 'App exit detected; but launchConfiguration.stopDebuggerOnAppExit is set to false, so keeping debug session running.';
×
849
                    this.logger.log('[configurationDoneRequest]', message);
×
850
                    this.sendEvent(new LogOutputEvent(message));
×
851
                    this.rokuAdapter.once('connected').then(async () => {
×
852
                        await this.rokuAdapter.setExceptionBreakpoints(this.exceptionBreakpoints);
×
853
                    }).catch(e => this.logger.error('Failed to set exception breakpoints after reconnect', e));
×
854
                }
855
            });
856
            //profiling supports connecting to the socket BEFORE a channel is published, so go ahead and connect now
857
            await this.tryProfilingConnectOnStart();
4✔
858

859
            //all setBreakpoints requests have arrived by this point (configurationDone is the DAP signal
860
            //that the client has finished sending configuration). Inject the STOPs and postfix each project's
861
            //own files now (still no zips. those are sealed after cross-project references are rewritten).
862
            await Promise.all([
4✔
863
                this.writeMainProjectBreakpoints(),
864
                this.writeAndPostfixComponentLibraries(this.launchConfiguration.componentLibraries)
865
            ]);
866

867
            //now that EVERY project (main + all complibs) is postfixed, rewrite cross-project `Library`
868
            //references to point at the postfixed file names. Must run before any project zip is sealed.
869
            await this.projectManager.applyLibraryReferencePostfixes();
4✔
870

871
            //now zip the main project, also zip and upload installable complibs, and start the webserver for non-installed complibs.
872
            await Promise.all([
4✔
873
                this.zipMainProject(),
874
                this.zipServeAndInstallComponentLibraries(this.launchConfiguration.componentLibraries, this.launchConfiguration.componentLibrariesPort)
875
            ]);
876

877
            this.sendLaunchProgress('update', 'Uploading to Roku');
4✔
878
            await this.publish();
4✔
879

880
            //hack for certain roku devices that lock up when this event is emitted (no idea why!).
881
            if (this.launchConfiguration.emitChannelPublishedEvent) {
3!
882
                this.sendEvent(new ChannelPublishedEvent(
3✔
883
                    this.launchConfiguration
884
                ));
885
            }
886

887
            //tell the adapter adapter that the channel has been launched.
888
            this.sendLaunchProgress('update', 'Waiting on application');
3✔
889
            await this.rokuAdapter.activate();
3✔
890
            if (this.rokuAdapter.isDestroyed) {
3!
891
                throw new Error('Debug session encountered an error');
×
892
            }
893
            if (!error) {
3!
894
                if (this.rokuAdapter.connected) {
3!
895
                    this.logger.info('Host connection was established before the main public process was completed');
3✔
896
                    this.logger.log(`deployed to Roku@${this.launchConfiguration.host}`);
3✔
897
                } else {
898
                    this.logger.info('Main public process was completed but we are still waiting for a connection to the host');
×
899
                    this.rokuAdapter.on('connected', (status) => {
×
900
                        if (status) {
×
901
                            this.logger.log(`deployed to Roku@${this.launchConfiguration.host}`);
×
902
                        }
903
                    });
904
                }
905
            } else {
906
                throw error;
×
907
            }
908

909
            //at this point, the project has been deployed. If we need to use a deep link, launch it now.
910
            if (this.launchConfiguration.deepLinkUrl && !this.enableDebugProtocol) {
3!
911
                //wait until the first entry breakpoint has been hit
912
                await this.firstRunDeferred.promise;
×
913
                //if we are at a breakpoint, continue
914
                await this.rokuAdapter.continue();
×
915
                //kill the app on the roku
916
                // await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
917
                //convert a hostname to an ip address
918
                const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl);
×
919
                //send the deep link http request
920
                await util.httpPost(deepLinkUrl);
×
921
            }
922

923
        } catch (e) {
924
            //if the message is anything other than compile errors, we want to display the error
925
            if (!(e instanceof CompileError)) {
1!
926
                util.log('Encountered an issue during the publish process');
×
927
                util.log((e as Error)?.stack);
×
928

929
                //send any compile errors to the client
930
                await this.rokuAdapter?.sendErrors();
×
931

932
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
933
                await this.shutdown(message as string, true);
×
934
            } else {
935
                this.sendLaunchProgress('end', 'Aborted (compile error)');
1✔
936
            }
937
        }
938
    }
939

940
    /**
941
     * Activate all required functionality for profiling
942
     */
943
    private async initializeProfiling() {
944

945
        // Initialize PerfettoManager
946
        this.perfettoManager = new PerfettoManager({
18✔
947
            host: this.launchConfiguration.host,
948
            rootDir: this.launchConfiguration.rootDir,
949
            remotePort: this.launchConfiguration.remotePort,
950
            ...this.launchConfiguration.profiling?.tracing
54✔
951
        });
952

953
        //send certain profiling events back to the client
954
        this.perfettoManager.on('enable', (event) => {
18✔
955
            this.sendEvent(new ProfilingEnableEvent({
×
956
                types: event.types
957
            }));
958
        });
959
        this.perfettoManager.on('start', (event) => {
18✔
960
            this.sendEvent(new ProfilingStartEvent({
×
961
                type: event.type
962
            }));
963
        });
964
        this.perfettoManager.on('stop', (event) => {
18✔
965
            this.sendEvent(new ProfilingStopEvent({
×
966
                type: event.type,
967
                result: event.result
968
            }));
969
        });
970
        this.perfettoManager.on('error', (event) => {
18✔
971
            this.sendEvent(new ProfilingErrorEvent({
×
972
                error: event.error
973
            }));
974
        });
975

976
        //tracing is explicitly enabled. Turn it on
977
        if (this.launchConfiguration.profiling?.tracing?.enable && this.supportsPerfettoTracing) {
18✔
978
            this.logger.info('Enabling perfetto tracing because it is supported by the device and enabled in the launch configuration');
2✔
979
            try {
2✔
980
                await this.perfettoManager?.enableTracing?.();
2!
981
            } catch (e) {
982
                this.logger.error('Failed to enable perfetto tracing', e);
1✔
983
            }
984

985
            //tracing is expicitly DISabled. turn it off
986
        } else if (this.launchConfiguration.profiling?.tracing?.enable === false && this.supportsPerfettoTracing) {
16✔
987
            this.logger.info('Disabling perfetto tracing because it is disabled in the launch configuration');
2✔
988
            //TODO implement a way to disable perfetto tracing on the device
989

990
            //tracing was requested but the device firmware does not meet the minimum requirement
991
        } else if (this.launchConfiguration.profiling?.tracing?.enable && !this.supportsPerfettoTracing) {
14✔
992
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
2!
993
            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✔
994
            this.logger.warn(message);
2✔
995
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
2✔
996

997
            //profiling.tracing.enabled is set to `undefined`, which means we should do nothing
998
        } else {
999
            this.logger.info('Skipping perfetto initalization because `profiling.tracing.enable` is not defined in the launch configuration');
12✔
1000
        }
1001
    }
1002

1003
    /**
1004
     * If profiling was marked "connectOnStart", try connecting right away
1005
     */
1006
    private async tryProfilingConnectOnStart() {
1007
        if (this.launchConfiguration.profiling?.tracing?.connectOnStart && this.supportsPerfettoTracing) {
8✔
1008
            try {
2✔
1009
                await this.perfettoManager?.startTracing?.();
2!
1010
            } catch (e) {
1011
                this.logger.error('Failed to start perfetto tracing on start', e);
1✔
1012
            }
1013
        } else if (this.launchConfiguration.profiling?.tracing?.connectOnStart && !this.supportsPerfettoTracing) {
6✔
1014
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
1!
1015
            const message = `Perfetto profiling is not available: device firmware ${firmwareVersion} is below the minimum required version (15.2). Tracing will not start automatically.`;
1✔
1016
            this.logger.warn(message);
1✔
1017
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
1✔
1018
        }
1019
    }
1020

1021
    /**
1022
     * Clear certain properties that need reset whenever a debug session is restarted (via vscode or launched from the Roku home screen)
1023
     */
1024
    private resetSessionState() {
1025
        // launchRequest gets invoked by our restart session flow.
1026
        // We need to clear/reset some state to avoid issues.
1027
        this.entryBreakpointWasHandled = false;
10✔
1028
        //reset all per-session breakpoint state (diff baseline + cached parsed ASTs) since a restart
1029
        //re-stages the project
1030
        this.breakpointManager.reset();
10✔
1031
    }
1032

1033
    /**
1034
     * Activate rendezvous tracking (IF enabled in the LaunchConfig)
1035
     */
1036
    public async initRendezvousTracking() {
1037
        const timeout = 5000;
3✔
1038
        let initCompleted = false;
3✔
1039
        await Promise.race([
3✔
1040
            util.sleep(timeout),
1041
            this._initRendezvousTracking().finally(() => {
1042
                initCompleted = true;
3✔
1043
            })
1044
        ]);
1045

1046
        if (initCompleted === false) {
3!
1047
            this.showPopupMessage(`Rendezvous tracking timed out after ${timeout}ms. Consider setting "rendezvousTracking": false in launch.json`, 'warn').catch((error) => {
×
1048
                this.logger.error('Error showing popup message', { error });
×
1049
            });
1050
        }
1051
    }
1052

1053
    private async _initRendezvousTracking() {
1054
        this.rendezvousTracker = new RendezvousTracker(this.deviceInfo, this.launchConfiguration);
3✔
1055

1056
        //pass the debug functions used to locate the client files and lines thought the adapter to the RendezvousTracker
1057
        this.rendezvousTracker.registerSourceLocator(async (debuggerPath: string, lineNumber: number) => {
3✔
1058
            return this.projectManager.getSourceLocation(debuggerPath, lineNumber);
×
1059
        });
1060

1061
        // Send rendezvous events to the debug protocol client
1062
        this.rendezvousTracker.on('rendezvous', (output) => {
3✔
1063
            this.sendEvent(new RendezvousEvent(output));
1✔
1064
        });
1065

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

1069
        //if rendezvous tracking is enabled, then enable it on the device
1070
        if (this.launchConfiguration.rendezvousTracking !== false) {
3✔
1071
            // start ECP rendezvous tracking (if possible)
1072
            await this.rendezvousTracker.activate();
2✔
1073
        }
1074
    }
1075

1076
    /**
1077
     * Anytime a roku adapter emits diagnostics, this method is called to handle it.
1078
     */
1079
    private async handleDiagnostics(diagnostics: BSDebugDiagnostic[]) {
1080
        // Roku device and sourcemap work with 1-based line numbers, VSCode expects 0-based lines.
1081
        for (let diagnostic of diagnostics) {
2✔
1082
            diagnostic.source = diagnosticSource;
2✔
1083
            let sourceLocation = await this.projectManager.getSourceLocation(diagnostic.path, diagnostic.range.start.line + 1);
2✔
1084
            if (sourceLocation) {
2✔
1085
                diagnostic.path = sourceLocation.filePath;
1✔
1086
                diagnostic.range.start.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1✔
1087
                diagnostic.range.end.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1✔
1088
            } else {
1089
                // TODO: may need to add a custom event if the source location could not be found by the ProjectManager
1090
                diagnostic.path = fileUtils.removeLeadingSlash(util.removeFileScheme(diagnostic.path));
1✔
1091
            }
1092
        }
1093

1094
        //find the first compile error (i.e. first DiagnosticSeverity.Error) if there is one
1095
        this.compileError = diagnostics.find(x => x.severity === DiagnosticSeverity.Error);
2✔
1096
        if (this.compileError) {
2✔
1097
            this.sendLaunchProgress('end', 'Aborted (compile error)');
1✔
1098
            this.sendEvent(new StoppedEvent(
1✔
1099
                StoppedEventReason.exception,
1100
                this.COMPILE_ERROR_THREAD_ID,
1101
                `CompileError: ${this.compileError.message}`
1102
            ));
1103
        }
1104

1105
        this.sendEvent(new DiagnosticsEvent(diagnostics));
2✔
1106
    }
1107

1108
    private publishTimeout = 60_000;
215✔
1109

1110
    private async publish() {
1111
        const uploadingEnd = this.logger.timeStart('log', 'Uploading zip');
2✔
1112
        let packageIsPublished = false;
2✔
1113

1114
        //delete any currently installed dev channel (if enabled to do so)
1115
        try {
2✔
1116
            if (this.launchConfiguration.deleteDevChannelBeforeInstall === true) {
2!
1117
                await this.rokuDeploy.deleteInstalledChannel({
×
1118
                    ...this.launchConfiguration
1119
                } as any as RokuDeployOptions);
1120
            }
1121
        } catch (e) {
1122
            const statusCode = e?.results?.response?.statusCode;
×
1123
            const message = e.message as string;
×
1124
            if (statusCode === 401) {
×
1125
                await this.shutdown(message, true);
×
1126
                throw e;
×
1127
            }
1128
            this.logger.warn('Failed to delete the dev channel...probably not a big deal', e);
×
1129
        }
1130

1131
        const isConnected = this.rokuAdapter.once('app-ready');
2✔
1132
        const options: RokuDeployOptions = {
2✔
1133
            ...this.launchConfiguration,
1134
            //typing fix
1135
            logLevel: LogLevelPriority[this.logger.logLevel],
1136
            // enable the debug protocol if true
1137
            remoteDebug: this.enableDebugProtocol,
1138
            //necessary for capturing compile errors from the protocol (has no effect on telnet)
1139
            remoteDebugConnectEarly: false,
1140
            //we don't want to fail if there were compile errors...we'll let our compile error processor handle that
1141
            failOnCompileError: true,
1142
            //pass any upload form overrides the client may have configured
1143
            packageUploadOverrides: this.launchConfiguration.packageUploadOverrides
1144
        };
1145
        //if packagePath is specified, use that info instead of outDir and outFile
1146
        if (this.launchConfiguration.packagePath) {
2✔
1147
            options.outDir = path.dirname(this.launchConfiguration.packagePath);
1✔
1148
            options.outFile = path.basename(this.launchConfiguration.packagePath);
1✔
1149
        }
1150

1151
        //publish the package to the target Roku
1152
        const publishPromise = this.rokuDeploy.publish(options).then(() => {
2✔
1153
            packageIsPublished = true;
2✔
1154
        }).catch(async (e) => {
1155
            const statusCode = e?.results?.response?.statusCode;
×
1156
            const message = e.message as string;
×
1157
            if ((statusCode && statusCode !== 200) || isUpdateCheckRequiredError(e) || isConnectionResetError(e)) {
×
1158
                await this.shutdown(message, true);
×
1159
                throw e;
×
1160
            }
1161
            this.logger.error(e);
×
1162
        });
1163

1164
        await publishPromise;
2✔
1165

1166
        uploadingEnd();
2✔
1167

1168
        //the channel has been deployed. Wait for the adapter to finish connecting.
1169
        //if it hasn't connected after 60 seconds, abort the launch.
1170
        let didTimeOut = false;
2✔
1171
        await Promise.race([
2✔
1172
            isConnected,
1173
            util.sleep(this.publishTimeout).then(() => {
1174
                didTimeOut = true;
2✔
1175
            })
1176
        ]);
1177
        this.logger.log('Finished racing promises');
2✔
1178
        if (didTimeOut) {
2✔
1179
            this.logger.warn('Timed out waiting for roku to connect');
1✔
1180
        }
1181
        //if the adapter is still not connected, then it will probably never connect. Abort.
1182
        if (packageIsPublished && !this.rokuAdapter.connected) {
2✔
1183
            return this.shutdown('Debug session cancelled: failed to connect to debug protocol control port.');
1✔
1184
        }
1185
    }
1186

1187
    private pendingSendLogPromise = Promise.resolve();
215✔
1188

1189
    /**
1190
     * Send log output to the "client" (i.e. vscode)
1191
     * @param logOutput
1192
     */
1193
    private sendLogOutput(logOutput: string) {
1194
        if (this.isCrashed) {
38!
1195
            return Promise.resolve();
×
1196
        }
1197
        this.fileLoggingManager.writeRokuDeviceLog(logOutput);
38✔
1198

1199
        this.pendingSendLogPromise = this.pendingSendLogPromise.then(async () => {
38✔
1200
            logOutput = await this.convertBacktracePaths(logOutput);
38✔
1201

1202
            const lines = logOutput.split(/\r?\n/g);
38✔
1203
            for (let i = 0; i < lines.length; i++) {
38✔
1204
                let line = lines[i];
264✔
1205
                if (i < lines.length - 1) {
264✔
1206
                    line += '\n';
226✔
1207
                }
1208

1209
                if (this.launchConfiguration.rewriteDevicePathsInLogs) {
264✔
1210
                    let potentialPaths = this.getPotentialPkgPaths(line);
91✔
1211
                    for (let potentialPath of potentialPaths) {
91✔
1212
                        let originalLocation = await this.projectManager.getSourceLocation(potentialPath.path, potentialPath.lineNumber, potentialPath.columnNumber);
28✔
1213
                        if (originalLocation) {
28✔
1214
                            let replacement: string;
1215
                            replacement = originalLocation.filePath.replaceAll(' ', '%20');
26✔
1216
                            if (replacement !== originalLocation.filePath) {
26✔
1217
                                if (this.isWindowsPlatform) {
6✔
1218
                                    replacement = `vscode://file/${replacement}`;
3✔
1219
                                } else {
1220
                                    replacement = `file://${replacement}`;
3✔
1221
                                }
1222
                            }
1223
                            replacement += `:${originalLocation.lineNumber}`;
26✔
1224
                            if (potentialPath.columnNumber !== undefined) {
26✔
1225
                                replacement += `:${originalLocation.columnIndex + 1}`;
10✔
1226
                            }
1227

1228
                            line = line.replaceAll(potentialPath.fullMatch, replacement);
26✔
1229
                        }
1230
                    }
1231
                }
1232
                this.sendEvent(new OutputEvent(line, 'stdout'));
264✔
1233
                this.sendEvent(new LogOutputEvent(line));
264✔
1234
            }
1235
        });
1236
        return this.pendingSendLogPromise;
38✔
1237
    }
1238

1239
    /**
1240
     * Extracts potential package paths from a given line of text.
1241
     *
1242
     * This method uses a regular expression to find matches in the provided line
1243
     * and returns an array of objects containing details about each match.
1244
     *
1245
     * @param input - The line of text to search for potential package paths.
1246
     * @returns An array of objects, each containing:
1247
     *   - `fullMatch`: The full matched string.
1248
     *   - `path`: The extracted path from the match.
1249
     *   - `lineNumber`: The line number extracted from the match.
1250
     *   - `columnNumber`: The column number extracted from the match, or `undefined` if not found.
1251
     */
1252
    private getPotentialPkgPaths(input: string): Array<{ fullMatch: string; path: string; lineNumber: number; columnNumber: number }> {
1253
        // https://regex101.com/r/ixpQiq/1
1254
        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✔
1255
        let paths: ReturnType<BrightScriptDebugSession['getPotentialPkgPaths']> = [];
91✔
1256
        if (matches) {
91!
1257
            for (let match of matches) {
91✔
1258
                let fullMatch = match[0];
28✔
1259
                let path = match[1];
28✔
1260
                let lineNumber = parseInt(match[2] ?? match[4]);
28✔
1261
                let columnNumber = parseInt(match[3] ?? match[5]);
28✔
1262
                if (isNaN(columnNumber)) {
28✔
1263
                    columnNumber = undefined;
17✔
1264
                }
1265
                paths.push({
28✔
1266
                    fullMatch: fullMatch,
1267
                    path: path,
1268
                    lineNumber: lineNumber,
1269
                    columnNumber: columnNumber
1270
                });
1271
            }
1272
        }
1273
        return paths;
91✔
1274
    }
1275

1276
    /**
1277
     * Converts the filename property in backtrace objects in the given input string to source paths if found
1278
     */
1279
    private async convertBacktracePaths(input: string) {
1280
        if (!this.launchConfiguration.rewriteDevicePathsInLogs) {
38✔
1281
            return input;
8✔
1282
        }
1283
        // Why does this not work? It should work, but it doesn't. I'm not sure why.
1284
        // let matches = input.matchAll(this.deviceBacktraceObjectRegex);
1285

1286
        // https://regex101.com/r/y1koaV/2
1287
        let deviceBacktraceObjectRegex = /{\s+filename:\s+"([A-Za-z0-9_\.\/\: ]+)"\s+function\:\s+".+"\s+(line_number\:\s+(\d+))\s+}/gi;
30✔
1288
        let matches = [];
30✔
1289
        let match = deviceBacktraceObjectRegex.exec(input);
30✔
1290
        while (match) {
30✔
1291
            matches.push(match);
5✔
1292
            match = deviceBacktraceObjectRegex.exec(input);
5✔
1293
        }
1294

1295
        if (matches) {
30!
1296
            for (let match of matches) {
30✔
1297
                let fullMatch = match[0] as string;
5✔
1298
                let filePath = match[1] as string;
5✔
1299
                let fullLineNumber = match[2] as string;
5✔
1300
                let lineNumber = parseInt(match[3] as string);
5✔
1301
                let originalLocation = await this.projectManager.getSourceLocation(filePath, lineNumber);
5✔
1302
                if (originalLocation) {
5✔
1303
                    let fileReplacement: string;
1304
                    fileReplacement = originalLocation.filePath.replaceAll(' ', '%20');
4✔
1305
                    if (fileReplacement !== originalLocation.filePath) {
4✔
1306
                        if (this.isWindowsPlatform) {
2✔
1307
                            fileReplacement = `vscode://file/${fileReplacement}`;
1✔
1308
                        } else {
1309
                            fileReplacement = `file://${fileReplacement}`;
1✔
1310
                        }
1311
                    }
1312
                    fileReplacement += `:${originalLocation.lineNumber}`;
4✔
1313

1314
                    let lineNumberReplacement = fullLineNumber.replace(lineNumber.toString(), originalLocation.lineNumber.toString());
4✔
1315

1316
                    // 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
1317
                    let completeReplacement = fullMatch.replace(filePath, fileReplacement);
4✔
1318
                    completeReplacement = completeReplacement.replace(fullLineNumber, lineNumberReplacement);
4✔
1319
                    input = input.replaceAll(fullMatch, completeReplacement);
4✔
1320
                }
1321

1322
            }
1323
        }
1324

1325
        return input;
30✔
1326
    }
1327

1328
    private async runAutomaticSceneGraphCommands(commands: string[]) {
1329
        if (commands) {
1!
1330
            let connection = new SceneGraphDebugCommandController(this.launchConfiguration.host, this.launchConfiguration.sceneGraphDebugCommandsPort);
×
1331

1332
            try {
×
1333
                await connection.connect();
×
1334
                for (let command of this.launchConfiguration.autoRunSgDebugCommands) {
×
1335
                    let response: SceneGraphCommandResponse;
1336
                    switch (command) {
×
1337
                        case 'chanperf':
1338
                            util.log('Enabling Chanperf Tracking');
×
1339
                            response = await connection.chanperf({ interval: 1 });
×
1340
                            if (!response.error) {
×
1341
                                util.log(response.result.rawResponse);
×
1342
                            }
1343
                            break;
×
1344

1345
                        case 'fpsdisplay':
1346
                            util.log('Enabling FPS Display');
×
1347
                            response = await connection.fpsDisplay('on');
×
1348
                            if (!response.error) {
×
1349
                                util.log(response.result.data as string);
×
1350
                            }
1351
                            break;
×
1352

1353
                        case 'logrendezvous':
1354
                            util.log('Enabling Rendezvous Logging:');
×
1355
                            response = await connection.logrendezvous('on');
×
1356
                            if (!response.error) {
×
1357
                                util.log(response.result.rawResponse);
×
1358
                            }
1359
                            break;
×
1360

1361
                        default:
1362
                            util.log(`Running custom SceneGraph debug command on port 8080 '${command}':`);
×
1363
                            response = await connection.exec(command);
×
1364
                            if (!response.error) {
×
1365
                                util.log(response.result.rawResponse);
×
1366
                            }
1367
                            break;
×
1368
                    }
1369
                }
1370
                await connection.end();
×
1371
            } catch (error) {
1372
                util.log(`Error connecting to port 8080: ${error.message}`);
×
1373
            }
1374
        }
1375
    }
1376

1377
    /**
1378
     * Stage, insert breakpoints, and package the main project
1379
     */
1380
    public async prepareMainProject() {
1381
        //add the main project
1382
        this.projectManager.mainProject = new Project({
2✔
1383
            rootDir: this.launchConfiguration.rootDir,
1384
            files: this.launchConfiguration.files,
1385
            outDir: this.launchConfiguration.outDir,
1386
            sourceDirs: this.launchConfiguration.sourceDirs,
1387
            bsConst: this.launchConfiguration.bsConst,
1388
            injectRaleTrackerTask: this.launchConfiguration.injectRaleTrackerTask,
1389
            raleTrackerTaskFileLocation: this.launchConfiguration.raleTrackerTaskFileLocation,
1390
            injectRdbOnDeviceComponent: this.launchConfiguration.injectRdbOnDeviceComponent,
1391
            rdbFilesBasePath: this.launchConfiguration.rdbFilesBasePath,
1392
            stagingDir: this.launchConfiguration.stagingDir,
1393
            packagePath: this.launchConfiguration.packagePath,
1394
            enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1395
        });
1396

1397
        util.log('Moving selected files to staging area');
2✔
1398
        await this.projectManager.mainProject.stage();
2✔
1399

1400
        //add the entry breakpoint if stopOnEntry is true
1401
        await this.handleEntryBreakpoint();
2✔
1402
    }
1403

1404
    /**
1405
     * Inject breakpoint STOP statements into the staged main project. Runs after the DAP `InitializedEvent`
1406
     * so client-side `setBreakpoints` requests have landed before any STOPs are written to the staged .brs
1407
     * files (telnet path). Kept separate from zipping so the cross-project `Library` reference rewrite can
1408
     * run in between (after every project is postfixed, before any zip is sealed).
1409
     */
1410
    private async writeMainProjectBreakpoints() {
1411
        //add breakpoint lines to source files and then publish
1412
        util.log('Adding stop statements for active breakpoints');
2✔
1413

1414
        //validate breakpoints for all debugger types (and write `stop` statements for telnet — decided internally)
1415
        await this.breakpointManager.validateAndWriteBreakpointsForProject(this.projectManager.mainProject);
2✔
1416
    }
1417

1418
    /**
1419
     * Create the main project's zip package (or run the configured packageTask). Must run AFTER
1420
     * `applyLibraryReferencePostfixes` so any rewritten `Library` statements are included in the zip.
1421
     */
1422
    private async zipMainProject() {
1423
        if (this.launchConfiguration.packageTask) {
2✔
1424
            util.log(`Executing task '${this.launchConfiguration.packageTask}' to assemble the app`);
1✔
1425
            await this.sendCustomRequest('executeTask', { task: this.launchConfiguration.packageTask });
1✔
1426

1427
            const options = {
1✔
1428
                ...this.launchConfiguration
1429
            } as any as RokuDeployOptions;
1430
            //if packagePath is specified, use that info instead of outDir and outFile
1431
            if (this.launchConfiguration.packagePath) {
1!
1432
                options.outDir = path.dirname(this.launchConfiguration.packagePath);
1✔
1433
                options.outFile = path.basename(this.launchConfiguration.packagePath);
1✔
1434
            }
1435
            const packagePath = this.launchConfiguration.packagePath ?? rokuDeploy.getOutputZipFilePath(options);
1!
1436

1437
            if (!fsExtra.pathExistsSync(packagePath as string)) {
1!
1438
                return this.shutdown(`Cancelling debug session. Package does not exist at '${packagePath}'`);
×
1439
            }
1440
        } else {
1441
            //create zip package from staging folder
1442
            util.log('Creating zip archive from project sources');
1✔
1443
            await this.projectManager.mainProject.zipPackage({ retainStagingFolder: true });
1✔
1444
        }
1445
    }
1446

1447
    /**
1448
     * Accepts custom events and requests from the extension
1449
     * @param command name of the command to execute
1450
     */
1451
    protected async customRequest(command: string, response: DebugProtocol.Response, args: any) {
1452
        if (command === 'rendezvous.clearHistory') {
×
1453
            this.rokuAdapter.clearRendezvousHistory();
×
1454
        } else if (command === 'chanperf.clearHistory') {
×
1455
            this.rokuAdapter.clearChanperfHistory();
×
1456

1457
        } else if (command === 'customRequestEventResponse') {
×
1458
            this.emit('customRequestEventResponse', args);
×
1459

1460
        } else if (command === 'popupMessageEventResponse') {
×
1461
            this.emit('popupMessageEventResponse', args);
×
1462

1463
        } else if (command === 'captureHeapSnapshot') {
×
1464
            this.perfettoManager?.captureHeapSnapshot?.().catch((e) => this.logger.error('Failed to capture heap snapshot', e));
×
1465

1466
        } else if (command === 'startPerfettoTracing') {
×
1467
            try {
×
1468
                await this.perfettoManager?.startTracing?.();
×
1469
            } catch (e) {
1470
                response.success = false;
×
1471
                response.body = { message: e?.message || String(e) };
×
1472
            }
1473

1474
        } else if (command === 'stopPerfettoTracing') {
×
1475
            try {
×
1476
                await this.perfettoManager?.stopTracing?.();
×
1477
            } catch (e) {
1478
                response.success = false;
×
1479
                response.body = { message: e?.message || String(e) };
×
1480
            }
1481

1482
        }
1483
        this.sendResponse(response);
×
1484
    }
1485

1486
    /**
1487
     * Stores the path to the staging folder for each component library
1488
     */
1489
    protected async prepareComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1490
        if (!componentLibraries || componentLibraries.length === 0) {
11✔
1491
            return;
2✔
1492
        }
1493
        let componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
9✔
1494
        //make sure this folder exists (and is empty)
1495
        await fsExtra.ensureDir(componentLibrariesOutDir);
9✔
1496
        await fsExtra.emptyDir(componentLibrariesOutDir);
9✔
1497

1498
        //create a ComponentLibraryProject for each component library
1499
        for (let libraryIndex = 0; libraryIndex < componentLibraries.length; libraryIndex++) {
9✔
1500
            let componentLibrary = componentLibraries[libraryIndex];
19✔
1501

1502
            this.projectManager.componentLibraryProjects.push(
19✔
1503
                new ComponentLibraryProject({
1504
                    rootDir: componentLibrary.rootDir,
1505
                    files: componentLibrary.files,
1506
                    outDir: componentLibrariesOutDir,
1507
                    outFile: componentLibrary.outFile,
1508
                    sourceDirs: componentLibrary.sourceDirs,
1509
                    bsConst: componentLibrary.bsConst,
1510
                    install: componentLibrary.install,
1511
                    enablePostfix: componentLibrary.enablePostfix,
1512
                    injectRaleTrackerTask: componentLibrary.injectRaleTrackerTask,
1513
                    raleTrackerTaskFileLocation: componentLibrary.raleTrackerTaskFileLocation,
1514
                    libraryIndex: libraryIndex,
1515
                    enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1516
                })
1517
            );
1518
        }
1519

1520
        //stage all of the libraries in parallel
1521
        await Promise.all(
9✔
1522
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.stage())
19✔
1523
        );
1524
    }
1525

1526
    /**
1527
     * Inject breakpoint STOPs into the staged complibs and postfix each library's own files. Runs in
1528
     * `configurationDoneRequest` (after `InitializedEvent`) so client-side `setBreakpoints` requests have
1529
     * landed before the staged .brs files are written. Kept separate from zipping so the cross-project
1530
     * `Library` reference rewrite can run after EVERY project is postfixed but before any zip is sealed.
1531
     */
1532
    protected async writeAndPostfixComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1533
        if (!componentLibraries || componentLibraries.length === 0) {
10✔
1534
            return;
2✔
1535
        }
1536

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

1540
        //validate breakpoints (and write STOPs for telnet) and postfix each complib's own files in parallel
1541
        await Promise.all(
8✔
1542
            this.projectManager.componentLibraryProjects.map(async (compLibProject) => {
1543
                await this.breakpointManager.validateAndWriteBreakpointsForProject(compLibProject);
18✔
1544
                await compLibProject.postfixFiles();
18✔
1545
            })
1546
        );
1547
    }
1548

1549
    /**
1550
     * Seal every component library's zip, install the installable ones (in order), and start static file
1551
     * hosting. Must run AFTER `applyLibraryReferencePostfixes` so each zip contains the rewritten `Library`
1552
     * statements.
1553
     */
1554
    protected async zipServeAndInstallComponentLibraries(componentLibraries: ComponentLibraryConfiguration[], port: number) {
1555
        if (!componentLibraries || componentLibraries.length === 0) {
10✔
1556
            return;
2✔
1557
        }
1558
        const componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
8✔
1559

1560
        const needToDeleteComplibs = this.projectManager.componentLibraryProjects.some(x => x.install);
9✔
1561
        if (needToDeleteComplibs) {
8✔
1562
            //deleteAllComponentLibraries pauses compile-error reporting (to swallow deletion-induced errors) and
1563
            //leaves it paused. Now that deletion is done and we're about to put libraries back on the device, settle
1564
            //(draining any lingering deletion output) and resume reporting so real install errors are surfaced.
1565
            await this.deleteAllComponentLibraries();
7✔
1566
        }
1567

1568
        //zip each complib in parallel
1569
        const packagePromises = this.projectManager.componentLibraryProjects.map(
8✔
1570
            compLibProject => compLibProject.zipPackage({ retainStagingFolder: true })
18✔
1571
        );
1572

1573
        //install component libraries strictly in their declared order (which must be dependency order:
1574
        //a library may only depend on libraries declared before it). Each install is fully awaited before the
1575
        //next begins, and a failure aborts the launch. Installing out of order, or continuing past a failed
1576
        //install, leaves the device with dangling `Library` references that fail to compile.
1577
        for (let i = 0; i < this.projectManager.componentLibraryProjects.length; i++) {
8✔
1578
            const compLibProject = this.projectManager.componentLibraryProjects[i];
18✔
1579

1580
            if (compLibProject.install === true) {
18✔
1581
                //wait for this complib to finish being packaged
1582
                await packagePromises[i];
12✔
1583

1584
                if (componentLibraries[i].packageTask) {
12✔
1585
                    await this.sendCustomRequest('executeTask', { task: componentLibraries[i].packageTask });
2✔
1586
                }
1587

1588
                const options: RokuDeployOptions = {
12✔
1589
                    host: this.launchConfiguration.host,
1590
                    password: this.launchConfiguration.password,
1591
                    username: this.launchConfiguration.username || 'rokudev',
24✔
1592
                    logLevel: LogLevelPriority[this.logger.logLevel],
1593
                    failOnCompileError: true,
1594
                    outDir: compLibProject.outDir,
1595
                    outFile: compLibProject.outFile,
1596
                    appType: 'dcl',
1597
                    packageUploadOverrides: componentLibraries[i].packageUploadOverrides || {}
22✔
1598
                };
1599

1600
                if (componentLibraries[i].packagePath) {
12✔
1601
                    options.outDir = path.dirname(componentLibraries[i].packagePath);
2✔
1602
                    options.outFile = path.basename(componentLibraries[i].packagePath);
2✔
1603
                }
1604

1605
                util.log(`Installing component library ${i} (${compLibProject.outFile})`);
12✔
1606
                try {
12✔
1607
                    await rokuDeploy.publish(options);
12✔
1608
                    util.log(`Installed component library ${i} (${compLibProject.outFile})`);
11✔
1609
                } catch (error) {
1610
                    //do NOT continue installing further libraries (or publishing the main app) - a failed install
1611
                    //here means later libraries and the main app would reference a library that isn't on the device
1612
                    this.logger.error(`Error installing component library ${i} (${compLibProject.outFile})`, error);
1✔
1613
                    throw error;
1✔
1614
                }
1615
            }
1616
        }
1617

1618
        const hostingPromise = this.componentLibraryServer.startStaticFileHosting(componentLibrariesOutDir, port, (message: string) => {
7✔
1619
            util.log(message);
×
1620
        });
1621

1622
        //wait for all complib packaging to finish and the file hosting to start
1623
        await Promise.all([
7✔
1624
            ...packagePromises,
1625
            hostingPromise
1626
        ]);
1627
    }
1628

1629
    /**
1630
     * Delete every component library installed on the device.
1631
     *
1632
     * A complib can only depend on complibs the user declared BEFORE it, so the user's configured order is already
1633
     * dependency order. Deleting in the REVERSE of that order therefore deletes each complib before the ones it
1634
     * depends on, avoiding compile errors entirely. We use that reverse order as the delete priority.
1635
     *
1636
     * Anything still installed that the user did NOT configure (e.g. leftovers from a previous session) has no known
1637
     * order, so we fall back to compile-error tolerance for those: deleting a complib while another still references
1638
     * it fails with a compile error, which we ignore and retry later (after its dependent is gone). We finish when
1639
     * every complib is gone, and fail if the only complibs left are ones that can never be deleted.
1640
     */
1641
    private async deleteAllComponentLibraries() {
1642
        const deviceOptions = {
16✔
1643
            host: this.launchConfiguration.host,
1644
            password: this.launchConfiguration.password,
1645
            username: this.launchConfiguration.username || 'rokudev'
32✔
1646
        };
1647

1648
        //The user's configured complibs, in REVERSE declaration order (dependents before their dependencies). A
1649
        //complib's position here is its delete priority; complibs the user didn't configure aren't in this list.
1650
        const deletePriority = this.projectManager.componentLibraryProjects
16✔
1651
            .map(complib => complib.outFile)
28✔
1652
            .reverse();
1653
        //sort key for a complib: its index in `deletePriority`, or Infinity (delete last) if it wasn't configured
1654
        const priorityOf = (fileName: string) => {
16✔
1655
            const index = deletePriority.indexOf(fileName);
24✔
1656
            return index === -1 ? Infinity : index;
24✔
1657
        };
1658

1659
        const maxAttempts = 5;
16✔
1660
        //how many times we've tried to delete each complib, and the ones we've given up on after maxAttempts
1661
        const attempts = new Map<string, number>();
16✔
1662
        const failed = new Set<string>();
16✔
1663

1664
        while (true) {
16✔
1665
            //re-fetch the installed complibs after each iteration: deleting one complib can cascade-delete others, so never delete a stale entry
1666
            const installed = (await rokuDeploy.listSideloadedPlugins(deviceOptions)).filter(x => x.appType === 'dcl');
40✔
1667

1668
            const attemptCount = (complib: { archiveFileName: string }) => attempts.get(complib.archiveFileName) ?? 0;
39✔
1669

1670
            //of the complibs we haven't given up on, pick the next to delete: fewest attempts first (spreads retries
1671
            //evenly so a dependent gets deleted before we circle back to a complib that previously failed), and among
1672
            //equal attempts, follow the reverse-configured delete priority (dependents before dependencies).
1673
            const candidates = installed.filter(complib => !failed.has(complib.archiveFileName));
40✔
1674
            const next = candidates.sort((a, b) =>
39✔
1675
                attemptCount(a) - attemptCount(b) ||
15✔
1676
                priorityOf(a.archiveFileName) - priorityOf(b.archiveFileName)
1677
            )[0];
1678

1679
            //nothing left to try — done if the device is clear, otherwise hard-fail with whatever remains
1680
            if (!next) {
39✔
1681
                if (installed.length > 0) {
15✔
1682
                    throw new Error(`Failed to delete existing component libraries on device; ${installed.length} still installed: ${installed.map(x => x.archiveFileName).join(', ')}`);
1✔
1683
                }
1684
                return;
14✔
1685
            }
1686

1687
            const fileName = next.archiveFileName;
24✔
1688
            attempts.set(fileName, (attempts.get(fileName) ?? 0) + 1);
24✔
1689
            try {
24✔
1690
                await rokuDeploy.deleteComponentLibrary({ ...deviceOptions, fileName: fileName });
24✔
1691
            } catch (error) {
1692
                //re-throw anything that isn't a dependency compile error (auth, network, etc.)
1693
                if (!this.isComponentLibraryDependencyCompileError(error)) {
9✔
1694
                    throw error;
1✔
1695
                }
1696
                //Deleting a complib that a dependent still references produces a compile error - but the delete may
1697
                //STILL have succeeded (the device reports both a compile error and 'Delete Succeeded'). If it actually
1698
                //deleted, treat it as done. Only if it did NOT succeed do we defer and retry it later.
1699
                if (this.wasComponentLibraryDeleteSuccessful(error)) {
8!
NEW
1700
                    this.logger.trace(`Deleted '${fileName}' (device reported a compile error but the delete succeeded)`);
×
1701
                } else {
1702
                    //another not-yet-deleted complib still depends on this one. Give up on it once it's out of
1703
                    //attempts; otherwise leave it pending so we retry after its dependents are gone.
1704
                    if (attempts.get(fileName) >= maxAttempts) {
8✔
1705
                        failed.add(fileName);
1✔
1706
                    }
1707
                    this.logger.log(`Deferring delete of '${fileName}'; another component library still depends on it`);
8✔
1708
                }
1709
            }
1710

1711
            //the Roku doesn't appreciate back-to-back deletes; give it a moment between requests
1712
            await util.sleep(10);
23✔
1713
        }
1714
    }
1715

1716
    /**
1717
     * Is this error the device reporting a compile failure (which, during complib deletion, means another
1718
     * installed complib still references the one we tried to delete)?
1719
     */
1720
    private isComponentLibraryDependencyCompileError(error: any): boolean {
1721
        const message = `${error?.message ?? ''}`;
9!
1722
        return /compile error|compilation failed/i.test(message);
9✔
1723
    }
1724

1725
    /**
1726
     * Did the component-library delete actually succeed, even though the device also reported a compile error?
1727
     * When we delete a complib that a dependent still references, the device reports BOTH a compile error AND a
1728
     * `Delete Succeeded` message - meaning the complib really was removed. roku-deploy attaches the parsed device
1729
     * messages (`{ errors, infos, successes }`) to the thrown error as `.results`; we look for the success there.
1730
     * Absence of that success message means the delete did NOT happen, so it should be treated as a failure/retry.
1731
     */
1732
    private wasComponentLibraryDeleteSuccessful(error: any): boolean {
1733
        const successes: string[] = error?.results?.successes ?? [];
8!
1734
        return successes.some(message => /delete succeeded/i.test(message));
8✔
1735
    }
1736

1737
    protected sourceRequest(response: DebugProtocol.SourceResponse, args: DebugProtocol.SourceArguments) {
1738
        this.logger.log('sourceRequest');
×
1739
        let old = this.sendResponse;
×
1740
        this.sendResponse = function sendResponse(...args) {
×
1741
            old.apply(this, args);
×
1742
            this.sendResponse = old;
×
1743
        };
1744
        super.sourceRequest(response, args);
×
1745
    }
1746

1747
    /**
1748
     * Called every time a breakpoint is created, modified, or deleted, for each file. This receives the entire list of breakpoints every time.
1749
     */
1750
    public async setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments) {
1751
        this.logger.log('setBreakpointsRequest', args);
6✔
1752
        let sanitizedBreakpoints = this.breakpointManager.replaceBreakpoints(args.source.path, args.breakpoints);
6✔
1753
        //sort the breakpoints
1754
        let sortedAndFilteredBreakpoints = orderBy(sanitizedBreakpoints, [x => x.line, x => x.column]);
6✔
1755

1756
        response.body = {
6✔
1757
            breakpoints: sortedAndFilteredBreakpoints
1758
        };
1759
        this.sendResponse(response);
6✔
1760

1761
        //ensure we've staged all the files
1762
        await this.stagingDefered.promise;
6✔
1763

1764
        await this.rokuAdapter?.syncBreakpoints();
6!
1765
    }
1766

1767
    protected exceptionInfoRequest(response: DebugProtocol.ExceptionInfoResponse, args: DebugProtocol.ExceptionInfoArguments) {
1768
        this.logger.log('exceptionInfoRequest');
×
1769
    }
1770

1771
    protected async threadsRequest(response: DebugProtocol.ThreadsResponse) {
1772
        this.logger.log('threadsRequest');
4✔
1773

1774
        let threads = [];
4✔
1775

1776
        //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
1777
        if (this.compileError) {
4!
1778
            threads.push(new Thread(this.COMPILE_ERROR_THREAD_ID, 'Compile Error'));
×
1779
        } else {
1780
            //wait for the roku adapter to load
1781
            await this.getRokuAdapter();
4✔
1782

1783
            //only send the threads request if we are at the debugger prompt
1784
            if (this.rokuAdapter.isAtDebuggerPrompt) {
4✔
1785
                let rokuThreads = await this.rokuAdapter.getThreads();
3✔
1786

1787
                for (let thread of rokuThreads) {
3✔
1788
                    const threadName = this.getThreadName(thread as AdapterThread);
4✔
1789
                    threads.push(
4✔
1790
                        new Thread(thread.threadId, threadName)
1791
                    );
1792
                }
1793

1794
                if (threads.length === 0) {
3!
1795
                    threads = [{
×
1796
                        id: 1001,
1797
                        name: 'unable to retrieve threads: not stopped',
1798
                        isFake: true
1799
                    }];
1800
                }
1801

1802
            } else {
1803
                this.logger.log('Skipped getting threads because the RokuAdapter is not accepting input at this time.');
1✔
1804
            }
1805

1806
        }
1807

1808
        response.body = {
4✔
1809
            threads: threads
1810
        };
1811

1812
        this.sendResponse(response);
4✔
1813
    }
1814

1815
    /**
1816
     * Get the thread name to display in the UI based on the thread info we have.
1817
     * This is what displays in the `call stack` region in vscode
1818
     * @param thread
1819
     * @returns
1820
     */
1821
    private getThreadName(thread: AdapterThread) {
1822
        let threadName = '';
24✔
1823
        if (thread.type || thread.name || thread.osThreadId) {
24✔
1824
            //build the name from only the parts that are present, so missing values don't leak into the name
1825
            const parts: string[] = [];
13✔
1826
            if (thread.type) {
13✔
1827
                parts.push(`[${thread.type}]`);
10✔
1828
            }
1829
            if (thread.name) {
13✔
1830
                parts.push(thread.name);
9✔
1831
            }
1832
            if (thread.osThreadId) {
13✔
1833
                parts.push(thread.osThreadId);
9✔
1834
            }
1835
            threadName = parts.join(' ');
13✔
1836
        }
1837
        //remove any extraneous whitespace to deal with missing values
1838
        threadName = threadName.replace(/\s+/g, ' ').trim();
24✔
1839

1840
        if (threadName === '') {
24✔
1841
            threadName = `Thread ${thread.threadId}`;
11✔
1842
        }
1843

1844
        if (thread.isDetached) {
24✔
1845
            threadName += ' [detached]';
4✔
1846
        }
1847

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

1851
        return threadName;
24✔
1852
    }
1853

1854
    protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) {
1855
        try {
3✔
1856
            this.logger.log('stackTraceRequest');
3✔
1857
            let frames: DebugProtocol.StackFrame[] = [];
3✔
1858

1859
            //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
1860
            if (this.compileError) {
3!
1861
                frames.push(new StackFrame(
×
1862
                    0,
1863
                    'Compile Error',
1864
                    new Source(path.basename(this.compileError.path), this.compileError.path),
1865
                    // range is 0-based; toClientLine/toClientColumn handle client coordinate conversion
1866
                    this.toClientLine(this.compileError.range.start.line),
1867
                    this.toClientColumn(this.compileError.range.start.character)
1868
                ));
1869
            } else if (args.threadId === 1001) {
3!
1870
                frames.push(new StackFrame(
×
1871
                    0,
1872
                    'ERROR: threads would not stop',
1873
                    new Source('main.brs', s`${this.launchConfiguration.stagingDir}/manifest`),
1874
                    this.toClientLine(0),
1875
                    this.toClientColumn(0)
1876
                ));
1877
                this.showPopupMessage('Unable to suspend threads. Debugger is in an unstable state, please press Continue to resume debugging', 'warn').catch((error) => {
×
1878
                    this.logger.error('Error showing popup message', { error });
×
1879
                });
1880
            } else {
1881
                //ensure the rokuAdapter is loaded
1882
                await this.getRokuAdapter();
3✔
1883

1884
                if (this.rokuAdapter.isAtDebuggerPrompt) {
3!
1885
                    let stackTrace = await this.rokuAdapter.getStackTrace(args.threadId);
3✔
1886
                    if (stackTrace.length === 0) {
3✔
1887
                        // Thread is detached or encountered an error requesting Stack Trace — show a non-interactive label so VS Code can display
1888
                        // the thread without letting the user navigate to a source location
1889
                        const frame = new StackFrame(0, '[unavailable]');
2✔
1890
                        frame.presentationHint = 'label';
2✔
1891
                        frames.push(frame);
2✔
1892
                    } else {
1893
                        for (let debugFrame of stackTrace) {
1✔
1894
                            let sourceLocation = await this.projectManager.getSourceLocation(debugFrame.filePath, debugFrame.lineNumber);
3✔
1895

1896
                            //the stacktrace returns function identifiers in all lower case. Try to get the actual case
1897
                            //load the contents of the file and get the correct casing for the function identifier
1898
                            try {
3✔
1899
                                let functionName = this.fileManager.getCorrectFunctionNameCase(sourceLocation?.filePath, debugFrame.functionIdentifier);
3!
1900
                                if (functionName) {
3!
1901

1902
                                    //search for original function name if this is an anonymous function.
1903
                                    //anonymous function names are prefixed with $ in the stack trace (i.e. $anon_1 or $functionname_40002)
1904
                                    if (functionName.startsWith('$')) {
3!
1905
                                        functionName = this.fileManager.getFunctionNameAtPosition(
×
1906
                                            sourceLocation.filePath,
1907
                                            sourceLocation.lineNumber - 1,
1908
                                            functionName
1909
                                        );
1910
                                    }
1911
                                    debugFrame.functionIdentifier = functionName;
3✔
1912
                                }
1913
                            } catch (error) {
1914
                                this.logger.error('Error correcting function identifier case', { error, sourceLocation, debugFrame });
×
1915
                            }
1916
                            const filePath = sourceLocation?.filePath ?? debugFrame.filePath;
3!
1917

1918
                            const frame: DebugProtocol.StackFrame = new StackFrame(
3✔
1919
                                debugFrame.frameId,
1920
                                `${debugFrame.functionIdentifier}`,
1921
                                new Source(path.basename(filePath), filePath),
1922
                                // lineNumber is 1-based from Roku; toClientLine expects 0-based
1923
                                this.toClientLine((sourceLocation?.lineNumber ?? debugFrame.lineNumber) - 1),
18!
1924
                                this.toClientColumn(0)
1925
                            );
1926
                            if (!sourceLocation) {
3!
1927
                                frame.presentationHint = 'subtle';
×
1928
                            }
1929
                            frames.push(frame);
3✔
1930
                        }
1931
                    }
1932
                } else {
1933
                    this.logger.log('Skipped calculating stacktrace because the RokuAdapter is not accepting input at this time');
×
1934
                }
1935
            }
1936
            response.body = {
3✔
1937
                stackFrames: frames,
1938
                totalFrames: frames.length
1939
            };
1940
            this.sendResponse(response);
3✔
1941
        } catch (error) {
1942
            this.logger.error('Error getting stacktrace', { error, args });
×
1943
        }
1944
    }
1945

1946
    protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) {
1947
        const logger = this.logger.createLogger(`scopesRequest ${this.idCounter}`);
1✔
1948
        logger.info('begin', { args });
1✔
1949
        try {
1✔
1950
            const scopes = new Array<DebugProtocol.Scope>();
1✔
1951

1952
            // create the locals scope
1953
            let v = this.getOrCreateLocalsScope(args.frameId);
1✔
1954

1955
            let localScope: DebugProtocol.Scope = {
1✔
1956
                name: 'Local',
1957
                variablesReference: v.variablesReference,
1958
                // Flag the locals scope as expensive if the client asked that it be loaded lazily
1959
                expensive: this.launchConfiguration.deferScopeLoading,
1960
                presentationHint: 'locals'
1961
            };
1962

1963
            const frame = this.rokuAdapter.getStackFrameById(args.frameId);
1✔
1964
            if (frame) {
×
1965
                const scopeRange = await this.projectManager.getScopeRange(frame.filePath, { line: frame.lineNumber - 1, character: 0 });
×
1966

1967
                if (scopeRange) {
×
1968
                    localScope.line = this.toClientLine(scopeRange.start.line - 1);
×
1969
                    localScope.column = this.toClientColumn(scopeRange.start.column);
×
1970
                    localScope.endLine = this.toClientLine(scopeRange.end.line - 1);
×
1971
                    localScope.endColumn = this.toClientColumn(scopeRange.end.column);
×
1972
                }
1973
            }
1974

1975
            scopes.push(localScope);
×
1976

1977
            // create the registry scope
1978
            let registryRefId = this.getEvaluateRefId('$$registry', Infinity);
×
1979
            scopes.push(<DebugProtocol.Scope>{
×
1980
                name: 'Registry',
1981
                variablesReference: registryRefId,
1982
                expensive: true
1983
            });
1984

1985
            this.variables[registryRefId] = {
×
1986
                variablesReference: registryRefId,
1987
                name: 'Registry',
1988
                value: '',
1989
                type: '$$Registry',
1990
                isScope: true,
1991
                childVariables: []
1992
            };
1993

1994
            response.body = {
×
1995
                scopes: scopes
1996
            };
1997
            logger.debug('send response', { response });
×
1998
            this.sendResponse(response);
×
1999
            logger.info('end');
×
2000
        } catch (error) {
2001
            logger.error('Error getting scopes', { error, args });
1✔
2002
        }
2003
    }
2004

2005
    /**
2006
     * Get the locals scope container for a frame, creating an (unpopulated) one if it doesn't exist yet.
2007
     * The child variables are filled in lazily by `populateScopeVariables`.
2008
     */
2009
    private getOrCreateLocalsScope(frameId: number): AugmentedVariable {
2010
        const refId = this.getEvaluateRefId('$$locals', frameId);
5✔
2011
        if (!this.variables[refId]) {
5✔
2012
            this.variables[refId] = {
1✔
2013
                variablesReference: refId,
2014
                name: 'Locals',
2015
                value: '',
2016
                type: '$$Locals',
2017
                frameId: frameId,
2018
                isScope: true,
2019
                childVariables: []
2020
            };
2021
        }
2022
        return this.variables[refId];
5✔
2023
    }
2024

2025
    protected async continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments) {
2026
        //if we have a compile error, we should shut down
2027
        if (this.compileError) {
×
2028
            this.sendResponse(response);
×
2029
            await this.shutdown();
×
2030
            return;
×
2031
        }
2032

2033
        this.logger.log('continueRequest');
×
2034
        await this.setTransientsToInvalid(); // call before clearState
×
2035
        this.clearState();
×
2036

2037
        // The debug session ends after the next line. Do not put new work after this line.
2038
        await this.rokuAdapter.continue();
×
2039
        this.sendResponse(response);
×
2040
    }
2041

2042
    protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
2043
        this.logger.log('pauseRequest');
×
2044

2045
        //if we have a compile error, we should shut down
2046
        if (this.compileError) {
×
2047
            this.sendResponse(response);
×
2048
            await this.shutdown();
×
2049
            return;
×
2050
        }
2051

2052
        await this.rokuAdapter.pause();
×
2053
        this.sendResponse(response);
×
2054
    }
2055

2056
    protected reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse, args: DebugProtocol.ReverseContinueArguments) {
2057
        this.logger.log('reverseContinueRequest');
×
2058
        this.sendResponse(response);
×
2059
    }
2060

2061
    /**
2062
     * Clicked the "Step Over" button
2063
     * @param response
2064
     * @param args
2065
     */
2066
    protected async nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) {
2067
        this.logger.log('[nextRequest] begin');
×
2068

2069
        //if we have a compile error, we should shut down
2070
        if (this.compileError) {
×
2071
            this.sendResponse(response);
×
2072
            await this.shutdown();
×
2073
            return;
×
2074
        }
2075

2076
        await this.setTransientsToInvalid(); // call before clearState
×
2077
        this.clearState();
×
2078

2079
        // The debug session ends after the next line. Do not put new work after this line.
2080
        try {
×
2081
            await this.rokuAdapter.stepOver(args.threadId);
×
2082
            this.logger.info('[nextRequest] end');
×
2083
        } catch (error) {
2084
            this.logger.error(`[nextRequest] Error running '${BrightScriptDebugSession.prototype.nextRequest.name}()'`, error);
×
2085
        }
2086
        this.sendResponse(response);
×
2087
    }
2088

2089
    protected async stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
2090
        this.logger.log('[stepInRequest]');
×
2091

2092
        //if we have a compile error, we should shut down
2093
        if (this.compileError) {
×
2094
            this.sendResponse(response);
×
2095
            await this.shutdown();
×
2096
            return;
×
2097
        }
2098

2099
        await this.setTransientsToInvalid(); // call before clearState
×
2100
        this.clearState();
×
2101
        // The debug session ends after the next line. Do not put new work after this line.
2102
        await this.rokuAdapter.stepInto(args.threadId);
×
2103
        this.sendResponse(response);
×
2104
        this.logger.info('[stepInRequest] end');
×
2105
    }
2106

2107
    protected async stepOutRequest(response: DebugProtocol.StepOutResponse, args: DebugProtocol.StepOutArguments) {
2108
        this.logger.log('[stepOutRequest] begin');
×
2109

2110
        //if we have a compile error, we should shut down
2111
        if (this.compileError) {
×
2112
            this.sendResponse(response);
×
2113
            await this.shutdown();
×
2114
            return;
×
2115
        }
2116

2117
        await this.setTransientsToInvalid(); // call before clearState
×
2118
        this.clearState();
×
2119

2120
        // The debug session ends after the next line. Do not put new work after this line.
2121
        await this.rokuAdapter.stepOut(args.threadId);
×
2122
        this.sendResponse(response);
×
2123
        this.logger.info('[stepOutRequest] end');
×
2124
    }
2125

2126
    protected stepBackRequest(response: DebugProtocol.StepBackResponse, args: DebugProtocol.StepBackArguments) {
2127
        this.logger.log('[stepBackRequest] begin');
×
2128
        this.sendResponse(response);
×
2129
        this.logger.info('[stepBackRequest] end');
×
2130
    }
2131

2132
    public async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) {
2133
        const logger = this.logger.createLogger('[variablesRequest]');
4✔
2134
        let sendInvalidatedEvent = false;
4✔
2135
        let frameId: number = null;
4✔
2136
        try {
4✔
2137
            logger.log('begin', { args });
4✔
2138

2139
            //ensure the rokuAdapter is loaded
2140
            await this.getRokuAdapter();
4✔
2141

2142
            let updatedVariables: AugmentedVariable[] = [];
4✔
2143
            //wait for any `evaluate` commands to finish so we have a higher likely hood of being at a debugger prompt
2144
            await this.evaluateRequestPromise;
4✔
2145
            if (this.rokuAdapter?.isAtDebuggerPrompt !== true) {
4!
2146
                logger.log('Skipped getting variables because the RokuAdapter is not accepting input at this time');
×
2147
                response.success = false;
×
2148
                response.message = 'Debug session is not paused';
×
2149
                return this.sendResponse(response);
×
2150
            }
2151

2152
            //find the variable with this reference
2153
            let v = this.variables[args.variablesReference];
4✔
2154
            if (!v) {
4!
2155
                response.success = false;
×
2156
                response.message = `Variable reference has expired`;
×
2157
                return this.sendResponse(response);
×
2158
            }
2159
            logger.log('variable', v);
4✔
2160

2161
            // Populate scope level values if needed
2162
            if (v.isScope) {
4✔
2163
                await this.populateScopeVariables(v, args);
2✔
2164
            }
2165

2166
            //query for child vars if we haven't done it yet or DAP is asking to resolve a lazy variable
2167
            if (v.childVariables.length === 0 || v.isResolved) {
4!
2168
                let tempVar: AugmentedVariable;
2169
                if (!v.isResolved) {
×
2170
                    // Evaluate the variable
2171
                    try {
×
2172
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: v.evaluateName, frameId: v.frameId }, util.getVariablePath(v.evaluateName));
×
2173
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, v.frameId);
×
2174
                        tempVar = await this.getVariableFromResult(result, v.frameId);
×
2175
                        tempVar.frameId = v.frameId;
×
2176
                        // Determine if the variable has changed
2177
                        sendInvalidatedEvent = v.type !== tempVar.type || v.indexedVariables !== tempVar.indexedVariables;
×
2178
                    } catch (error) {
2179
                        logger.error('Error getting variables', error);
×
2180
                        tempVar = new Variable('Error', `❌ Error: ${error.message}`);
×
2181
                        tempVar.type = '';
×
2182
                        tempVar.childVariables = [];
×
2183
                        sendInvalidatedEvent = true;
×
2184
                        response.success = false;
×
2185
                        response.message = error.message;
×
2186
                    }
2187

2188
                    // Merge the resulting updates together
2189
                    v.childVariables = tempVar.childVariables;
×
2190
                    v.value = tempVar.value;
×
2191
                    v.type = tempVar.type;
×
2192
                    v.indexedVariables = tempVar.indexedVariables;
×
2193
                    v.namedVariables = tempVar.namedVariables;
×
2194
                }
2195
                frameId = v.frameId;
×
2196

2197
                if (v?.presentationHint?.lazy || v.isResolved) {
×
2198
                    // If this was a lazy variable we need to respond with the updated variable and not the children
2199
                    if (v.isResolved && v.childVariables.length > 0) {
×
2200
                        updatedVariables = v.childVariables;
×
2201
                    } else {
2202
                        updatedVariables = [v];
×
2203
                    }
2204
                    v.isResolved = true;
×
2205
                } else {
2206
                    updatedVariables = v.childVariables;
×
2207
                }
2208

2209
                // If the variable has no children, set the reference to 0
2210
                // so it does not look expandable in the Ui
2211
                if (v.childVariables.length === 0) {
×
2212
                    v.variablesReference = 0;
×
2213
                }
2214

2215
                // If the variable was resolve in the past we may not have fetched a new temp var
2216
                tempVar ??= v;
×
2217
                if (v?.presentationHint) {
×
2218
                    v.presentationHint.lazy = tempVar.presentationHint?.lazy;
×
2219
                } else {
2220
                    v.presentationHint = tempVar.presentationHint;
×
2221
                }
2222

2223
            } else {
2224
                updatedVariables = v.childVariables;
4✔
2225
            }
2226

2227
            // Only send the updated variables if we are not going to trigger an invalidated event.
2228
            // This is to prevent the UI from updating twice and makes the experience much smoother to the end user.
2229
            response.body = {
4✔
2230
                variables: this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2231
                // TODO: Re-enable this when we can send the correct variables based on the initial inspect context
2232
                // variables: sendInvalidatedEvent ? [] : this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2233
            };
2234
        } catch (error) {
2235
            logger.error('Error during variablesRequest', error, { args });
×
2236
            response.success = false;
×
2237
            response.message = error?.message ?? 'Error during variablesRequest';
×
2238
        } finally {
2239
            logger.info('end', { response });
4✔
2240
        }
2241
        this.sendResponse(response);
4✔
2242
        if (sendInvalidatedEvent) {
4!
2243
            this.debounceSendInvalidatedEvent(null, frameId);
×
2244
        }
2245
    }
2246

2247
    private debounceSendInvalidatedEvent = debounce((threadId: number, frameId: number) => {
215✔
2248
        this.sendInvalidatedEvent(threadId, frameId);
×
2249
    }, 50);
2250

2251

2252
    private filterVariablesUpdates(updatedVariables: Array<AugmentedVariable>, args: DebugProtocol.VariablesArguments, v: DebugProtocol.Variable): Array<AugmentedVariable> {
2253
        if (!updatedVariables || !v) {
4!
2254
            return [];
×
2255
        }
2256

2257
        let start = args.start ?? 0;
4!
2258

2259
        //if the variable is an array, send only the requested range
2260
        if (Array.isArray(updatedVariables) && args.filter === 'indexed') {
4!
2261
            //only send the variable range requested by the debugger
2262
            if (!args.count) {
×
2263
                updatedVariables = updatedVariables.slice(0, v.indexedVariables);
×
2264
            } else {
2265
                updatedVariables = updatedVariables.slice(start, start + args.count);
×
2266
            }
2267
        }
2268

2269
        if (Array.isArray(updatedVariables) && args.filter === 'named') {
4!
2270
            // We currently do not support named variable paging so we always send all named variables
2271
            updatedVariables = updatedVariables.slice(v.indexedVariables);
4✔
2272
        }
2273

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

2277
        if (this.launchConfiguration.showHiddenVariables !== true) {
4✔
2278
            filteredUpdatedVariables = filteredUpdatedVariables.filter((child: AugmentedVariable) => {
2✔
2279
                //A transient variable that we show when there is a value
2280
                if (child.name === '__brs_err__' && child.type !== VariableType.Uninitialized) {
4!
2281
                    return true;
×
2282
                } else if (util.isTransientVariable(child.name)) {
4!
2283
                    return false;
×
2284
                } else {
2285
                    return true;
4✔
2286
                }
2287
            });
2288
        }
2289

2290
        return filteredUpdatedVariables;
4✔
2291
    }
2292

2293
    /**
2294
     * Takes a scope variable and populates its child variables based on the scope type and the current adapter type.
2295
     * @param v scope variable to populate
2296
     * @param args
2297
     */
2298
    private async populateScopeVariables(v: AugmentedVariable, args: DebugProtocol.VariablesArguments) {
2299
        if (v.childVariables.length > 0) {
4✔
2300
            // Already populated
2301
            return;
3✔
2302
        }
2303

2304
        let tempVar: AugmentedVariable;
2305
        try {
1✔
2306
            if (v.type === '$$Locals') {
1!
2307
                if (this.rokuAdapter.isDebugProtocolAdapter()) {
1!
2308
                    let result = await this.rokuAdapter.getLocalVariables(v.frameId);
1✔
2309
                    tempVar = await this.getVariableFromResult(result, v.frameId);
1✔
2310
                } else if (this.rokuAdapter.isTelnetAdapter()) {
×
2311
                    // NOTE: Legacy telnet support
2312
                    let variables: AugmentedVariable[] = [];
×
2313
                    const varNames = await this.rokuAdapter.getScopeVariables();
×
2314

2315
                    // Fetch each variable individually
2316
                    for (const varName of varNames) {
×
2317
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: varName, frameId: -1 }, util.getVariablePath(varName));
×
2318
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, -1);
×
2319
                        let tempLocalsVar = await this.getVariableFromResult(result, -1);
×
2320
                        variables.push(tempLocalsVar);
×
2321
                    }
2322
                    tempVar = {
×
2323
                        ...v,
2324
                        childVariables: variables,
2325
                        namedVariables: variables.length,
2326
                        indexedVariables: 0
2327
                    };
2328
                }
2329

2330
                // Merge the resulting updates together onto the original variable
2331
                v.childVariables = tempVar.childVariables;
1✔
2332
                v.namedVariables = tempVar.namedVariables;
1✔
2333
                v.indexedVariables = tempVar.indexedVariables;
1✔
2334
            } else if (v.type === '$$Registry') {
×
2335
                // This is a special scope variable used to load registry data via an ECP call
2336
                // Send the registry ECP call for the `dev` app as side loaded apps are always `dev`
2337
                await populateVariableFromRegistryEcp({ host: this.launchConfiguration.host, remotePort: this.launchConfiguration.remotePort, appId: 'dev' }, v, this.variables, this.getEvaluateRefId.bind(this));
×
2338
            }
2339
        } catch (error) {
2340
            logger.error(`Error getting variables for scope ${v.type}`, error);
×
2341
            tempVar = {
×
2342
                name: '',
2343
                value: `❌ Error: ${error.message}`,
2344
                variablesReference: 0,
2345
                childVariables: []
2346
            };
2347
            v.childVariables = [tempVar];
×
2348
            v.namedVariables = 1;
×
2349
            v.indexedVariables = 0;
×
2350
        }
2351

2352
        // Mark the scope as resolved so we don't re-fetch the variables
2353
        v.isResolved = true;
1✔
2354

2355
        // If the scope has no children, add a single child to indicate there are no values
2356
        if (v.childVariables.length === 0) {
1!
2357
            tempVar = {
×
2358
                name: '',
2359
                value: `No values for scope '${v.name}'`,
2360
                variablesReference: 0,
2361
                childVariables: []
2362
            };
2363
            v.childVariables = [tempVar];
×
2364
            v.namedVariables = 1;
×
2365
            v.indexedVariables = 0;
×
2366
        }
2367
    }
2368

2369
    private evaluateRequestPromise = Promise.resolve();
215✔
2370
    private evaluateVarIndexByFrameId = new Map<number, number>();
215✔
2371

2372
    private getNextVarIndex(frameId: number): number {
2373
        if (!this.evaluateVarIndexByFrameId.has(frameId)) {
6✔
2374
            this.evaluateVarIndexByFrameId.set(frameId, 0);
5✔
2375
        }
2376
        let value = this.evaluateVarIndexByFrameId.get(frameId);
6✔
2377
        this.evaluateVarIndexByFrameId.set(frameId, value + 1);
6✔
2378
        return value;
6✔
2379
    }
2380

2381
    public async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) {
2382
        //ensure the rokuAdapter is loaded
2383
        await this.getRokuAdapter();
15✔
2384

2385
        let deferred = defer<void>();
15✔
2386
        if (args.context === 'repl' && this.rokuAdapter.isTelnetAdapter() && args.expression.trim().startsWith('>')) {
15!
2387
            this.clearState();
×
2388
            this.rokuAdapter.clearCache();
×
2389
            const expression = args.expression.replace(/^\s*>\s*/, '');
×
2390
            this.logger.log('Sending raw telnet command...I sure hope you know what you\'re doing', { expression });
×
2391
            this.rokuAdapter.requestPipeline.client.write(`${expression}\r\n`);
×
2392
            this.sendResponse(response);
×
2393
            return deferred.promise;
×
2394
        }
2395

2396
        try {
15✔
2397
            this.evaluateRequestPromise = this.evaluateRequestPromise.then(() => {
15✔
2398
                return deferred.promise;
15✔
2399
            });
2400

2401
            //fix vscode hover bug that excludes closing quotemark sometimes.
2402
            if (args.context === 'hover') {
15✔
2403
                args.expression = util.ensureClosingQuote(args.expression);
4✔
2404
            }
2405

2406
            if (!this.rokuAdapter.isAtDebuggerPrompt) {
15✔
2407
                let message = 'Skipped evaluate request because RokuAdapter is not accepting requests at this time';
1✔
2408
                if (args.context === 'repl') {
1!
2409
                    this.sendEvent(new OutputEvent(message, 'stderr'));
1✔
2410
                    response.body = {
1✔
2411
                        result: 'invalid',
2412
                        variablesReference: 0
2413
                    };
2414
                } else {
2415
                    throw new Error(message);
×
2416
                }
2417

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

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

2425
                //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`
2426
                if (variablePath) {
14✔
2427
                    let refId = this.getEvaluateRefId(evalArgs.expression, evalArgs.frameId);
11✔
2428
                    let v: AugmentedVariable;
2429
                    //if we already looked this item up, return it
2430
                    if (this.variables[refId]) {
11✔
2431
                        v = this.variables[refId];
1✔
2432
                    } else {
2433
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, evalArgs.frameId);
10✔
2434
                        if (!result) {
10!
2435
                            throw new Error('Error: unable to evaluate expression');
×
2436
                        }
2437

2438
                        v = await this.getVariableFromResult(result, evalArgs.frameId);
10✔
2439
                        //TODO - testing something, remove later
2440
                        // eslint-disable-next-line camelcase
2441
                        v.request_seq = response.request_seq;
10✔
2442
                        v.frameId = evalArgs.frameId;
10✔
2443
                    }
2444
                    response.body = {
11✔
2445
                        result: v.value,
2446
                        type: v.type,
2447
                        variablesReference: v.variablesReference,
2448
                        namedVariables: v.namedVariables || 0,
21✔
2449
                        indexedVariables: v.indexedVariables || 0
21✔
2450
                    };
2451

2452
                    //run an `evaluate` call
2453
                } else {
2454
                    let commandResults = await this.rokuAdapter.evaluate(evalArgs.expression, evalArgs.frameId);
3✔
2455

2456
                    commandResults.message = util.trimDebugPrompt(commandResults.message);
3✔
2457
                    if (args.context === 'repl') {
3!
2458
                        // Clear variable cache since this action could have side-effects
2459
                        // Only do this for REPL requests as hovers and watches should not clear the cache
2460
                        this.clearState();
3✔
2461
                        this.sendInvalidatedEvent(null, evalArgs.frameId);
3✔
2462
                    }
2463

2464
                    // If the adapter captured output (probably only telnet), log the results
2465
                    if (typeof commandResults.message === 'string') {
3✔
2466
                        this.logger.debug('evaluateRequest', { commandResults });
1✔
2467
                        if (args.context === 'repl') {
1!
2468
                            // If the command was a repl command, send the output to the debug console for the developer as well
2469
                            // We limit this to repl only so you don't get extra logs when hovering over variables ro running watches
2470
                            this.sendEvent(new OutputEvent(commandResults.message, commandResults.type === 'error' ? 'stderr' : 'stdio'));
1!
2471
                        }
2472
                    }
2473

2474
                    if (this.enableDebugProtocol || (typeof commandResults.message !== 'string')) {
3✔
2475
                        response.body = {
2✔
2476
                            result: 'invalid',
2477
                            variablesReference: 0
2478
                        };
2479
                    } else {
2480
                        response.body = {
1✔
2481
                            result: commandResults.message === '\r\n' ? 'invalid' : commandResults.message,
1!
2482
                            variablesReference: 0
2483
                        };
2484
                    }
2485
                }
2486
            }
2487
        } catch (error) {
2488
            this.logger.error('Error during variables request', error);
×
2489
            response.success = false;
×
2490
            response.message = error?.message ?? error;
×
2491
        }
2492
        try {
15✔
2493
            this.sendResponse(response);
15✔
2494
        } catch { }
2495
        deferred.resolve();
15✔
2496
    }
2497

2498
    private async evaluateExpressionToTempVar(args: DebugProtocol.EvaluateArguments, variablePath: string[]): Promise<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }> {
2499
        let returnVal = { evalArgs: args, variablePath };
19✔
2500
        if (!variablePath && util.isAssignableExpression(args.expression)) {
19✔
2501
            let varIndex = this.getNextVarIndex(args.frameId);
6✔
2502
            let arrayVarName = this.tempVarPrefix + 'eval';
6✔
2503
            let command = '';
6✔
2504
            if (varIndex === 0) {
6✔
2505
                await this.rokuAdapter.evaluate(`if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`, args.frameId);
5✔
2506
            }
2507
            let statement = `${arrayVarName}[${varIndex}] = ${args.expression}`;
6✔
2508
            returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
6✔
2509
            command += statement;
6✔
2510
            let commandResults = await this.rokuAdapter.evaluate(command, args.frameId);
6✔
2511
            if (commandResults.type === 'error') {
6!
2512
                throw new Error(commandResults.message);
×
2513
            }
2514
            returnVal.variablePath = [arrayVarName, varIndex.toString()];
6✔
2515
        }
2516
        return returnVal;
19✔
2517
    }
2518

2519
    private async bulkEvaluateExpressionToTempVar(frameId: number, argsArray: Array<DebugProtocol.EvaluateArguments>, variablePathArray: Array<string[]>): Promise<{ evaluations: Array<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }>; bulkVarName: string }> {
2520
        let results = {
×
2521
            evaluations: [],
2522
            bulkVarName: ''
2523
        };
2524
        let storedVariables = [];
×
2525
        let command = '';
×
2526
        for (let i = 0; i < argsArray.length; i++) {
×
2527
            let args = argsArray[i];
×
2528
            let variablePath = variablePathArray[i];
×
2529
            let returnVal = { evalArgs: args, variablePath };
×
2530
            if (!variablePath && util.isAssignableExpression(args.expression)) {
×
2531
                let varIndex = this.getNextVarIndex(frameId);
×
2532
                let arrayVarName = this.tempVarPrefix + 'eval';
×
2533
                if (varIndex === 0) {
×
2534
                    command += `if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`;
×
2535
                }
2536
                let statement = `${arrayVarName}[${varIndex}] = ${args.expression}\n`;
×
2537
                returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
×
2538
                command += statement;
×
2539

2540
                storedVariables.push(`${arrayVarName}[${varIndex}]`);
×
2541
                returnVal.variablePath = [arrayVarName, varIndex.toString()];
×
2542
            }
2543

2544
            results.evaluations[i] = returnVal;
×
2545
        }
2546

2547
        if (command) {
×
2548

2549
            // create a bulk container for the command results
2550
            let varIndex = this.getNextVarIndex(frameId);
×
2551
            let arrayVarName = this.tempVarPrefix + 'eval';
×
2552
            let bulkContainerStatement = `${arrayVarName}[${varIndex}] = [\n`;
×
2553
            for (let storedVariable of storedVariables) {
×
2554
                bulkContainerStatement += `${storedVariable},\n`;
×
2555
            }
2556
            bulkContainerStatement += `]`;
×
2557

2558
            command += bulkContainerStatement;
×
2559

2560
            results.bulkVarName = `${arrayVarName}[${varIndex}]`;
×
2561

2562
            let commandResults = await this.rokuAdapter.evaluate(command, frameId);
×
2563
            if (commandResults.type === 'error') {
×
2564
                throw new Error(commandResults.message);
×
2565
            }
2566
        }
2567

2568
        return results;
×
2569
    }
2570

2571
    protected async completionsRequest(response: DebugProtocol.CompletionsResponse, args: DebugProtocol.CompletionsArguments, request?: DebugProtocol.Request) {
2572
        this.logger.log('completionsRequest', args, request);
20✔
2573
        // this.sendEvent(new LogOutputEvent(`completionsRequest: ${args.text}`));
2574
        // this.sendEvent(new OutputEvent(`completionsRequest: ${args.text}\n`, 'stderr'));
2575

2576
        try {
20✔
2577
            let supplyLocalScopeCompletions = false;
20✔
2578

2579
            let closestCompletionDetails = this.getClosestCompletionDetails(args);
20✔
2580

2581
            if (!closestCompletionDetails) {
20!
2582
                // If the cursor is not at the end of the line, then we should not supply completions at this time
2583
                response.body = {
×
2584
                    targets: []
2585
                };
2586
                return this.sendResponse(response);
×
2587
            }
2588
            let completions = new Map<string, DebugProtocol.CompletionItem>();
20✔
2589

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

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

2607
            // Get the completions if the variable path was valid
2608
            if (parentVariablePath) {
20!
2609

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

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

2618
                // provide completions for the parent variable if one was found
2619
                if (parentVariable) {
20✔
2620
                    // arrays and lists are integer-indexed; their `[N]` elements aren't valid `.` or `["..."]`
2621
                    // completions (you can't write `arr.[0]` or `arr["0"]`), so don't offer them as members.
2622
                    // Only the interface methods below (Count, Push, ...) apply to these containers.
2623
                    const isIntegerIndexed = parentVariable.type === VariableType.Array ||
19✔
2624
                        parentVariable.type === VariableType.List ||
2625
                        parentVariable.type === 'roXMLList' ||
2626
                        parentVariable.type === 'roByteArray';
2627

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

2633
                    for (let v of possibleFieldsAndMethods) {
19✔
2634
                        // Default completion type should be variable
2635
                        let completionType: DebugProtocol.CompletionItemType = 'variable';
21✔
2636
                        if (!supplyLocalScopeCompletions) {
21✔
2637
                            // We are not supplying local scope completions, so we need to determine the completion type relative to the parent variable
2638
                            if (parentVariable.type === 'roSGNode' || parentVariable.type === VariableType.AssociativeArray || parentVariable.type === VariableType.Object) {
17!
2639
                                completionType = 'field';
17✔
2640
                            }
2641

2642
                            switch (v.type) {
17!
2643
                                case VariableType.Function:
2644
                                case VariableType.Subroutine:
2645
                                    completionType = 'method';
×
2646
                                    break;
×
2647
                                default:
2648
                                    break;
17✔
2649
                            }
2650
                        }
2651

2652
                        const completionItem: DebugProtocol.CompletionItem = {
21✔
2653
                            label: v.name,
2654
                            type: completionType,
2655
                            //rank a variable's own members/locals above everything else
2656
                            sortText: `${CompletionSortTier.Member}${v.name}`
2657
                        };
2658
                        if (stringKeyClosing !== undefined) {
21✔
2659
                            // Insert the key and close the access, ex: `firstName"]` (the replacement range is applied
2660
                            // below). A `"` inside the key is escaped as `""` so the inserted string literal stays valid
2661
                            // (ex: a key of `a"b` is inserted as `a""b`).
2662
                            completionItem.text = `${v.name.replace(/"/g, '""')}${stringKeyClosing}`;
4✔
2663
                        } else if (!supplyLocalScopeCompletions && precededByDot && !/^[a-z_][a-z0-9_]*$/i.test(v.name)) {
17✔
2664
                            // The key can't be dot-accessed (ex: it has a space or a quote), so rewrite the access as
2665
                            // bracket notation and consume the `.` before the cursor: `m.` -> `m["my key"]`. A `"` in
2666
                            // the key is escaped as `""` so the inserted string literal stays valid.
2667
                            completionItem.text = `["${v.name.replace(/"/g, '""')}"]`;
3✔
2668
                            completionItem.start = replaceRange.start - 1;
3✔
2669
                            completionItem.length = replaceRange.length + 1;
3✔
2670
                        }
2671
                        completions.set(`${completionType}-${v.name}`, completionItem);
21✔
2672
                    }
2673

2674
                    // Interface methods aren't valid string keys, so skip them when completing a string key
2675
                    if (stringKeyClosing === undefined) {
19✔
2676
                        let parentComponentType = this.debuggerVarTypeToRoType(parentVariable.type).toLowerCase();
15✔
2677
                        //assemble a list of all methods on the parent component
2678
                        const methods = [
15✔
2679
                            //if the parent variable is an actual interface (if applicable) Ex: `ifString` or `ifArray`
2680
                            ...interfaces[parentComponentType as 'ifappinfo']?.methods ?? [],
90!
2681
                            //interfaces from component of this name (if applicable) Ex: `roSGNode` or `roDateTime`
2682
                            ...components[parentComponentType as 'roappinfo']?.interfaces.map((i) => interfaces[i.name.toLowerCase() as 'ifappinfo']?.methods) ?? [],
29!
2683
                            // Add parent event function completions (if applicable) Ex: `roSGNodeEvent` or `roDeviceInfoEvent`
2684
                            ...events[parentComponentType as 'roappmemorymonitorevent']?.methods ?? []
90!
2685
                        ].flat();
2686

2687
                        // Based on the results of interface, component, and event looks up, add all the methods to the completions
2688
                        for (const method of methods) {
15✔
2689
                            completions.set(`method-${method.name}`, {
185✔
2690
                                label: method.name,
2691
                                type: 'method',
2692
                                detail: method.description ?? '',
555!
2693
                                sortText: `${CompletionSortTier.Method}${method.name}`
2694
                            });
2695
                        }
2696
                    }
2697

2698
                    // Add the global functions to the completions results
2699
                    if (supplyLocalScopeCompletions) {
19✔
2700
                        for (let globalCallable of globalCallables) {
4✔
2701
                            completions.set(`function-${globalCallable.name.toLocaleLowerCase()}`, {
308✔
2702
                                label: globalCallable.name,
2703
                                type: 'function',
2704
                                detail: globalCallable.shortDescription ?? globalCallable.documentation ?? '',
1,848!
2705
                                sortText: `${CompletionSortTier.Global}${globalCallable.name}`
2706
                            });
2707
                        }
2708

2709
                        const frame = this.rokuAdapter.getStackFrameById(args.frameId);
4✔
2710

2711
                        try {
4✔
2712
                            let scopeFunctions = await this.projectManager.getScopeFunctionsForFile(frame.filePath as string);
4✔
2713
                            for (let scopeFunction of scopeFunctions) {
4✔
2714
                                if (!completions.has(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`)) {
1!
2715
                                    completions.set(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`, {
1✔
2716
                                        label: scopeFunction.name,
2717
                                        type: scopeFunction.completionItemKind,
2718
                                        sortText: `${CompletionSortTier.ScopeFunction}${scopeFunction.name}`
2719
                                    });
2720
                                }
2721
                            }
2722
                        } catch (e) {
2723
                            this.logger.warn('Could not build list of scope functions for file', e);
×
2724
                        }
2725
                    }
2726
                }
2727
            }
2728

2729
            // Apply the default replacement span to every completion that didn't already set its own (bracket
2730
            // rewrites above use an extended range that also consumes the preceding `.`).
2731
            for (const target of completions.values()) {
20✔
2732
                if (target.start === undefined) {
499✔
2733
                    target.start = replaceRange.start;
496✔
2734
                    target.length = replaceRange.length;
496✔
2735
                }
2736
            }
2737

2738
            response.body = {
20✔
2739
                targets: [...completions.values()]
2740
            };
2741
        } catch (error) {
2742
            // this.sendEvent(new LogOutputEvent(`text: ${args.text} | ${error}`));
2743
            // this.sendEvent(new OutputEvent(`text: ${args.text} | ${error}\n`, 'stderr'));
2744
            this.logger.error('Error during completionsRequest', error, { args });
×
2745
        }
2746
        this.sendResponse(response);
20✔
2747
    }
2748

2749
    /**
2750
     * Gets the closest completion details the incoming completion request.
2751
     */
2752
    private getClosestCompletionDetails(args: DebugProtocol.CompletionsArguments): { parentVariablePath: string[]; stringKeyClosing?: string } {
2753
        const incomingText = args.text;
69✔
2754
        const lines = incomingText.split('\n');
69✔
2755
        let lineNumber = this.toDebuggerLine(args.line, 0);
69✔
2756
        let column = this.toDebuggerColumn(args.column);
69✔
2757

2758
        const targetLine = lines[lineNumber] ?? '';
69!
2759

2760
        const cursorIndex = column - 1;
69✔
2761
        const variableChars = /[a-z0-9_\.]/i;
69✔
2762

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

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

2778
        const openBracket = this.findUnclosedOpener(targetLine, column);
67✔
2779
        if (openBracket?.char === '[') {
67✔
2780
            // find the opening quote (skipping any whitespace after the `[`)
2781
            let quoteIndex = openBracket.index + 1;
12✔
2782
            while (targetLine[quoteIndex] === ' ' || targetLine[quoteIndex] === '\t') {
12✔
2783
                quoteIndex++;
×
2784
            }
2785
            const quote = targetLine[quoteIndex];
12✔
2786
            if (quote === '"' || quote === `'`) {
12✔
2787
                // The user is typing a string key, so complete the keys of the expression before the `[`
2788
                endColumn = openBracket.index;
8✔
2789
                isMemberAccess = true;
8✔
2790
                // close the string and bracket only when there is nothing meaningful after the cursor
2791
                stringKeyClosing = targetLine.slice(column).trim() === '' ? `${quote}]` : '';
8✔
2792
            }
2793
        }
2794

2795
        // Walk backwards from `endColumn` to find the start of the variable path, stepping over balanced
2796
        // `[...]` index access so paths like `arr[0].name` are captured as a whole.
2797
        let startIndex = endColumn - 1;
67✔
2798
        let bracketDepth = 0;
67✔
2799
        while (startIndex >= 0) {
67✔
2800
            const char = targetLine[startIndex];
438✔
2801
            if (char === ']') {
438✔
2802
                bracketDepth++;
10✔
2803
            } else if (char === '[') {
428✔
2804
                if (bracketDepth === 0) {
12✔
2805
                    // An unbalanced `[` means we hit the start of an index/key being typed; stop here.
2806
                    break;
2✔
2807
                }
2808
                bracketDepth--;
10✔
2809
            } else if (bracketDepth === 0 && (char === undefined || !variableChars.test(char))) {
416✔
2810
                break;
23✔
2811
            }
2812
            startIndex--;
413✔
2813
        }
2814

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

2817
        // Attempted dot access on something unexpected.
2818
        // Example: `getPerson().name` where `getPerson()` is not a valid variable path,
2819
        // which leaves `.name` as the variable path string.
2820
        if (variablePathString.startsWith('.')) {
67✔
2821
            return undefined;
2✔
2822
        }
2823

2824
        if (variablePathString.endsWith('.')) {
65✔
2825
            isMemberAccess = true;
25✔
2826
        }
2827

2828
        // Get the variable path from the text
2829
        let variablePath: string[];
2830
        if (!variablePathString.trim()) {
65✔
2831
            // The text was empty so assume via '' that we are looking up the local scope variables and global functions
2832
            variablePath = [''];
10✔
2833
        } else if (variablePathString.endsWith('.')) {
55✔
2834
            // supplied text ends with a period, so strip it off to create a valid variable path
2835
            variablePath = util.getVariablePath(variablePathString.slice(0, -1));
25✔
2836
        } else {
2837
            variablePath = util.getVariablePath(variablePathString);
30✔
2838
        }
2839

2840
        // the target string is not a valid variable path
2841
        if (!variablePath) {
65✔
2842
            return undefined;
2✔
2843
        }
2844

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

2849
        // An empty parent path means we are looking up the local scope variables and global functions
2850
        if (parentVariablePath.length === 0) {
63✔
2851
            parentVariablePath = [''];
17✔
2852
        }
2853

2854
        const result: { parentVariablePath: string[]; stringKeyClosing?: string } = { parentVariablePath: parentVariablePath };
63✔
2855
        // Only attach the string-key context when we actually resolved a parent object to complete keys on
2856
        if (stringKeyClosing !== undefined && !(parentVariablePath.length === 1 && parentVariablePath[0] === '')) {
63✔
2857
            result.stringKeyClosing = stringKeyClosing;
8✔
2858
        }
2859
        return result;
63✔
2860
    }
2861

2862
    /**
2863
     * Compute the span of input text that a completion replaces: the run of identifier characters
2864
     * immediately before the cursor. This lets the client filter the list correctly as the user keeps
2865
     * typing past the first character.
2866
     *
2867
     * `start` is a 0-based offset into the line, NOT a client column. Per the Debug Adapter Protocol,
2868
     * `CompletionItem.start` is measured in UTF-16 code units and the client maps it to a position
2869
     * itself, so it must not be run through `toClientColumn` (unlike stack-frame, breakpoint, and scope
2870
     * positions). Our debugger column base is already 0-based, so the internal offset is sent as-is.
2871
     */
2872
    private getCompletionReplaceRange(args: DebugProtocol.CompletionsArguments): { start: number; length: number } {
2873
        const lines = args.text.split('\n');
20✔
2874
        const lineNumber = this.toDebuggerLine(args.line, 0);
20✔
2875
        const cursorOffset = this.toDebuggerColumn(args.column);
20✔
2876
        const targetLine = lines[lineNumber] ?? '';
20!
2877

2878
        const identifierChars = /[a-z0-9_]/i;
20✔
2879
        let wordStart = cursorOffset;
20✔
2880
        while (wordStart > 0 && identifierChars.test(targetLine[wordStart - 1])) {
20✔
2881
            wordStart--;
7✔
2882
        }
2883
        return {
20✔
2884
            start: wordStart,
2885
            length: cursorOffset - wordStart
2886
        };
2887
    }
2888

2889
    /**
2890
     * Scan backwards from `column` to find the nearest opening bracket (`(`, `[`, or `{`) that has not
2891
     * been closed before the cursor. Returns the opener's index and character, or undefined if none.
2892
     */
2893
    private findUnclosedOpener(line: string, column: number): { index: number; char: string } {
2894
        let depth = 0;
67✔
2895
        for (let i = column - 1; i >= 0; i--) {
67✔
2896
            const char = line[i];
604✔
2897
            if (char === ')' || char === ']' || char === '}') {
604✔
2898
                depth++;
12✔
2899
            } else if (char === '(' || char === '[' || char === '{') {
592✔
2900
                if (depth === 0) {
34✔
2901
                    return { index: i, char: char };
22✔
2902
                }
2903
                depth--;
12✔
2904
            }
2905
        }
2906
        return undefined;
45✔
2907
    }
2908

2909
    /**
2910
     * Resolve the parent variable for a completion request. Prefers the in-memory locals for the frame,
2911
     * then falls back to a device lookup. Device lookups are cached for the duration of the paused state
2912
     * (cleared by `clearState`) so repeated completion requests on the same path don't hammer the device.
2913
     */
2914
    private async resolveCompletionParentVariable(parentVariablePath: string[], frameId: number): Promise<AugmentedVariable> {
2915
        // For local-scope completions, make sure the frame's locals are fetched on demand. Otherwise they
2916
        // would only appear once the user expands the Variables panel (which is what triggers the fetch).
2917
        const isLocalScope = parentVariablePath.length === 1 && parentVariablePath[0] === '';
20✔
2918
        if (isLocalScope) {
20✔
2919
            const localsScope = this.getOrCreateLocalsScope(frameId);
4✔
2920
            if (!localsScope.isResolved) {
4!
2921
                try {
4✔
2922
                    await this.populateScopeVariables(localsScope, { variablesReference: localsScope.variablesReference } as DebugProtocol.VariablesArguments);
4✔
2923
                } catch (error) {
2924
                    this.logger.debug('Could not populate locals for completions', error, { frameId });
×
2925
                }
2926
            }
2927
        }
2928

2929
        const inMemory = this.findFrameVariableByPath(parentVariablePath, frameId);
20✔
2930
        if (inMemory && inMemory.childVariables.length > 0) {
20✔
2931
            return inMemory;
14✔
2932
        }
2933

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

2938
        const cacheKey = `${frameId}:${expression}`;
6✔
2939
        if (this.completionParentVariableCache.has(cacheKey)) {
6✔
2940
            return this.completionParentVariableCache.get(cacheKey);
1✔
2941
        }
2942

2943
        let parentVariable: AugmentedVariable;
2944
        try {
5✔
2945
            let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: expression, frameId: frameId }, parentVariablePath);
5✔
2946
            let result = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
5✔
2947
            parentVariable = await this.getVariableFromResult(result, frameId);
4✔
2948
        } catch (error) {
2949
            // A failed lookup is expected while the user is still typing an incomplete expression, so keep it quiet.
2950
            this.logger.debug('Could not resolve parent variable for completions', error, { parentVariablePath });
1✔
2951
            parentVariable = undefined;
1✔
2952
        }
2953

2954
        this.completionParentVariableCache.set(cacheKey, parentVariable);
5✔
2955
        return parentVariable;
5✔
2956
    }
2957

2958
    /**
2959
     * Rebuild a valid BrightScript accessor expression from a resolved variable path. String-literal keys
2960
     * arrive already quoted from `getVariablePath` and are emitted as `["key"]` so they stay case-sensitive
2961
     * on the device (Roku AAs can be set case-sensitive); numeric segments use `[index]`, and identifiers
2962
     * use dot access. This keeps array indices and string keys correct through the device lookup.
2963
     */
2964
    private buildVariableExpression(segments: string[]): string {
2965
        return segments.reduce((expression, segment, index) => {
14✔
2966
            if (index === 0) {
27✔
2967
                return segment;
14✔
2968
            }
2969
            //already-quoted string key (preserve the quotes so the device matches it case-sensitively).
2970
            //A lone `"` is not a quoted literal (the shortest is `""`), so require at least 2 chars.
2971
            if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
13✔
2972
                return `${expression}[${segment}]`;
2✔
2973
            }
2974
            if (/^[0-9]+$/.test(segment)) {
11✔
2975
                return `${expression}[${segment}]`;
3✔
2976
            }
2977
            if (/^[a-z_][a-z0-9_]*$/i.test(segment)) {
8✔
2978
                return `${expression}.${segment}`;
5✔
2979
            }
2980
            return `${expression}["${segment.replace(/"/g, '""')}"]`;
3✔
2981
        }, '');
2982
    }
2983

2984
    /**
2985
     * Normalize a variable path segment or variable name for matching: drop surrounding string-key quotes
2986
     * and lower-case it. BrightScript variables and dotted access are case-insensitive, and the device
2987
     * reports names lower-cased, so this lets the in-memory lookup find the parent regardless of the casing
2988
     * the user typed (ex: `topRef` matching the cached `topref`).
2989
     */
2990
    private normalizeVariableName(name: string): string {
2991
        let value = name ?? '';
46!
2992
        if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
46✔
2993
            value = value.slice(1, -1).replace(/""/g, '"');
2✔
2994
        }
2995
        return value.toLowerCase();
46✔
2996
    }
2997

2998
    /**
2999
     * Resolve a variable path against the current frame's local scope. The first path segment is matched
3000
     * against the frame's locals (not the global pool of every materialized variable), then we walk down
3001
     * the child variables. The empty path (`['']`) resolves to the locals scope container itself.
3002
     */
3003
    private findFrameVariableByPath(path: string[], frameId: number): AugmentedVariable {
3004
        const localsContainer = this.variables[this.getEvaluateRefId('$$locals', frameId)];
20✔
3005
        if (path.length === 1 && path[0] === '') {
20✔
3006
            return localsContainer;
4✔
3007
        }
3008
        return this.findVariableByPath(localsContainer?.childVariables ?? [], path, frameId);
16✔
3009
    }
3010

3011
    private findVariableByPath(variables: AugmentedVariable[], path: string[], frameId: number) {
3012
        let current: AugmentedVariable = null;
22✔
3013
        for (const name of path) {
22✔
3014
            const normalizedName = this.normalizeVariableName(name);
26✔
3015
            // Find the object matching the current name in the data (case-insensitive, per BrightScript)
3016
            current = (Array.isArray(variables) ? variables : current?.childVariables)?.find(obj => {
26!
3017
                return this.normalizeVariableName(obj.name) === normalizedName && obj.frameId === frameId;
20✔
3018
            });
3019

3020
            // If no match is found, return null
3021
            if (!current) {
26✔
3022
                return null;
7✔
3023
            }
3024

3025
            // Move to the children for the next iteration
3026
            variables = current.childVariables;
19✔
3027
        }
3028
        return current;
15✔
3029
    }
3030

3031
    private debuggerVarTypeToRoType(type: string): string {
3032
        switch (type) {
15!
3033
            case VariableType.Function:
3034
            case VariableType.Subroutine:
3035
                return 'roFunction';
×
3036
            case VariableType.AssociativeArray:
3037
                return 'roAssociativeArray';
11✔
3038
            case VariableType.List:
3039
                return 'roList';
×
3040
            case VariableType.Array:
3041
                return 'roArray';
1✔
3042
            case VariableType.Boolean:
3043
                return 'roBoolean';
×
3044
            case VariableType.Double:
3045
                return 'roDouble';
×
3046
            case VariableType.Float:
3047
                return 'roFloat';
×
3048
            case VariableType.Integer:
3049
                return 'roInteger';
×
3050
            case VariableType.LongInteger:
3051
                return 'roLongInteger';
×
3052
            case VariableType.String:
3053
                return 'roString';
×
3054
            default:
3055
                return type;
3✔
3056
        }
3057
    }
3058

3059
    /**
3060
     * Called when the host stops debugging
3061
     * @param response
3062
     * @param args
3063
     */
3064
    protected async disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments, request?: DebugProtocol.Request) {
3065
        //return to the home screen — best effort. The device may already be powered off or unreachable
3066
        //at disconnect time; without a guard pressHomeButton rejects (EHOSTDOWN / ECONNREFUSED / etc)
3067
        //and because @vscode/debugadapter dispatches this method without awaiting the returned Promise,
3068
        //that rejection escapes as an unhandledRejection and crashes the DAP process.
3069
        //See https://github.com/rokucommunity/vscode-brightscript-language/issues/807
3070
        //    https://github.com/rokucommunity/roku-debug/issues/332
3071
        if (!this.enableDebugProtocol) {
2!
3072
            try {
2✔
3073
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
2✔
3074
            } catch (e) {
3075
                this.logger.warn('Failed to press home button during disconnect; device may be unreachable', e);
2✔
3076
            }
3077
        }
3078
        this.sendResponse(response);
2✔
3079
        await this.shutdown();
2✔
3080
    }
3081

3082
    private createRokuAdapter(rendezvousTracker: RendezvousTracker) {
3083
        if (this.enableDebugProtocol) {
1!
3084
            this.rokuAdapter = new DebugProtocolAdapter(this.launchConfiguration, this.projectManager, this.breakpointManager, rendezvousTracker, this.deviceInfo);
×
3085
        } else {
3086
            this.rokuAdapter = new TelnetAdapter(this.launchConfiguration, rendezvousTracker);
1✔
3087
        }
3088
    }
3089

3090
    protected async restartRequest(response: DebugProtocol.RestartResponse, args: DebugProtocol.RestartArguments, request?: DebugProtocol.Request) {
3091
        this.logger.log('[restartRequest] begin');
×
3092
        if (this.rokuAdapter) {
×
3093
            if (!this.enableDebugProtocol) {
×
3094
                this.rokuAdapter.removeAllListeners();
×
3095
            }
3096
            await this.rokuAdapter.destroy();
×
3097
            await this.ensureAppIsInactive();
×
3098
            this.rokuAdapterDeferred = defer();
×
3099
            this.stagingDefered.tryResolve();
×
3100
            this.stagingDefered = defer();
×
3101
        }
3102
        await this.launchRequest(response, args.arguments as LaunchConfiguration);
×
3103
    }
3104

3105
    private exitAppTimeout = 5000;
215✔
3106
    private async ensureAppIsInactive() {
3107
        const startTime = Date.now();
×
3108

3109
        while (true) {
×
3110
            if (Date.now() - startTime > this.exitAppTimeout) {
×
3111
                return;
×
3112
            }
3113

3114
            try {
×
3115
                let appStateResult = await rokuECP.getAppState({
×
3116
                    host: this.launchConfiguration.host,
3117
                    remotePort: this.launchConfiguration.remotePort,
3118
                    appId: 'dev',
3119
                    requestOptions: { timeout: 300 }
3120
                });
3121

3122
                const state = appStateResult.state;
×
3123

3124
                if (state === AppState.active || state === AppState.background) {
×
3125
                    // Suspends or terminates an app that is running:
3126
                    // If the app supports Instant Resume and is running in the foreground, sending this command suspends the app (the app runs in the background).
3127
                    // 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.
3128
                    // This means that we might need to send this command twice to terminate the app.
3129
                    await rokuECP.exitApp({
×
3130
                        host: this.launchConfiguration.host,
3131
                        remotePort: this.launchConfiguration.remotePort,
3132
                        appId: 'dev',
3133
                        requestOptions: { timeout: 300 }
3134
                    });
3135
                } else if (state === AppState.inactive) {
×
3136
                    return;
×
3137
                }
3138
            } catch (e) {
3139
                this.logger.error('Error attempting to exit application', e);
×
3140
            }
3141

3142
            await util.sleep(200);
×
3143
        }
3144
    }
3145

3146
    /**
3147
     * Used to track whether the entry breakpoint has already been handled
3148
     */
3149
    private entryBreakpointWasHandled = false;
215✔
3150

3151
    /**
3152
     * Registers the main events for the RokuAdapter
3153
     */
3154
    private async connectRokuAdapter() {
3155
        this.rokuAdapter.on('start', () => {
×
3156
            this.sendLaunchProgress('end', 'Complete');
×
3157
            if (!this.firstRunDeferred.isCompleted) {
×
3158
                this.firstRunDeferred.resolve();
×
3159
            }
3160
        });
3161

3162
        this.rokuAdapter.on('launch-status', (message) => {
×
3163
            this.sendLaunchProgress('update', message);
×
3164
        });
3165

3166
        //when the debugger suspends (pauses for debugger input)
3167
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
3168
        this.rokuAdapter.on('suspend', async () => {
×
3169
            await this.onSuspend();
×
3170
        });
3171

3172
        //anytime the adapter encounters an exception on the roku,
3173
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
3174
        this.rokuAdapter.on('runtime-error', async (exception) => {
×
3175
            await this.getRokuAdapter();
×
3176
            const threads = await this.setupSuspendedState();
×
3177
            let threadId = threads[0]?.threadId;
×
3178
            this.sendEvent(new StoppedEvent(StoppedEventReason.exception, threadId, exception.message));
×
3179
        });
3180

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

3186
        //make the connection
3187
        await this.rokuAdapter.connect();
×
3188
        this.rokuAdapterDeferred.resolve(this.rokuAdapter);
×
3189
        return this.rokuAdapter;
×
3190
    }
3191

3192
    private async onSuspend() {
3193
        const threads = await this.setupSuspendedState();
1✔
3194
        const activeThread = threads.find(x => x.isSelected);
1✔
3195

3196
        //if !stopOnEntry, and we haven't encountered a suspend yet, THIS is the entry breakpoint. auto-continue
3197
        if (!this.entryBreakpointWasHandled && !this.launchConfiguration.stopOnEntry) {
1!
3198
            this.entryBreakpointWasHandled = true;
1✔
3199
            //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
3200
            if (activeThread && !await this.breakpointManager.lineHasBreakpoint(this.projectManager.getAllProjects(), activeThread.filePath, activeThread.lineNumber - 1)) {
1!
3201
                this.logger.info('Encountered entry breakpoint and `stopOnEntry` is disabled. Continuing...');
×
3202
                return this.rokuAdapter.continue();
×
3203
            }
3204
        }
3205

3206
        const event: StoppedEvent = new StoppedEvent(
1✔
3207
            StoppedEventReason.breakpoint,
3208
            //Not sure why, but sometimes there is no active thread. Just pick thread 0 to prevent the app from totally crashing
3209
            activeThread?.threadId ?? 0,
6!
3210
            '' //exception text
3211
        );
3212
        // Socket debugger will always stop all threads and supports multi thread inspection.
3213
        (event.body as any).allThreadsStopped = this.enableDebugProtocol;
1✔
3214
        this.sendEvent(event);
1✔
3215
    }
3216

3217
    private async setupSuspendedState() {
3218
        //clear the index for storing evalutated expressions
3219
        this.evaluateVarIndexByFrameId.clear();
8✔
3220

3221
        const threads = await this.rokuAdapter.getThreads();
8✔
3222

3223
        //TODO remove this once Roku fixes their threads off-by-one line number issues
3224
        //look up the correct line numbers for each thread from the StackTrace
3225
        await Promise.all(
8✔
3226
            threads.map(async (thread) => {
3227
                const stackTrace = await this.rokuAdapter.getStackTrace(thread.threadId);
9✔
3228
                const stackTraceLineNumber = stackTrace[0]?.lineNumber;
9✔
3229
                const stackTraceFilePath = stackTrace[0]?.filePath;
9✔
3230
                // Only apply the line correction when we actually have valid data — never clobber
3231
                // thread.filePath with undefined, which would crash getSourceLocation downstream.
3232
                if (stackTraceLineNumber !== undefined && stackTraceLineNumber !== thread.lineNumber) {
9✔
3233
                    this.logger.warn(`Thread ${thread.threadId} reported incorrect line (${thread.lineNumber}). Using line from stack trace instead (${stackTraceLineNumber})`, thread, stackTrace);
2✔
3234
                    thread.lineNumber = stackTraceLineNumber;
2✔
3235
                    thread.filePath = stackTraceFilePath ?? thread.filePath;
2!
3236
                }
3237
            })
3238
        );
3239

3240
        outer: for (const bp of this.breakpointManager.failedDeletions) {
8✔
3241
            for (const thread of threads) {
4✔
3242
                let sourceLocation = await this.projectManager.getSourceLocation(thread.filePath, thread.lineNumber);
4✔
3243
                // This stop was due to a breakpoint that we tried to delete, but couldn't.
3244
                // Now that we are stopped, we can delete it. We won't stop here again unless you re-add the breakpoint. You're welcome.
3245
                if (sourceLocation && (bp.srcPath === sourceLocation.filePath) && (bp.line === sourceLocation.lineNumber)) {
4✔
3246
                    this.showPopupMessage(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops.`, 'info').catch((error) => {
1✔
3247
                        this.logger.error('Error showing popup message', { error });
×
3248
                    });
3249
                    this.logger.warn(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops`, bp, thread, sourceLocation);
1✔
3250
                    break outer;
1✔
3251
                }
3252
            }
3253
        }
3254

3255
        //sync breakpoints
3256
        await this.rokuAdapter?.syncBreakpoints();
8!
3257

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

3260
        this.clearState();
8✔
3261
        return threads;
8✔
3262
    }
3263

3264
    private async getVariableFromResult(result: EvaluateContainer, frameId: number, maxDepth = 1) {
15✔
3265
        let v: AugmentedVariable;
3266

3267
        if (result) {
25!
3268
            if (this.rokuAdapter.isDebugProtocolAdapter()) {
25✔
3269
                let refId = this.getEvaluateRefId(result.evaluateName, frameId);
16✔
3270
                if (result.isCustom && !result.presentationHint?.lazy && result.evaluateNow) {
16!
3271
                    try {
×
3272
                        // We should not wait to resolve this variable later. Fetch, store, and merge the results right away.
3273
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: result.evaluateName, frameId: frameId }, util.getVariablePath(result.evaluateName));
×
3274
                        let newResult = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
×
3275
                        this.mergeEvaluateContainers(result, newResult);
×
3276
                    } catch (error) {
3277
                        logger.error('Error getting variables', error);
×
3278
                        this.mergeEvaluateContainers(result, {
×
3279
                            name: result.name,
3280
                            evaluateName: result.evaluateName,
3281
                            children: [],
3282
                            value: `❌ Error: ${error.message}`,
3283
                            type: '',
3284
                            highLevelType: undefined,
3285
                            keyType: undefined
3286
                        });
3287
                    }
3288
                }
3289

3290
                if (result.keyType) {
16✔
3291
                    let value = `${result.value ?? result.type}`;
5!
3292
                    let indexedVariables = result.indexedVariables;
5✔
3293
                    let namedVariables = result.namedVariables;
5✔
3294

3295
                    if (indexedVariables === undefined || namedVariables === undefined) {
5!
3296
                        // If either indexed or named variables are undefined, we should tell the debugger to ask for everything
3297
                        // by supplying undefined values for both
3298
                        indexedVariables = undefined;
×
3299
                        namedVariables = undefined;
×
3300
                    }
3301

3302
                    // check to see if this is an dictionary or a list
3303
                    if (result.keyType === 'Integer') {
5!
3304
                        // list type
3305
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
×
3306
                    } else if (result.keyType === 'String') {
5!
3307
                        // dictionary type
3308
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
5✔
3309
                    }
3310
                    v.type = result.type;
5✔
3311
                } else {
3312

3313
                    let value: string;
3314
                    if (result.type === VariableType.Invalid) {
11!
3315
                        value = result.value ?? 'Invalid';
×
3316
                    } else if (result.type === VariableType.Uninitialized) {
11!
3317
                        value = 'Uninitialized';
×
3318
                    } else {
3319
                        value = `${result.value}`;
11✔
3320
                    }
3321
                    // If the variable is lazy we must assign a refId to inform the system
3322
                    // to request this variable again in the future for value resolution
3323
                    v = new Variable(result.name, value, result?.presentationHint?.lazy ? refId : 0);
11!
3324
                }
3325
                this.variables[refId] = v;
16✔
3326
            } else if (this.rokuAdapter.isTelnetAdapter()) {
9!
3327
                if (result.highLevelType === 'primative' || result.highLevelType === 'uninitialized') {
9✔
3328
                    v = new Variable(result.name, `${result.value}`);
7✔
3329
                } else if (result.highLevelType === 'array') {
2✔
3330
                    let refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3331
                    v = new Variable(result.name, result.type, refId, result.children?.length ?? 0, 0);
1!
3332
                    this.variables[refId] = v;
1✔
3333
                } else if (result.highLevelType === 'object') {
1!
3334
                    let refId: number;
3335
                    //handle collections
3336
                    if (this.rokuAdapter.isScrapableContainObject(result.type)) {
1!
3337
                        refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3338
                    }
3339
                    v = new Variable(result.name, result.type, refId, 0, result.children?.length ?? 0);
1!
3340
                    this.variables[refId] = v;
1✔
3341
                } else if (result.highLevelType === 'function') {
×
3342
                    v = new Variable(result.name, `${result.value}`);
×
3343
                } else {
3344
                    //all other cases, but mostly for HighLevelType.unknown
3345
                    v = new Variable(result.name, `${result.value}`);
×
3346
                }
3347
            }
3348

3349
            v.type = result.type;
25✔
3350
            v.evaluateName = result.evaluateName;
25✔
3351
            v.frameId = frameId;
25✔
3352
            v.type = result.type;
25✔
3353
            v.presentationHint = result.presentationHint ? { kind: result.presentationHint?.kind, lazy: result.presentationHint?.lazy } : undefined;
25!
3354
            if (util.isTransientVariable(v.name)) {
25!
3355
                v.presentationHint = { kind: 'virtual' };
×
3356
            }
3357

3358
            if (result.children && maxDepth > 0) {
25✔
3359
                if (!v.childVariables) {
7!
3360
                    v.childVariables = [];
7✔
3361
                }
3362

3363
                // Create a mapping of the children to their index so we can evaluate them in bulk
3364
                let indexMappedChildren = result.children.map((child, index) => {
7✔
3365
                    let remapped = { child: child, index: index, evaluate: !!(child.isCustom && !child.presentationHint?.lazy && child.evaluateNow) };
10!
3366
                    return remapped;
10✔
3367
                });
3368
                if (this.enableDebugProtocol) {
7!
3369
                    let childrenToEvaluate = indexMappedChildren.filter(x => x.evaluate);
×
3370
                    let evaluateArgsArray = childrenToEvaluate.map(x => {
×
3371
                        return { expression: x.child.evaluateName, frameId: frameId };
×
3372
                    });
3373

3374
                    let variablePathArray = childrenToEvaluate.map(x => {
×
3375
                        return util.getVariablePath(x.child.evaluateName);
×
3376
                    });
3377

3378
                    try {
×
3379
                        let bulkEvaluations = await this.bulkEvaluateExpressionToTempVar(frameId, evaluateArgsArray, variablePathArray);
×
3380
                        if (bulkEvaluations.bulkVarName) {
×
3381
                            let newResults = await this.rokuAdapter.getVariable(bulkEvaluations.bulkVarName, frameId);
×
3382
                            childrenToEvaluate.map((mappedChild, index) => {
×
3383
                                let newResult = newResults.children[index];
×
3384
                                this.mergeEvaluateContainers(mappedChild.child, newResult);
×
3385
                                mappedChild.child.evaluateNow = false;
×
3386
                                return mappedChild;
×
3387
                            });
3388
                        }
3389
                    } catch (error) {
3390
                        this.logger.error('Error getting bulk variables, will fall back to var by var lookups', error);
×
3391
                    }
3392
                }
3393
                // If bulk evaluations failed, there is fall back logic in `getVariableFromResult` to do individual evaluations
3394
                v.childVariables = await Promise.all(indexMappedChildren.map(async (mappedChild) => {
7✔
3395
                    return this.getVariableFromResult(mappedChild.child, frameId, maxDepth - 1);
10✔
3396
                }));
3397
            } else {
3398
                v.childVariables = [];
18✔
3399
            }
3400

3401
            // if the var is an array and debugProtocol is enabled, include the array size
3402
            if (this.enableDebugProtocol && v.type === VariableType.Array) {
25!
3403
                if (isNaN(result.indexedVariables as number)) {
×
3404
                    v.value = v.type;
×
3405
                } else {
3406
                    v.value = `${v.type}(${result.indexedVariables})`;
×
3407
                }
3408
            }
3409
        }
3410
        return v;
25✔
3411
    }
3412

3413
    /**
3414
     * Helper function to merge the results of an evaluate call into an existing EvaluateContainer
3415
     * Used primarily for custom variables
3416
     */
3417
    private mergeEvaluateContainers(original: EvaluateContainer, updated: EvaluateContainer) {
3418
        original.children = updated.children;
×
3419
        original.value = updated.value;
×
3420
        original.type = updated.type;
×
3421
        original.highLevelType = updated.highLevelType;
×
3422
        original.keyType = updated.keyType;
×
3423
        original.indexedVariables = updated.indexedVariables;
×
3424
        original.namedVariables = updated.namedVariables;
×
3425
    }
3426

3427
    private getEvaluateRefId(expression: string, frameId: number) {
3428
        let evaluateRefId = `${expression}-${frameId}`;
77✔
3429
        if (!this.evaluateRefIdLookup[evaluateRefId]) {
77✔
3430
            this.evaluateRefIdLookup[evaluateRefId] = this.evaluateRefIdCounter++;
45✔
3431
        }
3432
        return this.evaluateRefIdLookup[evaluateRefId];
77✔
3433
    }
3434

3435
    private clearState() {
3436
        //erase all cached variables
3437
        this.variables = {};
12✔
3438
        this.completionParentVariableCache.clear();
12✔
3439
    }
3440

3441
    /**
3442
     * Sends a launch progress event to the client if the client supports progress reporting.
3443
     * - `'start'`: begins a new progress bar with the given message. Assigns a new progressId.
3444
     * - `'update'`: updates the message on the active progress bar.
3445
     * - `'end'`: dismisses the active progress bar with an optional final message.
3446
     */
3447
    private sendLaunchProgress(type: 'start' | 'update' | 'end', message?: string) {
3448
        if (!this.initRequestArgs?.supportsProgressReporting) {
75✔
3449
            return;
44✔
3450
        }
3451
        if (type === 'start') {
31✔
3452
            this.launchProgressId = `rokudebug-launch-${this.idCounter++}`;
10✔
3453
            this.sendEvent(new ProgressStartEvent(this.launchProgressId, 'Launching', `${message}...`));
10✔
3454
        } else if (this.launchProgressId) {
21✔
3455
            if (type === 'update') {
18✔
3456
                this.sendEvent(new ProgressUpdateEvent(this.launchProgressId, `${message}...`));
13✔
3457
            } else {
3458
                const lastId = this.launchProgressId;
5✔
3459
                this.sendEvent(new ProgressUpdateEvent(lastId, message));
5✔
3460
                setTimeout(() => {
5✔
3461
                    this.sendEvent(new ProgressEndEvent(lastId, message));
5✔
3462
                }, 1000); // add a slight delay before ending the progress to improve UX
3463
                this.launchProgressId = undefined;
5✔
3464
            }
3465
        }
3466
    }
3467

3468
    /**
3469
     * Tells the client to re-request all variables because we've invalidated them
3470
     * @param threadId
3471
     * @param stackFrameId
3472
     */
3473
    private sendInvalidatedEvent(threadId?: number, stackFrameId?: number) {
3474
        //if the client supports this request, send it
3475
        if (this.initRequestArgs.supportsInvalidatedEvent) {
3!
3476
            this.sendEvent(new InvalidatedEvent(['variables'], threadId, stackFrameId));
×
3477
        }
3478
    }
3479

3480
    /**
3481
     * If `stopOnEntry` is enabled, register the entry breakpoint.
3482
     */
3483
    public async handleEntryBreakpoint() {
3484
        if (!this.enableDebugProtocol) {
4!
3485
            this.entryBreakpointWasHandled = true;
4✔
3486
            if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) {
4✔
3487
                await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir);
1✔
3488
            }
3489
        }
3490
    }
3491

3492
    /**
3493
     * Converts a debugger line number to a client line number.
3494
     *
3495
     * @param debuggerLine - The line number from the debugger as zero based.
3496
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `debuggerLine` is not provided.
3497
     * @returns The corresponding client line number.
3498
     */
3499
    private toClientLine(debuggerLine: number, defaultDebuggerLine?: number) {
3500
        return this.convertDebuggerLineToClient(debuggerLine ?? defaultDebuggerLine);
3!
3501
    }
3502

3503
    /**
3504
     * Converts a debugger column number to a client column number.
3505
     *
3506
     * @param debuggerLine - The column number from the debugger as zero based.
3507
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `debuggerLine` is not provided.
3508
     * @returns The corresponding client column number.
3509
     */
3510
    private toClientColumn(debuggerLine: number, defaultDebuggerLine?: number) {
3511
        return this.convertDebuggerColumnToClient(debuggerLine ?? defaultDebuggerLine);
3!
3512
    }
3513

3514
    /**
3515
     * Converts a client line number to a debugger line number.
3516
     *
3517
     * @param clientLine - The line number from the client.
3518
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `clientLine` is not provided.
3519
     * @returns The corresponding debugger line number as zero based.
3520
     */
3521
    private toDebuggerLine(clientLine: number, defaultDebuggerLine?: number) {
3522
        if (typeof clientLine === 'number') {
109✔
3523
            return this.convertClientLineToDebugger(clientLine);
2✔
3524
        }
3525
        return defaultDebuggerLine;
107✔
3526
    }
3527

3528
    /**
3529
     * Converts a client column number to a debugger column number.
3530
     *
3531
     * @param clientLine - The column number from the client.
3532
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `clientLine` is not provided.
3533
     * @returns The corresponding debugger column number as zero based.
3534
     */
3535
    private toDebuggerColumn(clientLine: number, defaultDebuggerLine?: number) {
3536
        if (typeof clientLine === 'number') {
89!
3537
            return this.convertClientColumnToDebugger(clientLine);
89✔
3538
        }
3539
        return defaultDebuggerLine;
×
3540
    }
3541

3542
    private shutdownPromise: Promise<void> | undefined = undefined;
215✔
3543

3544
    /**
3545
     * 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
3546
     * the same promise on subsequent calls
3547
     */
3548
    public async shutdown(errorMessage?: string, modal = false): Promise<void> {
16✔
3549
        if (this.shutdownPromise === undefined) {
16!
3550
            this.logger.log('[shutdown] Beginning shutdown sequence', errorMessage);
16✔
3551
            //Backstop: if the graceful shutdown hangs (e.g. pressHomeButton against an unreachable
3552
            //device), force-exit anyway so we never leave an orphaned adapter running forever
3553
            const forceExitTimer = setTimeout(() => {
16✔
3554
                this.logger.error('[shutdown] graceful shutdown timed out; forcing exit');
×
3555
                this.forceExit();
×
3556
            }, this.shutdownForceExitTimeout);
3557
            forceExitTimer.unref?.();
16!
3558
            this.shutdownPromise = this._shutdown(errorMessage, modal).finally(() => {
16✔
3559
                clearTimeout(forceExitTimer);
16✔
3560
            });
3561
        } else {
3562
            this.logger.log('[shutdown] Tried to call `.shutdown()` again. Returning the same promise');
×
3563
        }
3564
        return this.shutdownPromise;
16✔
3565
    }
3566

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

3571
        //send the message FIRST before anything else. This improves the chances that the message will be displayed to the user
3572
        try {
16✔
3573
            if (errorMessage) {
16!
3574
                this.logger.error(errorMessage);
×
3575
                this.showPopupMessage(errorMessage, 'error', modal).catch((error) => {
×
3576
                    this.logger.error('Error showing popup message', { error });
×
3577
                });
3578
            }
3579
        } catch (e) {
3580
            this.logger.error(e);
×
3581
        }
3582
        // stop perfetto tracing if it's running
3583
        try {
16✔
3584
            await this.perfettoManager?.stopTracing?.();
16!
3585
        } catch (e) {
3586
            this.logger.error('Error stopping perfetto tracing', e);
×
3587
        }
3588

3589
        try {
16✔
3590
            await this.perfettoManager?.dispose?.();
16!
3591
        } catch (e) {
3592
            this.logger.error('Error disposing perfetto manager', e);
×
3593
        }
3594

3595
        //close the debugger connection
3596
        try {
16✔
3597
            this.logger.log('Destroy rokuAdapter');
16✔
3598
            await this.rokuAdapter?.destroy?.();
16!
3599
            //press the home button to return to the home screen
3600
            try {
16✔
3601
                this.logger.log('Press home button');
16✔
3602
                await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
16✔
3603
            } catch (e) {
3604
                this.logger.error(e);
×
3605
            }
3606
        } catch (e) {
3607
            this.logger.error(e);
×
3608
        }
3609

3610
        try {
16✔
3611
            this.projectManager?.dispose?.();
16!
3612
        } catch (e) {
3613
            this.logger.error(e);
×
3614
        }
3615

3616
        try {
16✔
3617
            this.componentLibraryServer?.stop();
16!
3618
        } catch (e) {
3619
            this.logger.error(e);
×
3620
        }
3621

3622
        try {
16✔
3623
            await this.rendezvousTracker?.destroy?.();
16!
3624
        } catch (e) {
3625
            this.logger.error(e);
×
3626
        }
3627

3628
        try {
16✔
3629
            await this.sourceMapManager?.destroy?.();
16!
3630
        } catch (e) {
3631
            this.logger.error(e);
×
3632
        }
3633

3634
        try {
16✔
3635
            //if configured, delete the staging directory
3636
            if (!this.launchConfiguration.retainStagingFolder) {
16!
3637
                const stagingDirs = this.projectManager?.getStagingDirs() ?? [];
16!
3638
                this.logger.info('deleting staging folders', stagingDirs);
16✔
3639
                for (let stagingDir of stagingDirs) {
16✔
3640
                    try {
2✔
3641
                        fsExtra.removeSync(stagingDir);
2✔
3642
                    } catch (e) {
3643
                        this.logger.error(e);
×
3644
                        util.log(`Error removing staging directory '${stagingDir}': ${JSON.stringify(e)}`);
×
3645
                    }
3646
                }
3647
            }
3648
        } catch (e) {
3649
            this.logger.error(e);
×
3650
        }
3651

3652
        try {
16✔
3653
            this.logger.log('Send terminated event');
16✔
3654
            this.sendEvent(new TerminatedEvent());
16✔
3655

3656
            //shut down the process
3657
            this.logger.log('super.shutdown()');
16✔
3658
            super.shutdown();
16✔
3659
            this.logger.log('shutdown complete');
16✔
3660
        } catch (e) {
3661
            this.logger.error(e);
×
3662
        }
3663

3664
        try {
16✔
3665
            this.teardownProcessErrorHandlers();
16✔
3666
        } catch (e) {
3667
            this.logger.error(e);
×
3668
        }
3669
    }
3670
}
3671

3672
export interface AugmentedVariable extends DebugProtocol.Variable {
3673
    childVariables?: AugmentedVariable[];
3674
    // eslint-disable-next-line camelcase
3675
    request_seq?: number;
3676
    frameId?: number;
3677
    /**
3678
     * only used for lazy variables
3679
     */
3680
    isResolved?: boolean;
3681
    /**
3682
     * used to indicate that this variable is a scope variable
3683
     * and may require special handling
3684
     */
3685
    isScope?: boolean;
3686
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc