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

rokucommunity / roku-debug / 31208378856

07 Aug 2026 06:44PM UTC coverage: 73.212% (+0.3%) from 72.934%
31208378856

push

github

web-flow
Migrate to roku-deploy v4 and Roku Cloud Emulator support- #399 (#398)

Co-authored-by: Bronley Plumb <bronley@gmail.com>

3901 of 5576 branches covered (69.96%)

Branch coverage included in aggregate %.

173 of 195 new or added lines in 12 files covered. (88.72%)

3 existing lines in 2 files now uncovered.

6069 of 8042 relevant lines covered (75.47%)

49.26 hits per line

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

67.3
/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, SideloadOptions } 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 type { ResolvedLaunchConfiguration } from '../LaunchConfiguration';
62
import { BreakpointManager } from '../managers/BreakpointManager';
2✔
63
import type { LogMessage } from '../logging';
64
import { PerfettoManager } from '../PerfettoManager';
2✔
65
import { logger, FileLoggingManager, debugServerLogOutputEventTransport } from '../logging';
2✔
66
import { VariableType } from '../debugProtocol/events/responses/VariablesResponse';
2✔
67
import { DiagnosticSeverity } from 'brighterscript';
2✔
68
import type { ExceptionBreakpoint } from '../debugProtocol/events/requests/SetExceptionBreakpointsRequest';
69
import { debounce } from 'debounce';
2✔
70
import { interfaces, components, events } from 'brighterscript/dist/roku-types';
2✔
71
import { globalCallables } from 'brighterscript/dist/globalCallables';
2✔
72
import { bscProjectWorkerPool } from '../bsc/threading/BscProjectWorkerPool';
2✔
73
import { populateVariableFromRegistryEcp } from './ecpRegistryUtils';
2✔
74
import { AppState, rokuECP } from '../RokuECP';
2✔
75
import { SocketConnectionInUseError } from '../Exceptions';
2✔
76

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

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

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

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

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

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

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

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

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

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

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

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

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

167
    private handlingProcessError = false;
224✔
168

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

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

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

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

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

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

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

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

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

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

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

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

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

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

329
    public logger = logger.createLogger(`[session]`);
224✔
330

331
    private readonly isWindowsPlatform = process.platform.startsWith('win');
224✔
332

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

338
    public fileManager: FileManager;
339

340
    public projectManager: ProjectManager;
341

342
    public fileLoggingManager: FileLoggingManager;
343

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

353
    public breakpointManager: BreakpointManager;
354

355
    public locationManager: LocationManager;
356

357
    public sourceMapManager: SourceMapManager;
358

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

362
    /**
363
     * A short human-readable identifier for the target device, safe for log and error messages
364
     * (never includes credentials like the rceToken)
365
     */
366
    private get deviceLabel(): string {
367
        return util.getDeviceLabel(this.launchConfiguration.device);
17✔
368
    }
369

370
    private componentLibraryServer = new ComponentLibraryServer();
224✔
371

372
    private rokuAdapterDeferred = defer<DebugProtocolAdapter | TelnetAdapter>();
224✔
373
    /**
374
     * A promise that is resolved whenever the app has started running for the first time
375
     */
376
    private firstRunDeferred = defer<void>();
224✔
377

378
    /**
379
     * Resolved whenever we're finished copying all the files to staging for all projects
380
     */
381
    private stagingDefered = defer<void>();
224✔
382

383
    private evaluateRefIdLookup: Record<string, number> = {};
224✔
384
    private evaluateRefIdCounter = 1;
224✔
385

386
    private variables: Record<number, AugmentedVariable> = {};
224✔
387

388
    /**
389
     * Caches the device lookups performed while resolving completion requests. Variables don't change
390
     * while the debugger is paused, so this avoids repeated round-trips for the same path. Cleared by
391
     * `clearState` whenever the debugger resumes or steps.
392
     */
393
    private completionParentVariableCache = new Map<string, AugmentedVariable>();
224✔
394

395
    private rokuAdapter: DebugProtocolAdapter | TelnetAdapter;
396

397
    private perfettoManager: PerfettoManager;
398

399
    private rendezvousTracker: RendezvousTracker;
400

401
    public tempVarPrefix = '__rokudebug__';
224✔
402

403
    /**
404
     * The progressId of the active launch progress bar, if any.
405
     * Cleared once the ProgressEndEvent is sent.
406
     */
407
    private launchProgressId: string | undefined;
408

409
    /**
410
     * Sends the deferred ProgressEndEvent for the launch progress bar (sendLaunchProgress holds it
411
     * back for UX). Kept here so shutdown can flush it immediately: the adapter can exit before the
412
     * delay elapses, which would otherwise leave the client's progress notification stuck open.
413
     * Cleared once the event has been sent.
414
     */
415
    private flushLaunchProgressEnd: (() => void) | undefined;
416

417
    /**
418
     * The first encountered compile error, will be used to send to the client as a runtime error (nicer UI presentation)
419
     */
420
    private compileError: BSDebugDiagnostic;
421

422
    /**
423
     * 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
424
     */
425
    private COMPILE_ERROR_THREAD_ID = 7_777;
224✔
426

427
    private get enableDebugProtocol() {
428
        return this.launchConfiguration?.enableDebugProtocol;
78!
429
    }
430

431
    /**
432
     * Check if the Roku firmware supports Perfetto tracing (requires OS 15.2 or higher)
433
     */
434
    private get supportsPerfettoTracing() {
435
        this.logger.log('Checking if device supports Perfetto tracing', this.deviceInfo.softwareVersion);
13✔
436
        return semver.satisfies(this.deviceInfo?.softwareVersion ?? '0.0', '>= 15.2');
13!
437
    }
438

439
    /**
440
     * Get a promise that resolves when the roku adapter is ready to be used
441
     */
442
    private async getRokuAdapter() {
443
        await this.rokuAdapterDeferred.promise;
26✔
444
        await this.rokuAdapter.onReady();
26✔
445
        return this.rokuAdapter;
26✔
446
    }
447

448
    /**
449
     * The normalized launch config for this session. The resolved type has no `host` (so nothing in
450
     * the session can read or write it) and a concrete `device` config, which is the only way the
451
     * debugger addresses the device. The raw `LaunchConfiguration` exists only as launchRequest's
452
     * DAP input; normalizeLaunchConfig converts it.
453
     */
454
    private launchConfiguration: ResolvedLaunchConfiguration;
455
    private initRequestArgs: DebugProtocol.InitializeRequestArguments;
456

457
    private exceptionBreakpoints: ExceptionBreakpoint[] = [];
224✔
458

459
    /**
460
     * The 'initialize' request is the first request called by the frontend
461
     * to interrogate the features the debug adapter provides.
462
     */
463
    public initializeRequest(response: DebugProtocol.InitializeResponse, args: DebugProtocol.InitializeRequestArguments): void {
464
        this.initRequestArgs = args;
2✔
465
        this.logger.log('initializeRequest');
2✔
466

467
        response.body ||= {};
2✔
468

469
        // This debug adapter implements the configurationDoneRequest.
470
        response.body.supportsConfigurationDoneRequest = true;
2✔
471

472
        // 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.
473
        response.body.supportsRestartRequest = true;
2✔
474

475
        // make VS Code to use 'evaluate' when hovering over source
476
        response.body.supportsEvaluateForHovers = true;
2✔
477

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

485
        //surface the filter list here so VS Code's BREAKPOINTS panel renders the checkboxes - the
486
        //panel only reads this list from the initialize response, not from later CapabilitiesEvents.
487
        //The `supportsExceptionFilterOptions` / `supportsExceptionOptions` booleans are deferred and
488
        //sent via CapabilitiesEvent once we know the connected device's protocol version.
489
        response.body.exceptionBreakpointFilters = [{
2✔
490
            filter: 'caught',
491
            supportsCondition: true,
492
            conditionDescription: '__brs_err__.rethrown = true',
493
            label: 'Caught Exceptions',
494
            description: `Breaks on all errors, even if they're caught later.`,
495
            default: false
496
        }, {
497
            filter: 'uncaught',
498
            supportsCondition: true,
499
            conditionDescription: '__brs_err__.rethrown = true',
500
            label: 'Uncaught Exceptions',
501
            description: 'Breaks only on errors that are not handled.',
502
            default: true
503
        }];
504

505
        response.body.supportsCompletionsRequest = true;
2✔
506
        response.body.completionTriggerCharacters = ['.', '(', '{', ',', ' '];
2✔
507

508
        this.sendResponse(response);
2✔
509

510
        //register the debug output log transport writer
511
        debugServerLogOutputEventTransport.setWriter((message: LogMessage) => {
2✔
512
            this.sendEvent(
558✔
513
                new DebugServerLogOutputEvent(
514
                    message.logger.formatMessage(message, false)
515
                )
516
            );
517
        });
518

519
        this.logger.log('initializeRequest finished');
2✔
520
    }
521

522
    protected async setExceptionBreakPointsRequest(response: DebugProtocol.SetExceptionBreakpointsResponse, args: DebugProtocol.SetExceptionBreakpointsArguments) {
523
        response.body ??= {};
9!
524
        try {
9✔
525

526
            let filterOptions: ExceptionBreakpoint[];
527
            if (args.filterOptions) {
9✔
528
                filterOptions = args.filterOptions.map(x => ({
2✔
529
                    filter: x.filterId as 'caught' | 'uncaught',
530
                    conditionExpression: x.condition
531
                }));
532
            } else if (args.filters) {
7✔
533
                filterOptions = args.filters.map(x => ({
9✔
534
                    filter: x as 'caught' | 'uncaught'
535
                }));
536
            }
537
            this.exceptionBreakpoints = filterOptions;
9✔
538

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

544
            if (this.rokuAdapter.supportsExceptionBreakpoints) {
9✔
545
                //the adapter queues these filters internally if the debug protocol client hasn't
546
                //connected yet, and replays them once it does
547
                await this.rokuAdapter.setExceptionBreakpoints(filterOptions);
8✔
548
                response.body.breakpoints = [
7✔
549
                    { verified: true },
550
                    { verified: true }
551
                ];
552
            } else {
553
                response.body.breakpoints = [
1✔
554
                    { verified: false },
555
                    { verified: false }
556
                ];
557
            }
558
        } catch (e) {
559
            //if error (or not supported)
560
            response.body.breakpoints = [
1✔
561
                { verified: false },
562
                { verified: false }
563
            ];
564
            this.logger.error('Failed to set exception breakpoints', e);
1✔
565
        } finally {
566
            this.sendResponse(response);
9✔
567
        }
568
    }
569

570

571
    protected async setTransientsToInvalid() {
572
        let brsErr = Object.values(this.variables).find((v) => v.name === '__brs_err__');
×
573
        if (brsErr && brsErr.type !== VariableType.Uninitialized) {
×
574
            // Assigning the variable to the function call results in it becoming unintialized
575
            await this.rokuAdapter.evaluate(`__brs_err__ = [].clear()`, brsErr.frameId);
×
576
        }
577
    }
578

579
    private async showPopupMessage<T extends string>(message: string, severity: 'error' | 'warn' | 'info', modal = false, actions?: T[]): Promise<T> {
5✔
580
        const response = await this.sendCustomRequest('showPopupMessage', { message: message, severity: severity, modal: modal, actions: actions });
5✔
581
        return response.selectedAction;
3✔
582
    }
583

584
    private static requestIdSequence = 0;
2✔
585

586
    private async sendCustomRequest<T = any, R = any>(name: string, data: T): Promise<R> {
587
        const requestId = BrightScriptDebugSession.requestIdSequence++;
4✔
588
        const responsePromise = new Promise<R>((resolve, reject) => {
4✔
589
            this.on(ClientToServerCustomEventName.customRequestEventResponse, (response) => {
4✔
590
                if (response.requestId === requestId) {
4✔
591
                    if (response.error) {
2!
592
                        throw response.error;
×
593
                    } else {
594
                        resolve(response as R);
2✔
595
                    }
596
                }
597
            });
598
        });
599
        this.sendEvent(
4✔
600
            new CustomRequestEvent({
601
                requestId: requestId,
602
                name: name,
603
                ...data ?? {}
12!
604
            }));
605
        return responsePromise;
4✔
606
    }
607

608
    /**
609
      * Get the cwd from the launchConfiguration, or default to process.cwd()
610
      */
611
    private get cwd() {
612
        return this.launchConfiguration?.cwd ?? process.cwd();
9!
613
    }
614

615
    public deviceInfo: DeviceInfo;
616

617
    /**
618
     * Set defaults and standardize values for all of the LaunchConfiguration values
619
     * @param config
620
     * @returns
621
     */
622
    private normalizeLaunchConfig(config: LaunchConfiguration): ResolvedLaunchConfiguration {
623
        //`device` is the canonical way to address the target device; `host` is a deprecated alias.
624
        //this is the ONLY place the debugger reads the top-level `host` field: whatever was supplied
625
        //is resolved to a concrete device config here, and everything downstream uses `device`.
626
        if (!config.device && config.host) {
13✔
627
            config.device = { host: config.host };
4✔
628
        }
629
        //the deprecated field is now consumed. Delete it so the runtime object matches the resolved
630
        //type and nothing downstream (including the configs echoed back to the client) carries it.
631
        delete config.host;
13✔
632
        //an RCE device config without a token picks one up from the environment (the extension
633
        //injects ROKU_RCE_TOKEN into this process so the token does not have to travel through the
634
        //launch config over DAP). The custom events that echo this config back to the client scrub
635
        //the token out again (see Events.ts), so it never rides the DAP wire in either direction.
636
        config.device = util.hydrateRceTokenFromEnv(config.device);
13✔
637
        config.cwd ??= process.cwd();
13✔
638
        config.outDir ??= s`${config.cwd}/out`;
13✔
639
        config.stagingDir ??= util.getStagingDir({ outDir: config.outDir, cwd: config.cwd });
13✔
640
        config.componentLibrariesPort ??= 8080;
13!
641
        config.packagePort ??= 80;
13!
642
        config.remotePort ??= 8060;
13!
643
        config.sceneGraphDebugCommandsPort ??= 8080;
13!
644
        config.controlPort ??= 8081;
13!
645
        config.brightScriptConsolePort ??= 8085;
13!
646
        config.stagingDir ??= config.stagingFolderPath;
13!
647
        config.emitChannelPublishedEvent ??= true;
13!
648
        config.rewriteDevicePathsInLogs ??= true;
13!
649
        config.autoResolveVirtualVariables ??= false;
13!
650
        config.enhanceREPLCompletions ??= true;
13!
651
        config.username ??= 'rokudev';
13!
652
        if (config.profiling?.tracing?.enable) {
13!
653
            config.profiling.tracing.dir ??= s`${config.cwd}/traces/`;
×
654
            // eslint-disable-next-line no-template-curly-in-string
655
            config.profiling.tracing.filename ??= '${appTitle}_${timestamp}.perfetto-trace';
×
656
        }
657

658
        // migrate the old `enableVariablesPanel` setting to the new `deferScopeLoading` setting
659
        if (typeof config.enableVariablesPanel !== 'boolean') {
13!
660
            config.enableVariablesPanel = true;
13✔
661
        }
662
        config.deferScopeLoading ??= config.enableVariablesPanel === false;
13!
663
        return config as ResolvedLaunchConfiguration;
13✔
664
    }
665

666
    public async launchRequest(response: DebugProtocol.LaunchResponse, config: LaunchConfiguration) {
667
        const logEnd = this.logger.timeStart('log', '[launchRequest] launch');
10✔
668

669
        try {
10✔
670
            this.resetSessionState();
10✔
671
            this.launchConfiguration = this.normalizeLaunchConfig(config);
10✔
672
            //fail fast when the launch config supplied no device addressing at all, rather than
673
            //failing later with a confusing dns or connection error for an undefined host
674
            if (!this.launchConfiguration.device) {
10✔
675
                return await this.shutdown(`Launch config does not specify a target device. Please supply the 'device' option (or the deprecated 'host' option).`);
1✔
676
            }
677
            this.setupProcessErrorHandlers();
9✔
678

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

682
            //set the logLevel provided by the launch config
683
            if (this.launchConfiguration.logLevel) {
9!
684
                logger.logLevel = this.launchConfiguration.logLevel;
×
685
            }
686

687
            this.sendLaunchProgress('start', 'Finding device on network');
9✔
688

689
            //do a DNS lookup for the host to fix issues with roku rejecting ECP. Only local devices
690
            //are addressed by host; other device types (like the Roku Cloud Emulator) pass through unchanged
691
            try {
9✔
692
                this.launchConfiguration.device = await this.rokuDeploy.withDnsResolvedHost(this.launchConfiguration.device);
9✔
693
            } catch (e) {
NEW
694
                return this.shutdown(`Could not resolve ip address for host '${this.deviceLabel}'`);
×
695
            }
696

697
            // fetch device info if not supplied via launch config
698
            try {
9✔
699
                if (this.launchConfiguration.deviceInfo) {
9✔
700
                    this.deviceInfo = rokuDeploy.enhanceDeviceInfo(this.launchConfiguration.deviceInfo);
1✔
701
                } else {
702
                    this.deviceInfo = await rokuDeploy.getDeviceInfo({ device: this.launchConfiguration.device, ecpPort: this.launchConfiguration.remotePort, enhance: true, timeout: 4_000 });
8✔
703
                }
704
                if (this.deviceInfo.ecpSettingMode === 'limited') {
9!
NEW
705
                    return await this.shutdown(`To allow the debugger to communicate properly, please ensure on the Roku device that 'Settings' > 'System' > 'Advanced system settings' > 'Control by mobile apps' is set to "Enabled" or "Permissive". Current mode: Limited (device: ${this.deviceLabel})`);
×
706
                }
707
            } catch (e) {
708
                if (e instanceof EcpNetworkAccessModeDisabledError) {
×
NEW
709
                    return this.shutdown(`To allow the debugger to communicate properly, please ensure on the Roku device that 'Settings' > 'System' > 'Advanced system settings' > 'Control by mobile apps' is set to "Enabled" or "Permissive". Current mode: Disabled (device: ${this.deviceLabel})`);
×
710
                }
NEW
711
                return this.shutdown(`Unable to connect to roku at '${this.deviceLabel}'. Verify the device address is correct and that the device is powered on and reachable.`);
×
712
            }
713

714
            if (this.deviceInfo && !this.deviceInfo.developerEnabled) {
9!
NEW
715
                return await this.shutdown(`Developer mode is not enabled for device '${this.deviceLabel}'.`);
×
716
            }
717

718
            // everything is ready, send the response to the launch request so the UI can update and configuration can begin
719
            this.sendResponse(response);
9✔
720

721
            //initialize all file logging (rokuDevice, debugger, etc)
722
            this.fileLoggingManager.activate(this.launchConfiguration?.fileLogging, this.cwd);
9!
723

724
            this.projectManager.launchConfiguration = this.launchConfiguration;
9✔
725
            this.breakpointManager.launchConfiguration = this.launchConfiguration;
9✔
726

727
            this.sendEvent(new LaunchStartEvent(this.launchConfiguration));
9✔
728

729
            this.logger.log('[launchRequest] Packaging and deploying to roku');
9✔
730
            const packageEnd = this.logger.timeStart('log', 'Packaging');
9✔
731
            this.sendLaunchProgress('update', `Packaging Project${(this.launchConfiguration?.componentLibraries?.length ?? 0) > 0 ? 's' : ''}`);
9!
732
            //build the main project and all component libraries at the same time
733
            await Promise.all([
9✔
734
                this.prepareMainProject(),
735
                this.prepareComponentLibraries(this.launchConfiguration.componentLibraries)
736
            ]);
737

738
            //all of the projects have been successfully staged.
739
            this.stagingDefered.tryResolve();
9✔
740

741
            //if the client supports it, let it process (inspect/modify) each project's staging dir before we package them
742
            if (this.launchConfiguration.clientCapabilities?.supportsProcessStagingDir) {
9!
743
                await this.sendCustomRequest('processStagingDir', {
×
744
                    projects: this.projectManager.getProjectStagingInfo()
745
                });
746
            }
747

748
            packageEnd();
9✔
749

750
            if (this.enableDebugProtocol) {
9!
NEW
751
                util.log(`Connecting to Roku via the BrightScript debug protocol at ${this.deviceLabel}:${this.launchConfiguration.controlPort}`);
×
752
            } else {
753
                util.log(`Connecting to Roku via telnet at ${this.deviceLabel}:${this.launchConfiguration.brightScriptConsolePort}`);
9✔
754
            }
755

756
            //activate rendezvous tracking (if enabled). Log the error and move on if it crashes, this shouldn't bring down the session.
757
            try {
9✔
758
                const rendezvousEnd = this.logger.timeStart('log', 'Rendezvous tracking');
9✔
759
                await this.initRendezvousTracking();
9✔
760
                rendezvousEnd();
9✔
761
            } catch (e) {
762
                this.logger.error('Failed to initialize rendezvous tracking', e);
×
763
            }
764

765
            this.sendLaunchProgress('update', 'Connecting to debug server');
9✔
766
            const connectAdapterEnd = this.logger.timeStart('log', 'Connect adapter');
9✔
767
            this.createRokuAdapter(this.rendezvousTracker);
9✔
768

769
            // Manipulating complibs on-device autolaunches the dev app and often triggers compile errors,
770
            // so if we have at least one installable complib, delete the dev app and any complibs to avoid all that.
771
            if (this.launchConfiguration.componentLibraries?.some(x => x.install)) {
9!
772
                this.sendLaunchProgress('update', 'Removing existing dev app and component libraries');
×
773
                await rokuDeploy.deleteAllSideloadedPlugins({
×
774
                    device: this.launchConfiguration.device,
775
                    password: this.launchConfiguration.password
776
                });
777
            }
778

779
            await this.connectRokuAdapter();
9✔
780
            connectAdapterEnd();
9✔
781

782
            // Capabilities that depend on the adapter or device version. The exception-breakpoint
783
            // FILTER LIST was surfaced in initializeRequest (VS Code only reads it from there).
784
            // Everything below is read per-action in VS Code, so a CapabilitiesEvent update takes
785
            // effect dynamically.
786
            const supportsExceptionBreakpoints = this.rokuAdapter.supportsExceptionBreakpoints;
9✔
787
            this.sendEvent(new CapabilitiesEvent({
9✔
788
                supportsLogPoints: !this.enableDebugProtocol,
789
                supportsExceptionFilterOptions: supportsExceptionBreakpoints,
790
                supportsExceptionOptions: supportsExceptionBreakpoints,
791
                supportsConditionalBreakpoints: this.rokuAdapter.supportsConditionalBreakpoints,
792
                supportsHitConditionalBreakpoints: this.rokuAdapter.supportsHitConditionalBreakpoints
793
            }));
794

795
            this.sendLaunchProgress('update', 'Configuring breakpoints');
9✔
796

797
            util.log('Done initializing');
9✔
798

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

803
            await this.initializeProfiling();
9✔
804

805
        } catch (e) {
806
            //if the message is anything other than compile errors, we want to display the error
807
            if (!(e instanceof CompileError)) {
×
808
                util.log('Encountered an issue during the launch process');
×
809
                util.log((e as Error)?.stack);
×
810

811
                //send any compile errors to the client
812
                await this.rokuAdapter?.sendErrors();
×
813

814
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
815
                await this.shutdown(message as string, true);
×
816
            } else {
817
                this.sendLaunchProgress('end', 'Aborted (compile error)');
×
818
            }
819
        }
820
        logEnd();
9✔
821
    }
822

823
    protected async configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse, args: DebugProtocol.ConfigurationDoneArguments) {
824
        this.logger.log('configurationDoneRequest');
4✔
825
        super.configurationDoneRequest(response, args);
4✔
826

827
        let error: Error;
828
        try {
4✔
829
            await this.runAutomaticSceneGraphCommands(this.launchConfiguration.autoRunSgDebugCommands);
4✔
830

831
            //press the home button to ensure we're at the home screen
832
            await this.rokuDeploy.keyPress({ device: this.launchConfiguration.device, key: 'Home', ecpPort: this.launchConfiguration.remotePort });
4✔
833

834
            //pass the log level down thought the adapter to the RendezvousTracker and ChanperfTracker
835
            this.rokuAdapter.setConsoleOutput(this.launchConfiguration.consoleOutput);
4✔
836

837
            //pass along the console output
838
            if (this.launchConfiguration.consoleOutput === 'full') {
4!
839
                this.rokuAdapter.on('console-output', (data) => {
×
840
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
×
841
                });
842
            } else {
843
                this.rokuAdapter.on('unhandled-console-output', (data) => {
4✔
844
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
×
845
                });
846
            }
847

848
            this.rokuAdapter.on('device-unresponsive', async (data: { lastCommand: string }) => {
4✔
849
                const stopDebuggerAction = 'Stop Debugger';
×
NEW
850
                const message = `Roku device ${this.deviceLabel} is not responding and may not recover.` +
×
851
                    (data.lastCommand ? `\n\nActive command:\n"${util.truncate(data.lastCommand, 30)}"` : '');
×
852
                this.logger.log(message, data);
×
853
                const response = await this.showPopupMessage(message, 'warn', false, [stopDebuggerAction]);
×
854
                if (response === stopDebuggerAction) {
×
855
                    await this.shutdown();
×
856
                }
857
            });
858

859
            // Send chanperf events to the extension
860
            this.rokuAdapter.on('chanperf', (output) => {
4✔
861
                this.sendEvent(new ChanperfEvent(output));
×
862
            });
863

864
            //listen for a closed connection (shut down when received)
865
            this.rokuAdapter.on('close', (reason = '') => {
4!
866
                if (reason === 'compileErrors') {
×
867
                    error = new Error('compileErrors');
×
868
                } else {
869
                    error = new Error('Unable to connect to Roku. Is another device already connected?');
×
870
                }
871
            });
872

873
            // handle any compile errors
874
            this.rokuAdapter.on('diagnostics', (diagnostics: BSDebugDiagnostic[]) => {
4✔
875
                this.handleDiagnostics(diagnostics).catch(e => this.logger.error('Failed to handle diagnostics', e));
×
876
            });
877

878
            // close disconnect if required when the app is exited
879
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
880
            this.rokuAdapter.on('app-exit', async () => {
4✔
881
                this.resetSessionState();
×
882

883
                if (this.launchConfiguration.stopDebuggerOnAppExit) {
×
884
                    let message = `App exit event detected and launchConfiguration.stopDebuggerOnAppExit is true`;
×
885
                    message += ' - shutting down debug session';
×
886

887
                    this.logger.log('on app-exit', message);
×
888
                    this.sendEvent(new LogOutputEvent(message));
×
889
                    await this.shutdown();
×
890
                } else {
891
                    const message = 'App exit detected; but launchConfiguration.stopDebuggerOnAppExit is set to false, so keeping debug session running.';
×
892
                    this.logger.log('[configurationDoneRequest]', message);
×
893
                    this.sendEvent(new LogOutputEvent(message));
×
894
                    this.rokuAdapter.once('connected').then(async () => {
×
895
                        await this.rokuAdapter.setExceptionBreakpoints(this.exceptionBreakpoints);
×
896
                    }).catch(e => this.logger.error('Failed to set exception breakpoints after reconnect', e));
×
897
                }
898
            });
899
            //profiling supports connecting to the socket BEFORE a channel is published, so go ahead and connect now
900
            await this.tryProfilingConnectOnStart();
4✔
901

902
            //all setBreakpoints requests have arrived by this point (configurationDone is the DAP signal
903
            //that the client has finished sending configuration). Validate breakpoints across every project
904
            //in one pass (and inject the STOPs for telnet) - validating per project would fail every other
905
            //project's breakpoints. Must finish before postfixing renames the complib files.
906
            await this.writeBreakpoints();
4✔
907

908
            //postfix each complib's own files now (still no zips. those are sealed after cross-project
909
            //references are rewritten).
910
            await this.postfixComponentLibraries(this.launchConfiguration.componentLibraries);
4✔
911

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

916
            //now zip the main project, also zip and upload installable complibs, and start the webserver for non-installed complibs.
917
            await Promise.all([
4✔
918
                this.zipMainProject(),
919
                this.zipServeAndInstallComponentLibraries(this.launchConfiguration.componentLibraries, this.launchConfiguration.componentLibrariesPort)
920
            ]);
921

922
            this.sendLaunchProgress('update', 'Uploading to Roku');
4✔
923
            await this.publish();
4✔
924

925
            //hack for certain roku devices that lock up when this event is emitted (no idea why!).
926
            if (this.launchConfiguration.emitChannelPublishedEvent) {
3!
927
                this.sendEvent(new ChannelPublishedEvent(
3✔
928
                    this.launchConfiguration
929
                ));
930
            }
931

932
            //tell the adapter adapter that the channel has been launched.
933
            this.sendLaunchProgress('update', 'Waiting on application');
3✔
934
            await this.rokuAdapter.activate();
3✔
935
            if (this.rokuAdapter.isDestroyed) {
3!
936
                throw new Error('Debug session encountered an error');
×
937
            }
938
            if (!error) {
3!
939
                if (this.rokuAdapter.connected) {
3!
940
                    this.logger.info('Host connection was established before the main public process was completed');
3✔
941
                    this.logger.log(`deployed to Roku@${this.deviceLabel}`);
3✔
942
                } else {
943
                    this.logger.info('Main public process was completed but we are still waiting for a connection to the host');
×
944
                    this.rokuAdapter.on('connected', (status) => {
×
945
                        if (status) {
×
NEW
946
                            this.logger.log(`deployed to Roku@${this.deviceLabel}`);
×
947
                        }
948
                    });
949
                }
950
            } else {
951
                throw error;
×
952
            }
953

954
            //at this point, the project has been deployed. If we need to use a deep link, launch it now.
955
            if (this.launchConfiguration.deepLinkUrl && !this.enableDebugProtocol) {
3!
956
                //wait until the first entry breakpoint has been hit
957
                await this.firstRunDeferred.promise;
×
958
                //if we are at a breakpoint, continue
959
                await this.rokuAdapter.continue();
×
960
                //kill the app on the roku
961
                // await this.rokuDeploy.keyPress({ device: this.launchConfiguration.device, key: 'Home', ecpPort: this.launchConfiguration.remotePort });
962
                //convert a hostname to an ip address
963
                const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl);
×
964
                //send the deep link http request
965
                await util.httpPost(deepLinkUrl);
×
966
            }
967

968
        } catch (e) {
969
            //if the message is anything other than compile errors, we want to display the error
970
            if (!(e instanceof CompileError)) {
1!
971
                util.log('Encountered an issue during the publish process');
×
972
                util.log((e as Error)?.stack);
×
973

974
                //send any compile errors to the client
975
                await this.rokuAdapter?.sendErrors();
×
976

977
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
×
978
                await this.shutdown(message as string, true);
×
979
            } else {
980
                this.sendLaunchProgress('end', 'Aborted (compile error)');
1✔
981
            }
982
        }
983
    }
984

985
    /**
986
     * Activate all required functionality for profiling
987
     */
988
    private async initializeProfiling() {
989

990
        // Initialize PerfettoManager
991
        this.perfettoManager = new PerfettoManager({
18✔
992
            device: this.launchConfiguration.device,
993
            rootDir: this.launchConfiguration.rootDir,
994
            remotePort: this.launchConfiguration.remotePort,
995
            ...this.launchConfiguration.profiling?.tracing
54✔
996
        });
997

998
        //send certain profiling events back to the client
999
        this.perfettoManager.on('enable', (event) => {
18✔
1000
            this.sendEvent(new ProfilingEnableEvent({
×
1001
                types: event.types
1002
            }));
1003
        });
1004
        this.perfettoManager.on('start', (event) => {
18✔
1005
            this.sendEvent(new ProfilingStartEvent({
×
1006
                type: event.type
1007
            }));
1008
        });
1009
        this.perfettoManager.on('stop', (event) => {
18✔
1010
            this.sendEvent(new ProfilingStopEvent({
×
1011
                type: event.type,
1012
                result: event.result
1013
            }));
1014
        });
1015
        this.perfettoManager.on('error', (event) => {
18✔
1016
            this.sendEvent(new ProfilingErrorEvent({
×
1017
                error: event.error
1018
            }));
1019
        });
1020

1021
        //tracing is explicitly enabled. Turn it on
1022
        if (this.launchConfiguration.profiling?.tracing?.enable && this.supportsPerfettoTracing) {
18✔
1023
            this.logger.info('Enabling perfetto tracing because it is supported by the device and enabled in the launch configuration');
2✔
1024
            try {
2✔
1025
                await this.perfettoManager?.enableTracing?.();
2!
1026
            } catch (e) {
1027
                this.logger.error('Failed to enable perfetto tracing', e);
1✔
1028
            }
1029

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

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

1042
            //profiling.tracing.enabled is set to `undefined`, which means we should do nothing
1043
        } else {
1044
            this.logger.info('Skipping perfetto initalization because `profiling.tracing.enable` is not defined in the launch configuration');
12✔
1045
        }
1046
    }
1047

1048
    /**
1049
     * If profiling was marked "connectOnStart", try connecting right away
1050
     */
1051
    private async tryProfilingConnectOnStart() {
1052
        if (this.launchConfiguration.profiling?.tracing?.connectOnStart && this.supportsPerfettoTracing) {
8✔
1053
            try {
2✔
1054
                await this.perfettoManager?.startTracing?.();
2!
1055
            } catch (e) {
1056
                this.logger.error('Failed to start perfetto tracing on start', e);
1✔
1057
            }
1058
        } else if (this.launchConfiguration.profiling?.tracing?.connectOnStart && !this.supportsPerfettoTracing) {
6✔
1059
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
1!
1060
            const message = `Perfetto profiling is not available: device firmware ${firmwareVersion} is below the minimum required version (15.2). Tracing will not start automatically.`;
1✔
1061
            this.logger.warn(message);
1✔
1062
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
1✔
1063
        }
1064
    }
1065

1066
    /**
1067
     * Clear certain properties that need reset whenever a debug session is restarted (via vscode or launched from the Roku home screen)
1068
     */
1069
    private resetSessionState() {
1070
        // launchRequest gets invoked by our restart session flow.
1071
        // We need to clear/reset some state to avoid issues.
1072
        this.entryBreakpointWasHandled = false;
11✔
1073
        //reset all per-session breakpoint state (diff baseline + cached parsed ASTs) since a restart
1074
        //re-stages the project
1075
        this.breakpointManager.reset();
11✔
1076
    }
1077

1078
    /**
1079
     * Activate rendezvous tracking (IF enabled in the LaunchConfig)
1080
     */
1081
    public async initRendezvousTracking() {
1082
        const timeout = 5000;
3✔
1083
        let initCompleted = false;
3✔
1084
        await Promise.race([
3✔
1085
            util.sleep(timeout),
1086
            this._initRendezvousTracking().finally(() => {
1087
                initCompleted = true;
3✔
1088
            })
1089
        ]);
1090

1091
        if (initCompleted === false) {
3✔
1092
            this.showPopupMessage(`Rendezvous tracking timed out after ${timeout}ms. Consider setting "rendezvousTracking": false in launch.json`, 'warn').catch((error) => {
1✔
1093
                this.logger.error('Error showing popup message', { error });
×
1094
            });
1095
        }
1096
    }
1097

1098
    private async _initRendezvousTracking() {
1099
        this.rendezvousTracker = new RendezvousTracker(this.deviceInfo, this.launchConfiguration);
3✔
1100

1101
        //pass the debug functions used to locate the client files and lines thought the adapter to the RendezvousTracker
1102
        this.rendezvousTracker.registerSourceLocator(async (debuggerPath: string, lineNumber: number) => {
3✔
1103
            return this.projectManager.getSourceLocation(debuggerPath, lineNumber);
×
1104
        });
1105

1106
        // Send rendezvous events to the debug protocol client
1107
        this.rendezvousTracker.on('rendezvous', (output) => {
3✔
1108
            this.sendEvent(new RendezvousEvent(output));
1✔
1109
        });
1110

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

1114
        //if rendezvous tracking is enabled, then enable it on the device
1115
        if (this.launchConfiguration.rendezvousTracking !== false) {
3✔
1116
            // start ECP rendezvous tracking (if possible)
1117
            await this.rendezvousTracker.activate();
2✔
1118
        }
1119
    }
1120

1121
    /**
1122
     * Anytime a roku adapter emits diagnostics, this method is called to handle it.
1123
     */
1124
    private async handleDiagnostics(diagnostics: BSDebugDiagnostic[]) {
1125
        // Roku device and sourcemap work with 1-based line numbers, VSCode expects 0-based lines.
1126
        for (let diagnostic of diagnostics) {
2✔
1127
            diagnostic.source = diagnosticSource;
2✔
1128
            let sourceLocation = await this.projectManager.getSourceLocation(diagnostic.path, diagnostic.range.start.line + 1);
2✔
1129
            if (sourceLocation) {
2✔
1130
                diagnostic.path = sourceLocation.filePath;
1✔
1131
                diagnostic.range.start.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1✔
1132
                diagnostic.range.end.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1✔
1133
            } else {
1134
                // TODO: may need to add a custom event if the source location could not be found by the ProjectManager
1135
                diagnostic.path = fileUtils.removeLeadingSlash(util.removeFileScheme(diagnostic.path));
1✔
1136
            }
1137
        }
1138

1139
        //find the first compile error (i.e. first DiagnosticSeverity.Error) if there is one
1140
        this.compileError = diagnostics.find(x => x.severity === DiagnosticSeverity.Error);
2✔
1141
        if (this.compileError) {
2✔
1142
            this.sendLaunchProgress('end', 'Aborted (compile error)');
1✔
1143
            this.sendEvent(new StoppedEvent(
1✔
1144
                StoppedEventReason.exception,
1145
                this.COMPILE_ERROR_THREAD_ID,
1146
                `CompileError: ${this.compileError.message}`
1147
            ));
1148
        }
1149

1150
        this.sendEvent(new DiagnosticsEvent(diagnostics));
2✔
1151
    }
1152

1153
    private publishTimeout = 60_000;
224✔
1154

1155
    private async publish() {
1156
        const uploadingEnd = this.logger.timeStart('log', 'Uploading zip');
3✔
1157
        let packageIsPublished = false;
3✔
1158

1159
        //delete any currently installed dev channel (if enabled to do so)
1160
        try {
3✔
1161
            if (this.launchConfiguration.deleteDevChannelBeforeInstall === true) {
3!
NEW
1162
                await this.rokuDeploy.deleteDevChannel({
×
1163
                    device: this.launchConfiguration.device,
1164
                    password: this.launchConfiguration.password,
1165
                    username: this.launchConfiguration.username,
1166
                    packagePort: this.launchConfiguration.packagePort
1167
                });
1168
            }
1169
        } catch (e) {
1170
            const statusCode = e?.results?.response?.statusCode;
×
1171
            const message = e.message as string;
×
1172
            if (statusCode === 401) {
×
1173
                await this.shutdown(message, true);
×
1174
                throw e;
×
1175
            }
1176
            this.logger.warn('Failed to delete the dev channel...probably not a big deal', e);
×
1177
        }
1178

1179
        const isConnected = this.rokuAdapter.once('app-ready');
3✔
1180
        const options: SideloadOptions = {
3✔
1181
            device: this.launchConfiguration.device,
1182
            password: this.launchConfiguration.password,
1183
            username: this.launchConfiguration.username,
1184
            packagePort: this.launchConfiguration.packagePort,
1185
            ecpPort: this.launchConfiguration.remotePort,
1186
            //sideload the zip that was already built from the staging folder (or supplied via packagePath)
1187
            zip: this.launchConfiguration.packagePath ?? util.getOutputZipPath({ outDir: this.launchConfiguration.outDir }),
9✔
1188
            // enable the debug protocol if true
1189
            remoteDebug: this.enableDebugProtocol,
1190
            //necessary for capturing compile errors from the protocol (has no effect on telnet)
1191
            remoteDebugConnectEarly: false,
1192
            //we don't want to fail if there were compile errors...we'll let our compile error processor handle that
1193
            failOnCompileError: true,
1194
            //deleting the dev channel (when enabled) was already handled at the start of this function
1195
            deleteDevChannel: false,
1196
            //the device was already sent to the home screen during configurationDone
1197
            close: false,
1198
            //pass any upload form overrides the client may have configured
1199
            packageUploadOverrides: this.launchConfiguration.packageUploadOverrides
1200
        };
1201

1202
        //publish the package to the target Roku
1203
        const publishPromise = this.rokuDeploy.sideload(options).then(() => {
3✔
1204
            packageIsPublished = true;
3✔
1205
        }).catch(async (e) => {
1206
            const statusCode = e?.results?.response?.statusCode;
×
1207
            const message = e.message as string;
×
1208
            if ((statusCode && statusCode !== 200) || isUpdateCheckRequiredError(e) || isConnectionResetError(e)) {
×
1209
                await this.shutdown(message, true);
×
1210
                throw e;
×
1211
            }
1212
            this.logger.error(e);
×
1213
        });
1214

1215
        await publishPromise;
3✔
1216

1217
        uploadingEnd();
3✔
1218

1219
        //the channel has been deployed. Wait for the adapter to finish connecting.
1220
        //if it hasn't connected after 60 seconds, abort the launch.
1221
        let didTimeOut = false;
3✔
1222
        await Promise.race([
3✔
1223
            isConnected,
1224
            util.sleep(this.publishTimeout).then(() => {
1225
                didTimeOut = true;
3✔
1226
            })
1227
        ]);
1228
        this.logger.log('Finished racing promises');
3✔
1229
        if (didTimeOut) {
3✔
1230
            this.logger.warn('Timed out waiting for roku to connect');
1✔
1231
        }
1232
        //if the adapter is still not connected, then it will probably never connect. Abort.
1233
        if (packageIsPublished && !this.rokuAdapter.connected) {
3✔
1234
            return this.shutdown('Debug session cancelled: failed to connect to debug protocol control port.');
1✔
1235
        }
1236
    }
1237

1238
    private pendingSendLogPromise = Promise.resolve();
224✔
1239

1240
    /**
1241
     * Send log output to the "client" (i.e. vscode)
1242
     * @param logOutput
1243
     */
1244
    private sendLogOutput(logOutput: string) {
1245
        if (this.isCrashed) {
38!
1246
            return Promise.resolve();
×
1247
        }
1248
        this.fileLoggingManager.writeRokuDeviceLog(logOutput);
38✔
1249

1250
        this.pendingSendLogPromise = this.pendingSendLogPromise.then(async () => {
38✔
1251
            logOutput = await this.convertBacktracePaths(logOutput);
38✔
1252

1253
            const lines = logOutput.split(/\r?\n/g);
38✔
1254
            for (let i = 0; i < lines.length; i++) {
38✔
1255
                let line = lines[i];
264✔
1256
                if (i < lines.length - 1) {
264✔
1257
                    line += '\n';
226✔
1258
                }
1259

1260
                if (this.launchConfiguration.rewriteDevicePathsInLogs) {
264✔
1261
                    let potentialPaths = this.getPotentialPkgPaths(line);
91✔
1262
                    for (let potentialPath of potentialPaths) {
91✔
1263
                        let originalLocation = await this.projectManager.getSourceLocation(potentialPath.path, potentialPath.lineNumber, potentialPath.columnNumber);
28✔
1264
                        if (originalLocation) {
28✔
1265
                            let replacement: string;
1266
                            replacement = originalLocation.filePath.replaceAll(' ', '%20');
26✔
1267
                            if (replacement !== originalLocation.filePath) {
26✔
1268
                                if (this.isWindowsPlatform) {
6✔
1269
                                    replacement = `vscode://file/${replacement}`;
3✔
1270
                                } else {
1271
                                    replacement = `file://${replacement}`;
3✔
1272
                                }
1273
                            }
1274
                            replacement += `:${originalLocation.lineNumber}`;
26✔
1275
                            if (potentialPath.columnNumber !== undefined) {
26✔
1276
                                replacement += `:${originalLocation.columnIndex + 1}`;
10✔
1277
                            }
1278

1279
                            line = line.replaceAll(potentialPath.fullMatch, replacement);
26✔
1280
                        }
1281
                    }
1282
                }
1283
                this.sendEvent(new OutputEvent(line, 'stdout'));
264✔
1284
                this.sendEvent(new LogOutputEvent(line));
264✔
1285
            }
1286
        });
1287
        return this.pendingSendLogPromise;
38✔
1288
    }
1289

1290
    /**
1291
     * Extracts potential package paths from a given line of text.
1292
     *
1293
     * This method uses a regular expression to find matches in the provided line
1294
     * and returns an array of objects containing details about each match.
1295
     *
1296
     * @param input - The line of text to search for potential package paths.
1297
     * @returns An array of objects, each containing:
1298
     *   - `fullMatch`: The full matched string.
1299
     *   - `path`: The extracted path from the match.
1300
     *   - `lineNumber`: The line number extracted from the match.
1301
     *   - `columnNumber`: The column number extracted from the match, or `undefined` if not found.
1302
     */
1303
    private getPotentialPkgPaths(input: string): Array<{ fullMatch: string; path: string; lineNumber: number; columnNumber: number }> {
1304
        // https://regex101.com/r/ixpQiq/1
1305
        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✔
1306
        let paths: ReturnType<BrightScriptDebugSession['getPotentialPkgPaths']> = [];
91✔
1307
        if (matches) {
91!
1308
            for (let match of matches) {
91✔
1309
                let fullMatch = match[0];
28✔
1310
                let path = match[1];
28✔
1311
                let lineNumber = parseInt(match[2] ?? match[4]);
28✔
1312
                let columnNumber = parseInt(match[3] ?? match[5]);
28✔
1313
                if (isNaN(columnNumber)) {
28✔
1314
                    columnNumber = undefined;
17✔
1315
                }
1316
                paths.push({
28✔
1317
                    fullMatch: fullMatch,
1318
                    path: path,
1319
                    lineNumber: lineNumber,
1320
                    columnNumber: columnNumber
1321
                });
1322
            }
1323
        }
1324
        return paths;
91✔
1325
    }
1326

1327
    /**
1328
     * Converts the filename property in backtrace objects in the given input string to source paths if found
1329
     */
1330
    private async convertBacktracePaths(input: string) {
1331
        if (!this.launchConfiguration.rewriteDevicePathsInLogs) {
38✔
1332
            return input;
8✔
1333
        }
1334
        // Why does this not work? It should work, but it doesn't. I'm not sure why.
1335
        // let matches = input.matchAll(this.deviceBacktraceObjectRegex);
1336

1337
        // https://regex101.com/r/y1koaV/2
1338
        let deviceBacktraceObjectRegex = /{\s+filename:\s+"([A-Za-z0-9_\.\/\: ]+)"\s+function\:\s+".+"\s+(line_number\:\s+(\d+))\s+}/gi;
30✔
1339
        let matches = [];
30✔
1340
        let match = deviceBacktraceObjectRegex.exec(input);
30✔
1341
        while (match) {
30✔
1342
            matches.push(match);
5✔
1343
            match = deviceBacktraceObjectRegex.exec(input);
5✔
1344
        }
1345

1346
        if (matches) {
30!
1347
            for (let match of matches) {
30✔
1348
                let fullMatch = match[0] as string;
5✔
1349
                let filePath = match[1] as string;
5✔
1350
                let fullLineNumber = match[2] as string;
5✔
1351
                let lineNumber = parseInt(match[3] as string);
5✔
1352
                let originalLocation = await this.projectManager.getSourceLocation(filePath, lineNumber);
5✔
1353
                if (originalLocation) {
5✔
1354
                    let fileReplacement: string;
1355
                    fileReplacement = originalLocation.filePath.replaceAll(' ', '%20');
4✔
1356
                    if (fileReplacement !== originalLocation.filePath) {
4✔
1357
                        if (this.isWindowsPlatform) {
2✔
1358
                            fileReplacement = `vscode://file/${fileReplacement}`;
1✔
1359
                        } else {
1360
                            fileReplacement = `file://${fileReplacement}`;
1✔
1361
                        }
1362
                    }
1363
                    fileReplacement += `:${originalLocation.lineNumber}`;
4✔
1364

1365
                    let lineNumberReplacement = fullLineNumber.replace(lineNumber.toString(), originalLocation.lineNumber.toString());
4✔
1366

1367
                    // 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
1368
                    let completeReplacement = fullMatch.replace(filePath, fileReplacement);
4✔
1369
                    completeReplacement = completeReplacement.replace(fullLineNumber, lineNumberReplacement);
4✔
1370
                    input = input.replaceAll(fullMatch, completeReplacement);
4✔
1371
                }
1372

1373
            }
1374
        }
1375

1376
        return input;
30✔
1377
    }
1378

1379
    private async runAutomaticSceneGraphCommands(commands: string[]) {
1380
        if (commands) {
1!
NEW
1381
            let connection = new SceneGraphDebugCommandController(this.launchConfiguration.device, this.launchConfiguration.sceneGraphDebugCommandsPort);
×
1382

1383
            try {
×
1384
                await connection.connect();
×
1385
                for (let command of this.launchConfiguration.autoRunSgDebugCommands) {
×
1386
                    let response: SceneGraphCommandResponse;
1387
                    switch (command) {
×
1388
                        case 'chanperf':
1389
                            util.log('Enabling Chanperf Tracking');
×
1390
                            response = await connection.chanperf({ interval: 1 });
×
1391
                            if (!response.error) {
×
1392
                                util.log(response.result.rawResponse);
×
1393
                            }
1394
                            break;
×
1395

1396
                        case 'fpsdisplay':
1397
                            util.log('Enabling FPS Display');
×
1398
                            response = await connection.fpsDisplay('on');
×
1399
                            if (!response.error) {
×
1400
                                util.log(response.result.data as string);
×
1401
                            }
1402
                            break;
×
1403

1404
                        case 'logrendezvous':
1405
                            util.log('Enabling Rendezvous Logging:');
×
1406
                            response = await connection.logrendezvous('on');
×
1407
                            if (!response.error) {
×
1408
                                util.log(response.result.rawResponse);
×
1409
                            }
1410
                            break;
×
1411

1412
                        default:
1413
                            util.log(`Running custom SceneGraph debug command on port 8080 '${command}':`);
×
1414
                            response = await connection.exec(command);
×
1415
                            if (!response.error) {
×
1416
                                util.log(response.result.rawResponse);
×
1417
                            }
1418
                            break;
×
1419
                    }
1420
                }
1421
                await connection.end();
×
1422
            } catch (error) {
1423
                util.log(`Error connecting to port 8080: ${error.message}`);
×
1424
            }
1425
        }
1426
    }
1427

1428
    /**
1429
     * Stage, insert breakpoints, and package the main project
1430
     */
1431
    public async prepareMainProject() {
1432
        //add the main project
1433
        this.projectManager.mainProject = new Project({
2✔
1434
            rootDir: this.launchConfiguration.rootDir,
1435
            files: this.launchConfiguration.files,
1436
            outDir: this.launchConfiguration.outDir,
1437
            sourceDirs: this.launchConfiguration.sourceDirs,
1438
            bsConst: this.launchConfiguration.bsConst,
1439
            injectRaleTrackerTask: this.launchConfiguration.injectRaleTrackerTask,
1440
            raleTrackerTaskFileLocation: this.launchConfiguration.raleTrackerTaskFileLocation,
1441
            injectRdbOnDeviceComponent: this.launchConfiguration.injectRdbOnDeviceComponent,
1442
            rdbFilesBasePath: this.launchConfiguration.rdbFilesBasePath,
1443
            stagingDir: this.launchConfiguration.stagingDir,
1444
            packagePath: this.launchConfiguration.packagePath,
1445
            enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1446
        });
1447

1448
        util.log('Moving selected files to staging area');
2✔
1449
        await this.projectManager.mainProject.stage();
2✔
1450

1451
        //add the entry breakpoint if stopOnEntry is true
1452
        await this.handleEntryBreakpoint();
2✔
1453
    }
1454

1455
    /**
1456
     * Validate every breakpoint against the staged projects and (telnet only) write STOP statements into the
1457
     * staged .brs files. Runs after the DAP `InitializedEvent` so client-side `setBreakpoints` requests have
1458
     * landed before any STOPs are written. Validates the main project and every component library in ONE pass:
1459
     * a breakpoint is only failed when it can't be placed in any project, so validating per project would fail
1460
     * every other project's breakpoints. Must complete before `postfixComponentLibraries` renames the complib
1461
     * files so telnet STOPs are written to the original file names.
1462
     */
1463
    private async writeBreakpoints() {
1464
        //add breakpoint lines to source files and then publish
1465
        util.log('Adding stop statements for active breakpoints');
11✔
1466

1467
        //validate breakpoints for all debugger types (and write `stop` statements for telnet — decided internally)
1468
        await this.breakpointManager.validateAndWriteBreakpointsForProjects(this.projectManager.getAllProjects());
11✔
1469
    }
1470

1471
    /**
1472
     * Create the main project's zip package (or run the configured packageTask). Must run AFTER
1473
     * `applyLibraryReferencePostfixes` so any rewritten `Library` statements are included in the zip.
1474
     */
1475
    private async zipMainProject() {
1476
        if (this.launchConfiguration.packageTask) {
2✔
1477
            util.log(`Executing task '${this.launchConfiguration.packageTask}' to assemble the app`);
1✔
1478
            await this.sendCustomRequest('executeTask', { task: this.launchConfiguration.packageTask });
1✔
1479

1480
            const packagePath = this.launchConfiguration.packagePath ?? util.getOutputZipPath({ outDir: this.launchConfiguration.outDir });
1!
1481

1482
            if (!fsExtra.pathExistsSync(packagePath)) {
1!
1483
                return this.shutdown(`Cancelling debug session. Package does not exist at '${packagePath}'`);
×
1484
            }
1485
        } else {
1486
            //create zip package from staging folder
1487
            util.log('Creating zip archive from project sources');
1✔
1488
            await this.projectManager.mainProject.zipPackage({ retainStagingFolder: true });
1✔
1489
        }
1490
    }
1491

1492
    /**
1493
     * Accepts custom events and requests from the extension
1494
     * @param command name of the command to execute
1495
     */
1496
    protected async customRequest(command: string, response: DebugProtocol.Response, args: any) {
1497
        if (command === 'rendezvous.clearHistory') {
×
1498
            this.rokuAdapter.clearRendezvousHistory();
×
1499
        } else if (command === 'chanperf.clearHistory') {
×
1500
            this.rokuAdapter.clearChanperfHistory();
×
1501

1502
        } else if (command === 'customRequestEventResponse') {
×
1503
            this.emit('customRequestEventResponse', args);
×
1504

1505
        } else if (command === 'popupMessageEventResponse') {
×
1506
            this.emit('popupMessageEventResponse', args);
×
1507

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

1511
        } else if (command === 'startPerfettoTracing') {
×
1512
            try {
×
1513
                await this.perfettoManager?.startTracing?.();
×
1514
            } catch (e) {
1515
                response.success = false;
×
1516
                response.body = { message: e?.message || String(e) };
×
1517
            }
1518

1519
        } else if (command === 'stopPerfettoTracing') {
×
1520
            try {
×
1521
                await this.perfettoManager?.stopTracing?.();
×
1522
            } catch (e) {
1523
                response.success = false;
×
1524
                response.body = { message: e?.message || String(e) };
×
1525
            }
1526

1527
        }
1528
        this.sendResponse(response);
×
1529
    }
1530

1531
    /**
1532
     * Stores the path to the staging folder for each component library
1533
     */
1534
    protected async prepareComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1535
        if (!componentLibraries || componentLibraries.length === 0) {
11✔
1536
            return;
2✔
1537
        }
1538
        let componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
9✔
1539
        //make sure this folder exists (and is empty)
1540
        await fsExtra.ensureDir(componentLibrariesOutDir);
9✔
1541
        await fsExtra.emptyDir(componentLibrariesOutDir);
9✔
1542

1543
        //create a ComponentLibraryProject for each component library
1544
        for (let libraryIndex = 0; libraryIndex < componentLibraries.length; libraryIndex++) {
9✔
1545
            let componentLibrary = componentLibraries[libraryIndex];
19✔
1546

1547
            this.projectManager.componentLibraryProjects.push(
19✔
1548
                new ComponentLibraryProject({
1549
                    rootDir: componentLibrary.rootDir,
1550
                    files: componentLibrary.files,
1551
                    outDir: componentLibrariesOutDir,
1552
                    outFile: componentLibrary.outFile,
1553
                    sourceDirs: componentLibrary.sourceDirs,
1554
                    bsConst: componentLibrary.bsConst,
1555
                    install: componentLibrary.install,
1556
                    enablePostfix: componentLibrary.enablePostfix,
1557
                    injectRaleTrackerTask: componentLibrary.injectRaleTrackerTask,
1558
                    raleTrackerTaskFileLocation: componentLibrary.raleTrackerTaskFileLocation,
1559
                    libraryIndex: libraryIndex,
1560
                    enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1561
                })
1562
            );
1563
        }
1564

1565
        //stage all of the libraries in parallel
1566
        await Promise.all(
9✔
1567
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.stage())
19✔
1568
        );
1569
    }
1570

1571
    /**
1572
     * Postfix each staged complib's own files (rename its `.brs` files with the `__lib<index>` postfix and
1573
     * rewrite its `uri=` references). Runs in `configurationDoneRequest` AFTER `writeBreakpoints` so any
1574
     * telnet STOPs have already been written to the original file names. Kept separate from zipping so the
1575
     * cross-project `Library` reference rewrite can run after EVERY project is postfixed but before any zip
1576
     * is sealed.
1577
     */
1578
    protected async postfixComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1579
        if (!componentLibraries || componentLibraries.length === 0) {
10✔
1580
            return;
2✔
1581
        }
1582

1583
        //postfix each complib's own files in parallel
1584
        await Promise.all(
8✔
1585
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.postfixFiles())
18✔
1586
        );
1587
    }
1588

1589
    /**
1590
     * Seal every component library's zip, install the installable ones (in order), and start static file
1591
     * hosting. Must run AFTER `applyLibraryReferencePostfixes` so each zip contains the rewritten `Library`
1592
     * statements.
1593
     */
1594
    protected async zipServeAndInstallComponentLibraries(componentLibraries: ComponentLibraryConfiguration[], port: number) {
1595
        if (!componentLibraries || componentLibraries.length === 0) {
10✔
1596
            return;
2✔
1597
        }
1598
        const componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
8✔
1599

1600
        const needToDeleteComplibs = this.projectManager.componentLibraryProjects.some(x => x.install);
9✔
1601
        if (needToDeleteComplibs) {
8✔
1602
            //deleteAllComponentLibraries pauses compile-error reporting (to swallow deletion-induced errors) and
1603
            //leaves it paused. Now that deletion is done and we're about to put libraries back on the device, settle
1604
            //(draining any lingering deletion output) and resume reporting so real install errors are surfaced.
1605
            await this.deleteAllComponentLibraries();
7✔
1606
        }
1607

1608
        //zip each complib in parallel
1609
        const packagePromises = this.projectManager.componentLibraryProjects.map(
8✔
1610
            compLibProject => compLibProject.zipPackage({ retainStagingFolder: true })
18✔
1611
        );
1612

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

1620
            if (compLibProject.install === true) {
18✔
1621
                //wait for this complib to finish being packaged
1622
                await packagePromises[i];
12✔
1623

1624
                if (componentLibraries[i].packageTask) {
12✔
1625
                    await this.sendCustomRequest('executeTask', { task: componentLibraries[i].packageTask });
2✔
1626
                }
1627

1628
                const options: SideloadOptions = {
12✔
1629
                    device: this.launchConfiguration.device,
1630
                    password: this.launchConfiguration.password,
1631
                    username: this.launchConfiguration.username || 'rokudev',
24✔
1632
                    packagePort: this.launchConfiguration.packagePort,
1633
                    ecpPort: this.launchConfiguration.remotePort,
1634
                    zip: componentLibraries[i].packagePath ?? util.getOutputZipPath({ outDir: compLibProject.outDir, outFile: compLibProject.outFile }),
36✔
1635
                    failOnCompileError: true,
1636
                    appType: 'dcl',
1637
                    //installing a component library should never close or delete the sideloaded channel
1638
                    close: false,
1639
                    deleteDevChannel: false,
1640
                    packageUploadOverrides: componentLibraries[i].packageUploadOverrides || {}
22✔
1641
                };
1642

1643
                util.log(`Installing component library ${i} (${compLibProject.outFile})`);
12✔
1644
                try {
12✔
1645
                    await rokuDeploy.sideload(options);
12✔
1646
                    util.log(`Installed component library ${i} (${compLibProject.outFile})`);
11✔
1647
                } catch (error) {
1648
                    //do NOT continue installing further libraries (or publishing the main app) - a failed install
1649
                    //here means later libraries and the main app would reference a library that isn't on the device
1650
                    this.logger.error(`Error installing component library ${i} (${compLibProject.outFile})`, error);
1✔
1651
                    throw error;
1✔
1652
                }
1653
            }
1654
        }
1655

1656
        const hostingPromise = this.componentLibraryServer.startStaticFileHosting(componentLibrariesOutDir, port, (message: string) => {
7✔
1657
            util.log(message);
×
1658
        });
1659

1660
        //wait for all complib packaging to finish and the file hosting to start
1661
        await Promise.all([
7✔
1662
            ...packagePromises,
1663
            hostingPromise
1664
        ]);
1665
    }
1666

1667
    /**
1668
     * Delete every component library installed on the device.
1669
     *
1670
     * A complib can only depend on complibs the user declared BEFORE it, so the user's configured order is already
1671
     * dependency order. Deleting in the REVERSE of that order therefore deletes each complib before the ones it
1672
     * depends on, avoiding compile errors entirely. We use that reverse order as the delete priority.
1673
     *
1674
     * Anything still installed that the user did NOT configure (e.g. leftovers from a previous session) has no known
1675
     * order, so we fall back to compile-error tolerance for those: deleting a complib while another still references
1676
     * it fails with a compile error, which we ignore and retry later (after its dependent is gone). We finish when
1677
     * every complib is gone, and fail if the only complibs left are ones that can never be deleted.
1678
     */
1679
    private async deleteAllComponentLibraries() {
1680
        const deviceOptions = {
16✔
1681
            device: this.launchConfiguration.device,
1682
            password: this.launchConfiguration.password,
1683
            username: this.launchConfiguration.username || 'rokudev'
32✔
1684
        };
1685

1686
        //The user's configured complibs, in REVERSE declaration order (dependents before their dependencies). A
1687
        //complib's position here is its delete priority; complibs the user didn't configure aren't in this list.
1688
        const deletePriority = this.projectManager.componentLibraryProjects
16✔
1689
            .map(complib => complib.outFile)
28✔
1690
            .reverse();
1691
        //sort key for a complib: its index in `deletePriority`, or Infinity (delete last) if it wasn't configured
1692
        const priorityOf = (fileName: string) => {
16✔
1693
            const index = deletePriority.indexOf(fileName);
24✔
1694
            return index === -1 ? Infinity : index;
24✔
1695
        };
1696

1697
        const maxAttempts = 5;
16✔
1698
        //how many times we've tried to delete each complib, and the ones we've given up on after maxAttempts
1699
        const attempts = new Map<string, number>();
16✔
1700
        const failed = new Set<string>();
16✔
1701

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

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

1708
            //of the complibs we haven't given up on, pick the next to delete: fewest attempts first (spreads retries
1709
            //evenly so a dependent gets deleted before we circle back to a complib that previously failed), and among
1710
            //equal attempts, follow the reverse-configured delete priority (dependents before dependencies).
1711
            const candidates = installed.filter(complib => !failed.has(complib.archiveFileName));
40✔
1712
            const next = candidates.sort((a, b) =>
39✔
1713
                attemptCount(a) - attemptCount(b) ||
15✔
1714
                priorityOf(a.archiveFileName) - priorityOf(b.archiveFileName)
1715
            )[0];
1716

1717
            //nothing left to try — done if the device is clear, otherwise hard-fail with whatever remains
1718
            if (!next) {
39✔
1719
                if (installed.length > 0) {
15✔
1720
                    throw new Error(`Failed to delete existing component libraries on device; ${installed.length} still installed: ${installed.map(x => x.archiveFileName).join(', ')}`);
1✔
1721
                }
1722
                return;
14✔
1723
            }
1724

1725
            const fileName = next.archiveFileName;
24✔
1726
            attempts.set(fileName, (attempts.get(fileName) ?? 0) + 1);
24✔
1727
            try {
24✔
1728
                await rokuDeploy.deleteComponentLibrary({ ...deviceOptions, fileName: fileName });
24✔
1729
            } catch (error) {
1730
                //re-throw anything that isn't a dependency compile error (auth, network, etc.)
1731
                if (!this.isComponentLibraryDependencyCompileError(error)) {
9✔
1732
                    throw error;
1✔
1733
                }
1734
                //Deleting a complib that a dependent still references produces a compile error - but the delete may
1735
                //STILL have succeeded (the device reports both a compile error and 'Delete Succeeded'). If it actually
1736
                //deleted, treat it as done. Only if it did NOT succeed do we defer and retry it later.
1737
                if (this.wasComponentLibraryDeleteSuccessful(error)) {
8!
1738
                    this.logger.trace(`Deleted '${fileName}' (device reported a compile error but the delete succeeded)`);
×
1739
                } else {
1740
                    //another not-yet-deleted complib still depends on this one. Give up on it once it's out of
1741
                    //attempts; otherwise leave it pending so we retry after its dependents are gone.
1742
                    if (attempts.get(fileName) >= maxAttempts) {
8✔
1743
                        failed.add(fileName);
1✔
1744
                    }
1745
                    this.logger.log(`Deferring delete of '${fileName}'; another component library still depends on it`);
8✔
1746
                }
1747
            }
1748

1749
            //the Roku doesn't appreciate back-to-back deletes; give it a moment between requests
1750
            await util.sleep(10);
23✔
1751
        }
1752
    }
1753

1754
    /**
1755
     * Is this error the device reporting a compile failure (which, during complib deletion, means another
1756
     * installed complib still references the one we tried to delete)?
1757
     */
1758
    private isComponentLibraryDependencyCompileError(error: any): boolean {
1759
        const message = `${error?.message ?? ''}`;
9!
1760
        return /compile error|compilation failed/i.test(message);
9✔
1761
    }
1762

1763
    /**
1764
     * Did the component-library delete actually succeed, even though the device also reported a compile error?
1765
     * When we delete a complib that a dependent still references, the device reports BOTH a compile error AND a
1766
     * `Delete Succeeded` message - meaning the complib really was removed. roku-deploy attaches the parsed device
1767
     * messages (`{ errors, infos, successes }`) to the thrown error as `rokuMessages`; we look for the success there.
1768
     * Absence of that success message means the delete did NOT happen, so it should be treated as a failure/retry.
1769
     */
1770
    private wasComponentLibraryDeleteSuccessful(error: any): boolean {
1771
        const successes: string[] = error?.rokuMessages?.successes ?? [];
8!
1772
        return successes.some(message => /delete succeeded/i.test(message));
8✔
1773
    }
1774

1775
    protected sourceRequest(response: DebugProtocol.SourceResponse, args: DebugProtocol.SourceArguments) {
1776
        this.logger.log('sourceRequest');
×
1777
        let old = this.sendResponse;
×
1778
        this.sendResponse = function sendResponse(...args) {
×
1779
            old.apply(this, args);
×
1780
            this.sendResponse = old;
×
1781
        };
1782
        super.sourceRequest(response, args);
×
1783
    }
1784

1785
    /**
1786
     * Called every time a breakpoint is created, modified, or deleted, for each file. This receives the entire list of breakpoints every time.
1787
     */
1788
    public async setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments) {
1789
        this.logger.log('setBreakpointsRequest', args);
6✔
1790
        let sanitizedBreakpoints = this.breakpointManager.replaceBreakpoints(args.source.path, args.breakpoints);
6✔
1791
        //sort the breakpoints
1792
        let sortedAndFilteredBreakpoints = orderBy(sanitizedBreakpoints, [x => x.line, x => x.column]);
6✔
1793

1794
        response.body = {
6✔
1795
            breakpoints: sortedAndFilteredBreakpoints
1796
        };
1797
        this.sendResponse(response);
6✔
1798

1799
        //ensure we've staged all the files
1800
        await this.stagingDefered.promise;
6✔
1801

1802
        await this.rokuAdapter?.syncBreakpoints();
6!
1803
    }
1804

1805
    protected exceptionInfoRequest(response: DebugProtocol.ExceptionInfoResponse, args: DebugProtocol.ExceptionInfoArguments) {
1806
        this.logger.log('exceptionInfoRequest');
×
1807
    }
1808

1809
    protected async threadsRequest(response: DebugProtocol.ThreadsResponse) {
1810
        this.logger.log('threadsRequest');
4✔
1811

1812
        let threads = [];
4✔
1813

1814
        //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
1815
        if (this.compileError) {
4!
1816
            threads.push(new Thread(this.COMPILE_ERROR_THREAD_ID, 'Compile Error'));
×
1817
        } else {
1818
            //wait for the roku adapter to load
1819
            await this.getRokuAdapter();
4✔
1820

1821
            //only send the threads request if we are at the debugger prompt
1822
            if (this.rokuAdapter.isAtDebuggerPrompt) {
4✔
1823
                let rokuThreads = await this.rokuAdapter.getThreads();
3✔
1824

1825
                for (let thread of rokuThreads) {
3✔
1826
                    const threadName = this.getThreadName(thread as AdapterThread);
4✔
1827
                    threads.push(
4✔
1828
                        new Thread(thread.threadId, threadName)
1829
                    );
1830
                }
1831

1832
                if (threads.length === 0) {
3!
1833
                    threads = [{
×
1834
                        id: 1001,
1835
                        name: 'unable to retrieve threads: not stopped',
1836
                        isFake: true
1837
                    }];
1838
                }
1839

1840
            } else {
1841
                this.logger.log('Skipped getting threads because the RokuAdapter is not accepting input at this time.');
1✔
1842
            }
1843

1844
        }
1845

1846
        response.body = {
4✔
1847
            threads: threads
1848
        };
1849

1850
        this.sendResponse(response);
4✔
1851
    }
1852

1853
    /**
1854
     * Get the thread name to display in the UI based on the thread info we have.
1855
     * This is what displays in the `call stack` region in vscode
1856
     * @param thread
1857
     * @returns
1858
     */
1859
    private getThreadName(thread: AdapterThread) {
1860
        let threadName = '';
24✔
1861
        if (thread.type || thread.name || thread.osThreadId) {
24✔
1862
            //build the name from only the parts that are present, so missing values don't leak into the name
1863
            const parts: string[] = [];
13✔
1864
            if (thread.type) {
13✔
1865
                parts.push(`[${thread.type}]`);
10✔
1866
            }
1867
            if (thread.name) {
13✔
1868
                parts.push(thread.name);
9✔
1869
            }
1870
            if (thread.osThreadId) {
13✔
1871
                parts.push(thread.osThreadId);
9✔
1872
            }
1873
            threadName = parts.join(' ');
13✔
1874
        }
1875
        //remove any extraneous whitespace to deal with missing values
1876
        threadName = threadName.replace(/\s+/g, ' ').trim();
24✔
1877

1878
        if (threadName === '') {
24✔
1879
            threadName = `Thread ${thread.threadId}`;
11✔
1880
        }
1881

1882
        if (thread.isDetached) {
24✔
1883
            threadName += ' [detached]';
4✔
1884
        }
1885

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

1889
        return threadName;
24✔
1890
    }
1891

1892
    protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) {
1893
        try {
3✔
1894
            this.logger.log('stackTraceRequest');
3✔
1895
            let frames: DebugProtocol.StackFrame[] = [];
3✔
1896

1897
            //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
1898
            if (this.compileError) {
3!
1899
                frames.push(new StackFrame(
×
1900
                    0,
1901
                    'Compile Error',
1902
                    new Source(path.basename(this.compileError.path), this.compileError.path),
1903
                    // range is 0-based; toClientLine/toClientColumn handle client coordinate conversion
1904
                    this.toClientLine(this.compileError.range.start.line),
1905
                    this.toClientColumn(this.compileError.range.start.character)
1906
                ));
1907
            } else if (args.threadId === 1001) {
3!
1908
                frames.push(new StackFrame(
×
1909
                    0,
1910
                    'ERROR: threads would not stop',
1911
                    new Source('main.brs', s`${this.launchConfiguration.stagingDir}/manifest`),
1912
                    this.toClientLine(0),
1913
                    this.toClientColumn(0)
1914
                ));
1915
                this.showPopupMessage('Unable to suspend threads. Debugger is in an unstable state, please press Continue to resume debugging', 'warn').catch((error) => {
×
1916
                    this.logger.error('Error showing popup message', { error });
×
1917
                });
1918
            } else {
1919
                //ensure the rokuAdapter is loaded
1920
                await this.getRokuAdapter();
3✔
1921

1922
                if (this.rokuAdapter.isAtDebuggerPrompt) {
3!
1923
                    let stackTrace = await this.rokuAdapter.getStackTrace(args.threadId);
3✔
1924
                    if (stackTrace.length === 0) {
3✔
1925
                        // Thread is detached or encountered an error requesting Stack Trace — show a non-interactive label so VS Code can display
1926
                        // the thread without letting the user navigate to a source location
1927
                        const frame = new StackFrame(0, '[unavailable]');
2✔
1928
                        frame.presentationHint = 'label';
2✔
1929
                        frames.push(frame);
2✔
1930
                    } else {
1931
                        for (let debugFrame of stackTrace) {
1✔
1932
                            let sourceLocation = await this.projectManager.getSourceLocation(debugFrame.filePath, debugFrame.lineNumber);
3✔
1933

1934
                            //the stacktrace returns function identifiers in all lower case. Try to get the actual case
1935
                            //load the contents of the file and get the correct casing for the function identifier
1936
                            try {
3✔
1937
                                let functionName = this.fileManager.getCorrectFunctionNameCase(sourceLocation?.filePath, debugFrame.functionIdentifier);
3!
1938
                                if (functionName) {
3!
1939

1940
                                    //search for original function name if this is an anonymous function.
1941
                                    //anonymous function names are prefixed with $ in the stack trace (i.e. $anon_1 or $functionname_40002)
1942
                                    if (functionName.startsWith('$')) {
3!
1943
                                        functionName = this.fileManager.getFunctionNameAtPosition(
×
1944
                                            sourceLocation.filePath,
1945
                                            sourceLocation.lineNumber - 1,
1946
                                            functionName
1947
                                        );
1948
                                    }
1949
                                    debugFrame.functionIdentifier = functionName;
3✔
1950
                                }
1951
                            } catch (error) {
1952
                                this.logger.error('Error correcting function identifier case', { error, sourceLocation, debugFrame });
×
1953
                            }
1954
                            const filePath = sourceLocation?.filePath ?? debugFrame.filePath;
3!
1955

1956
                            const frame: DebugProtocol.StackFrame = new StackFrame(
3✔
1957
                                debugFrame.frameId,
1958
                                `${debugFrame.functionIdentifier}`,
1959
                                new Source(path.basename(filePath), filePath),
1960
                                // lineNumber is 1-based from Roku; toClientLine expects 0-based
1961
                                this.toClientLine((sourceLocation?.lineNumber ?? debugFrame.lineNumber) - 1),
18!
1962
                                this.toClientColumn(0)
1963
                            );
1964
                            if (!sourceLocation) {
3!
1965
                                frame.presentationHint = 'subtle';
×
1966
                            }
1967
                            frames.push(frame);
3✔
1968
                        }
1969
                    }
1970
                } else {
1971
                    this.logger.log('Skipped calculating stacktrace because the RokuAdapter is not accepting input at this time');
×
1972
                }
1973
            }
1974
            response.body = {
3✔
1975
                stackFrames: frames,
1976
                totalFrames: frames.length
1977
            };
1978
            this.sendResponse(response);
3✔
1979
        } catch (error) {
1980
            this.logger.error('Error getting stacktrace', { error, args });
×
1981
        }
1982
    }
1983

1984
    protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) {
1985
        const logger = this.logger.createLogger(`scopesRequest ${this.idCounter}`);
1✔
1986
        logger.info('begin', { args });
1✔
1987
        try {
1✔
1988
            const scopes = new Array<DebugProtocol.Scope>();
1✔
1989

1990
            // create the locals scope
1991
            let v = this.getOrCreateLocalsScope(args.frameId);
1✔
1992

1993
            let localScope: DebugProtocol.Scope = {
1✔
1994
                name: 'Local',
1995
                variablesReference: v.variablesReference,
1996
                // Flag the locals scope as expensive if the client asked that it be loaded lazily
1997
                expensive: this.launchConfiguration.deferScopeLoading,
1998
                presentationHint: 'locals'
1999
            };
2000

2001
            const frame = this.rokuAdapter.getStackFrameById(args.frameId);
1✔
2002
            if (frame) {
×
2003
                const scopeRange = await this.projectManager.getScopeRange(frame.filePath, { line: frame.lineNumber - 1, character: 0 });
×
2004

2005
                if (scopeRange) {
×
2006
                    localScope.line = this.toClientLine(scopeRange.start.line - 1);
×
2007
                    localScope.column = this.toClientColumn(scopeRange.start.column);
×
2008
                    localScope.endLine = this.toClientLine(scopeRange.end.line - 1);
×
2009
                    localScope.endColumn = this.toClientColumn(scopeRange.end.column);
×
2010
                }
2011
            }
2012

2013
            scopes.push(localScope);
×
2014

2015
            // create the registry scope
2016
            let registryRefId = this.getEvaluateRefId('$$registry', Infinity);
×
2017
            scopes.push(<DebugProtocol.Scope>{
×
2018
                name: 'Registry',
2019
                variablesReference: registryRefId,
2020
                expensive: true
2021
            });
2022

2023
            this.variables[registryRefId] = {
×
2024
                variablesReference: registryRefId,
2025
                name: 'Registry',
2026
                value: '',
2027
                type: '$$Registry',
2028
                isScope: true,
2029
                childVariables: []
2030
            };
2031

2032
            response.body = {
×
2033
                scopes: scopes
2034
            };
2035
            logger.debug('send response', { response });
×
2036
            this.sendResponse(response);
×
2037
            logger.info('end');
×
2038
        } catch (error) {
2039
            logger.error('Error getting scopes', { error, args });
1✔
2040
        }
2041
    }
2042

2043
    /**
2044
     * Get the locals scope container for a frame, creating an (unpopulated) one if it doesn't exist yet.
2045
     * The child variables are filled in lazily by `populateScopeVariables`.
2046
     */
2047
    private getOrCreateLocalsScope(frameId: number): AugmentedVariable {
2048
        const refId = this.getEvaluateRefId('$$locals', frameId);
5✔
2049
        if (!this.variables[refId]) {
5✔
2050
            this.variables[refId] = {
1✔
2051
                variablesReference: refId,
2052
                name: 'Locals',
2053
                value: '',
2054
                type: '$$Locals',
2055
                frameId: frameId,
2056
                isScope: true,
2057
                childVariables: []
2058
            };
2059
        }
2060
        return this.variables[refId];
5✔
2061
    }
2062

2063
    protected async continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments) {
2064
        //if we have a compile error, we should shut down
2065
        if (this.compileError) {
×
2066
            this.sendResponse(response);
×
2067
            await this.shutdown();
×
2068
            return;
×
2069
        }
2070

2071
        this.logger.log('continueRequest');
×
2072
        await this.setTransientsToInvalid(); // call before clearState
×
2073
        this.clearState();
×
2074

2075
        // The debug session ends after the next line. Do not put new work after this line.
2076
        await this.rokuAdapter.continue();
×
2077
        this.sendResponse(response);
×
2078
    }
2079

2080
    protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
2081
        this.logger.log('pauseRequest');
×
2082

2083
        //if we have a compile error, we should shut down
2084
        if (this.compileError) {
×
2085
            this.sendResponse(response);
×
2086
            await this.shutdown();
×
2087
            return;
×
2088
        }
2089

2090
        await this.rokuAdapter.pause();
×
2091
        this.sendResponse(response);
×
2092
    }
2093

2094
    protected reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse, args: DebugProtocol.ReverseContinueArguments) {
2095
        this.logger.log('reverseContinueRequest');
×
2096
        this.sendResponse(response);
×
2097
    }
2098

2099
    /**
2100
     * Clicked the "Step Over" button
2101
     * @param response
2102
     * @param args
2103
     */
2104
    protected async nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) {
2105
        this.logger.log('[nextRequest] begin');
×
2106

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

2114
        await this.setTransientsToInvalid(); // call before clearState
×
2115
        this.clearState();
×
2116

2117
        // The debug session ends after the next line. Do not put new work after this line.
2118
        try {
×
2119
            await this.rokuAdapter.stepOver(args.threadId);
×
2120
            this.logger.info('[nextRequest] end');
×
2121
        } catch (error) {
2122
            this.logger.error(`[nextRequest] Error running '${BrightScriptDebugSession.prototype.nextRequest.name}()'`, error);
×
2123
        }
2124
        this.sendResponse(response);
×
2125
    }
2126

2127
    protected async stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
2128
        this.logger.log('[stepInRequest]');
×
2129

2130
        //if we have a compile error, we should shut down
2131
        if (this.compileError) {
×
2132
            this.sendResponse(response);
×
2133
            await this.shutdown();
×
2134
            return;
×
2135
        }
2136

2137
        await this.setTransientsToInvalid(); // call before clearState
×
2138
        this.clearState();
×
2139
        // The debug session ends after the next line. Do not put new work after this line.
2140
        await this.rokuAdapter.stepInto(args.threadId);
×
2141
        this.sendResponse(response);
×
2142
        this.logger.info('[stepInRequest] end');
×
2143
    }
2144

2145
    protected async stepOutRequest(response: DebugProtocol.StepOutResponse, args: DebugProtocol.StepOutArguments) {
2146
        this.logger.log('[stepOutRequest] begin');
×
2147

2148
        //if we have a compile error, we should shut down
2149
        if (this.compileError) {
×
2150
            this.sendResponse(response);
×
2151
            await this.shutdown();
×
2152
            return;
×
2153
        }
2154

2155
        await this.setTransientsToInvalid(); // call before clearState
×
2156
        this.clearState();
×
2157

2158
        // The debug session ends after the next line. Do not put new work after this line.
2159
        await this.rokuAdapter.stepOut(args.threadId);
×
2160
        this.sendResponse(response);
×
2161
        this.logger.info('[stepOutRequest] end');
×
2162
    }
2163

2164
    protected stepBackRequest(response: DebugProtocol.StepBackResponse, args: DebugProtocol.StepBackArguments) {
2165
        this.logger.log('[stepBackRequest] begin');
×
2166
        this.sendResponse(response);
×
2167
        this.logger.info('[stepBackRequest] end');
×
2168
    }
2169

2170
    public async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) {
2171
        const logger = this.logger.createLogger('[variablesRequest]');
4✔
2172
        let sendInvalidatedEvent = false;
4✔
2173
        let frameId: number = null;
4✔
2174
        try {
4✔
2175
            logger.log('begin', { args });
4✔
2176

2177
            //ensure the rokuAdapter is loaded
2178
            await this.getRokuAdapter();
4✔
2179

2180
            let updatedVariables: AugmentedVariable[] = [];
4✔
2181
            //wait for any `evaluate` commands to finish so we have a higher likely hood of being at a debugger prompt
2182
            await this.evaluateRequestPromise;
4✔
2183
            if (this.rokuAdapter?.isAtDebuggerPrompt !== true) {
4!
2184
                logger.log('Skipped getting variables because the RokuAdapter is not accepting input at this time');
×
2185
                response.success = false;
×
2186
                response.message = 'Debug session is not paused';
×
2187
                return this.sendResponse(response);
×
2188
            }
2189

2190
            //find the variable with this reference
2191
            let v = this.variables[args.variablesReference];
4✔
2192
            if (!v) {
4!
2193
                response.success = false;
×
2194
                response.message = `Variable reference has expired`;
×
2195
                return this.sendResponse(response);
×
2196
            }
2197
            logger.log('variable', v);
4✔
2198

2199
            // Populate scope level values if needed
2200
            if (v.isScope) {
4✔
2201
                await this.populateScopeVariables(v, args);
2✔
2202
            }
2203

2204
            //query for child vars if we haven't done it yet or DAP is asking to resolve a lazy variable
2205
            if (v.childVariables.length === 0 || v.isResolved) {
4!
2206
                let tempVar: AugmentedVariable;
2207
                if (!v.isResolved) {
×
2208
                    // Evaluate the variable
2209
                    try {
×
2210
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: v.evaluateName, frameId: v.frameId }, util.getVariablePath(v.evaluateName));
×
2211
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, v.frameId);
×
2212
                        tempVar = await this.getVariableFromResult(result, v.frameId);
×
2213
                        tempVar.frameId = v.frameId;
×
2214
                        // Determine if the variable has changed
2215
                        sendInvalidatedEvent = v.type !== tempVar.type || v.indexedVariables !== tempVar.indexedVariables;
×
2216
                    } catch (error) {
2217
                        logger.error('Error getting variables', error);
×
2218
                        tempVar = new Variable('Error', `❌ Error: ${error.message}`);
×
2219
                        tempVar.type = '';
×
2220
                        tempVar.childVariables = [];
×
2221
                        sendInvalidatedEvent = true;
×
2222
                        response.success = false;
×
2223
                        response.message = error.message;
×
2224
                    }
2225

2226
                    // Merge the resulting updates together
2227
                    v.childVariables = tempVar.childVariables;
×
2228
                    v.value = tempVar.value;
×
2229
                    v.type = tempVar.type;
×
2230
                    v.indexedVariables = tempVar.indexedVariables;
×
2231
                    v.namedVariables = tempVar.namedVariables;
×
2232
                }
2233
                frameId = v.frameId;
×
2234

2235
                if (v?.presentationHint?.lazy || v.isResolved) {
×
2236
                    // If this was a lazy variable we need to respond with the updated variable and not the children
2237
                    if (v.isResolved && v.childVariables.length > 0) {
×
2238
                        updatedVariables = v.childVariables;
×
2239
                    } else {
2240
                        updatedVariables = [v];
×
2241
                    }
2242
                    v.isResolved = true;
×
2243
                } else {
2244
                    updatedVariables = v.childVariables;
×
2245
                }
2246

2247
                // If the variable has no children, set the reference to 0
2248
                // so it does not look expandable in the Ui
2249
                if (v.childVariables.length === 0) {
×
2250
                    v.variablesReference = 0;
×
2251
                }
2252

2253
                // If the variable was resolve in the past we may not have fetched a new temp var
2254
                tempVar ??= v;
×
2255
                if (v?.presentationHint) {
×
2256
                    v.presentationHint.lazy = tempVar.presentationHint?.lazy;
×
2257
                } else {
2258
                    v.presentationHint = tempVar.presentationHint;
×
2259
                }
2260

2261
            } else {
2262
                updatedVariables = v.childVariables;
4✔
2263
            }
2264

2265
            // Only send the updated variables if we are not going to trigger an invalidated event.
2266
            // This is to prevent the UI from updating twice and makes the experience much smoother to the end user.
2267
            response.body = {
4✔
2268
                variables: this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2269
                // TODO: Re-enable this when we can send the correct variables based on the initial inspect context
2270
                // variables: sendInvalidatedEvent ? [] : this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
2271
            };
2272
        } catch (error) {
2273
            logger.error('Error during variablesRequest', error, { args });
×
2274
            response.success = false;
×
2275
            response.message = error?.message ?? 'Error during variablesRequest';
×
2276
        } finally {
2277
            logger.info('end', { response });
4✔
2278
        }
2279
        this.sendResponse(response);
4✔
2280
        if (sendInvalidatedEvent) {
4!
2281
            this.debounceSendInvalidatedEvent(null, frameId);
×
2282
        }
2283
    }
2284

2285
    private debounceSendInvalidatedEvent = debounce((threadId: number, frameId: number) => {
224✔
2286
        this.sendInvalidatedEvent(threadId, frameId);
×
2287
    }, 50);
2288

2289

2290
    private filterVariablesUpdates(updatedVariables: Array<AugmentedVariable>, args: DebugProtocol.VariablesArguments, v: DebugProtocol.Variable): Array<AugmentedVariable> {
2291
        if (!updatedVariables || !v) {
4!
2292
            return [];
×
2293
        }
2294

2295
        let start = args.start ?? 0;
4!
2296

2297
        //if the variable is an array, send only the requested range
2298
        if (Array.isArray(updatedVariables) && args.filter === 'indexed') {
4!
2299
            //only send the variable range requested by the debugger
2300
            if (!args.count) {
×
2301
                updatedVariables = updatedVariables.slice(0, v.indexedVariables);
×
2302
            } else {
2303
                updatedVariables = updatedVariables.slice(start, start + args.count);
×
2304
            }
2305
        }
2306

2307
        if (Array.isArray(updatedVariables) && args.filter === 'named') {
4!
2308
            // We currently do not support named variable paging so we always send all named variables
2309
            updatedVariables = updatedVariables.slice(v.indexedVariables);
4✔
2310
        }
2311

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

2315
        if (this.launchConfiguration.showHiddenVariables !== true) {
4✔
2316
            filteredUpdatedVariables = filteredUpdatedVariables.filter((child: AugmentedVariable) => {
2✔
2317
                //A transient variable that we show when there is a value
2318
                if (child.name === '__brs_err__' && child.type !== VariableType.Uninitialized) {
4!
2319
                    return true;
×
2320
                } else if (util.isTransientVariable(child.name)) {
4!
2321
                    return false;
×
2322
                } else {
2323
                    return true;
4✔
2324
                }
2325
            });
2326
        }
2327

2328
        return filteredUpdatedVariables;
4✔
2329
    }
2330

2331
    /**
2332
     * Takes a scope variable and populates its child variables based on the scope type and the current adapter type.
2333
     * @param v scope variable to populate
2334
     * @param args
2335
     */
2336
    private async populateScopeVariables(v: AugmentedVariable, args: DebugProtocol.VariablesArguments) {
2337
        if (v.childVariables.length > 0) {
4✔
2338
            // Already populated
2339
            return;
3✔
2340
        }
2341

2342
        let tempVar: AugmentedVariable;
2343
        try {
1✔
2344
            if (v.type === '$$Locals') {
1!
2345
                if (this.rokuAdapter.isDebugProtocolAdapter()) {
1!
2346
                    let result = await this.rokuAdapter.getLocalVariables(v.frameId);
1✔
2347
                    tempVar = await this.getVariableFromResult(result, v.frameId);
1✔
2348
                } else if (this.rokuAdapter.isTelnetAdapter()) {
×
2349
                    // NOTE: Legacy telnet support
2350
                    let variables: AugmentedVariable[] = [];
×
2351
                    const varNames = await this.rokuAdapter.getScopeVariables();
×
2352

2353
                    // Fetch each variable individually
2354
                    for (const varName of varNames) {
×
2355
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: varName, frameId: -1 }, util.getVariablePath(varName));
×
2356
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, -1);
×
2357
                        let tempLocalsVar = await this.getVariableFromResult(result, -1);
×
2358
                        variables.push(tempLocalsVar);
×
2359
                    }
2360
                    tempVar = {
×
2361
                        ...v,
2362
                        childVariables: variables,
2363
                        namedVariables: variables.length,
2364
                        indexedVariables: 0
2365
                    };
2366
                }
2367

2368
                // Merge the resulting updates together onto the original variable
2369
                v.childVariables = tempVar.childVariables;
1✔
2370
                v.namedVariables = tempVar.namedVariables;
1✔
2371
                v.indexedVariables = tempVar.indexedVariables;
1✔
2372
            } else if (v.type === '$$Registry') {
×
2373
                // This is a special scope variable used to load registry data via an ECP call
2374
                // Send the registry ECP call for the `dev` app as side loaded apps are always `dev`
NEW
2375
                await populateVariableFromRegistryEcp({ remotePort: this.launchConfiguration.remotePort, device: this.launchConfiguration.device, appId: 'dev' }, v, this.variables, this.getEvaluateRefId.bind(this));
×
2376
            }
2377
        } catch (error) {
2378
            logger.error(`Error getting variables for scope ${v.type}`, error);
×
2379
            tempVar = {
×
2380
                name: '',
2381
                value: `❌ Error: ${error.message}`,
2382
                variablesReference: 0,
2383
                childVariables: []
2384
            };
2385
            v.childVariables = [tempVar];
×
2386
            v.namedVariables = 1;
×
2387
            v.indexedVariables = 0;
×
2388
        }
2389

2390
        // Mark the scope as resolved so we don't re-fetch the variables
2391
        v.isResolved = true;
1✔
2392

2393
        // If the scope has no children, add a single child to indicate there are no values
2394
        if (v.childVariables.length === 0) {
1!
2395
            tempVar = {
×
2396
                name: '',
2397
                value: `No values for scope '${v.name}'`,
2398
                variablesReference: 0,
2399
                childVariables: []
2400
            };
2401
            v.childVariables = [tempVar];
×
2402
            v.namedVariables = 1;
×
2403
            v.indexedVariables = 0;
×
2404
        }
2405
    }
2406

2407
    private evaluateRequestPromise = Promise.resolve();
224✔
2408
    private evaluateVarIndexByFrameId = new Map<number, number>();
224✔
2409

2410
    private getNextVarIndex(frameId: number): number {
2411
        if (!this.evaluateVarIndexByFrameId.has(frameId)) {
6✔
2412
            this.evaluateVarIndexByFrameId.set(frameId, 0);
5✔
2413
        }
2414
        let value = this.evaluateVarIndexByFrameId.get(frameId);
6✔
2415
        this.evaluateVarIndexByFrameId.set(frameId, value + 1);
6✔
2416
        return value;
6✔
2417
    }
2418

2419
    public async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) {
2420
        //ensure the rokuAdapter is loaded
2421
        await this.getRokuAdapter();
15✔
2422

2423
        let deferred = defer<void>();
15✔
2424
        if (args.context === 'repl' && this.rokuAdapter.isTelnetAdapter() && args.expression.trim().startsWith('>')) {
15!
2425
            this.clearState();
×
2426
            this.rokuAdapter.clearCache();
×
2427
            const expression = args.expression.replace(/^\s*>\s*/, '');
×
2428
            this.logger.log('Sending raw telnet command...I sure hope you know what you\'re doing', { expression });
×
2429
            this.rokuAdapter.requestPipeline.client.write(`${expression}\r\n`);
×
2430
            this.sendResponse(response);
×
2431
            return deferred.promise;
×
2432
        }
2433

2434
        try {
15✔
2435
            this.evaluateRequestPromise = this.evaluateRequestPromise.then(() => {
15✔
2436
                return deferred.promise;
15✔
2437
            });
2438

2439
            //fix vscode hover bug that excludes closing quotemark sometimes.
2440
            if (args.context === 'hover') {
15✔
2441
                args.expression = util.ensureClosingQuote(args.expression);
4✔
2442
            }
2443

2444
            if (!this.rokuAdapter.isAtDebuggerPrompt) {
15✔
2445
                let message = 'Skipped evaluate request because RokuAdapter is not accepting requests at this time';
1✔
2446
                if (args.context === 'repl') {
1!
2447
                    this.sendEvent(new OutputEvent(message, 'stderr'));
1✔
2448
                    response.body = {
1✔
2449
                        result: 'invalid',
2450
                        variablesReference: 0
2451
                    };
2452
                } else {
2453
                    throw new Error(message);
×
2454
                }
2455

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

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

2463
                //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`
2464
                if (variablePath) {
14✔
2465
                    let refId = this.getEvaluateRefId(evalArgs.expression, evalArgs.frameId);
11✔
2466
                    let v: AugmentedVariable;
2467
                    //if we already looked this item up, return it
2468
                    if (this.variables[refId]) {
11✔
2469
                        v = this.variables[refId];
1✔
2470
                    } else {
2471
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, evalArgs.frameId);
10✔
2472
                        if (!result) {
10!
2473
                            throw new Error('Error: unable to evaluate expression');
×
2474
                        }
2475

2476
                        v = await this.getVariableFromResult(result, evalArgs.frameId);
10✔
2477
                        //TODO - testing something, remove later
2478
                        // eslint-disable-next-line camelcase
2479
                        v.request_seq = response.request_seq;
10✔
2480
                        v.frameId = evalArgs.frameId;
10✔
2481
                    }
2482
                    response.body = {
11✔
2483
                        result: v.value,
2484
                        type: v.type,
2485
                        variablesReference: v.variablesReference,
2486
                        namedVariables: v.namedVariables || 0,
21✔
2487
                        indexedVariables: v.indexedVariables || 0
21✔
2488
                    };
2489

2490
                    //run an `evaluate` call
2491
                } else {
2492
                    let commandResults = await this.rokuAdapter.evaluate(evalArgs.expression, evalArgs.frameId);
3✔
2493

2494
                    commandResults.message = util.trimDebugPrompt(commandResults.message);
3✔
2495
                    if (args.context === 'repl') {
3!
2496
                        // Clear variable cache since this action could have side-effects
2497
                        // Only do this for REPL requests as hovers and watches should not clear the cache
2498
                        this.clearState();
3✔
2499
                        this.sendInvalidatedEvent(null, evalArgs.frameId);
3✔
2500
                    }
2501

2502
                    // If the adapter captured output (probably only telnet), log the results
2503
                    if (typeof commandResults.message === 'string') {
3✔
2504
                        this.logger.debug('evaluateRequest', { commandResults });
1✔
2505
                        if (args.context === 'repl') {
1!
2506
                            // If the command was a repl command, send the output to the debug console for the developer as well
2507
                            // We limit this to repl only so you don't get extra logs when hovering over variables ro running watches
2508
                            this.sendEvent(new OutputEvent(commandResults.message, commandResults.type === 'error' ? 'stderr' : 'stdio'));
1!
2509
                        }
2510
                    }
2511

2512
                    if (this.enableDebugProtocol || (typeof commandResults.message !== 'string')) {
3✔
2513
                        response.body = {
2✔
2514
                            result: 'invalid',
2515
                            variablesReference: 0
2516
                        };
2517
                    } else {
2518
                        response.body = {
1✔
2519
                            result: commandResults.message === '\r\n' ? 'invalid' : commandResults.message,
1!
2520
                            variablesReference: 0
2521
                        };
2522
                    }
2523
                }
2524
            }
2525
        } catch (error) {
2526
            this.logger.error('Error during variables request', error);
×
2527
            response.success = false;
×
2528
            response.message = error?.message ?? error;
×
2529
        }
2530
        try {
15✔
2531
            this.sendResponse(response);
15✔
2532
        } catch { }
2533
        deferred.resolve();
15✔
2534
    }
2535

2536
    private async evaluateExpressionToTempVar(args: DebugProtocol.EvaluateArguments, variablePath: string[]): Promise<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }> {
2537
        let returnVal = { evalArgs: args, variablePath };
19✔
2538
        if (!variablePath && util.isAssignableExpression(args.expression)) {
19✔
2539
            let varIndex = this.getNextVarIndex(args.frameId);
6✔
2540
            let arrayVarName = this.tempVarPrefix + 'eval';
6✔
2541
            let command = '';
6✔
2542
            if (varIndex === 0) {
6✔
2543
                await this.rokuAdapter.evaluate(`if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`, args.frameId);
5✔
2544
            }
2545
            let statement = `${arrayVarName}[${varIndex}] = ${args.expression}`;
6✔
2546
            returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
6✔
2547
            command += statement;
6✔
2548
            let commandResults = await this.rokuAdapter.evaluate(command, args.frameId);
6✔
2549
            if (commandResults.type === 'error') {
6!
2550
                throw new Error(commandResults.message);
×
2551
            }
2552
            returnVal.variablePath = [arrayVarName, varIndex.toString()];
6✔
2553
        }
2554
        return returnVal;
19✔
2555
    }
2556

2557
    private async bulkEvaluateExpressionToTempVar(frameId: number, argsArray: Array<DebugProtocol.EvaluateArguments>, variablePathArray: Array<string[]>): Promise<{ evaluations: Array<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }>; bulkVarName: string }> {
2558
        let results = {
×
2559
            evaluations: [],
2560
            bulkVarName: ''
2561
        };
2562
        let storedVariables = [];
×
2563
        let command = '';
×
2564
        for (let i = 0; i < argsArray.length; i++) {
×
2565
            let args = argsArray[i];
×
2566
            let variablePath = variablePathArray[i];
×
2567
            let returnVal = { evalArgs: args, variablePath };
×
2568
            if (!variablePath && util.isAssignableExpression(args.expression)) {
×
2569
                let varIndex = this.getNextVarIndex(frameId);
×
2570
                let arrayVarName = this.tempVarPrefix + 'eval';
×
2571
                if (varIndex === 0) {
×
2572
                    command += `if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`;
×
2573
                }
2574
                let statement = `${arrayVarName}[${varIndex}] = ${args.expression}\n`;
×
2575
                returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
×
2576
                command += statement;
×
2577

2578
                storedVariables.push(`${arrayVarName}[${varIndex}]`);
×
2579
                returnVal.variablePath = [arrayVarName, varIndex.toString()];
×
2580
            }
2581

2582
            results.evaluations[i] = returnVal;
×
2583
        }
2584

2585
        if (command) {
×
2586

2587
            // create a bulk container for the command results
2588
            let varIndex = this.getNextVarIndex(frameId);
×
2589
            let arrayVarName = this.tempVarPrefix + 'eval';
×
2590
            let bulkContainerStatement = `${arrayVarName}[${varIndex}] = [\n`;
×
2591
            for (let storedVariable of storedVariables) {
×
2592
                bulkContainerStatement += `${storedVariable},\n`;
×
2593
            }
2594
            bulkContainerStatement += `]`;
×
2595

2596
            command += bulkContainerStatement;
×
2597

2598
            results.bulkVarName = `${arrayVarName}[${varIndex}]`;
×
2599

2600
            let commandResults = await this.rokuAdapter.evaluate(command, frameId);
×
2601
            if (commandResults.type === 'error') {
×
2602
                throw new Error(commandResults.message);
×
2603
            }
2604
        }
2605

2606
        return results;
×
2607
    }
2608

2609
    protected async completionsRequest(response: DebugProtocol.CompletionsResponse, args: DebugProtocol.CompletionsArguments, request?: DebugProtocol.Request) {
2610
        this.logger.log('completionsRequest', args, request);
20✔
2611
        // this.sendEvent(new LogOutputEvent(`completionsRequest: ${args.text}`));
2612
        // this.sendEvent(new OutputEvent(`completionsRequest: ${args.text}\n`, 'stderr'));
2613

2614
        try {
20✔
2615
            let supplyLocalScopeCompletions = false;
20✔
2616

2617
            let closestCompletionDetails = this.getClosestCompletionDetails(args);
20✔
2618

2619
            if (!closestCompletionDetails) {
20!
2620
                // If the cursor is not at the end of the line, then we should not supply completions at this time
2621
                response.body = {
×
2622
                    targets: []
2623
                };
2624
                return this.sendResponse(response);
×
2625
            }
2626
            let completions = new Map<string, DebugProtocol.CompletionItem>();
20✔
2627

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

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

2645
            // Get the completions if the variable path was valid
2646
            if (parentVariablePath) {
20!
2647

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

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

2656
                // provide completions for the parent variable if one was found
2657
                if (parentVariable) {
20✔
2658
                    // arrays and lists are integer-indexed; their `[N]` elements aren't valid `.` or `["..."]`
2659
                    // completions (you can't write `arr.[0]` or `arr["0"]`), so don't offer them as members.
2660
                    // Only the interface methods below (Count, Push, ...) apply to these containers.
2661
                    const isIntegerIndexed = parentVariable.type === VariableType.Array ||
19✔
2662
                        parentVariable.type === VariableType.List ||
2663
                        parentVariable.type === 'roXMLList' ||
2664
                        parentVariable.type === 'roByteArray';
2665

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

2671
                    for (let v of possibleFieldsAndMethods) {
19✔
2672
                        // Default completion type should be variable
2673
                        let completionType: DebugProtocol.CompletionItemType = 'variable';
21✔
2674
                        if (!supplyLocalScopeCompletions) {
21✔
2675
                            // We are not supplying local scope completions, so we need to determine the completion type relative to the parent variable
2676
                            if (parentVariable.type === 'roSGNode' || parentVariable.type === VariableType.AssociativeArray || parentVariable.type === VariableType.Object) {
17!
2677
                                completionType = 'field';
17✔
2678
                            }
2679

2680
                            switch (v.type) {
17!
2681
                                case VariableType.Function:
2682
                                case VariableType.Subroutine:
2683
                                    completionType = 'method';
×
2684
                                    break;
×
2685
                                default:
2686
                                    break;
17✔
2687
                            }
2688
                        }
2689

2690
                        const completionItem: DebugProtocol.CompletionItem = {
21✔
2691
                            label: v.name,
2692
                            type: completionType,
2693
                            //rank a variable's own members/locals above everything else
2694
                            sortText: `${CompletionSortTier.Member}${v.name}`
2695
                        };
2696
                        if (stringKeyClosing !== undefined) {
21✔
2697
                            // Insert the key and close the access, ex: `firstName"]` (the replacement range is applied
2698
                            // below). A `"` inside the key is escaped as `""` so the inserted string literal stays valid
2699
                            // (ex: a key of `a"b` is inserted as `a""b`).
2700
                            completionItem.text = `${v.name.replace(/"/g, '""')}${stringKeyClosing}`;
4✔
2701
                        } else if (!supplyLocalScopeCompletions && precededByDot && !/^[a-z_][a-z0-9_]*$/i.test(v.name)) {
17✔
2702
                            // The key can't be dot-accessed (ex: it has a space or a quote), so rewrite the access as
2703
                            // bracket notation and consume the `.` before the cursor: `m.` -> `m["my key"]`. A `"` in
2704
                            // the key is escaped as `""` so the inserted string literal stays valid.
2705
                            completionItem.text = `["${v.name.replace(/"/g, '""')}"]`;
3✔
2706
                            completionItem.start = replaceRange.start - 1;
3✔
2707
                            completionItem.length = replaceRange.length + 1;
3✔
2708
                        }
2709
                        completions.set(`${completionType}-${v.name}`, completionItem);
21✔
2710
                    }
2711

2712
                    // Interface methods aren't valid string keys, so skip them when completing a string key
2713
                    if (stringKeyClosing === undefined) {
19✔
2714
                        let parentComponentType = this.debuggerVarTypeToRoType(parentVariable.type).toLowerCase();
15✔
2715
                        //assemble a list of all methods on the parent component
2716
                        const methods = [
15✔
2717
                            //if the parent variable is an actual interface (if applicable) Ex: `ifString` or `ifArray`
2718
                            ...interfaces[parentComponentType as 'ifappinfo']?.methods ?? [],
90!
2719
                            //interfaces from component of this name (if applicable) Ex: `roSGNode` or `roDateTime`
2720
                            ...components[parentComponentType as 'roappinfo']?.interfaces.map((i) => interfaces[i.name.toLowerCase() as 'ifappinfo']?.methods) ?? [],
29!
2721
                            // Add parent event function completions (if applicable) Ex: `roSGNodeEvent` or `roDeviceInfoEvent`
2722
                            ...events[parentComponentType as 'roappmemorymonitorevent']?.methods ?? []
90!
2723
                        ].flat();
2724

2725
                        // Based on the results of interface, component, and event looks up, add all the methods to the completions
2726
                        for (const method of methods) {
15✔
2727
                            completions.set(`method-${method.name}`, {
185✔
2728
                                label: method.name,
2729
                                type: 'method',
2730
                                detail: method.description ?? '',
555!
2731
                                sortText: `${CompletionSortTier.Method}${method.name}`
2732
                            });
2733
                        }
2734
                    }
2735

2736
                    // Add the global functions to the completions results
2737
                    if (supplyLocalScopeCompletions) {
19✔
2738
                        for (let globalCallable of globalCallables) {
4✔
2739
                            completions.set(`function-${globalCallable.name.toLocaleLowerCase()}`, {
308✔
2740
                                label: globalCallable.name,
2741
                                type: 'function',
2742
                                detail: globalCallable.shortDescription ?? globalCallable.documentation ?? '',
1,848!
2743
                                sortText: `${CompletionSortTier.Global}${globalCallable.name}`
2744
                            });
2745
                        }
2746

2747
                        const frame = this.rokuAdapter.getStackFrameById(args.frameId);
4✔
2748

2749
                        try {
4✔
2750
                            let scopeFunctions = await this.projectManager.getScopeFunctionsForFile(frame.filePath as string);
4✔
2751
                            for (let scopeFunction of scopeFunctions) {
4✔
2752
                                if (!completions.has(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`)) {
1!
2753
                                    completions.set(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`, {
1✔
2754
                                        label: scopeFunction.name,
2755
                                        type: scopeFunction.completionItemKind,
2756
                                        sortText: `${CompletionSortTier.ScopeFunction}${scopeFunction.name}`
2757
                                    });
2758
                                }
2759
                            }
2760
                        } catch (e) {
2761
                            this.logger.warn('Could not build list of scope functions for file', e);
×
2762
                        }
2763
                    }
2764
                }
2765
            }
2766

2767
            // Apply the default replacement span to every completion that didn't already set its own (bracket
2768
            // rewrites above use an extended range that also consumes the preceding `.`).
2769
            for (const target of completions.values()) {
20✔
2770
                if (target.start === undefined) {
499✔
2771
                    target.start = replaceRange.start;
496✔
2772
                    target.length = replaceRange.length;
496✔
2773
                }
2774
            }
2775

2776
            response.body = {
20✔
2777
                targets: [...completions.values()]
2778
            };
2779
        } catch (error) {
2780
            // this.sendEvent(new LogOutputEvent(`text: ${args.text} | ${error}`));
2781
            // this.sendEvent(new OutputEvent(`text: ${args.text} | ${error}\n`, 'stderr'));
2782
            this.logger.error('Error during completionsRequest', error, { args });
×
2783
        }
2784
        this.sendResponse(response);
20✔
2785
    }
2786

2787
    /**
2788
     * Gets the closest completion details the incoming completion request.
2789
     */
2790
    private getClosestCompletionDetails(args: DebugProtocol.CompletionsArguments): { parentVariablePath: string[]; stringKeyClosing?: string } {
2791
        const incomingText = args.text;
69✔
2792
        const lines = incomingText.split('\n');
69✔
2793
        let lineNumber = this.toDebuggerLine(args.line, 0);
69✔
2794
        let column = this.toDebuggerColumn(args.column);
69✔
2795

2796
        const targetLine = lines[lineNumber] ?? '';
69!
2797

2798
        const cursorIndex = column - 1;
69✔
2799
        const variableChars = /[a-z0-9_\.]/i;
69✔
2800

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

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

2816
        const openBracket = this.findUnclosedOpener(targetLine, column);
67✔
2817
        if (openBracket?.char === '[') {
67✔
2818
            // find the opening quote (skipping any whitespace after the `[`)
2819
            let quoteIndex = openBracket.index + 1;
12✔
2820
            while (targetLine[quoteIndex] === ' ' || targetLine[quoteIndex] === '\t') {
12✔
2821
                quoteIndex++;
×
2822
            }
2823
            const quote = targetLine[quoteIndex];
12✔
2824
            if (quote === '"' || quote === `'`) {
12✔
2825
                // The user is typing a string key, so complete the keys of the expression before the `[`
2826
                endColumn = openBracket.index;
8✔
2827
                isMemberAccess = true;
8✔
2828
                // close the string and bracket only when there is nothing meaningful after the cursor
2829
                stringKeyClosing = targetLine.slice(column).trim() === '' ? `${quote}]` : '';
8✔
2830
            }
2831
        }
2832

2833
        // Walk backwards from `endColumn` to find the start of the variable path, stepping over balanced
2834
        // `[...]` index access so paths like `arr[0].name` are captured as a whole.
2835
        let startIndex = endColumn - 1;
67✔
2836
        let bracketDepth = 0;
67✔
2837
        while (startIndex >= 0) {
67✔
2838
            const char = targetLine[startIndex];
438✔
2839
            if (char === ']') {
438✔
2840
                bracketDepth++;
10✔
2841
            } else if (char === '[') {
428✔
2842
                if (bracketDepth === 0) {
12✔
2843
                    // An unbalanced `[` means we hit the start of an index/key being typed; stop here.
2844
                    break;
2✔
2845
                }
2846
                bracketDepth--;
10✔
2847
            } else if (bracketDepth === 0 && (char === undefined || !variableChars.test(char))) {
416✔
2848
                break;
23✔
2849
            }
2850
            startIndex--;
413✔
2851
        }
2852

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

2855
        // Attempted dot access on something unexpected.
2856
        // Example: `getPerson().name` where `getPerson()` is not a valid variable path,
2857
        // which leaves `.name` as the variable path string.
2858
        if (variablePathString.startsWith('.')) {
67✔
2859
            return undefined;
2✔
2860
        }
2861

2862
        if (variablePathString.endsWith('.')) {
65✔
2863
            isMemberAccess = true;
25✔
2864
        }
2865

2866
        // Get the variable path from the text
2867
        let variablePath: string[];
2868
        if (!variablePathString.trim()) {
65✔
2869
            // The text was empty so assume via '' that we are looking up the local scope variables and global functions
2870
            variablePath = [''];
10✔
2871
        } else if (variablePathString.endsWith('.')) {
55✔
2872
            // supplied text ends with a period, so strip it off to create a valid variable path
2873
            variablePath = util.getVariablePath(variablePathString.slice(0, -1));
25✔
2874
        } else {
2875
            variablePath = util.getVariablePath(variablePathString);
30✔
2876
        }
2877

2878
        // the target string is not a valid variable path
2879
        if (!variablePath) {
65✔
2880
            return undefined;
2✔
2881
        }
2882

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

2887
        // An empty parent path means we are looking up the local scope variables and global functions
2888
        if (parentVariablePath.length === 0) {
63✔
2889
            parentVariablePath = [''];
17✔
2890
        }
2891

2892
        const result: { parentVariablePath: string[]; stringKeyClosing?: string } = { parentVariablePath: parentVariablePath };
63✔
2893
        // Only attach the string-key context when we actually resolved a parent object to complete keys on
2894
        if (stringKeyClosing !== undefined && !(parentVariablePath.length === 1 && parentVariablePath[0] === '')) {
63✔
2895
            result.stringKeyClosing = stringKeyClosing;
8✔
2896
        }
2897
        return result;
63✔
2898
    }
2899

2900
    /**
2901
     * Compute the span of input text that a completion replaces: the run of identifier characters
2902
     * immediately before the cursor. This lets the client filter the list correctly as the user keeps
2903
     * typing past the first character.
2904
     *
2905
     * `start` is a 0-based offset into the line, NOT a client column. Per the Debug Adapter Protocol,
2906
     * `CompletionItem.start` is measured in UTF-16 code units and the client maps it to a position
2907
     * itself, so it must not be run through `toClientColumn` (unlike stack-frame, breakpoint, and scope
2908
     * positions). Our debugger column base is already 0-based, so the internal offset is sent as-is.
2909
     */
2910
    private getCompletionReplaceRange(args: DebugProtocol.CompletionsArguments): { start: number; length: number } {
2911
        const lines = args.text.split('\n');
20✔
2912
        const lineNumber = this.toDebuggerLine(args.line, 0);
20✔
2913
        const cursorOffset = this.toDebuggerColumn(args.column);
20✔
2914
        const targetLine = lines[lineNumber] ?? '';
20!
2915

2916
        const identifierChars = /[a-z0-9_]/i;
20✔
2917
        let wordStart = cursorOffset;
20✔
2918
        while (wordStart > 0 && identifierChars.test(targetLine[wordStart - 1])) {
20✔
2919
            wordStart--;
7✔
2920
        }
2921
        return {
20✔
2922
            start: wordStart,
2923
            length: cursorOffset - wordStart
2924
        };
2925
    }
2926

2927
    /**
2928
     * Scan backwards from `column` to find the nearest opening bracket (`(`, `[`, or `{`) that has not
2929
     * been closed before the cursor. Returns the opener's index and character, or undefined if none.
2930
     */
2931
    private findUnclosedOpener(line: string, column: number): { index: number; char: string } {
2932
        let depth = 0;
67✔
2933
        for (let i = column - 1; i >= 0; i--) {
67✔
2934
            const char = line[i];
604✔
2935
            if (char === ')' || char === ']' || char === '}') {
604✔
2936
                depth++;
12✔
2937
            } else if (char === '(' || char === '[' || char === '{') {
592✔
2938
                if (depth === 0) {
34✔
2939
                    return { index: i, char: char };
22✔
2940
                }
2941
                depth--;
12✔
2942
            }
2943
        }
2944
        return undefined;
45✔
2945
    }
2946

2947
    /**
2948
     * Resolve the parent variable for a completion request. Prefers the in-memory locals for the frame,
2949
     * then falls back to a device lookup. Device lookups are cached for the duration of the paused state
2950
     * (cleared by `clearState`) so repeated completion requests on the same path don't hammer the device.
2951
     */
2952
    private async resolveCompletionParentVariable(parentVariablePath: string[], frameId: number): Promise<AugmentedVariable> {
2953
        // For local-scope completions, make sure the frame's locals are fetched on demand. Otherwise they
2954
        // would only appear once the user expands the Variables panel (which is what triggers the fetch).
2955
        const isLocalScope = parentVariablePath.length === 1 && parentVariablePath[0] === '';
20✔
2956
        if (isLocalScope) {
20✔
2957
            const localsScope = this.getOrCreateLocalsScope(frameId);
4✔
2958
            if (!localsScope.isResolved) {
4!
2959
                try {
4✔
2960
                    await this.populateScopeVariables(localsScope, { variablesReference: localsScope.variablesReference } as DebugProtocol.VariablesArguments);
4✔
2961
                } catch (error) {
2962
                    this.logger.debug('Could not populate locals for completions', error, { frameId });
×
2963
                }
2964
            }
2965
        }
2966

2967
        const inMemory = this.findFrameVariableByPath(parentVariablePath, frameId);
20✔
2968
        if (inMemory && inMemory.childVariables.length > 0) {
20✔
2969
            return inMemory;
14✔
2970
        }
2971

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

2976
        const cacheKey = `${frameId}:${expression}`;
6✔
2977
        if (this.completionParentVariableCache.has(cacheKey)) {
6✔
2978
            return this.completionParentVariableCache.get(cacheKey);
1✔
2979
        }
2980

2981
        let parentVariable: AugmentedVariable;
2982
        try {
5✔
2983
            let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: expression, frameId: frameId }, parentVariablePath);
5✔
2984
            let result = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
5✔
2985
            parentVariable = await this.getVariableFromResult(result, frameId);
4✔
2986
        } catch (error) {
2987
            // A failed lookup is expected while the user is still typing an incomplete expression, so keep it quiet.
2988
            this.logger.debug('Could not resolve parent variable for completions', error, { parentVariablePath });
1✔
2989
            parentVariable = undefined;
1✔
2990
        }
2991

2992
        this.completionParentVariableCache.set(cacheKey, parentVariable);
5✔
2993
        return parentVariable;
5✔
2994
    }
2995

2996
    /**
2997
     * Rebuild a valid BrightScript accessor expression from a resolved variable path. String-literal keys
2998
     * arrive already quoted from `getVariablePath` and are emitted as `["key"]` so they stay case-sensitive
2999
     * on the device (Roku AAs can be set case-sensitive); numeric segments use `[index]`, and identifiers
3000
     * use dot access. This keeps array indices and string keys correct through the device lookup.
3001
     */
3002
    private buildVariableExpression(segments: string[]): string {
3003
        return segments.reduce((expression, segment, index) => {
14✔
3004
            if (index === 0) {
27✔
3005
                return segment;
14✔
3006
            }
3007
            //already-quoted string key (preserve the quotes so the device matches it case-sensitively).
3008
            //A lone `"` is not a quoted literal (the shortest is `""`), so require at least 2 chars.
3009
            if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
13✔
3010
                return `${expression}[${segment}]`;
2✔
3011
            }
3012
            if (/^[0-9]+$/.test(segment)) {
11✔
3013
                return `${expression}[${segment}]`;
3✔
3014
            }
3015
            if (/^[a-z_][a-z0-9_]*$/i.test(segment)) {
8✔
3016
                return `${expression}.${segment}`;
5✔
3017
            }
3018
            return `${expression}["${segment.replace(/"/g, '""')}"]`;
3✔
3019
        }, '');
3020
    }
3021

3022
    /**
3023
     * Normalize a variable path segment or variable name for matching: drop surrounding string-key quotes
3024
     * and lower-case it. BrightScript variables and dotted access are case-insensitive, and the device
3025
     * reports names lower-cased, so this lets the in-memory lookup find the parent regardless of the casing
3026
     * the user typed (ex: `topRef` matching the cached `topref`).
3027
     */
3028
    private normalizeVariableName(name: string): string {
3029
        let value = name ?? '';
46!
3030
        if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
46✔
3031
            value = value.slice(1, -1).replace(/""/g, '"');
2✔
3032
        }
3033
        return value.toLowerCase();
46✔
3034
    }
3035

3036
    /**
3037
     * Resolve a variable path against the current frame's local scope. The first path segment is matched
3038
     * against the frame's locals (not the global pool of every materialized variable), then we walk down
3039
     * the child variables. The empty path (`['']`) resolves to the locals scope container itself.
3040
     */
3041
    private findFrameVariableByPath(path: string[], frameId: number): AugmentedVariable {
3042
        const localsContainer = this.variables[this.getEvaluateRefId('$$locals', frameId)];
20✔
3043
        if (path.length === 1 && path[0] === '') {
20✔
3044
            return localsContainer;
4✔
3045
        }
3046
        return this.findVariableByPath(localsContainer?.childVariables ?? [], path, frameId);
16✔
3047
    }
3048

3049
    private findVariableByPath(variables: AugmentedVariable[], path: string[], frameId: number) {
3050
        let current: AugmentedVariable = null;
22✔
3051
        for (const name of path) {
22✔
3052
            const normalizedName = this.normalizeVariableName(name);
26✔
3053
            // Find the object matching the current name in the data (case-insensitive, per BrightScript)
3054
            current = (Array.isArray(variables) ? variables : current?.childVariables)?.find(obj => {
26!
3055
                return this.normalizeVariableName(obj.name) === normalizedName && obj.frameId === frameId;
20✔
3056
            });
3057

3058
            // If no match is found, return null
3059
            if (!current) {
26✔
3060
                return null;
7✔
3061
            }
3062

3063
            // Move to the children for the next iteration
3064
            variables = current.childVariables;
19✔
3065
        }
3066
        return current;
15✔
3067
    }
3068

3069
    private debuggerVarTypeToRoType(type: string): string {
3070
        switch (type) {
15!
3071
            case VariableType.Function:
3072
            case VariableType.Subroutine:
3073
                return 'roFunction';
×
3074
            case VariableType.AssociativeArray:
3075
                return 'roAssociativeArray';
11✔
3076
            case VariableType.List:
3077
                return 'roList';
×
3078
            case VariableType.Array:
3079
                return 'roArray';
1✔
3080
            case VariableType.Boolean:
3081
                return 'roBoolean';
×
3082
            case VariableType.Double:
3083
                return 'roDouble';
×
3084
            case VariableType.Float:
3085
                return 'roFloat';
×
3086
            case VariableType.Integer:
3087
                return 'roInteger';
×
3088
            case VariableType.LongInteger:
3089
                return 'roLongInteger';
×
3090
            case VariableType.String:
3091
                return 'roString';
×
3092
            default:
3093
                return type;
3✔
3094
        }
3095
    }
3096

3097
    /**
3098
     * Called when the host stops debugging
3099
     * @param response
3100
     * @param args
3101
     */
3102
    protected async disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments, request?: DebugProtocol.Request) {
3103
        //return to the home screen — best effort. The device may already be powered off or unreachable
3104
        //at disconnect time; without a guard the home key press rejects (EHOSTDOWN / ECONNREFUSED / etc)
3105
        //and because @vscode/debugadapter dispatches this method without awaiting the returned Promise,
3106
        //that rejection escapes as an unhandledRejection and crashes the DAP process.
3107
        //See https://github.com/rokucommunity/vscode-brightscript-language/issues/807
3108
        //    https://github.com/rokucommunity/roku-debug/issues/332
3109
        if (!this.enableDebugProtocol) {
2!
3110
            try {
2✔
3111
                await this.rokuDeploy.keyPress({ device: this.launchConfiguration.device, key: 'Home', ecpPort: this.launchConfiguration.remotePort });
2✔
3112
            } catch (e) {
3113
                this.logger.warn('Failed to press home button during disconnect; device may be unreachable', e);
2✔
3114
            }
3115
        }
3116
        this.sendResponse(response);
2✔
3117
        await this.shutdown();
2✔
3118
    }
3119

3120
    private createRokuAdapter(rendezvousTracker: RendezvousTracker) {
3121
        if (this.enableDebugProtocol) {
1!
3122
            this.rokuAdapter = new DebugProtocolAdapter(this.launchConfiguration, this.projectManager, this.breakpointManager, rendezvousTracker, this.deviceInfo);
×
3123
        } else {
3124
            this.rokuAdapter = new TelnetAdapter(this.launchConfiguration, rendezvousTracker);
1✔
3125
        }
3126
    }
3127

3128
    protected async restartRequest(response: DebugProtocol.RestartResponse, args: DebugProtocol.RestartArguments, request?: DebugProtocol.Request) {
3129
        this.logger.log('[restartRequest] begin');
×
3130
        if (this.rokuAdapter) {
×
3131
            if (!this.enableDebugProtocol) {
×
3132
                this.rokuAdapter.removeAllListeners();
×
3133
            }
3134
            await this.rokuAdapter.destroy();
×
3135
            await this.ensureAppIsInactive();
×
3136
            this.rokuAdapterDeferred = defer();
×
3137
            this.stagingDefered.tryResolve();
×
3138
            this.stagingDefered = defer();
×
3139
        }
3140
        await this.launchRequest(response, args.arguments as LaunchConfiguration);
×
3141
    }
3142

3143
    private exitAppTimeout = 5000;
224✔
3144
    private async ensureAppIsInactive() {
3145
        const startTime = Date.now();
×
3146

3147
        while (true) {
×
3148
            if (Date.now() - startTime > this.exitAppTimeout) {
×
3149
                return;
×
3150
            }
3151

3152
            try {
×
3153
                let appStateResult = await rokuECP.getAppState({
×
3154
                    remotePort: this.launchConfiguration.remotePort,
3155
                    device: this.launchConfiguration.device,
3156
                    appId: 'dev',
3157
                    requestOptions: { timeout: 300 }
3158
                });
3159

3160
                const state = appStateResult.state;
×
3161

3162
                if (state === AppState.active || state === AppState.background) {
×
3163
                    // Suspends or terminates an app that is running:
3164
                    // If the app supports Instant Resume and is running in the foreground, sending this command suspends the app (the app runs in the background).
3165
                    // 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.
3166
                    // This means that we might need to send this command twice to terminate the app.
3167
                    await rokuECP.exitApp({
×
3168
                        remotePort: this.launchConfiguration.remotePort,
3169
                        device: this.launchConfiguration.device,
3170
                        appId: 'dev',
3171
                        requestOptions: { timeout: 300 }
3172
                    });
3173
                } else if (state === AppState.inactive) {
×
3174
                    return;
×
3175
                }
3176
            } catch (e) {
3177
                this.logger.error('Error attempting to exit application', e);
×
3178
            }
3179

3180
            await util.sleep(200);
×
3181
        }
3182
    }
3183

3184
    /**
3185
     * Used to track whether the entry breakpoint has already been handled
3186
     */
3187
    private entryBreakpointWasHandled = false;
224✔
3188

3189
    /**
3190
     * Registers the main events for the RokuAdapter
3191
     */
3192
    private async connectRokuAdapter() {
3193
        this.rokuAdapter.on('start', () => {
×
3194
            this.sendLaunchProgress('end', 'Complete');
×
3195
            if (!this.firstRunDeferred.isCompleted) {
×
3196
                this.firstRunDeferred.resolve();
×
3197
            }
3198
        });
3199

3200
        this.rokuAdapter.on('launch-status', (message) => {
×
3201
            this.sendLaunchProgress('update', message);
×
3202
        });
3203

3204
        //when the debugger suspends (pauses for debugger input)
3205
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
3206
        this.rokuAdapter.on('suspend', async () => {
×
3207
            await this.onSuspend();
×
3208
        });
3209

3210
        //anytime the adapter encounters an exception on the roku,
3211
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
3212
        this.rokuAdapter.on('runtime-error', async (exception) => {
×
3213
            await this.getRokuAdapter();
×
3214
            const threads = await this.setupSuspendedState();
×
3215
            let threadId = threads[0]?.threadId;
×
3216
            this.sendEvent(new StoppedEvent(StoppedEventReason.exception, threadId, exception.message));
×
3217
        });
3218

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

3224
        //make the connection
3225
        await this.rokuAdapter.connect();
×
3226
        this.rokuAdapterDeferred.resolve(this.rokuAdapter);
×
3227
        return this.rokuAdapter;
×
3228
    }
3229

3230
    private async onSuspend() {
3231
        const threads = await this.setupSuspendedState();
1✔
3232
        const activeThread = threads.find(x => x.isSelected);
1✔
3233

3234
        //if !stopOnEntry, and we haven't encountered a suspend yet, THIS is the entry breakpoint. auto-continue
3235
        if (!this.entryBreakpointWasHandled && !this.launchConfiguration.stopOnEntry) {
1!
3236
            this.entryBreakpointWasHandled = true;
1✔
3237
            //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
3238
            if (activeThread && !await this.breakpointManager.lineHasBreakpoint(this.projectManager.getAllProjects(), activeThread.filePath, activeThread.lineNumber - 1)) {
1!
3239
                this.logger.info('Encountered entry breakpoint and `stopOnEntry` is disabled. Continuing...');
×
3240
                return this.rokuAdapter.continue();
×
3241
            }
3242
        }
3243

3244
        const event: StoppedEvent = new StoppedEvent(
1✔
3245
            StoppedEventReason.breakpoint,
3246
            //Not sure why, but sometimes there is no active thread. Just pick thread 0 to prevent the app from totally crashing
3247
            activeThread?.threadId ?? 0,
6!
3248
            '' //exception text
3249
        );
3250
        // Socket debugger will always stop all threads and supports multi thread inspection.
3251
        (event.body as any).allThreadsStopped = this.enableDebugProtocol;
1✔
3252
        this.sendEvent(event);
1✔
3253
    }
3254

3255
    private async setupSuspendedState() {
3256
        //clear the index for storing evalutated expressions
3257
        this.evaluateVarIndexByFrameId.clear();
8✔
3258

3259
        const threads = await this.rokuAdapter.getThreads();
8✔
3260

3261
        //TODO remove this once Roku fixes their threads off-by-one line number issues
3262
        //look up the correct line numbers for each thread from the StackTrace
3263
        await Promise.all(
8✔
3264
            threads.map(async (thread) => {
3265
                const stackTrace = await this.rokuAdapter.getStackTrace(thread.threadId);
9✔
3266
                const stackTraceLineNumber = stackTrace[0]?.lineNumber;
9✔
3267
                const stackTraceFilePath = stackTrace[0]?.filePath;
9✔
3268
                // Only apply the line correction when we actually have valid data — never clobber
3269
                // thread.filePath with undefined, which would crash getSourceLocation downstream.
3270
                if (stackTraceLineNumber !== undefined && stackTraceLineNumber !== thread.lineNumber) {
9✔
3271
                    this.logger.warn(`Thread ${thread.threadId} reported incorrect line (${thread.lineNumber}). Using line from stack trace instead (${stackTraceLineNumber})`, thread, stackTrace);
2✔
3272
                    thread.lineNumber = stackTraceLineNumber;
2✔
3273
                    thread.filePath = stackTraceFilePath ?? thread.filePath;
2!
3274
                }
3275
            })
3276
        );
3277

3278
        outer: for (const bp of this.breakpointManager.failedDeletions) {
8✔
3279
            for (const thread of threads) {
4✔
3280
                let sourceLocation = await this.projectManager.getSourceLocation(thread.filePath, thread.lineNumber);
4✔
3281
                // This stop was due to a breakpoint that we tried to delete, but couldn't.
3282
                // Now that we are stopped, we can delete it. We won't stop here again unless you re-add the breakpoint. You're welcome.
3283
                if (sourceLocation && (bp.srcPath === sourceLocation.filePath) && (bp.line === sourceLocation.lineNumber)) {
4✔
3284
                    this.showPopupMessage(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops.`, 'info').catch((error) => {
1✔
3285
                        this.logger.error('Error showing popup message', { error });
×
3286
                    });
3287
                    this.logger.warn(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops`, bp, thread, sourceLocation);
1✔
3288
                    break outer;
1✔
3289
                }
3290
            }
3291
        }
3292

3293
        //sync breakpoints
3294
        await this.rokuAdapter?.syncBreakpoints();
8!
3295

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

3298
        this.clearState();
8✔
3299
        return threads;
8✔
3300
    }
3301

3302
    private async getVariableFromResult(result: EvaluateContainer, frameId: number, maxDepth = 1) {
15✔
3303
        let v: AugmentedVariable;
3304

3305
        if (result) {
25!
3306
            if (this.rokuAdapter.isDebugProtocolAdapter()) {
25✔
3307
                let refId = this.getEvaluateRefId(result.evaluateName, frameId);
16✔
3308
                if (result.isCustom && !result.presentationHint?.lazy && result.evaluateNow) {
16!
3309
                    try {
×
3310
                        // We should not wait to resolve this variable later. Fetch, store, and merge the results right away.
3311
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: result.evaluateName, frameId: frameId }, util.getVariablePath(result.evaluateName));
×
3312
                        let newResult = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
×
3313
                        this.mergeEvaluateContainers(result, newResult);
×
3314
                    } catch (error) {
3315
                        logger.error('Error getting variables', error);
×
3316
                        this.mergeEvaluateContainers(result, {
×
3317
                            name: result.name,
3318
                            evaluateName: result.evaluateName,
3319
                            children: [],
3320
                            value: `❌ Error: ${error.message}`,
3321
                            type: '',
3322
                            highLevelType: undefined,
3323
                            keyType: undefined
3324
                        });
3325
                    }
3326
                }
3327

3328
                if (result.keyType) {
16✔
3329
                    let value = `${result.value ?? result.type}`;
5!
3330
                    let indexedVariables = result.indexedVariables;
5✔
3331
                    let namedVariables = result.namedVariables;
5✔
3332

3333
                    if (indexedVariables === undefined || namedVariables === undefined) {
5!
3334
                        // If either indexed or named variables are undefined, we should tell the debugger to ask for everything
3335
                        // by supplying undefined values for both
3336
                        indexedVariables = undefined;
×
3337
                        namedVariables = undefined;
×
3338
                    }
3339

3340
                    // check to see if this is an dictionary or a list
3341
                    if (result.keyType === 'Integer') {
5!
3342
                        // list type
3343
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
×
3344
                    } else if (result.keyType === 'String') {
5!
3345
                        // dictionary type
3346
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
5✔
3347
                    }
3348
                    v.type = result.type;
5✔
3349
                } else {
3350

3351
                    let value: string;
3352
                    if (result.type === VariableType.Invalid) {
11!
3353
                        value = result.value ?? 'Invalid';
×
3354
                    } else if (result.type === VariableType.Uninitialized) {
11!
3355
                        value = 'Uninitialized';
×
3356
                    } else {
3357
                        value = `${result.value}`;
11✔
3358
                    }
3359
                    // If the variable is lazy we must assign a refId to inform the system
3360
                    // to request this variable again in the future for value resolution
3361
                    v = new Variable(result.name, value, result?.presentationHint?.lazy ? refId : 0);
11!
3362
                }
3363
                this.variables[refId] = v;
16✔
3364
            } else if (this.rokuAdapter.isTelnetAdapter()) {
9!
3365
                if (result.highLevelType === 'primative' || result.highLevelType === 'uninitialized') {
9✔
3366
                    v = new Variable(result.name, `${result.value}`);
7✔
3367
                } else if (result.highLevelType === 'array') {
2✔
3368
                    let refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3369
                    v = new Variable(result.name, result.type, refId, result.children?.length ?? 0, 0);
1!
3370
                    this.variables[refId] = v;
1✔
3371
                } else if (result.highLevelType === 'object') {
1!
3372
                    let refId: number;
3373
                    //handle collections
3374
                    if (this.rokuAdapter.isScrapableContainObject(result.type)) {
1!
3375
                        refId = this.getEvaluateRefId(result.evaluateName, frameId);
1✔
3376
                    }
3377
                    v = new Variable(result.name, result.type, refId, 0, result.children?.length ?? 0);
1!
3378
                    this.variables[refId] = v;
1✔
3379
                } else if (result.highLevelType === 'function') {
×
3380
                    v = new Variable(result.name, `${result.value}`);
×
3381
                } else {
3382
                    //all other cases, but mostly for HighLevelType.unknown
3383
                    v = new Variable(result.name, `${result.value}`);
×
3384
                }
3385
            }
3386

3387
            v.type = result.type;
25✔
3388
            v.evaluateName = result.evaluateName;
25✔
3389
            v.frameId = frameId;
25✔
3390
            v.type = result.type;
25✔
3391
            v.presentationHint = result.presentationHint ? { kind: result.presentationHint?.kind, lazy: result.presentationHint?.lazy } : undefined;
25!
3392
            if (util.isTransientVariable(v.name)) {
25!
3393
                v.presentationHint = { kind: 'virtual' };
×
3394
            }
3395

3396
            if (result.children && maxDepth > 0) {
25✔
3397
                if (!v.childVariables) {
7!
3398
                    v.childVariables = [];
7✔
3399
                }
3400

3401
                // Create a mapping of the children to their index so we can evaluate them in bulk
3402
                let indexMappedChildren = result.children.map((child, index) => {
7✔
3403
                    let remapped = { child: child, index: index, evaluate: !!(child.isCustom && !child.presentationHint?.lazy && child.evaluateNow) };
10!
3404
                    return remapped;
10✔
3405
                });
3406
                if (this.enableDebugProtocol) {
7!
3407
                    let childrenToEvaluate = indexMappedChildren.filter(x => x.evaluate);
×
3408
                    let evaluateArgsArray = childrenToEvaluate.map(x => {
×
3409
                        return { expression: x.child.evaluateName, frameId: frameId };
×
3410
                    });
3411

3412
                    let variablePathArray = childrenToEvaluate.map(x => {
×
3413
                        return util.getVariablePath(x.child.evaluateName);
×
3414
                    });
3415

3416
                    try {
×
3417
                        let bulkEvaluations = await this.bulkEvaluateExpressionToTempVar(frameId, evaluateArgsArray, variablePathArray);
×
3418
                        if (bulkEvaluations.bulkVarName) {
×
3419
                            let newResults = await this.rokuAdapter.getVariable(bulkEvaluations.bulkVarName, frameId);
×
3420
                            childrenToEvaluate.map((mappedChild, index) => {
×
3421
                                let newResult = newResults.children[index];
×
3422
                                this.mergeEvaluateContainers(mappedChild.child, newResult);
×
3423
                                mappedChild.child.evaluateNow = false;
×
3424
                                return mappedChild;
×
3425
                            });
3426
                        }
3427
                    } catch (error) {
3428
                        this.logger.error('Error getting bulk variables, will fall back to var by var lookups', error);
×
3429
                    }
3430
                }
3431
                // If bulk evaluations failed, there is fall back logic in `getVariableFromResult` to do individual evaluations
3432
                v.childVariables = await Promise.all(indexMappedChildren.map(async (mappedChild) => {
7✔
3433
                    return this.getVariableFromResult(mappedChild.child, frameId, maxDepth - 1);
10✔
3434
                }));
3435
            } else {
3436
                v.childVariables = [];
18✔
3437
            }
3438

3439
            // if the var is an array and debugProtocol is enabled, include the array size
3440
            if (this.enableDebugProtocol && v.type === VariableType.Array) {
25!
3441
                if (isNaN(result.indexedVariables as number)) {
×
3442
                    v.value = v.type;
×
3443
                } else {
3444
                    v.value = `${v.type}(${result.indexedVariables})`;
×
3445
                }
3446
            }
3447
        }
3448
        return v;
25✔
3449
    }
3450

3451
    /**
3452
     * Helper function to merge the results of an evaluate call into an existing EvaluateContainer
3453
     * Used primarily for custom variables
3454
     */
3455
    private mergeEvaluateContainers(original: EvaluateContainer, updated: EvaluateContainer) {
3456
        original.children = updated.children;
×
3457
        original.value = updated.value;
×
3458
        original.type = updated.type;
×
3459
        original.highLevelType = updated.highLevelType;
×
3460
        original.keyType = updated.keyType;
×
3461
        original.indexedVariables = updated.indexedVariables;
×
3462
        original.namedVariables = updated.namedVariables;
×
3463
    }
3464

3465
    private getEvaluateRefId(expression: string, frameId: number) {
3466
        let evaluateRefId = `${expression}-${frameId}`;
77✔
3467
        if (!this.evaluateRefIdLookup[evaluateRefId]) {
77✔
3468
            this.evaluateRefIdLookup[evaluateRefId] = this.evaluateRefIdCounter++;
45✔
3469
        }
3470
        return this.evaluateRefIdLookup[evaluateRefId];
77✔
3471
    }
3472

3473
    private clearState() {
3474
        //erase all cached variables
3475
        this.variables = {};
12✔
3476
        this.completionParentVariableCache.clear();
12✔
3477
    }
3478

3479
    /**
3480
     * Sends a launch progress event to the client if the client supports progress reporting.
3481
     * - `'start'`: begins a new progress bar with the given message. Assigns a new progressId.
3482
     * - `'update'`: updates the message on the active progress bar.
3483
     * - `'end'`: dismisses the active progress bar with an optional final message.
3484
     */
3485
    private sendLaunchProgress(type: 'start' | 'update' | 'end', message?: string) {
3486
        if (!this.initRequestArgs?.supportsProgressReporting) {
78✔
3487
            return;
44✔
3488
        }
3489
        if (type === 'start') {
34✔
3490
            this.launchProgressId = `rokudebug-launch-${this.idCounter++}`;
11✔
3491
            this.sendEvent(new ProgressStartEvent(this.launchProgressId, 'Launching', `${message}...`));
11✔
3492
        } else if (this.launchProgressId) {
23✔
3493
            if (type === 'update') {
19✔
3494
                this.sendEvent(new ProgressUpdateEvent(this.launchProgressId, `${message}...`));
13✔
3495
            } else {
3496
                const lastId = this.launchProgressId;
6✔
3497
                this.sendEvent(new ProgressUpdateEvent(lastId, message));
6✔
3498
                const endTimer = setTimeout(() => this.flushLaunchProgressEnd?.(), 1000); // add a slight delay before ending the progress to improve UX
6!
3499
                this.flushLaunchProgressEnd = () => {
6✔
3500
                    clearTimeout(endTimer);
6✔
3501
                    this.flushLaunchProgressEnd = undefined;
6✔
3502
                    this.sendEvent(new ProgressEndEvent(lastId, message));
6✔
3503
                };
3504
                this.launchProgressId = undefined;
6✔
3505
            }
3506
        }
3507
    }
3508

3509
    /**
3510
     * Tells the client to re-request all variables because we've invalidated them
3511
     * @param threadId
3512
     * @param stackFrameId
3513
     */
3514
    private sendInvalidatedEvent(threadId?: number, stackFrameId?: number) {
3515
        //if the client supports this request, send it
3516
        if (this.initRequestArgs.supportsInvalidatedEvent) {
3!
3517
            this.sendEvent(new InvalidatedEvent(['variables'], threadId, stackFrameId));
×
3518
        }
3519
    }
3520

3521
    /**
3522
     * If `stopOnEntry` is enabled, register the entry breakpoint.
3523
     */
3524
    public async handleEntryBreakpoint() {
3525
        if (!this.enableDebugProtocol) {
4!
3526
            this.entryBreakpointWasHandled = true;
4✔
3527
            if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) {
4✔
3528
                await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir);
1✔
3529
            }
3530
        }
3531
    }
3532

3533
    /**
3534
     * Converts a debugger line number to a client line number.
3535
     *
3536
     * @param debuggerLine - The line number from the debugger as zero based.
3537
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `debuggerLine` is not provided.
3538
     * @returns The corresponding client line number.
3539
     */
3540
    private toClientLine(debuggerLine: number, defaultDebuggerLine?: number) {
3541
        return this.convertDebuggerLineToClient(debuggerLine ?? defaultDebuggerLine);
3!
3542
    }
3543

3544
    /**
3545
     * Converts a debugger column number to a client column number.
3546
     *
3547
     * @param debuggerLine - The column number from the debugger as zero based.
3548
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `debuggerLine` is not provided.
3549
     * @returns The corresponding client column number.
3550
     */
3551
    private toClientColumn(debuggerLine: number, defaultDebuggerLine?: number) {
3552
        return this.convertDebuggerColumnToClient(debuggerLine ?? defaultDebuggerLine);
3!
3553
    }
3554

3555
    /**
3556
     * Converts a client line number to a debugger line number.
3557
     *
3558
     * @param clientLine - The line number from the client.
3559
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `clientLine` is not provided.
3560
     * @returns The corresponding debugger line number as zero based.
3561
     */
3562
    private toDebuggerLine(clientLine: number, defaultDebuggerLine?: number) {
3563
        if (typeof clientLine === 'number') {
109✔
3564
            return this.convertClientLineToDebugger(clientLine);
2✔
3565
        }
3566
        return defaultDebuggerLine;
107✔
3567
    }
3568

3569
    /**
3570
     * Converts a client column number to a debugger column number.
3571
     *
3572
     * @param clientLine - The column number from the client.
3573
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `clientLine` is not provided.
3574
     * @returns The corresponding debugger column number as zero based.
3575
     */
3576
    private toDebuggerColumn(clientLine: number, defaultDebuggerLine?: number) {
3577
        if (typeof clientLine === 'number') {
89!
3578
            return this.convertClientColumnToDebugger(clientLine);
89✔
3579
        }
3580
        return defaultDebuggerLine;
×
3581
    }
3582

3583
    private shutdownPromise: Promise<void> | undefined = undefined;
224✔
3584

3585
    /**
3586
     * 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
3587
     * the same promise on subsequent calls
3588
     */
3589
    public async shutdown(errorMessage?: string, modal = false): Promise<void> {
17✔
3590
        if (this.shutdownPromise === undefined) {
17!
3591
            this.logger.log('[shutdown] Beginning shutdown sequence', errorMessage);
17✔
3592
            //Backstop: if the graceful shutdown hangs (e.g. a home key press against an unreachable
3593
            //device), force-exit anyway so we never leave an orphaned adapter running forever
3594
            const forceExitTimer = setTimeout(() => {
17✔
3595
                this.logger.error('[shutdown] graceful shutdown timed out; forcing exit');
×
3596
                this.forceExit();
×
3597
            }, this.shutdownForceExitTimeout);
3598
            forceExitTimer.unref?.();
17!
3599
            this.shutdownPromise = this._shutdown(errorMessage, modal).finally(() => {
17✔
3600
                clearTimeout(forceExitTimer);
17✔
3601
            });
3602
        } else {
3603
            this.logger.log('[shutdown] Tried to call `.shutdown()` again. Returning the same promise');
×
3604
        }
3605
        return this.shutdownPromise;
17✔
3606
    }
3607

3608
    private async _shutdown(errorMessage?: string, modal = false): Promise<void> {
×
3609
        // Ensure any active launch progress bar is dismissed before showing error messages or the terminated event.
3610
        this.sendLaunchProgress('end', 'Complete');
17✔
3611
        // 'end' defers its ProgressEndEvent for UX; deliver it right now (whether from the line above or from an
3612
        // earlier 'end' whose delay has not elapsed yet) - the adapter exits before a pending timer would fire,
3613
        // which would leave the client's progress notification stuck open
3614
        this.flushLaunchProgressEnd?.();
17✔
3615

3616
        //send the message FIRST before anything else. This improves the chances that the message will be displayed to the user
3617
        try {
17✔
3618
            if (errorMessage) {
17!
3619
                this.logger.error(errorMessage);
×
3620
                this.showPopupMessage(errorMessage, 'error', modal).catch((error) => {
×
3621
                    this.logger.error('Error showing popup message', { error });
×
3622
                });
3623
            }
3624
        } catch (e) {
3625
            this.logger.error(e);
×
3626
        }
3627
        // stop perfetto tracing if it's running
3628
        try {
17✔
3629
            await this.perfettoManager?.stopTracing?.();
17!
3630
        } catch (e) {
3631
            this.logger.error('Error stopping perfetto tracing', e);
×
3632
        }
3633

3634
        try {
17✔
3635
            await this.perfettoManager?.dispose?.();
17!
3636
        } catch (e) {
3637
            this.logger.error('Error disposing perfetto manager', e);
×
3638
        }
3639

3640
        //close the debugger connection
3641
        try {
17✔
3642
            this.logger.log('Destroy rokuAdapter');
17✔
3643
            await this.rokuAdapter?.destroy?.();
17!
3644
            //press the home button to return to the home screen
3645
            try {
17✔
3646
                this.logger.log('Press home button');
17✔
3647
                await this.rokuDeploy.keyPress({ device: this.launchConfiguration.device, key: 'Home', ecpPort: this.launchConfiguration.remotePort });
17✔
3648
            } catch (e) {
3649
                this.logger.error(e);
×
3650
            }
3651
        } catch (e) {
3652
            this.logger.error(e);
×
3653
        }
3654

3655
        try {
17✔
3656
            this.projectManager?.dispose?.();
17!
3657
        } catch (e) {
3658
            this.logger.error(e);
×
3659
        }
3660

3661
        try {
17✔
3662
            this.componentLibraryServer?.stop();
17!
3663
        } catch (e) {
3664
            this.logger.error(e);
×
3665
        }
3666

3667
        try {
17✔
3668
            await this.rendezvousTracker?.destroy?.();
17!
3669
        } catch (e) {
3670
            this.logger.error(e);
×
3671
        }
3672

3673
        try {
17✔
3674
            await this.sourceMapManager?.destroy?.();
17!
3675
        } catch (e) {
3676
            this.logger.error(e);
×
3677
        }
3678

3679
        try {
17✔
3680
            //if configured, delete the staging directory
3681
            if (!this.launchConfiguration.retainStagingFolder) {
17!
3682
                const stagingDirs = this.projectManager?.getStagingDirs() ?? [];
17!
3683
                this.logger.info('deleting staging folders', stagingDirs);
17✔
3684
                for (let stagingDir of stagingDirs) {
17✔
3685
                    try {
2✔
3686
                        fsExtra.removeSync(stagingDir);
2✔
3687
                    } catch (e) {
3688
                        this.logger.error(e);
×
3689
                        util.log(`Error removing staging directory '${stagingDir}': ${JSON.stringify(e)}`);
×
3690
                    }
3691
                }
3692
            }
3693
        } catch (e) {
3694
            this.logger.error(e);
×
3695
        }
3696

3697
        try {
17✔
3698
            this.logger.log('Send terminated event');
17✔
3699
            this.sendEvent(new TerminatedEvent());
17✔
3700

3701
            //shut down the process
3702
            this.logger.log('super.shutdown()');
17✔
3703
            super.shutdown();
17✔
3704
            this.logger.log('shutdown complete');
17✔
3705
        } catch (e) {
3706
            this.logger.error(e);
×
3707
        }
3708

3709
        try {
17✔
3710
            this.teardownProcessErrorHandlers();
17✔
3711
        } catch (e) {
3712
            this.logger.error(e);
×
3713
        }
3714
    }
3715
}
3716

3717
export interface AugmentedVariable extends DebugProtocol.Variable {
3718
    childVariables?: AugmentedVariable[];
3719
    // eslint-disable-next-line camelcase
3720
    request_seq?: number;
3721
    frameId?: number;
3722
    /**
3723
     * only used for lazy variables
3724
     */
3725
    isResolved?: boolean;
3726
    /**
3727
     * used to indicate that this variable is a scope variable
3728
     * and may require special handling
3729
     */
3730
    isScope?: boolean;
3731
}
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