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

rokucommunity / roku-debug / 39450157926

20 Oct 2026 03:37PM UTC coverage: 74.198% (+0.6%) from 73.647%
39450157926

Pull #435

github

web-flow
Merge 2ccf50e6b into aec6474ed
Pull Request #435: chore: updated roku-deploy and brighterscript

3933 of 5575 branches covered (70.55%)

Branch coverage included in aggregate %.

6428 of 8389 relevant lines covered (76.62%)

49.78 hits per line

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

68.29
/src/debugSession/BrightScriptDebugSession.ts
1
import * as fsExtra from 'fs-extra';
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';
2 ✔
7
import {
2 ✔
8
    BreakpointEvent,
2 ✔
9
    LoggingDebugSession,
2 ✔
10
    Logger as DapLogger,
2 ✔
11
    logger as dapLogger,
2 ✔
12
    CapabilitiesEvent,
2 ✔
13
    InitializedEvent,
2 ✔
14
    InvalidatedEvent,
2 ✔
15
    OutputEvent,
2 ✔
16
    ProgressEndEvent,
2 ✔
17
    ProgressStartEvent,
2 ✔
18
    ProgressUpdateEvent,
2 ✔
19
    Source,
2 ✔
20
    StackFrame,
2 ✔
21
    StoppedEvent,
2 ✔
22
    TerminatedEvent,
2 ✔
23
    Thread,
2 ✔
24
    Variable
2 ✔
25
} from '@vscode/debugadapter';
2 ✔
26
import type { SceneGraphCommandResponse } from '../SceneGraphDebugCommandController';
2 ✔
27
import { SceneGraphDebugCommandController } from '../SceneGraphDebugCommandController';
2 ✔
28
import type { DebugProtocol } from '@vscode/debugprotocol';
2 ✔
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';
2 ✔
34
import { DebugProtocolAdapter } from '../adapters/DebugProtocolAdapter';
2 ✔
35
import { TelnetAdapter } from '../adapters/TelnetAdapter';
36
import type { BSDebugDiagnostic } from '../CompileErrorProcessor';
37
import { RendezvousTracker } from '../RendezvousTracker';
38
import {
39
    LaunchStartEvent,
40
    LogOutputEvent,
2 ✔
41
    RendezvousEvent,
2 ✔
42
    DiagnosticsEvent,
2 ✔
43
    StoppedEventReason,
2 ✔
44
    ChanperfEvent,
2 ✔
45
    DebugServerLogOutputEvent,
4 ✔
46
    ChannelPublishedEvent,
47
    ChannelSideloadedEvent,
48
    CustomRequestEvent,
233 ✔
49
    ClientToServerCustomEventName,
233 ✔
50
    ProfilingErrorEvent,
233 ✔
51
    ProfilingStartEvent,
233 ✔
52
    ProfilingStopEvent,
53
    ProfilingEnabledEvent as ProfilingEnableEvent,
54
    ProcessCrashEvent
55
} from './Events';
233 ✔
56
import type { ProcessCrashEventData } from './Events';
233 ✔
57
import type { LaunchConfiguration, ComponentLibraryConfiguration } from '../LaunchConfiguration';
233 ✔
58
import { FileManager } from '../managers/FileManager';
59
import { SourceMapManager } from '../managers/SourceMapManager';
233 ✔
60
import { LocationManager } from '../managers/LocationManager';
61
import type { AugmentedSourceBreakpoint } from '../managers/BreakpointManager';
233 ✔
62
import type { ResolvedLaunchConfiguration } from '../LaunchConfiguration';
63
import { BreakpointManager } from '../managers/BreakpointManager';
233 ✔
64
import type { LogMessage } from '../logging';
233 ✔
65
import { PerfettoManager } from '../PerfettoManager';
233 ✔
66
import { logger, FileLoggingManager, debugServerLogOutputEventTransport } from '../logging';
67
import { VariableType } from '../debugProtocol/events/responses/VariablesResponse';
68
import { DiagnosticSeverity } from 'brighterscript';
69
import type { ExceptionBreakpoint } from '../debugProtocol/events/requests/SetExceptionBreakpointsRequest';
233 ✔
70
import { debounce } from 'debounce';
71
import { interfaces, components, events } from 'brighterscript/dist/roku-types';
72
import { globalCallables } from 'brighterscript/dist/globalCallables';
73
import { bscProjectWorkerPool } from '../bsc/threading/BscProjectWorkerPool';
233 ✔
74
import { populateVariableFromRegistryEcp } from './ecpRegistryUtils';
233 ✔
75
import { AppState, rokuECP } from '../RokuECP';
233 ✔
76
import { SocketConnectionInUseError } from '../Exceptions';
233 ✔
77

78
const diagnosticSource = 'roku-debug';
79

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

233 ✔
91
export class BrightScriptDebugSession extends LoggingDebugSession {
233 ✔
92
    public constructor() {
×
93
        super();
94

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

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

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

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

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

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

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

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

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

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

168
    private handlingProcessError = false;
169

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

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

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

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

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

42 !
216
            const clientName = this.initRequestArgs?.clientName ?? 'unknown';
217

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

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

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

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

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

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

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

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

330
    public logger = logger.createLogger(`[session]`);
331

332
    private readonly isWindowsPlatform = process.platform.startsWith('win');
13 ✔
333

13 !
334
    /**
335
     * A sequence used to help identify log statements for requests
336
     */
337
    private idCounter = 1;
338

339
    public fileManager: FileManager;
26 ✔
340

26 ✔
341
    public projectManager: ProjectManager;
26 ✔
342

343
    public fileLoggingManager: FileLoggingManager;
344

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

354
    public breakpointManager: BreakpointManager;
2 ✔
355

356
    public locationManager: LocationManager;
2 ✔
357

358
    public sourceMapManager: SourceMapManager;
359

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

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

371
    private componentLibraryServer = new ComponentLibraryServer();
372

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

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

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

2 ✔
387
    private variables: Record<number, AugmentedVariable> = {};
554 ✔
388

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

396
    private rokuAdapter: DebugProtocolAdapter | TelnetAdapter;
9 ✔
397

2 ✔
398
    private perfettoManager: PerfettoManager;
399

400
    private rendezvousTracker: RendezvousTracker;
401

402
    public tempVarPrefix = '__rokudebug__';
7 ✔
403

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

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

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

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

428
    private get enableDebugProtocol() {
429
        return this.launchConfiguration?.enableDebugProtocol;
430
    }
1 ✔
431

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

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

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

×
458
    private exceptionBreakpoints: ExceptionBreakpoint[] = [];
459

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

468
        response.body ||= {};
12 !
469

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

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

476
        // make VS Code to use 'evaluate' when hovering over source
477
        response.body.supportsEvaluateForHovers = true;
9 !
478

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

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

18 !
506
        response.body.supportsCompletionsRequest = true;
18 !
507
        response.body.completionTriggerCharacters = ['.', '(', '{', ',', ' '];
18 !
508

18 !
509
        this.sendResponse(response);
18 !
510

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

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

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

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

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

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

571

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

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

×
585
    private static requestIdSequence = 0;
586

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

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

616
    public deviceInfo: DeviceInfo;
617

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

668
        // migrate the old `enableVariablesPanel` setting to the new `deferScopeLoading` setting
669
        if (typeof config.enableVariablesPanel !== 'boolean') {
×
670
            config.enableVariablesPanel = true;
671
        }
672
        config.deferScopeLoading ??= config.enableVariablesPanel === false;
9 ✔
673
        return config as ResolvedLaunchConfiguration;
674
    }
675

676
    public async launchRequest(response: DebugProtocol.LaunchResponse, config: LaunchConfiguration) {
4 ✔
677
        const logEnd = this.logger.timeStart('log', '[launchRequest] launch');
4 ✔
678

679
        try {
4 ✔
680
            this.resetSessionState();
4 ✔
681
            this.launchConfiguration = this.normalizeLaunchConfig(config);
682
            //fail fast when the launch config supplied no device addressing at all, rather than
4 ✔
683
            //failing later with a confusing dns or connection error for an undefined host
684
            if (!this.launchConfiguration.device) {
4 ✔
685
                return await this.shutdown(`Launch config does not specify a target device. Please supply the 'device' option (or the deprecated 'host' option).`);
686
            }
4 !
687
            this.setupProcessErrorHandlers();
×
688

×
689
            //prebake some threads for our ProjectManager to use later on (1 for the main project, and 1 for every complib)
690
            bscProjectWorkerPool.preload(1 + (this.launchConfiguration?.componentLibraries?.length ?? 0));
691

692
            //set the logLevel provided by the launch config
4 ✔
693
            if (this.launchConfiguration.logLevel) {
×
694
                logger.logLevel = this.launchConfiguration.logLevel;
695
            }
696

4 ✔
697
            this.sendLaunchProgress('start', 'Finding device on network');
×
698

×
699
            //do a DNS lookup for the host to fix issues with roku rejecting ECP. Only local devices
×
700
            //are addressed by host; other device types (like the Roku Cloud Emulator) pass through unchanged
×
701
            try {
×
702
                this.launchConfiguration.device = await this.rokuDeploy.withDnsResolvedHost(this.launchConfiguration.device);
×
703
            } catch (e) {
×
704
                return this.shutdown(`Could not resolve ip address for host '${this.deviceLabel}'`);
705
            }
706

707
            // fetch device info if not supplied via launch config
4 ✔
708
            try {
×
709
                if (this.launchConfiguration.deviceInfo) {
710
                    this.deviceInfo = rokuDeploy.enhanceDeviceInfo(this.launchConfiguration.deviceInfo);
711
                } else {
4 !
712
                    this.deviceInfo = await rokuDeploy.getDeviceInfo({ device: this.launchConfiguration.device, ecpPort: this.launchConfiguration.remotePort, enhance: true, timeout: 4_000 });
×
713
                }
×
714
                if (this.deviceInfo.ecpSettingMode === 'limited') {
715
                    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})`);
716
                }
×
717
            } catch (e) {
718
                if (e instanceof EcpNetworkAccessModeDisabledError) {
719
                    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})`);
720
                }
4 ✔
721
                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.`);
×
722
            }
723

724
            if (this.deviceInfo && !this.deviceInfo.developerEnabled) {
725
                return await this.shutdown(`Developer mode is not enabled for device '${this.deviceLabel}'.`);
4 ✔
726
            }
×
727

×
728
            // everything is ready, send the response to the launch request so the UI can update and configuration can begin
×
729
            this.sendResponse(response);
×
730

×
731
            //initialize all file logging (rokuDevice, debugger, etc)
×
732
            this.fileLoggingManager.activate(this.launchConfiguration?.fileLogging, this.cwd);
×
733

734
            this.projectManager.launchConfiguration = this.launchConfiguration;
735
            this.breakpointManager.launchConfiguration = this.launchConfiguration;
×
736

×
737
            this.sendEvent(new LaunchStartEvent(this.launchConfiguration));
×
738

×
739
            this.logger.log('[launchRequest] Packaging and deploying to roku');
×
740
            const packageEnd = this.logger.timeStart('log', 'Packaging');
×
741
            this.sendLaunchProgress('update', `Packaging Project${(this.launchConfiguration?.componentLibraries?.length ?? 0) > 0 ? 's' : ''}`);
742
            //build the main project and all component libraries at the same time
743
            await Promise.all([
744
                this.prepareMainProject(),
4 ✔
745
                this.prepareComponentLibraries(this.launchConfiguration.componentLibraries)
746
            ]);
747

748
            //all of the projects have been successfully staged.
749
            this.stagingDefered.tryResolve();
4 ✔
750

751
            //if the client supports it, let it process (inspect/modify) each project's staging dir before we package them
752
            if (this.launchConfiguration.clientCapabilities?.supportsProcessStagingDir) {
4 ✔
753
                await this.sendCustomRequest('processStagingDir', {
754
                    projects: this.projectManager.getProjectStagingInfo()
755
                });
4 ✔
756
            }
757

4 ✔
758
            packageEnd();
759

760
            if (this.enableDebugProtocol) {
761
                util.log(`Connecting to Roku via the BrightScript debug protocol at ${this.deviceLabel}:${this.launchConfiguration.controlPort}`);
4 ✔
762
            } else {
4 ✔
763
                util.log(`Connecting to Roku via telnet at ${this.deviceLabel}:${this.launchConfiguration.brightScriptConsolePort}`);
764
            }
3 !
765

3 ✔
766
            //activate rendezvous tracking (if enabled). Log the error and move on if it crashes, this shouldn't bring down the session.
767
            try {
768
                const rendezvousEnd = this.logger.timeStart('log', 'Rendezvous tracking');
3 ✔
769
                await this.initRendezvousTracking();
3 ✔
770
                rendezvousEnd();
3 !
771
            } catch (e) {
×
772
                this.logger.error('Failed to initialize rendezvous tracking', e);
773
            }
3 !
774

3 !
775
            this.sendLaunchProgress('update', 'Connecting to debug server');
3 ✔
776
            const connectAdapterEnd = this.logger.timeStart('log', 'Connect adapter');
3 ✔
777
            this.createRokuAdapter(this.rendezvousTracker);
778

779
            // Manipulating complibs on-device autolaunches the dev app and often triggers compile errors,
×
780
            // so if we have at least one installable complib, delete the dev app and any complibs to avoid all that.
×
781
            if (this.launchConfiguration.componentLibraries?.some(x => x.install)) {
×
782
                this.sendLaunchProgress('update', 'Removing existing dev app and component libraries');
×
783
                await rokuDeploy.deleteAllSideloadedPlugins({
784
                    device: this.launchConfiguration.device,
785
                    password: this.launchConfiguration.password
786
                });
787
            }
788

×
789
            await this.connectRokuAdapter();
790
            connectAdapterEnd();
791

3 !
792
            // Capabilities that depend on the adapter or device version. The exception-breakpoint
793
            // FILTER LIST was surfaced in initializeRequest (VS Code only reads it from there).
×
794
            // Everything below is read per-action in VS Code, so a CapabilitiesEvent update takes
795
            // effect dynamically.
×
796
            const supportsExceptionBreakpoints = this.rokuAdapter.supportsExceptionBreakpoints;
797
            this.sendEvent(new CapabilitiesEvent({
798
                supportsLogPoints: !this.enableDebugProtocol,
799
                supportsExceptionFilterOptions: supportsExceptionBreakpoints,
×
800
                supportsExceptionOptions: supportsExceptionBreakpoints,
801
                supportsConditionalBreakpoints: this.rokuAdapter.supportsConditionalBreakpoints,
×
802
                supportsHitConditionalBreakpoints: this.rokuAdapter.supportsHitConditionalBreakpoints
803
            }));
804

805
            this.sendLaunchProgress('update', 'Configuring breakpoints');
806

1 !
807
            util.log('Done initializing');
×
808

×
809
            // notify VS Code that the adapter is ready to receive configuration (breakpoints, etc.)
810
            // VS Code will respond with setBreakpoints, setExceptionBreakpoints, then configurationDone
×
811
            this.sendEvent(new InitializedEvent());
×
812

×
813
            await this.initializeProfiling();
814

815
        } catch (e) {
1 ✔
816
            //if the message is anything other than compile errors, we want to display the error
817
            if (!(e instanceof CompileError)) {
818
                util.log('Encountered an issue during the launch process');
819
                util.log((e as Error)?.stack);
820

821
                //send any compile errors to the client
822
                await this.rokuAdapter?.sendErrors();
823

824
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
825
                await this.shutdown(message as string, true);
18 ✔
826
            } else {
827
                this.sendLaunchProgress('end', 'Aborted (compile error)');
828
            }
829
        }
54 ✔
830
        logEnd();
831
    }
832

18 ✔
833
    protected async configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse, args: DebugProtocol.ConfigurationDoneArguments) {
×
834
        this.logger.log('configurationDoneRequest');
835
        super.configurationDoneRequest(response, args);
836

837
        let error: Error;
18 ✔
838
        try {
×
839
            await this.runAutomaticSceneGraphCommands(this.launchConfiguration.autoRunSgDebugCommands);
840

841
            //press the home button to ensure we're at the home screen
842
            await this.rokuDeploy.keyPress({ device: this.launchConfiguration.device, key: 'Home', ecpPort: this.launchConfiguration.remotePort });
18 ✔
843

×
844
            //pass the log level down thought the adapter to the RendezvousTracker and ChanperfTracker
845
            this.rokuAdapter.setConsoleOutput(this.launchConfiguration.consoleOutput);
846

847
            //pass along the console output
848
            if (this.launchConfiguration.consoleOutput === 'full') {
18 ✔
849
                this.rokuAdapter.on('console-output', (data) => {
×
850
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
851
                });
852
            } else {
853
                this.rokuAdapter.on('unhandled-console-output', (data) => {
854
                    this.sendLogOutput(data).catch(e => this.logger.error('Failed to send log output', e));
18 ✔
855
                });
2 ✔
856
            }
2 ✔
857

2 !
858
            this.rokuAdapter.on('device-unresponsive', async (data: { lastCommand: string }) => {
859
                const stopDebuggerAction = 'Stop Debugger';
860
                const message = `Roku device ${this.deviceLabel} is not responding and may not recover.` +
1 ✔
861
                    (data.lastCommand ? `\n\nActive command:\n"${util.truncate(data.lastCommand, 30)}"` : '');
862
                this.logger.log(message, data);
863
                const response = await this.showPopupMessage(message, 'warn', false, [stopDebuggerAction]);
864
                if (response === stopDebuggerAction) {
16 ✔
865
                    await this.shutdown();
2 ✔
866
                }
867
            });
868

869
            // Send chanperf events to the extension
14 ✔
870
            this.rokuAdapter.on('chanperf', (output) => {
2 !
871
                this.sendEvent(new ChanperfEvent(output));
2 ✔
872
            });
2 ✔
873

2 ✔
874
            //listen for a closed connection (shut down when received)
875
            this.rokuAdapter.on('close', (reason = '') => {
876
                if (reason === 'compileErrors') {
877
                    error = new Error('compileErrors');
12 ✔
878
                } else {
879
                    error = new Error('Unable to connect to Roku. Is another device already connected?');
880
                }
881
            });
882

883
            // handle any compile errors
884
            this.rokuAdapter.on('diagnostics', (diagnostics: BSDebugDiagnostic[]) => {
885
                this.handleDiagnostics(diagnostics).catch(e => this.logger.error('Failed to handle diagnostics', e));
886
            });
10 ✔
887

7 ✔
888
            // close disconnect if required when the app is exited
889
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
3 !
890
            this.rokuAdapter.on('app-exit', async () => {
2 ✔
891
                this.resetSessionState();
2 !
892

893
                if (this.launchConfiguration.stopDebuggerOnAppExit) {
894
                    let message = `App exit event detected and launchConfiguration.stopDebuggerOnAppExit is true`;
1 ✔
895
                    message += ' - shutting down debug session';
896

897
                    this.logger.log('on app-exit', message);
1 !
898
                    this.sendEvent(new LogOutputEvent(message));
1 !
899
                    await this.shutdown();
1 ✔
900
                } else {
1 ✔
901
                    const message = 'App exit detected; but launchConfiguration.stopDebuggerOnAppExit is set to false, so keeping debug session running.';
1 ✔
902
                    this.logger.log('[configurationDoneRequest]', message);
903
                    this.sendEvent(new LogOutputEvent(message));
904
                    this.rokuAdapter.once('connected').then(async () => {
905
                        await this.rokuAdapter.setExceptionBreakpoints(this.exceptionBreakpoints);
906
                    }).catch(e => this.logger.error('Failed to set exception breakpoints after reconnect', e));
907
                }
908
            });
909
            //profiling supports connecting to the socket BEFORE a channel is published, so go ahead and connect now
910
            await this.tryProfilingConnectOnStart();
11 ✔
911

912
            //all setBreakpoints requests have arrived by this point (configurationDone is the DAP signal
913
            //that the client has finished sending configuration). Validate breakpoints across every project
11 ✔
914
            //in one pass (and inject the STOPs for telnet) - validating per project would fail every other
915
            //project's breakpoints. Must finish before postfixing renames the complib files.
916
            await this.writeBreakpoints();
917

918
            //postfix each complib's own files now (still no zips. those are sealed after cross-project
919
            //references are rewritten).
3 ✔
920
            await this.postfixComponentLibraries(this.launchConfiguration.componentLibraries);
3 ✔
921

3 ✔
922
            //now that EVERY project (main + all complibs) is postfixed, rewrite cross-project `Library`
923
            //references to point at the postfixed file names. Must run before any project zip is sealed.
924
            await this.projectManager.applyLibraryReferencePostfixes();
3 ✔
925

926
            //now zip the main project, also zip and upload installable complibs, and start the webserver for non-installed complibs.
927
            await Promise.all([
3 ✔
928
                this.zipMainProject(),
1 ✔
929
                this.zipServeAndInstallComponentLibraries(this.launchConfiguration.componentLibraries, this.launchConfiguration.componentLibrariesPort)
×
930
            ]);
931

932
            this.sendLaunchProgress('update', 'Uploading to Roku');
933
            await this.publish();
934

3 ✔
935
            //hack for certain roku devices that lock up when this event is emitted (no idea why!).
936
            if (this.launchConfiguration.emitChannelPublishedEvent) {
3 ✔
937
                this.sendEvent(new ChannelPublishedEvent(
×
938
                    this.launchConfiguration
939
                ));
940
            }
3 ✔
941

1 ✔
942
            //tell the adapter adapter that the channel has been launched.
943
            this.sendLaunchProgress('update', 'Waiting on application');
944
            await this.rokuAdapter.activate();
3 ✔
945
            if (this.rokuAdapter.isDestroyed) {
946
                throw new Error('Debug session encountered an error');
3 ✔
947
            }
948
            if (!error) {
2 ✔
949
                if (this.rokuAdapter.connected) {
950
                    this.logger.info('Host connection was established before the main public process was completed');
951
                    this.logger.log(`deployed to Roku@${this.deviceLabel}`);
952
                } else {
953
                    this.logger.info('Main public process was completed but we are still waiting for a connection to the host');
954
                    this.rokuAdapter.on('connected', (status) => {
955
                        if (status) {
956
                            this.logger.log(`deployed to Roku@${this.deviceLabel}`);
2 ✔
957
                        }
2 ✔
958
                    });
2 ✔
959
                }
2 ✔
960
            } else {
1 ✔
961
                throw error;
1 ✔
962
            }
1 ✔
963

964
            //at this point, the project has been deployed. If we need to use a deep link, launch it now.
965
            if (this.launchConfiguration.deepLinkUrl && !this.enableDebugProtocol) {
966
                //wait until the first entry breakpoint has been hit
1 ✔
967
                await this.firstRunDeferred.promise;
968
                //if we are at a breakpoint, continue
969
                await this.rokuAdapter.continue();
970
                //kill the app on the roku
2 ✔
971
                // await this.rokuDeploy.keyPress({ device: this.launchConfiguration.device, key: 'Home', ecpPort: this.launchConfiguration.remotePort });
2 ✔
972
                //convert a hostname to an ip address
1 ✔
973
                const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl);
1 ✔
974
                //send the deep link http request
975
                await util.httpPost(deepLinkUrl);
2 ✔
976
            }
977

978
        } catch (e) {
979
            //if the message is anything other than compile errors, we want to display the error
5 ✔
980
            if (!(e instanceof CompileError)) {
5 ✔
981
                util.log('Encountered an issue during the publish process');
982
                util.log((e as Error)?.stack);
5 ✔
983

5 !
984
                //send any compile errors to the client
×
985
                await this.rokuAdapter?.sendErrors();
986

987
                const message = (e instanceof SocketConnectionInUseError) ? e.message : (e?.stack ?? e);
988
                await this.shutdown(message as string, true);
989
            } else {
990
                this.sendLaunchProgress('end', 'Aborted (compile error)');
991
            }
992
        }
993
    }
×
994

×
995
    /**
×
996
     * Activate all required functionality for profiling
×
997
     */
×
998
    private async initializeProfiling() {
999

×
1000
        // Initialize PerfettoManager
1001
        this.perfettoManager = new PerfettoManager({
5 ✔
1002
            device: this.launchConfiguration.device,
5 ✔
1003
            rootDir: this.launchConfiguration.rootDir,
1004
            remotePort: this.launchConfiguration.remotePort,
1005
            ...this.launchConfiguration.profiling?.tracing
1006
        });
1007

1008
        //send certain profiling events back to the client
1009
        this.perfettoManager.on('enable', (event) => {
15 ✔
1010
            this.sendEvent(new ProfilingEnableEvent({
1011
                types: event.types
1012
            }));
1013
        });
1014
        this.perfettoManager.on('start', (event) => {
1015
            this.sendEvent(new ProfilingStartEvent({
1016
                type: event.type
1017
            }));
1018
        });
1019
        this.perfettoManager.on('stop', (event) => {
1020
            this.sendEvent(new ProfilingStopEvent({
1021
                type: event.type,
1022
                result: event.result
1023
            }));
1024
        });
5 ✔
1025
        this.perfettoManager.on('error', (event) => {
4 ✔
1026
            this.sendEvent(new ProfilingErrorEvent({
1027
                error: event.error
1028
            }));
4 ✔
1029
        });
1030

1031
        //tracing is explicitly enabled. Turn it on
1 !
1032
        if (this.launchConfiguration.profiling?.tracing?.enable && this.supportsPerfettoTracing) {
1 ✔
1033
            this.logger.info('Enabling perfetto tracing because it is supported by the device and enabled in the launch configuration');
1 !
1034
            try {
1 ✔
1035
                await this.perfettoManager?.enableTracing?.();
1 ✔
1036
            } catch (e) {
1037
                this.logger.error('Failed to enable perfetto tracing', e);
×
1038
            }
1039

5 ✔
1040
            //tracing is expicitly DISabled. turn it off
4 ✔
1041
        } else if (this.launchConfiguration.profiling?.tracing?.enable === false && this.supportsPerfettoTracing) {
1042
            this.logger.info('Disabling perfetto tracing because it is disabled in the launch configuration');
1043
            //TODO implement a way to disable perfetto tracing on the device
4 ✔
1044

4 ✔
1045
            //tracing was requested but the device firmware does not meet the minimum requirement
1046
        } else if (this.launchConfiguration.profiling?.tracing?.enable && !this.supportsPerfettoTracing) {
1047
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
4 ✔
1048
            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.`;
1049
            this.logger.warn(message);
1050
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
4 ✔
1051

4 ✔
1052
            //profiling.tracing.enabled is set to `undefined`, which means we should do nothing
2 ✔
1053
        } else {
1054
            this.logger.info('Skipping perfetto initalization because `profiling.tracing.enable` is not defined in the launch configuration');
1055
        }
4 ✔
1056
    }
2 ✔
1057

1058
    /**
1059
     * If profiling was marked "connectOnStart", try connecting right away
1060
     */
1061
    private async tryProfilingConnectOnStart() {
1062
        //tracing is off for this session, so there is nothing to connect to
1063
        if (!this.launchConfiguration.profiling?.tracing?.enable) {
1064
            return;
38 !
1065
        }
×
1066
        if (this.launchConfiguration.profiling?.tracing?.connectOnStart && this.supportsPerfettoTracing) {
1067
            try {
38 ✔
1068
                await this.perfettoManager?.startTracing?.();
38 ✔
1069
            } catch (e) {
38 ✔
1070
                this.logger.error('Failed to start perfetto tracing on start', e);
38 ✔
1071
            }
38 ✔
1072
        } else if (this.launchConfiguration.profiling?.tracing?.connectOnStart && !this.supportsPerfettoTracing) {
228 ✔
1073
            const firmwareVersion = this.deviceInfo?.softwareVersion ?? 'unknown';
228 ✔
1074
            const message = `Perfetto profiling is not available: device firmware ${firmwareVersion} is below the minimum required version (15.2). Tracing will not start automatically.`;
190 ✔
1075
            this.logger.warn(message);
1076
            this.showPopupMessage(message, 'warn').catch(e => this.logger.error('Failed to show Perfetto unavailable notification', e));
228 ✔
1077
        }
91 ✔
1078
    }
91 ✔
1079

28 ✔
1080
    /**
28 ✔
1081
     * Clear certain properties that need reset whenever a debug session is restarted (via vscode or launched from the Roku home screen)
1082
     */
26 ✔
1083
    private resetSessionState() {
26 ✔
1084
        // launchRequest gets invoked by our restart session flow.
6 ✔
1085
        // We need to clear/reset some state to avoid issues.
3 ✔
1086
        this.entryBreakpointWasHandled = false;
1087
        //reset all per-session breakpoint state (diff baseline + cached parsed ASTs) since a restart
1088
        //re-stages the project
3 ✔
1089
        this.breakpointManager.reset();
1090
    }
1091

26 ✔
1092
    /**
26 ✔
1093
     * Activate rendezvous tracking (IF enabled in the LaunchConfig)
10 ✔
1094
     */
1095
    public async initRendezvousTracking() {
26 ✔
1096
        const timeout = 5000;
1097
        let initCompleted = false;
1098
        await Promise.race([
1099
            util.sleep(timeout),
228 ✔
1100
            this._initRendezvousTracking().finally(() => {
228 ✔
1101
                initCompleted = true;
1102
            })
1103
        ]);
38 ✔
1104

1105
        if (initCompleted === false) {
1106
            this.showPopupMessage(`Rendezvous tracking timed out after ${timeout}ms. Consider setting "rendezvousTracking": false in launch.json`, 'warn').catch((error) => {
1107
                this.logger.error('Error showing popup message', { error });
1108
            });
1109
        }
1110
    }
1111

1112
    private async _initRendezvousTracking() {
1113
        this.rendezvousTracker = new RendezvousTracker(this.deviceInfo, this.launchConfiguration);
1114

1115
        //pass the debug functions used to locate the client files and lines thought the adapter to the RendezvousTracker
1116
        this.rendezvousTracker.registerSourceLocator(async (debuggerPath: string, lineNumber: number) => {
1117
            return this.projectManager.getSourceLocation(debuggerPath, lineNumber);
1118
        });
1119

1120
        // Send rendezvous events to the debug protocol client
1121
        this.rendezvousTracker.on('rendezvous', (output) => {
91 ✔
1122
            this.sendEvent(new RendezvousEvent(output));
91 ✔
1123
        });
91 !
1124

91 ✔
1125
        //clear the history so the user doesn't have leftover rendezvous data from a previous session
28 ✔
1126
        this.rendezvousTracker.clearHistory();
28 ✔
1127

28 ✔
1128
        //if rendezvous tracking is enabled, then enable it on the device
28 ✔
1129
        if (this.launchConfiguration.rendezvousTracking !== false) {
28 ✔
1130
            // start ECP rendezvous tracking (if possible)
17 ✔
1131
            await this.rendezvousTracker.activate();
1132
        }
28 ✔
1133
    }
1134

1135
    /**
1136
     * Anytime a roku adapter emits diagnostics, this method is called to handle it.
1137
     */
1138
    private async handleDiagnostics(diagnostics: BSDebugDiagnostic[]) {
1139
        // Roku device and sourcemap work with 1-based line numbers, VSCode expects 0-based lines.
1140
        for (let diagnostic of diagnostics) {
91 ✔
1141
            diagnostic.source = diagnosticSource;
1142
            let sourceLocation = await this.projectManager.getSourceLocation(diagnostic.path, diagnostic.range.start.line + 1);
1143
            if (sourceLocation) {
1144
                diagnostic.path = sourceLocation.filePath;
1145
                diagnostic.range.start.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
1146
                diagnostic.range.end.line = sourceLocation.lineNumber - 1; //sourceLocation is 1-based, but we need 0-based
38 ✔
1147
            } else {
8 ✔
1148
                // TODO: may need to add a custom event if the source location could not be found by the ProjectManager
1149
                diagnostic.path = fileUtils.removeLeadingSlash(util.removeFileScheme(diagnostic.path));
1150
            }
1151
        }
1152

30 ✔
1153
        //find the first compile error (i.e. first DiagnosticSeverity.Error) if there is one
30 ✔
1154
        this.compileError = diagnostics.find(x => x.severity === DiagnosticSeverity.Error);
30 ✔
1155
        if (this.compileError) {
30 ✔
1156
            this.sendLaunchProgress('end', 'Aborted (compile error)');
5 ✔
1157
            this.sendEvent(new StoppedEvent(
5 ✔
1158
                StoppedEventReason.exception,
1159
                this.COMPILE_ERROR_THREAD_ID,
30 !
1160
                `CompileError: ${this.compileError.message}`
30 ✔
1161
            ));
5 ✔
1162
        }
5 ✔
1163

5 ✔
1164
        this.sendEvent(new DiagnosticsEvent(diagnostics));
5 ✔
1165
    }
5 ✔
1166

5 ✔
1167
    private publishTimeout = 60_000;
1168

4 ✔
1169
    private async publish() {
4 ✔
1170
        const uploadingEnd = this.logger.timeStart('log', 'Uploading zip');
2 ✔
1171
        let packageIsPublished = false;
1 ✔
1172

1173
        //delete any currently installed dev channel (if enabled to do so)
1174
        try {
1 ✔
1175
            if (this.launchConfiguration.deleteDevChannelBeforeInstall === true) {
1176
                await this.rokuDeploy.deleteDevChannel({
1177
                    device: this.launchConfiguration.device,
4 ✔
1178
                    password: this.launchConfiguration.password,
4 ✔
1179
                    username: this.launchConfiguration.username,
1180
                    packagePort: this.launchConfiguration.packagePort
4 ✔
1181
                });
4 ✔
1182
            }
4 ✔
1183
        } catch (e) {
1184
            const statusCode = e?.results?.response?.statusCode;
1185
            const message = e.message as string;
1186
            if (statusCode === 401) {
30 ✔
1187
                await this.shutdown(message, true);
1188
                throw e;
1189
            }
1 !
1190
            this.logger.warn('Failed to delete the dev channel...probably not a big deal', e);
×
1191
        }
×
1192

×
1193
        const isConnected = this.rokuAdapter.once('app-ready');
×
1194
        const options: SideloadOptions = {
1195
            device: this.launchConfiguration.device,
×
1196
            password: this.launchConfiguration.password,
1197
            username: this.launchConfiguration.username,
×
1198
            packagePort: this.launchConfiguration.packagePort,
×
1199
            ecpPort: this.launchConfiguration.remotePort,
×
1200
            //sideload the zip that was already built from the staging folder (or supplied via packagePath)
×
1201
            zip: this.launchConfiguration.packagePath ?? util.getOutputZipPath({ outDir: this.launchConfiguration.outDir }),
1202
            // enable the debug protocol if true
×
1203
            remoteDebug: this.enableDebugProtocol,
1204
            //necessary for capturing compile errors from the protocol (has no effect on telnet)
×
1205
            remoteDebugConnectEarly: false,
×
1206
            //we don't want to fail if there were compile errors...we'll let our compile error processor handle that
×
1207
            failOnCompileError: true,
×
1208
            //deleting the dev channel (when enabled) was already handled at the start of this function
1209
            deleteDevChannel: false,
×
1210
            //the device was already sent to the home screen during configurationDone
1211
            close: false,
×
1212
            //pass any upload form overrides the client may have configured
×
1213
            packageUploadOverrides: this.launchConfiguration.packageUploadOverrides
×
1214
        };
×
1215

1216
        //publish the package to the target Roku
×
1217
        const publishPromise = this.rokuDeploy.sideload(options).then(() => {
1218
            packageIsPublished = true;
×
1219
            //the device is now launching THIS package but won't open the debug protocol port until the JS (Hermes)
×
1220
            //runtime stops waiting for its debugger, so tell the client now rather than after `isConnected`
×
1221
            this.sendEvent(new ChannelSideloadedEvent(
×
1222
                this.launchConfiguration
1223
            ));
×
1224
        }).catch(async (e) => {
1225
            const statusCode = e?.results?.response?.statusCode;
1226
            const message = e.message as string;
×
1227
            if ((statusCode && statusCode !== 200) || isUpdateCheckRequiredError(e) || isConnectionResetError(e)) {
1228
                await this.shutdown(message, true);
1229
                throw e;
×
1230
            }
1231
            this.logger.error(e);
1232
        });
1233

1234
        await publishPromise;
1235

1236
        uploadingEnd();
1237

1238
        //the channel has been deployed. Wait for the adapter to finish connecting.
2 ✔
1239
        //if it hasn't connected after 60 seconds, abort the launch.
1240
        let didTimeOut = false;
1241
        await Promise.race([
1242
            isConnected,
1243
            util.sleep(this.publishTimeout).then(() => {
1244
                didTimeOut = true;
1245
            })
1246
        ]);
1247
        this.logger.log('Finished racing promises');
1248
        if (didTimeOut) {
1249
            this.logger.warn('Timed out waiting for roku to connect');
1250
        }
1251
        //if the adapter is still not connected, then it will probably never connect. Abort.
1252
        if (packageIsPublished && !this.rokuAdapter.connected) {
2 ✔
1253
            return this.shutdown('Debug session cancelled: failed to connect to debug protocol control port.');
2 ✔
1254
        }
1255
    }
2 ✔
1256

1257
    private pendingSendLogPromise = Promise.resolve();
1258

1259
    /**
1260
     * Send log output to the "client" (i.e. vscode)
1261
     * @param logOutput
1262
     */
1263
    private sendLogOutput(logOutput: string) {
1264
        if (this.isCrashed) {
1265
            return Promise.resolve();
1266
        }
1267
        this.fileLoggingManager.writeRokuDeviceLog(logOutput);
11 ✔
1268

1269
        this.pendingSendLogPromise = this.pendingSendLogPromise.then(async () => {
11 ✔
1270
            logOutput = await this.convertBacktracePaths(logOutput);
1271

1272
            const lines = logOutput.split(/\r?\n/g);
1273
            for (let i = 0; i < lines.length; i++) {
1274
                let line = lines[i];
1275
                if (i < lines.length - 1) {
1276
                    line += '\n';
1277
                }
2 ✔
1278

1 ✔
1279
                if (this.launchConfiguration.rewriteDevicePathsInLogs) {
1 ✔
1280
                    let potentialPaths = this.getPotentialPkgPaths(line);
1 !
1281
                    for (let potentialPath of potentialPaths) {
1 !
1282
                        let originalLocation = await this.projectManager.getSourceLocation(potentialPath.path, potentialPath.lineNumber, potentialPath.columnNumber);
×
1283
                        if (originalLocation) {
1284
                            let replacement: string;
1285
                            replacement = originalLocation.filePath.replaceAll(' ', '%20');
1286
                            if (replacement !== originalLocation.filePath) {
1287
                                if (this.isWindowsPlatform) {
1 ✔
1288
                                    replacement = `vscode://file/${replacement}`;
1 ✔
1289
                                } else {
1290
                                    replacement = `file://${replacement}`;
1291
                                }
1292
                            }
1293
                            replacement += `:${originalLocation.lineNumber}`;
1294
                            if (potentialPath.columnNumber !== undefined) {
1295
                                replacement += `:${originalLocation.columnIndex + 1}`;
1296
                            }
1297

×
1298
                            line = line.replaceAll(potentialPath.fullMatch, replacement);
×
1299
                        }
1300
                    }
×
1301
                }
×
1302
                this.sendEvent(new OutputEvent(line, 'stdout'));
1303
                this.sendEvent(new LogOutputEvent(line));
×
1304
            }
×
1305
        });
1306
        return this.pendingSendLogPromise;
×
1307
    }
×
1308

1309
    /**
×
1310
     * Extracts potential package paths from a given line of text.
×
1311
     *
1312
     * This method uses a regular expression to find matches in the provided line
×
1313
     * and returns an array of objects containing details about each match.
×
1314
     *
×
1315
     * @param input - The line of text to search for potential package paths.
1316
     * @returns An array of objects, each containing:
1317
     *   - `fullMatch`: The full matched string.
×
1318
     *   - `path`: The extracted path from the match.
×
1319
     *   - `lineNumber`: The line number extracted from the match.
1320
     *   - `columnNumber`: The column number extracted from the match, or `undefined` if not found.
1321
     */
×
1322
    private getPotentialPkgPaths(input: string): Array<{ fullMatch: string; path: string; lineNumber: number; columnNumber: number }> {
×
1323
        // https://regex101.com/r/ixpQiq/1
×
1324
        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);
1325
        let paths: ReturnType<BrightScriptDebugSession['getPotentialPkgPaths']> = [];
1326
        if (matches) {
×
1327
            for (let match of matches) {
×
1328
                let fullMatch = match[0];
1329
                let path = match[1];
1330
                let lineNumber = parseInt(match[2] ?? match[4]);
×
1331
                let columnNumber = parseInt(match[3] ?? match[5]);
1332
                if (isNaN(columnNumber)) {
1333
                    columnNumber = undefined;
1334
                }
1335
                paths.push({
1336
                    fullMatch: fullMatch,
11 ✔
1337
                    path: path,
2 ✔
1338
                    lineNumber: lineNumber,
1339
                    columnNumber: columnNumber
9 ✔
1340
                });
1341
            }
9 ✔
1342
        }
9 ✔
1343
        return paths;
1344
    }
9 ✔
1345

19 ✔
1346
    /**
19 ✔
1347
     * Converts the filename property in backtrace objects in the given input string to source paths if found
1348
     */
1349
    private async convertBacktracePaths(input: string) {
1350
        if (!this.launchConfiguration.rewriteDevicePathsInLogs) {
1351
            return input;
1352
        }
1353
        // Why does this not work? It should work, but it doesn't. I'm not sure why.
1354
        // let matches = input.matchAll(this.deviceBacktraceObjectRegex);
1355

1356
        // https://regex101.com/r/y1koaV/2
1357
        let deviceBacktraceObjectRegex = /{\s+filename:\s+"([A-Za-z0-9_\.\/\: ]+)"\s+function\:\s+".+"\s+(line_number\:\s+(\d+))\s+}/gi;
1358
        let matches = [];
1359
        let match = deviceBacktraceObjectRegex.exec(input);
1360
        while (match) {
1361
            matches.push(match);
1362
            match = deviceBacktraceObjectRegex.exec(input);
19 ✔
1363
        }
1364

1365
        if (matches) {
1366
            for (let match of matches) {
1367
                let fullMatch = match[0] as string;
1368
                let filePath = match[1] as string;
1369
                let fullLineNumber = match[2] as string;
1370
                let lineNumber = parseInt(match[3] as string);
1371
                let originalLocation = await this.projectManager.getSourceLocation(filePath, lineNumber);
1372
                if (originalLocation) {
10 ✔
1373
                    let fileReplacement: string;
2 ✔
1374
                    fileReplacement = originalLocation.filePath.replaceAll(' ', '%20');
1375
                    if (fileReplacement !== originalLocation.filePath) {
1376
                        if (this.isWindowsPlatform) {
18 ✔
1377
                            fileReplacement = `vscode://file/${fileReplacement}`;
1378
                        } else {
1379
                            fileReplacement = `file://${fileReplacement}`;
1380
                        }
1381
                    }
1382
                    fileReplacement += `:${originalLocation.lineNumber}`;
1383

1384
                    let lineNumberReplacement = fullLineNumber.replace(lineNumber.toString(), originalLocation.lineNumber.toString());
1385

10 ✔
1386
                    // 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
2 ✔
1387
                    let completeReplacement = fullMatch.replace(filePath, fileReplacement);
1388
                    completeReplacement = completeReplacement.replace(fullLineNumber, lineNumberReplacement);
8 ✔
1389
                    input = input.replaceAll(fullMatch, completeReplacement);
9 ✔
1390
                }
8 ✔
1391

1392
            }
1393
        }
1394

7 ✔
1395
        return input;
1396
    }
1397

18 ✔
1398
    private async runAutomaticSceneGraphCommands(commands: string[]) {
1399
        if (commands) {
1400
            let connection = new SceneGraphDebugCommandController(this.launchConfiguration.device, this.launchConfiguration.sceneGraphDebugCommandsPort);
1401

1402
            try {
8 ✔
1403
                await connection.connect();
18 ✔
1404
                for (let command of this.launchConfiguration.autoRunSgDebugCommands) {
18 ✔
1405
                    let response: SceneGraphCommandResponse;
1406
                    switch (command) {
12 ✔
1407
                        case 'chanperf':
12 ✔
1408
                            util.log('Enabling Chanperf Tracking');
2 ✔
1409
                            response = await connection.chanperf({ interval: 1 });
1410
                            if (!response.error) {
12 ✔
1411
                                util.log(response.result.rawResponse);
1412
                            }
1413
                            break;
24 ✔
1414

1415
                        case 'fpsdisplay':
1416
                            util.log('Enabling FPS Display');
36 ✔
1417
                            response = await connection.fpsDisplay('on');
1418
                            if (!response.error) {
1419
                                util.log(response.result.data as string);
1420
                            }
1421
                            break;
1422

22 ✔
1423
                        case 'logrendezvous':
1424
                            util.log('Enabling Rendezvous Logging:');
12 ✔
1425
                            response = await connection.logrendezvous('on');
12 ✔
1426
                            if (!response.error) {
12 ✔
1427
                                util.log(response.result.rawResponse);
11 ✔
1428
                            }
1429
                            break;
1430

1431
                        default:
1432
                            util.log(`Running custom SceneGraph debug command on port 8080 '${command}':`);
1 ✔
1433
                            response = await connection.exec(command);
1 ✔
1434
                            if (!response.error) {
1435
                                util.log(response.result.rawResponse);
1436
                            }
1437
                            break;
7 ✔
1438
                    }
×
1439
                }
1440
                await connection.end();
1441
            } catch (error) {
7 ✔
1442
                util.log(`Error connecting to port 8080: ${error.message}`);
1443
            }
1444
        }
1445
    }
1446

1447
    /**
1448
     * Stage, insert breakpoints, and package the main project
1449
     */
1450
    public async prepareMainProject() {
1451
        //add the main project
1452
        this.projectManager.mainProject = new Project({
1453
            rootDir: this.launchConfiguration.rootDir,
1454
            files: this.launchConfiguration.files,
1455
            outDir: this.launchConfiguration.outDir,
1456
            sourceDirs: this.launchConfiguration.sourceDirs,
1457
            bsConst: this.launchConfiguration.bsConst,
1458
            injectRaleTrackerTask: this.launchConfiguration.injectRaleTrackerTask,
1459
            raleTrackerTaskFileLocation: this.launchConfiguration.raleTrackerTaskFileLocation,
1460
            injectRdbOnDeviceComponent: this.launchConfiguration.injectRdbOnDeviceComponent,
16 ✔
1461
            rdbFilesBasePath: this.launchConfiguration.rdbFilesBasePath,
1462
            stagingDir: this.launchConfiguration.stagingDir,
1463
            packagePath: this.launchConfiguration.packagePath,
32 ✔
1464
            enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
1465
        });
1466

1467
        util.log('Moving selected files to staging area');
16 ✔
1468
        await this.projectManager.mainProject.stage();
28 ✔
1469

1470
        //add the entry breakpoint if stopOnEntry is true
1471
        await this.handleEntryBreakpoint();
16 ✔
1472
    }
24 ✔
1473

24 ✔
1474
    /**
1475
     * Validate every breakpoint against the staged projects and (telnet only) write STOP statements into the
16 ✔
1476
     * staged .brs files. Runs after the DAP `InitializedEvent` so client-side `setBreakpoints` requests have
1477
     * landed before any STOPs are written. Validates the main project and every component library in ONE pass:
16 ✔
1478
     * a breakpoint is only failed when it can't be placed in any project, so validating per project would fail
16 ✔
1479
     * every other project's breakpoints. Must complete before `postfixComponentLibraries` renames the complib
16 ✔
1480
     * files so telnet STOPs are written to the original file names.
1481
     */
40 ✔
1482
    private async writeBreakpoints() {
39 ✔
1483
        //add breakpoint lines to source files and then publish
1484
        util.log('Adding stop statements for active breakpoints');
1485

1486
        //validate breakpoints for all debugger types (and write `stop` statements for telnet — decided internally)
40 ✔
1487
        await this.breakpointManager.validateAndWriteBreakpointsForProjects(this.projectManager.getAllProjects());
39 ✔
1488
    }
1489

1490
    /**
39 ✔
1491
     * Create the main project's zip package (or run the configured packageTask). Must run AFTER
15 ✔
1492
     * `applyLibraryReferencePostfixes` so any rewritten `Library` statements are included in the zip.
1 ✔
1493
     */
1494
    private async zipMainProject() {
14 ✔
1495
        if (this.launchConfiguration.packageTask) {
1496
            util.log(`Executing task '${this.launchConfiguration.packageTask}' to assemble the app`);
24 ✔
1497
            await this.sendCustomRequest('executeTask', { task: this.launchConfiguration.packageTask });
24 ✔
1498

24 ✔
1499
            const packagePath = this.launchConfiguration.packagePath ?? util.getOutputZipPath({ outDir: this.launchConfiguration.outDir });
24 ✔
1500

1501
            if (!fsExtra.pathExistsSync(packagePath)) {
1502
                return this.shutdown(`Cancelling debug session. Package does not exist at '${packagePath}'`);
1503
            }
9 ✔
1504
        } else {
1 ✔
1505
            //create zip package from staging folder
1506
            util.log('Creating zip archive from project sources');
1507
            await this.projectManager.mainProject.zipPackage({ retainStagingFolder: true });
1508
        }
1509
    }
8 !
1510

×
1511
    /**
1512
     * Accepts custom events and requests from the extension
1513
     * @param command name of the command to execute
1514
     */
1515
    protected async customRequest(command: string, response: DebugProtocol.Response, args: any) {
8 ✔
1516
        if (command === 'rendezvous.clearHistory') {
1 ✔
1517
            this.rokuAdapter.clearRendezvousHistory();
1518
        } else if (command === 'chanperf.clearHistory') {
8 ✔
1519
            this.rokuAdapter.clearChanperfHistory();
1520

1521
        } else if (command === 'customRequestEventResponse') {
1522
            this.emit('customRequestEventResponse', args);
23 ✔
1523

1524
        } else if (command === 'popupMessageEventResponse') {
1525
            this.emit('popupMessageEventResponse', args);
1526

1527
        } else if (command === 'captureHeapSnapshot') {
1528
            this.perfettoManager?.captureHeapSnapshot?.().catch((e) => this.logger.error('Failed to capture heap snapshot', e));
1529

1530
        } else if (command === 'startPerfettoTracing') {
1531
            try {
9 !
1532
                await this.perfettoManager?.startTracing?.();
9 ✔
1533
            } catch (e) {
1534
                response.success = false;
1535
                response.body = { message: e?.message || String(e) };
1536
            }
1537

1538
        } else if (command === 'stopPerfettoTracing') {
1539
            try {
1540
                await this.perfettoManager?.stopTracing?.();
1541
            } catch (e) {
1542
                response.success = false;
1543
                response.body = { message: e?.message || String(e) };
8 !
1544
            }
8 ✔
1545

1546
        }
1547
        this.sendResponse(response);
×
1548
    }
×
1549

×
1550
    /**
×
1551
     * Stores the path to the staging folder for each component library
×
1552
     */
1553
    protected async prepareComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
×
1554
        if (!componentLibraries || componentLibraries.length === 0) {
1555
            return;
1556
        }
1557
        let componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
1558
        //make sure this folder exists (and is empty)
1559
        await fsExtra.ensureDir(componentLibrariesOutDir);
1560
        await fsExtra.emptyDir(componentLibrariesOutDir);
6 ✔
1561

6 ✔
1562
        //create a ComponentLibraryProject for each component library
1563
        for (let libraryIndex = 0; libraryIndex < componentLibraries.length; libraryIndex++) {
6 ✔
1564
            let componentLibrary = componentLibraries[libraryIndex];
6 ✔
1565

1566
            this.projectManager.componentLibraryProjects.push(
1567
                new ComponentLibraryProject({
6 ✔
1568
                    rootDir: componentLibrary.rootDir,
1569
                    files: componentLibrary.files,
6 ✔
1570
                    outDir: componentLibrariesOutDir,
6 !
1571
                    outFile: componentLibrary.outFile,
1572
                    sourceDirs: componentLibrary.sourceDirs,
1573
                    bsConst: componentLibrary.bsConst,
×
1574
                    install: componentLibrary.install,
1575
                    enablePostfix: componentLibrary.enablePostfix,
1576
                    injectRaleTrackerTask: componentLibrary.injectRaleTrackerTask,
4 ✔
1577
                    raleTrackerTaskFileLocation: componentLibrary.raleTrackerTaskFileLocation,
4 ✔
1578
                    libraryIndex: libraryIndex,
1579
                    enhanceREPLCompletions: this.launchConfiguration.enhanceREPLCompletions
4 !
1580
                })
×
1581
            );
1582
        }
1583

1584
        //stage all of the libraries in parallel
4 ✔
1585
        await Promise.all(
1586
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.stage())
4 ✔
1587
        );
3 ✔
1588
    }
3 ✔
1589

4 ✔
1590
    /**
4 ✔
1591
     * Postfix each staged complib's own files (rename its `.brs` files with the `__lib<index>` postfix and
1592
     * rewrite its `uri=` references). Runs in `configurationDoneRequest` AFTER `writeBreakpoints` so any
3 !
1593
     * telnet STOPs have already been written to the original file names. Kept separate from zipping so the
×
1594
     * cross-project `Library` reference rewrite can run after EVERY project is postfixed but before any zip
1595
     * is sealed.
1596
     */
1597
    protected async postfixComponentLibraries(componentLibraries: ComponentLibraryConfiguration[]) {
1598
        if (!componentLibraries || componentLibraries.length === 0) {
1599
            return;
1600
        }
1601

1 ✔
1602
        //postfix each complib's own files in parallel
1603
        await Promise.all(
1604
            this.projectManager.componentLibraryProjects.map(compLibProject => compLibProject.postfixFiles())
4 ✔
1605
        );
1606
    }
1607

4 ✔
1608
    /**
1609
     * Seal every component library's zip, install the installable ones (in order), and start static file
1610
     * hosting. Must run AFTER `applyLibraryReferencePostfixes` so each zip contains the rewritten `Library`
1611
     * statements.
1612
     */
1613
    protected async zipServeAndInstallComponentLibraries(componentLibraries: ComponentLibraryConfiguration[], port: number) {
1614
        if (!componentLibraries || componentLibraries.length === 0) {
1615
            return;
1616
        }
24 ✔
1617
        const componentLibrariesOutDir = s`${this.launchConfiguration.outDir}/component-libraries`;
24 ✔
1618

1619
        const needToDeleteComplibs = this.projectManager.componentLibraryProjects.some(x => x.install);
13 ✔
1620
        if (needToDeleteComplibs) {
13 ✔
1621
            //deleteAllComponentLibraries pauses compile-error reporting (to swallow deletion-induced errors) and
10 ✔
1622
            //leaves it paused. Now that deletion is done and we're about to put libraries back on the device, settle
1623
            //(draining any lingering deletion output) and resume reporting so real install errors are surfaced.
13 ✔
1624
            await this.deleteAllComponentLibraries();
9 ✔
1625
        }
1626

13 ✔
1627
        //zip each complib in parallel
9 ✔
1628
        const packagePromises = this.projectManager.componentLibraryProjects.map(
1629
            compLibProject => compLibProject.zipPackage({ retainStagingFolder: true })
13 ✔
1630
        );
1631

1632
        //install component libraries strictly in their declared order (which must be dependency order:
24 ✔
1633
        //a library may only depend on libraries declared before it). Each install is fully awaited before the
24 ✔
1634
        //next begins, and a failure aborts the launch. Installing out of order, or continuing past a failed
11 ✔
1635
        //install, leaves the device with dangling `Library` references that fail to compile.
1636
        for (let i = 0; i < this.projectManager.componentLibraryProjects.length; i++) {
24 ✔
1637
            const compLibProject = this.projectManager.componentLibraryProjects[i];
4 ✔
1638

1639
            if (compLibProject.install === true) {
1640
                //wait for this complib to finish being packaged
24 ✔
1641
                await packagePromises[i];
24 ✔
1642

1643
                if (componentLibraries[i].packageTask) {
1644
                    await this.sendCustomRequest('executeTask', { task: componentLibraries[i].packageTask });
1645
                }
3 ✔
1646

3 ✔
1647
                const options: SideloadOptions = {
3 ✔
1648
                    device: this.launchConfiguration.device,
1649
                    password: this.launchConfiguration.password,
3 !
1650
                    username: this.launchConfiguration.username || 'rokudev',
×
1651
                    packagePort: this.launchConfiguration.packagePort,
1652
                    ecpPort: this.launchConfiguration.remotePort,
1653
                    zip: componentLibraries[i].packagePath ?? util.getOutputZipPath({ outDir: compLibProject.outDir, outFile: compLibProject.outFile }),
1654
                    failOnCompileError: true,
3 !
1655
                    appType: 'dcl',
×
1656
                    //installing a component library should never close or delete the sideloaded channel
×
1657
                    close: false,
×
1658
                    deleteDevChannel: false,
1659
                    packageUploadOverrides: componentLibraries[i].packageUploadOverrides || {}
1660
                };
1661

1662
                util.log(`Installing component library ${i} (${compLibProject.outFile})`);
3 ✔
1663
                try {
3 !
1664
                    await rokuDeploy.sideload(options);
3 ✔
1665
                    util.log(`Installed component library ${i} (${compLibProject.outFile})`);
3 ✔
1666
                } catch (error) {
1667
                    //do NOT continue installing further libraries (or publishing the main app) - a failed install
1668
                    //here means later libraries and the main app would reference a library that isn't on the device
2 ✔
1669
                    this.logger.error(`Error installing component library ${i} (${compLibProject.outFile})`, error);
2 ✔
1670
                    throw error;
2 ✔
1671
                }
1672
            }
1673
        }
1 ✔
1674

3 ✔
1675
        const hostingPromise = this.componentLibraryServer.startStaticFileHosting(componentLibrariesOutDir, port, (message: string) => {
1676
            util.log(message);
1677
        });
3 ✔
1678

3 !
1679
        //wait for all complib packaging to finish and the file hosting to start
3 !
1680
        await Promise.all([
1681
            ...packagePromises,
1682
            hostingPromise
3 !
1683
        ]);
×
1684
    }
1685

3 ✔
1686
    /**
1687
     * Delete every component library installed on the device.
1688
     *
1689
     * A complib can only depend on complibs the user declared BEFORE it, so the user's configured order is already
×
1690
     * dependency order. Deleting in the REVERSE of that order therefore deletes each complib before the ones it
1691
     * depends on, avoiding compile errors entirely. We use that reverse order as the delete priority.
3 !
1692
     *
3 ✔
1693
     * Anything still installed that the user did NOT configure (e.g. leftovers from a previous session) has no known
1694
     * order, so we fall back to compile-error tolerance for those: deleting a complib while another still references
18 !
1695
     * it fails with a compile error, which we ignore and retry later (after its dependent is gone). We finish when
3 !
1696
     * every complib is gone, and fail if the only complibs left are ones that can never be deleted.
×
1697
     */
1698
    private async deleteAllComponentLibraries() {
3 ✔
1699
        const deviceOptions = {
1700
            device: this.launchConfiguration.device,
1701
            password: this.launchConfiguration.password,
1702
            username: this.launchConfiguration.username || 'rokudev'
1703
        };
×
1704

1705
        //The user's configured complibs, in REVERSE declaration order (dependents before their dependencies). A
1706
        //complib's position here is its delete priority; complibs the user didn't configure aren't in this list.
3 ✔
1707
        const deletePriority = this.projectManager.componentLibraryProjects
1708
            .map(complib => complib.outFile)
1709
            .reverse();
1710
        //sort key for a complib: its index in `deletePriority`, or Infinity (delete last) if it wasn't configured
3 ✔
1711
        const priorityOf = (fileName: string) => {
1712
            const index = deletePriority.indexOf(fileName);
1713
            return index === -1 ? Infinity : index;
×
1714
        };
1715

1716
        const maxAttempts = 5;
1717
        //how many times we've tried to delete each complib, and the ones we've given up on after maxAttempts
1 ✔
1718
        const attempts = new Map<string, number>();
1 ✔
1719
        const failed = new Set<string>();
1 ✔
1720

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

1725
            const attemptCount = (complib: { archiveFileName: string }) => attempts.get(complib.archiveFileName) ?? 0;
1726

1727
            //of the complibs we haven't given up on, pick the next to delete: fewest attempts first (spreads retries
1728
            //evenly so a dependent gets deleted before we circle back to a complib that previously failed), and among
1729
            //equal attempts, follow the reverse-configured delete priority (dependents before dependencies).
1730
            const candidates = installed.filter(complib => !failed.has(complib.archiveFileName));
1 ✔
1731
            const next = candidates.sort((a, b) =>
×
1732
                attemptCount(a) - attemptCount(b) ||
×
1733
                priorityOf(a.archiveFileName) - priorityOf(b.archiveFileName)
×
1734
            )[0];
×
1735

×
1736
            //nothing left to try — done if the device is clear, otherwise hard-fail with whatever remains
×
1737
            if (!next) {
×
1738
                if (installed.length > 0) {
1739
                    throw new Error(`Failed to delete existing component libraries on device; ${installed.length} still installed: ${installed.map(x => x.archiveFileName).join(', ')}`);
1740
                }
×
1741
                return;
1742
            }
×
1743

×
1744
            const fileName = next.archiveFileName;
1745
            attempts.set(fileName, (attempts.get(fileName) ?? 0) + 1);
1746
            try {
1747
                await rokuDeploy.deleteComponentLibrary({ ...deviceOptions, fileName: fileName });
1748
            } catch (error) {
×
1749
                //re-throw anything that isn't a dependency compile error (auth, network, etc.)
1750
                if (!this.isComponentLibraryDependencyCompileError(error)) {
1751
                    throw error;
1752
                }
1753
                //Deleting a complib that a dependent still references produces a compile error - but the delete may
1754
                //STILL have succeeded (the device reports both a compile error and 'Delete Succeeded'). If it actually
1755
                //deleted, treat it as done. Only if it did NOT succeed do we defer and retry it later.
1756
                if (this.wasComponentLibraryDeleteSuccessful(error)) {
×
1757
                    this.logger.trace(`Deleted '${fileName}' (device reported a compile error but the delete succeeded)`);
1758
                } else {
1759
                    //another not-yet-deleted complib still depends on this one. Give up on it once it's out of
×
1760
                    //attempts; otherwise leave it pending so we retry after its dependents are gone.
×
1761
                    if (attempts.get(fileName) >= maxAttempts) {
×
1762
                        failed.add(fileName);
1763
                    }
1764
                    this.logger.log(`Deferring delete of '${fileName}'; another component library still depends on it`);
1 ✔
1765
                }
1766
            }
1767

1768
            //the Roku doesn't appreciate back-to-back deletes; give it a moment between requests
1769
            await util.sleep(10);
1770
        }
1771
    }
1772

5 ✔
1773
    /**
5 ✔
1774
     * Is this error the device reporting a compile failure (which, during complib deletion, means another
1 ✔
1775
     * installed complib still references the one we tried to delete)?
1776
     */
1777
    private isComponentLibraryDependencyCompileError(error: any): boolean {
1778
        const message = `${error?.message ?? ''}`;
1779
        return /compile error|compilation failed/i.test(message);
1780
    }
1781

1782
    /**
1783
     * Did the component-library delete actually succeed, even though the device also reported a compile error?
1784
     * When we delete a complib that a dependent still references, the device reports BOTH a compile error AND a
5 ✔
1785
     * `Delete Succeeded` message - meaning the complib really was removed. roku-deploy attaches the parsed device
1786
     * messages (`{ errors, infos, successes }`) to the thrown error as `rokuMessages`; we look for the success there.
1787
     * Absence of that success message means the delete did NOT happen, so it should be treated as a failure/retry.
1788
     */
×
1789
    private wasComponentLibraryDeleteSuccessful(error: any): boolean {
×
1790
        const successes: string[] = error?.rokuMessages?.successes ?? [];
×
1791
        return successes.some(message => /delete succeeded/i.test(message));
×
1792
    }
1793

×
1794
    protected sourceRequest(response: DebugProtocol.SourceResponse, args: DebugProtocol.SourceArguments) {
×
1795
        this.logger.log('sourceRequest');
×
1796
        let old = this.sendResponse;
1797
        this.sendResponse = function sendResponse(...args) {
×
1798
            old.apply(this, args);
×
1799
            this.sendResponse = old;
1800
        };
1801
        super.sourceRequest(response, args);
×
1802
    }
1803

×
1804
    /**
×
1805
     * Called every time a breakpoint is created, modified, or deleted, for each file. This receives the entire list of breakpoints every time.
×
1806
     */
×
1807
    public async setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments) {
1808
        this.logger.log('setBreakpointsRequest', args);
×
1809
        let sanitizedBreakpoints = this.breakpointManager.replaceBreakpoints(args.source.path, args.breakpoints);
×
1810
        //sort the breakpoints
1811
        let sortedAndFilteredBreakpoints = orderBy(sanitizedBreakpoints, [x => x.line, x => x.column]);
1812

×
1813
        response.body = {
×
1814
            breakpoints: sortedAndFilteredBreakpoints
1815
        };
1816
        this.sendResponse(response);
1817

1818
        //ensure we've staged all the files
1819
        await this.stagingDefered.promise;
1820

1821
        await this.rokuAdapter?.syncBreakpoints();
×
1822
    }
1823

×
1824
    protected exceptionInfoRequest(response: DebugProtocol.ExceptionInfoResponse, args: DebugProtocol.ExceptionInfoArguments) {
×
1825
        this.logger.log('exceptionInfoRequest');
×
1826
    }
×
1827

1828
    protected async threadsRequest(response: DebugProtocol.ThreadsResponse) {
×
1829
        this.logger.log('threadsRequest');
×
1830

1831
        let threads = [];
×
1832

×
1833
        //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
×
1834
        if (this.compileError) {
1835
            threads.push(new Thread(this.COMPILE_ERROR_THREAD_ID, 'Compile Error'));
1836
        } else {
×
1837
            //wait for the roku adapter to load
1838
            await this.getRokuAdapter();
×
1839

1840
            //only send the threads request if we are at the debugger prompt
1841
            if (this.rokuAdapter.isAtDebuggerPrompt) {
×
1842
                let rokuThreads = await this.rokuAdapter.getThreads();
1843

×
1844
                for (let thread of rokuThreads) {
×
1845
                    const threadName = this.getThreadName(thread as AdapterThread);
×
1846
                    threads.push(
×
1847
                        new Thread(thread.threadId, threadName)
1848
                    );
×
1849
                }
×
1850

1851
                if (threads.length === 0) {
×
1852
                    threads = [{
×
1853
                        id: 1001,
×
1854
                        name: 'unable to retrieve threads: not stopped',
1855
                        isFake: true
1856
                    }];
×
1857
                }
1858

×
1859
            } else {
×
1860
                this.logger.log('Skipped getting threads because the RokuAdapter is not accepting input at this time.');
×
1861
            }
×
1862

1863
        }
×
1864

×
1865
        response.body = {
1866
            threads: threads
×
1867
        };
×
1868

×
1869
        this.sendResponse(response);
1870
    }
1871

×
1872
    /**
×
1873
     * Get the thread name to display in the UI based on the thread info we have.
×
1874
     * This is what displays in the `call stack` region in vscode
1875
     * @param thread
1876
     * @returns
1877
     */
4 ✔
1878
    private getThreadName(thread: AdapterThread) {
4 ✔
1879
        let threadName = '';
4 ✔
1880
        if (thread.type || thread.name || thread.osThreadId) {
4 ✔
1881
            //build the name from only the parts that are present, so missing values don't leak into the name
4 ✔
1882
            const parts: string[] = [];
1883
            if (thread.type) {
4 ✔
1884
                parts.push(`[${thread.type}]`);
4 ✔
1885
            }
1886
            if (thread.name) {
4 ✔
1887
                parts.push(thread.name);
4 !
1888
            }
×
1889
            if (thread.osThreadId) {
×
1890
                parts.push(thread.osThreadId);
×
1891
            }
×
1892
            threadName = parts.join(' ');
1893
        }
1894
        //remove any extraneous whitespace to deal with missing values
4 ✔
1895
        threadName = threadName.replace(/\s+/g, ' ').trim();
4 !
1896

×
1897
        if (threadName === '') {
×
1898
            threadName = `Thread ${thread.threadId}`;
×
1899
        }
1900

4 ✔
1901
        if (thread.isDetached) {
1902
            threadName += ' [detached]';
4 ✔
1903
        }
2 ✔
1904

1905
        //remove any extraneous whitespace to deal with missing values
1906
        threadName = threadName.replace(/\s+/g, ' ').trim();
4 !
1907

1908
        return threadName;
×
1909
    }
1910

×
1911
    protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) {
×
1912
        try {
×
1913
            this.logger.log('stackTraceRequest');
×
1914
            let frames: DebugProtocol.StackFrame[] = [];
×
1915

1916
            //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
×
1917
            if (this.compileError) {
1918
                frames.push(new StackFrame(
1919
                    0,
×
1920
                    'Compile Error',
×
1921
                    new Source(path.basename(this.compileError.path), this.compileError.path),
×
1922
                    // range is 0-based; toClientLine/toClientColumn handle client coordinate conversion
×
1923
                    this.toClientLine(this.compileError.range.start.line),
×
1924
                    this.toClientColumn(this.compileError.range.start.character)
×
1925
                ));
×
1926
            } else if (args.threadId === 1001) {
1927
                frames.push(new StackFrame(
1928
                    0,
×
1929
                    'ERROR: threads would not stop',
×
1930
                    new Source('main.brs', s`${this.launchConfiguration.stagingDir}/manifest`),
×
1931
                    this.toClientLine(0),
×
1932
                    this.toClientColumn(0)
×
1933
                ));
1934
                this.showPopupMessage('Unable to suspend threads. Debugger is in an unstable state, please press Continue to resume debugging', 'warn').catch((error) => {
×
1935
                    this.logger.error('Error showing popup message', { error });
×
1936
                });
1937
            } else {
×
1938
                //ensure the rokuAdapter is loaded
×
1939
                await this.getRokuAdapter();
1940

1941
                if (this.rokuAdapter.isAtDebuggerPrompt) {
×
1942
                    let stackTrace = await this.rokuAdapter.getStackTrace(args.threadId);
1943
                    if (stackTrace.length === 0) {
×
1944
                        // Thread is detached or encountered an error requesting Stack Trace — show a non-interactive label so VS Code can display
1945
                        // the thread without letting the user navigate to a source location
1946
                        const frame = new StackFrame(0, '[unavailable]');
×
1947
                        frame.presentationHint = 'label';
1948
                        frames.push(frame);
1949
                    } else {
1950
                        for (let debugFrame of stackTrace) {
×
1951
                            let sourceLocation = await this.projectManager.getSourceLocation(debugFrame.filePath, debugFrame.lineNumber);
×
1952

1953
                            //the stacktrace returns function identifiers in all lower case. Try to get the actual case
1954
                            //load the contents of the file and get the correct casing for the function identifier
×
1955
                            try {
×
1956
                                let functionName = this.fileManager.getCorrectFunctionNameCase(sourceLocation?.filePath, debugFrame.functionIdentifier);
×
1957
                                if (functionName) {
1958

1959
                                    //search for original function name if this is an anonymous function.
×
1960
                                    //anonymous function names are prefixed with $ in the stack trace (i.e. $anon_1 or $functionname_40002)
1961
                                    if (functionName.startsWith('$')) {
1962
                                        functionName = this.fileManager.getFunctionNameAtPosition(
1963
                                            sourceLocation.filePath,
4 ✔
1964
                                            sourceLocation.lineNumber - 1,
1965
                                            functionName
1966
                                        );
1967
                                    }
4 ✔
1968
                                    debugFrame.functionIdentifier = functionName;
1969
                                }
1970
                            } catch (error) {
1971
                                this.logger.error('Error correcting function identifier case', { error, sourceLocation, debugFrame });
1972
                            }
1973
                            const filePath = sourceLocation?.filePath ?? debugFrame.filePath;
1974

×
1975
                            const frame: DebugProtocol.StackFrame = new StackFrame(
×
1976
                                debugFrame.frameId,
×
1977
                                `${debugFrame.functionIdentifier}`,
1978
                                new Source(path.basename(filePath), filePath),
1979
                                // lineNumber is 1-based from Roku; toClientLine expects 0-based
4 ✔
1980
                                this.toClientLine((sourceLocation?.lineNumber ?? debugFrame.lineNumber) - 1),
1981
                                this.toClientColumn(0)
4 ✔
1982
                            );
4 !
1983
                            if (!sourceLocation) {
×
1984
                                frame.presentationHint = 'subtle';
1985
                            }
1986
                            frames.push(frame);
1987
                        }
1988
                    }
4 !
1989
                } else {
×
1990
                    this.logger.log('Skipped calculating stacktrace because the RokuAdapter is not accepting input at this time');
1991
                }
4 !
1992
            }
1993
            response.body = {
4 !
1994
                stackFrames: frames,
1995
                totalFrames: frames.length
×
1996
            };
×
1997
            this.sendResponse(response);
1998
        } catch (error) {
1999
            this.logger.error('Error getting stacktrace', { error, args });
×
2000
        }
2001
    }
2002

4 !
2003
    protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) {
2004
        const logger = this.logger.createLogger(`scopesRequest ${this.idCounter}`);
4 ✔
2005
        logger.info('begin', { args });
2006
        try {
6 ✔
2007
            const scopes = new Array<DebugProtocol.Scope>();
4 ✔
2008

2 ✔
2009
            // create the locals scope
2010
            let v = this.getOrCreateLocalsScope(args.frameId);
4 !
2011

×
2012
            let localScope: DebugProtocol.Scope = {
2013
                name: 'Local',
4 !
2014
                variablesReference: v.variablesReference,
×
2015
                // Flag the locals scope as expensive if the client asked that it be loaded lazily
2016
                expensive: this.launchConfiguration.deferScopeLoading,
2017
                presentationHint: 'locals'
4 ✔
2018
            };
2019

2020
            const frame = this.rokuAdapter.getStackFrameById(args.frameId);
2021
            if (frame) {
4 ✔
2022
                const scopeRange = await this.projectManager.getScopeRange(frame.filePath, { line: frame.lineNumber - 1, character: 0 });
2023

2024
                if (scopeRange) {
2025
                    localScope.line = this.toClientLine(scopeRange.start.line - 1);
2026
                    localScope.column = this.toClientColumn(scopeRange.start.column);
2027
                    localScope.endLine = this.toClientLine(scopeRange.end.line - 1);
2028
                    localScope.endColumn = this.toClientColumn(scopeRange.end.column);
2029
                }
4 ✔
2030
            }
2031

3 ✔
2032
            scopes.push(localScope);
2033

2034
            // create the registry scope
1 ✔
2035
            let registryRefId = this.getEvaluateRefId('$$registry', Infinity);
1 !
2036
            scopes.push(<DebugProtocol.Scope>{
1 !
2037
                name: 'Registry',
1 ✔
2038
                variablesReference: registryRefId,
1 ✔
2039
                expensive: true
2040
            });
×
2041

2042
            this.variables[registryRefId] = {
×
2043
                variablesReference: registryRefId,
×
2044
                name: 'Registry',
2045
                value: '',
×
2046
                type: '$$Registry',
×
2047
                isScope: true,
×
2048
                childVariables: []
×
2049
            };
×
2050

2051
            response.body = {
×
2052
                scopes: scopes
2053
            };
2054
            logger.debug('send response', { response });
2055
            this.sendResponse(response);
2056
            logger.info('end');
2057
        } catch (error) {
2058
            logger.error('Error getting scopes', { error, args });
2059
        }
1 ✔
2060
    }
1 ✔
2061

1 ✔
2062
    /**
2063
     * Get the locals scope container for a frame, creating an (unpopulated) one if it doesn't exist yet.
×
2064
     * The child variables are filled in lazily by `populateScopeVariables`.
2065
     */
2066
    private getOrCreateLocalsScope(frameId: number): AugmentedVariable {
×
2067
        const refId = this.getEvaluateRefId('$$locals', frameId);
2068
        if (!this.variables[refId]) {
2069
            this.variables[refId] = {
2070
                variablesReference: refId,
×
2071
                name: 'Locals',
×
2072
                value: '',
2073
                type: '$$Locals',
2074
                frameId: frameId,
2075
                isScope: true,
2076
                childVariables: []
2077
            };
×
2078
        }
×
2079
        return this.variables[refId];
×
2080
    }
2081

2082
    protected async continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments) {
1 ✔
2083
        //if we have a compile error, we should shut down
2084
        if (this.compileError) {
1 !
2085
            this.sendResponse(response);
×
2086
            await this.shutdown();
2087
            return;
2088
        }
2089

2090
        this.logger.log('continueRequest');
2091
        await this.setTransientsToInvalid(); // call before clearState
×
2092
        this.clearState();
×
2093

×
2094
        // The debug session ends after the next line. Do not put new work after this line.
2095
        await this.rokuAdapter.continue();
2096
        this.sendResponse(response);
2097
    }
6 ✔
2098

5 ✔
2099
    protected async pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
2100
        this.logger.log('pauseRequest');
6 ✔
2101

6 ✔
2102
        //if we have a compile error, we should shut down
6 ✔
2103
        if (this.compileError) {
2104
            this.sendResponse(response);
2105
            await this.shutdown();
2106
            return;
2107
        }
15 ✔
2108

15 ✔
2109
        await this.rokuAdapter.pause();
15 !
2110
        this.sendResponse(response);
×
2111
    }
×
2112

×
2113
    protected reverseContinueRequest(response: DebugProtocol.ReverseContinueResponse, args: DebugProtocol.ReverseContinueArguments) {
×
2114
        this.logger.log('reverseContinueRequest');
×
2115
        this.sendResponse(response);
×
2116
    }
×
2117

2118
    /**
15 ✔
2119
     * Clicked the "Step Over" button
15 ✔
2120
     * @param response
15 ✔
2121
     * @param args
2122
     */
2123
    protected async nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) {
15 ✔
2124
        this.logger.log('[nextRequest] begin');
4 ✔
2125

2126
        //if we have a compile error, we should shut down
15 ✔
2127
        if (this.compileError) {
1 ✔
2128
            this.sendResponse(response);
1 !
2129
            await this.shutdown();
1 ✔
2130
            return;
1 ✔
2131
        }
2132

2133
        await this.setTransientsToInvalid(); // call before clearState
2134
        this.clearState();
2135

2136
        // The debug session ends after the next line. Do not put new work after this line.
×
2137
        try {
2138
            await this.rokuAdapter.stepOver(args.threadId);
2139
            this.logger.info('[nextRequest] end');
2140
        } catch (error) {
14 !
2141
            this.logger.error(`[nextRequest] Error running '${BrightScriptDebugSession.prototype.nextRequest.name}()'`, error);
2142
        }
2143
        this.sendResponse(response);
14 ✔
2144
    }
2145

14 ✔
2146
    protected async stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
11 ✔
2147
        this.logger.log('[stepInRequest]');
2148

2149
        //if we have a compile error, we should shut down
11 ✔
2150
        if (this.compileError) {
1 ✔
2151
            this.sendResponse(response);
2152
            await this.shutdown();
2153
            return;
10 ✔
2154
        }
10 !
2155

×
2156
        await this.setTransientsToInvalid(); // call before clearState
2157
        this.clearState();
10 ✔
2158
        // The debug session ends after the next line. Do not put new work after this line.
2159
        await this.rokuAdapter.stepInto(args.threadId);
2160
        this.sendResponse(response);
10 ✔
2161
        this.logger.info('[stepInRequest] end');
10 ✔
2162
    }
2163

11 ✔
2164
    protected async stepOutRequest(response: DebugProtocol.StepOutResponse, args: DebugProtocol.StepOutArguments) {
2165
        this.logger.log('[stepOutRequest] begin');
2166

2167
        //if we have a compile error, we should shut down
21 ✔
2168
        if (this.compileError) {
21 ✔
2169
            this.sendResponse(response);
2170
            await this.shutdown();
2171
            return;
2172
        }
2173

3 ✔
2174
        await this.setTransientsToInvalid(); // call before clearState
3 ✔
2175
        this.clearState();
3 !
2176

2177
        // The debug session ends after the next line. Do not put new work after this line.
2178
        await this.rokuAdapter.stepOut(args.threadId);
3 ✔
2179
        this.sendResponse(response);
3 ✔
2180
        this.logger.info('[stepOutRequest] end');
2181
    }
2182

3 ✔
2183
    protected stepBackRequest(response: DebugProtocol.StepBackResponse, args: DebugProtocol.StepBackArguments) {
1 ✔
2184
        this.logger.log('[stepBackRequest] begin');
1 !
2185
        this.sendResponse(response);
2186
        this.logger.info('[stepBackRequest] end');
2187
    }
1 !
2188

2189
    public async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) {
2190
        const logger = this.logger.createLogger('[variablesRequest]');
3 ✔
2191
        let sendInvalidatedEvent = false;
2 ✔
2192
        let frameId: number = null;
2193
        try {
2194
            logger.log('begin', { args });
2195

2196
            //ensure the rokuAdapter is loaded
2197
            await this.getRokuAdapter();
1 ✔
2198

1 !
2199
            let updatedVariables: AugmentedVariable[] = [];
2200
            //wait for any `evaluate` commands to finish so we have a higher likely hood of being at a debugger prompt
2201
            await this.evaluateRequestPromise;
2202
            if (this.rokuAdapter?.isAtDebuggerPrompt !== true) {
2203
                logger.log('Skipped getting variables because the RokuAdapter is not accepting input at this time');
2204
                response.success = false;
2205
                response.message = 'Debug session is not paused';
2206
                return this.sendResponse(response);
×
2207
            }
×
2208

×
2209
            //find the variable with this reference
2210
            let v = this.variables[args.variablesReference];
15 ✔
2211
            if (!v) {
15 ✔
2212
                response.success = false;
2213
                response.message = `Variable reference has expired`;
2214
                return this.sendResponse(response);
15 ✔
2215
            }
2216
            logger.log('variable', v);
2217

19 ✔
2218
            // Populate scope level values if needed
19 ✔
2219
            if (v.isScope) {
6 ✔
2220
                await this.populateScopeVariables(v, args);
6 ✔
2221
            }
6 ✔
2222

6 ✔
2223
            //query for child vars if we haven't done it yet or DAP is asking to resolve a lazy variable
5 ✔
2224
            if (v.childVariables.length === 0 || v.isResolved) {
2225
                let tempVar: AugmentedVariable;
6 ✔
2226
                if (!v.isResolved) {
6 ✔
2227
                    // Evaluate the variable
6 ✔
2228
                    try {
6 ✔
2229
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: v.evaluateName, frameId: v.frameId }, util.getVariablePath(v.evaluateName));
6 !
2230
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, v.frameId);
×
2231
                        tempVar = await this.getVariableFromResult(result, v.frameId);
2232
                        tempVar.frameId = v.frameId;
6 ✔
2233
                        // Determine if the variable has changed
2234
                        sendInvalidatedEvent = v.type !== tempVar.type || v.indexedVariables !== tempVar.indexedVariables;
19 ✔
2235
                    } catch (error) {
2236
                        logger.error('Error getting variables', error);
2237
                        tempVar = new Variable('Error', `❌ Error: ${error.message}`);
×
2238
                        tempVar.type = '';
2239
                        tempVar.childVariables = [];
2240
                        sendInvalidatedEvent = true;
2241
                        response.success = false;
×
2242
                        response.message = error.message;
×
2243
                    }
×
2244

×
2245
                    // Merge the resulting updates together
×
2246
                    v.childVariables = tempVar.childVariables;
×
2247
                    v.value = tempVar.value;
×
2248
                    v.type = tempVar.type;
×
2249
                    v.indexedVariables = tempVar.indexedVariables;
×
2250
                    v.namedVariables = tempVar.namedVariables;
×
2251
                }
×
2252
                frameId = v.frameId;
2253

×
2254
                if (v?.presentationHint?.lazy || v.isResolved) {
×
2255
                    // If this was a lazy variable we need to respond with the updated variable and not the children
×
2256
                    if (v.isResolved && v.childVariables.length > 0) {
×
2257
                        updatedVariables = v.childVariables;
×
2258
                    } else {
2259
                        updatedVariables = [v];
×
2260
                    }
2261
                    v.isResolved = true;
×
2262
                } else {
2263
                    updatedVariables = v.childVariables;
×
2264
                }
×
2265

×
2266
                // If the variable has no children, set the reference to 0
×
2267
                // so it does not look expandable in the Ui
×
2268
                if (v.childVariables.length === 0) {
2269
                    v.variablesReference = 0;
×
2270
                }
×
2271

×
2272
                // If the variable was resolve in the past we may not have fetched a new temp var
×
2273
                tempVar ??= v;
×
2274
                if (v?.presentationHint) {
×
2275
                    v.presentationHint.lazy = tempVar.presentationHint?.lazy;
2276
                } else {
2277
                    v.presentationHint = tempVar.presentationHint;
×
2278
                }
2279

2280
            } else {
2281
                updatedVariables = v.childVariables;
20 ✔
2282
            }
2283

2284
            // Only send the updated variables if we are not going to trigger an invalidated event.
20 ✔
2285
            // This is to prevent the UI from updating twice and makes the experience much smoother to the end user.
20 ✔
2286
            response.body = {
20 ✔
2287
                variables: this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
20 !
2288
                // TODO: Re-enable this when we can send the correct variables based on the initial inspect context
2289
                // variables: sendInvalidatedEvent ? [] : this.filterVariablesUpdates(updatedVariables, args, this.variables[args.variablesReference])
×
2290
            };
2291
        } catch (error) {
2292
            logger.error('Error during variablesRequest', error, { args });
×
2293
            response.success = false;
2294
            response.message = error?.message ?? 'Error during variablesRequest';
20 ✔
2295
        } finally {
20 ✔
2296
            logger.info('end', { response });
2297
        }
2298
        this.sendResponse(response);
2299
        if (sendInvalidatedEvent) {
20 ✔
2300
            this.debounceSendInvalidatedEvent(null, frameId);
2301
        }
2302
    }
2303

20 ✔
2304
    private debounceSendInvalidatedEvent = debounce((threadId: number, frameId: number) => {
2305
        this.sendInvalidatedEvent(threadId, frameId);
2306
    }, 50);
2307

20 ✔
2308

20 !
2309
    private filterVariablesUpdates(updatedVariables: Array<AugmentedVariable>, args: DebugProtocol.VariablesArguments, v: DebugProtocol.Variable): Array<AugmentedVariable> {
20 ✔
2310
        if (!updatedVariables || !v) {
2311
            return [];
20 !
2312
        }
2313

20 ✔
2314
        let start = args.start ?? 0;
4 ✔
2315

2316
        //if the variable is an array, send only the requested range
2317
        if (Array.isArray(updatedVariables) && args.filter === 'indexed') {
20 ✔
2318
            //only send the variable range requested by the debugger
2319
            if (!args.count) {
20 ✔
2320
                updatedVariables = updatedVariables.slice(0, v.indexedVariables);
2321
            } else {
2322
                updatedVariables = updatedVariables.slice(start, start + args.count);
2323
            }
19 ✔
2324
        }
2325

2326
        if (Array.isArray(updatedVariables) && args.filter === 'named') {
2327
            // We currently do not support named variable paging so we always send all named variables
19 ✔
2328
            updatedVariables = updatedVariables.slice(v.indexedVariables);
2329
        }
2330

21 !
2331
        let filteredUpdatedVariables = this.launchConfiguration.showHiddenVariables !== true ? updatedVariables.filter(
19 ✔
2332
            (child: AugmentedVariable) => !child.name.startsWith(this.tempVarPrefix)) : updatedVariables;
2333

21 ✔
2334
        if (this.launchConfiguration.showHiddenVariables !== true) {
21 ✔
2335
            filteredUpdatedVariables = filteredUpdatedVariables.filter((child: AugmentedVariable) => {
2336
                //A transient variable that we show when there is a value
17 !
2337
                if (child.name === '__brs_err__' && child.type !== VariableType.Uninitialized) {
17 ✔
2338
                    return true;
2339
                } else if (util.isTransientVariable(child.name)) {
17 !
2340
                    return false;
2341
                } else {
2342
                    return true;
×
2343
                }
×
2344
            });
2345
        }
17 ✔
2346

2347
        return filteredUpdatedVariables;
2348
    }
21 ✔
2349

2350
    /**
2351
     * Takes a scope variable and populates its child variables based on the scope type and the current adapter type.
2352
     * @param v scope variable to populate
2353
     * @param args
2354
     */
21 ✔
2355
    private async populateScopeVariables(v: AugmentedVariable, args: DebugProtocol.VariablesArguments) {
2356
        if (v.childVariables.length > 0) {
2357
            // Already populated
2358
            return;
4 ✔
2359
        }
2360

17 ✔
2361
        let tempVar: AugmentedVariable;
2362
        try {
2363
            if (v.type === '$$Locals') {
2364
                if (this.rokuAdapter.isDebugProtocolAdapter()) {
3 ✔
2365
                    let result = await this.rokuAdapter.getLocalVariables(v.frameId);
3 ✔
2366
                    tempVar = await this.getVariableFromResult(result, v.frameId);
3 ✔
2367
                } else if (this.rokuAdapter.isTelnetAdapter()) {
2368
                    // NOTE: Legacy telnet support
21 ✔
2369
                    let variables: AugmentedVariable[] = [];
2370
                    const varNames = await this.rokuAdapter.getScopeVariables();
2371

19 ✔
2372
                    // Fetch each variable individually
15 ✔
2373
                    for (const varName of varNames) {
2374
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: varName, frameId: -1 }, util.getVariablePath(varName));
15 ✔
2375
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, -1);
2376
                        let tempLocalsVar = await this.getVariableFromResult(result, -1);
90 !
2377
                        variables.push(tempLocalsVar);
2378
                    }
29 !
2379
                    tempVar = {
2380
                        ...v,
90 !
2381
                        childVariables: variables,
2382
                        namedVariables: variables.length,
2383
                        indexedVariables: 0
15 ✔
2384
                    };
185 ✔
2385
                }
2386

2387
                // Merge the resulting updates together onto the original variable
555 !
2388
                v.childVariables = tempVar.childVariables;
2389
                v.namedVariables = tempVar.namedVariables;
2390
                v.indexedVariables = tempVar.indexedVariables;
2391
            } else if (v.type === '$$Registry') {
2392
                // This is a special scope variable used to load registry data via an ECP call
2393
                // Send the registry ECP call for the `dev` app as side loaded apps are always `dev`
19 ✔
2394
                await populateVariableFromRegistryEcp({ remotePort: this.launchConfiguration.remotePort, device: this.launchConfiguration.device, appId: 'dev' }, v, this.variables, this.getEvaluateRefId.bind(this));
4 ✔
2395
            }
308 ✔
2396
        } catch (error) {
2397
            logger.error(`Error getting variables for scope ${v.type}`, error);
2398
            tempVar = {
1,848 !
2399
                name: '',
2400
                value: `❌ Error: ${error.message}`,
2401
                variablesReference: 0,
2402
                childVariables: []
4 ✔
2403
            };
4 ✔
2404
            v.childVariables = [tempVar];
4 ✔
2405
            v.namedVariables = 1;
4 ✔
2406
            v.indexedVariables = 0;
1 !
2407
        }
1 ✔
2408

2409
        // Mark the scope as resolved so we don't re-fetch the variables
2410
        v.isResolved = true;
2411

2412
        // If the scope has no children, add a single child to indicate there are no values
2413
        if (v.childVariables.length === 0) {
2414
            tempVar = {
2415
                name: '',
2416
                value: `No values for scope '${v.name}'`,
×
2417
                variablesReference: 0,
2418
                childVariables: []
2419
            };
2420
            v.childVariables = [tempVar];
2421
            v.namedVariables = 1;
2422
            v.indexedVariables = 0;
2423
        }
20 ✔
2424
    }
499 ✔
2425

496 ✔
2426
    private evaluateRequestPromise = Promise.resolve();
496 ✔
2427
    private evaluateVarIndexByFrameId = new Map<number, number>();
2428

2429
    private getNextVarIndex(frameId: number): number {
20 ✔
2430
        if (!this.evaluateVarIndexByFrameId.has(frameId)) {
2431
            this.evaluateVarIndexByFrameId.set(frameId, 0);
2432
        }
2433
        let value = this.evaluateVarIndexByFrameId.get(frameId);
2434
        this.evaluateVarIndexByFrameId.set(frameId, value + 1);
2435
        return value;
2436
    }
×
2437

2438
    public async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) {
20 ✔
2439
        //ensure the rokuAdapter is loaded
2440
        await this.getRokuAdapter();
2441

2442
        let deferred = defer<void>();
2443
        if (args.context === 'repl' && this.rokuAdapter.isTelnetAdapter() && args.expression.trim().startsWith('>')) {
2444
            this.clearState();
2445
            this.rokuAdapter.clearCache();
69 ✔
2446
            const expression = args.expression.replace(/^\s*>\s*/, '');
69 ✔
2447
            this.logger.log('Sending raw telnet command...I sure hope you know what you\'re doing', { expression });
69 ✔
2448
            this.rokuAdapter.requestPipeline.client.write(`${expression}\r\n`);
69 ✔
2449
            this.sendResponse(response);
69 !
2450
            return deferred.promise;
69 ✔
2451
        }
69 ✔
2452

2453
        try {
2454
            this.evaluateRequestPromise = this.evaluateRequestPromise.then(() => {
69 ✔
2455
                return deferred.promise;
2 ✔
2456
            });
2457

2458
            //fix vscode hover bug that excludes closing quotemark sometimes.
2459
            if (args.context === 'hover') {
2460
                args.expression = util.ensureClosingQuote(args.expression);
67 ✔
2461
            }
67 ✔
2462

2463
            if (!this.rokuAdapter.isAtDebuggerPrompt) {
2464
                let message = 'Skipped evaluate request because RokuAdapter is not accepting requests at this time';
2465
                if (args.context === 'repl') {
67 ✔
2466
                    this.sendEvent(new OutputEvent(message, 'stderr'));
67 ✔
2467
                    response.body = {
2468
                        result: 'invalid',
12 ✔
2469
                        variablesReference: 0
12 ✔
2470
                    };
×
2471
                } else {
2472
                    throw new Error(message);
12 ✔
2473
                }
12 ✔
2474

2475
                //is at debugger prompt
8 ✔
2476
            } else if (args.expression.trim()) {
8 ✔
2477
                // We trim and check that the expression is not an empty string so that we do not send empty expressions to the Roku
2478
                // This happens mostly when hovering over leading whitespace in the editor
8 ✔
2479

2480
                let { evalArgs, variablePath } = await this.evaluateExpressionToTempVar(args, util.getVariablePath(args.expression));
2481

2482
                //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`
2483
                if (variablePath) {
67 ✔
2484
                    let refId = this.getEvaluateRefId(evalArgs.expression, evalArgs.frameId);
67 ✔
2485
                    let v: AugmentedVariable;
67 ✔
2486
                    //if we already looked this item up, return it
438 ✔
2487
                    if (this.variables[refId]) {
438 ✔
2488
                        v = this.variables[refId];
10 ✔
2489
                    } else {
2490
                        let result = await this.rokuAdapter.getVariable(evalArgs.expression, evalArgs.frameId);
428 ✔
2491
                        if (!result) {
12 ✔
2492
                            throw new Error('Error: unable to evaluate expression');
2493
                        }
2 ✔
2494

2495
                        v = await this.getVariableFromResult(result, evalArgs.frameId);
10 ✔
2496
                        //TODO - testing something, remove later
2497
                        // eslint-disable-next-line camelcase
416 ✔
2498
                        v.request_seq = response.request_seq;
23 ✔
2499
                        v.frameId = evalArgs.frameId;
2500
                    }
413 ✔
2501
                    response.body = {
2502
                        result: v.value,
67 ✔
2503
                        type: v.type,
2504
                        variablesReference: v.variablesReference,
2505
                        namedVariables: v.namedVariables || 0,
2506
                        indexedVariables: v.indexedVariables || 0
67 ✔
2507
                    };
2 ✔
2508

2509
                    //run an `evaluate` call
65 ✔
2510
                } else {
25 ✔
2511
                    let commandResults = await this.rokuAdapter.evaluate(evalArgs.expression, evalArgs.frameId);
2512

2513
                    commandResults.message = util.trimDebugPrompt(commandResults.message);
2514
                    if (args.context === 'repl') {
65 ✔
2515
                        // Clear variable cache since this action could have side-effects
2516
                        // Only do this for REPL requests as hovers and watches should not clear the cache
10 ✔
2517
                        this.clearState();
2518
                        this.sendInvalidatedEvent(null, evalArgs.frameId);
55 ✔
2519
                    }
2520

25 ✔
2521
                    // If the adapter captured output (probably only telnet), log the results
2522
                    if (typeof commandResults.message === 'string') {
2523
                        this.logger.debug('evaluateRequest', { commandResults });
30 ✔
2524
                        if (args.context === 'repl') {
2525
                            // If the command was a repl command, send the output to the debug console for the developer as well
2526
                            // We limit this to repl only so you don't get extra logs when hovering over variables ro running watches
65 ✔
2527
                            this.sendEvent(new OutputEvent(commandResults.message, commandResults.type === 'error' ? 'stderr' : 'stdio'));
2 ✔
2528
                        }
2529
                    }
2530

2531
                    if (this.enableDebugProtocol || (typeof commandResults.message !== 'string')) {
63 ✔
2532
                        response.body = {
2533
                            result: 'invalid',
63 ✔
2534
                            variablesReference: 0
17 ✔
2535
                        };
2536
                    } else {
63 ✔
2537
                        response.body = {
2538
                            result: commandResults.message === '\r\n' ? 'invalid' : commandResults.message,
63 ✔
2539
                            variablesReference: 0
8 ✔
2540
                        };
2541
                    }
63 ✔
2542
                }
2543
            }
2544
        } catch (error) {
2545
            this.logger.error('Error during variables request', error);
2546
            response.success = false;
2547
            response.message = error?.message ?? error;
2548
        }
2549
        try {
2550
            this.sendResponse(response);
2551
        } catch { }
2552
        deferred.resolve();
2553
    }
2554

2555
    private async evaluateExpressionToTempVar(args: DebugProtocol.EvaluateArguments, variablePath: string[]): Promise<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }> {
20 ✔
2556
        let returnVal = { evalArgs: args, variablePath };
20 ✔
2557
        if (!variablePath && util.isAssignableExpression(args.expression)) {
20 ✔
2558
            let varIndex = this.getNextVarIndex(args.frameId);
20 !
2559
            let arrayVarName = this.tempVarPrefix + 'eval';
20 ✔
2560
            let command = '';
20 ✔
2561
            if (varIndex === 0) {
20 ✔
2562
                await this.rokuAdapter.evaluate(`if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`, args.frameId);
7 ✔
2563
            }
2564
            let statement = `${arrayVarName}[${varIndex}] = ${args.expression}`;
20 ✔
2565
            returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
2566
            command += statement;
2567
            let commandResults = await this.rokuAdapter.evaluate(command, args.frameId);
2568
            if (commandResults.type === 'error') {
2569
                throw new Error(commandResults.message);
2570
            }
2571
            returnVal.variablePath = [arrayVarName, varIndex.toString()];
2572
        }
2573
        return returnVal;
2574
    }
67 ✔
2575

67 ✔
2576
    private async bulkEvaluateExpressionToTempVar(frameId: number, argsArray: Array<DebugProtocol.EvaluateArguments>, variablePathArray: Array<string[]>): Promise<{ evaluations: Array<{ evalArgs: DebugProtocol.EvaluateArguments; variablePath: string[] }>; bulkVarName: string }> {
604 ✔
2577
        let results = {
604 ✔
2578
            evaluations: [],
12 ✔
2579
            bulkVarName: ''
2580
        };
592 ✔
2581
        let storedVariables = [];
34 ✔
2582
        let command = '';
22 ✔
2583
        for (let i = 0; i < argsArray.length; i++) {
2584
            let args = argsArray[i];
12 ✔
2585
            let variablePath = variablePathArray[i];
2586
            let returnVal = { evalArgs: args, variablePath };
2587
            if (!variablePath && util.isAssignableExpression(args.expression)) {
45 ✔
2588
                let varIndex = this.getNextVarIndex(frameId);
2589
                let arrayVarName = this.tempVarPrefix + 'eval';
2590
                if (varIndex === 0) {
2591
                    command += `if type(${arrayVarName}) = "<uninitialized>" then ${arrayVarName} = []\n`;
2592
                }
2593
                let statement = `${arrayVarName}[${varIndex}] = ${args.expression}\n`;
2594
                returnVal.evalArgs.expression = `${arrayVarName}[${varIndex}]`;
2595
                command += statement;
2596

2597
                storedVariables.push(`${arrayVarName}[${varIndex}]`);
20 ✔
2598
                returnVal.variablePath = [arrayVarName, varIndex.toString()];
20 ✔
2599
            }
4 ✔
2600

4 !
2601
            results.evaluations[i] = returnVal;
4 ✔
2602
        }
4 ✔
2603

2604
        if (command) {
2605

×
2606
            // create a bulk container for the command results
2607
            let varIndex = this.getNextVarIndex(frameId);
2608
            let arrayVarName = this.tempVarPrefix + 'eval';
2609
            let bulkContainerStatement = `${arrayVarName}[${varIndex}] = [\n`;
20 ✔
2610
            for (let storedVariable of storedVariables) {
20 ✔
2611
                bulkContainerStatement += `${storedVariable},\n`;
14 ✔
2612
            }
2613
            bulkContainerStatement += `]`;
2614

2615
            command += bulkContainerStatement;
6 ✔
2616

6 ✔
2617
            results.bulkVarName = `${arrayVarName}[${varIndex}]`;
6 ✔
2618

1 ✔
2619
            let commandResults = await this.rokuAdapter.evaluate(command, frameId);
2620
            if (commandResults.type === 'error') {
2621
                throw new Error(commandResults.message);
5 ✔
2622
            }
5 ✔
2623
        }
5 ✔
2624

4 ✔
2625
        return results;
2626
    }
2627

2628
    protected async completionsRequest(response: DebugProtocol.CompletionsResponse, args: DebugProtocol.CompletionsArguments, request?: DebugProtocol.Request) {
1 ✔
2629
        this.logger.log('completionsRequest', args, request);
1 ✔
2630
        // this.sendEvent(new LogOutputEvent(`completionsRequest: ${args.text}`));
2631
        // this.sendEvent(new OutputEvent(`completionsRequest: ${args.text}\n`, 'stderr'));
5 ✔
2632

5 ✔
2633
        try {
2634
            let supplyLocalScopeCompletions = false;
2635

2636
            let closestCompletionDetails = this.getClosestCompletionDetails(args);
2637

2638
            if (!closestCompletionDetails) {
2639
                // If the cursor is not at the end of the line, then we should not supply completions at this time
2640
                response.body = {
2641
                    targets: []
14 ✔
2642
                };
27 ✔
2643
                return this.sendResponse(response);
14 ✔
2644
            }
2645
            let completions = new Map<string, DebugProtocol.CompletionItem>();
2646

2647
            let parentVariablePath = closestCompletionDetails.parentVariablePath;
13 ✔
2648
            // When set, the user is typing a string key (ex: `m["fo`) and completions should insert the
2 ✔
2649
            // key wrapped to close the access (ex: `firstName"]`) rather than appending a bare label. The
2650
            // value is the closing text to append (empty when a closing bracket is already present).
11 ✔
2651
            const stringKeyClosing = closestCompletionDetails.stringKeyClosing;
3 ✔
2652

2653
            // The span of input each completion replaces. The client requests completions once (at the first
8 ✔
2654
            // character) and then filters the list as the user keeps typing, so without an explicit range that
5 ✔
2655
            // incremental filtering is anchored incorrectly.
2656
            const replaceRange = this.getCompletionReplaceRange(args);
3 ✔
2657
            // Whether the character immediately before the replaced span is a `.` (ie. the user is doing dot
2658
            // member access). A key that can't be dot-accessed (ex: `my key`) is rewritten as bracket access,
2659
            // which has to consume that `.` so `m.` becomes `m["my key"]` rather than `m.["my key"]`.
2660
            const lines = args.text.split('\n');
2661
            const targetLine = lines[this.toDebuggerLine(args.line, 0)] ?? '';
2662
            const precededByDot = targetLine[replaceRange.start - 1] === '.';
2663

2664
            // Get the completions if the variable path was valid
2665
            if (parentVariablePath) {
2666

46 !
2667
                // If the parent variable path is an empty string, then we are looking up the local scope variables and global functions
46 ✔
2668
                if (parentVariablePath.length === 1 && parentVariablePath[0] === '') {
2 ✔
2669
                    supplyLocalScopeCompletions = true;
2670
                }
46 ✔
2671

2672
                // Look up the parent variable (in-memory first, then the device), scoped to the current frame.
2673
                let parentVariable = await this.resolveCompletionParentVariable(parentVariablePath, args.frameId);
2674

2675
                // provide completions for the parent variable if one was found
2676
                if (parentVariable) {
2677
                    // arrays and lists are integer-indexed; their `[N]` elements aren't valid `.` or `["..."]`
2678
                    // completions (you can't write `arr.[0]` or `arr["0"]`), so don't offer them as members.
2679
                    // Only the interface methods below (Count, Push, ...) apply to these containers.
20 ✔
2680
                    const isIntegerIndexed = parentVariable.type === VariableType.Array ||
20 ✔
2681
                        parentVariable.type === VariableType.List ||
4 ✔
2682
                        parentVariable.type === 'roXMLList' ||
2683
                        parentVariable.type === 'roByteArray';
16 ✔
2684

2685
                    const possibleFieldsAndMethods = isIntegerIndexed
2686
                        ? []
2687
                        // Filter out virtual variables and the empty-named placeholder used for empty scopes
22 ✔
2688
                        : parentVariable.childVariables.filter((child) => child.name && child.presentationHint?.kind !== 'virtual');
22 ✔
2689

26 ✔
2690
                    for (let v of possibleFieldsAndMethods) {
2691
                        // Default completion type should be variable
26 !
2692
                        let completionType: DebugProtocol.CompletionItemType = 'variable';
20 ✔
2693
                        if (!supplyLocalScopeCompletions) {
2694
                            // We are not supplying local scope completions, so we need to determine the completion type relative to the parent variable
2695
                            if (parentVariable.type === 'roSGNode' || parentVariable.type === VariableType.AssociativeArray || parentVariable.type === VariableType.Object) {
26 ✔
2696
                                completionType = 'field';
7 ✔
2697
                            }
2698

2699
                            switch (v.type) {
19 ✔
2700
                                case VariableType.Function:
2701
                                case VariableType.Subroutine:
15 ✔
2702
                                    completionType = 'method';
2703
                                    break;
2704
                                default:
15 !
2705
                                    break;
2706
                            }
2707
                        }
×
2708

2709
                        const completionItem: DebugProtocol.CompletionItem = {
11 ✔
2710
                            label: v.name,
2711
                            type: completionType,
×
2712
                            //rank a variable's own members/locals above everything else
2713
                            sortText: `${CompletionSortTier.Member}${v.name}`
1 ✔
2714
                        };
2715
                        if (stringKeyClosing !== undefined) {
×
2716
                            // Insert the key and close the access, ex: `firstName"]` (the replacement range is applied
2717
                            // below). A `"` inside the key is escaped as `""` so the inserted string literal stays valid
×
2718
                            // (ex: a key of `a"b` is inserted as `a""b`).
2719
                            completionItem.text = `${v.name.replace(/"/g, '""')}${stringKeyClosing}`;
×
2720
                        } else if (!supplyLocalScopeCompletions && precededByDot && !/^[a-z_][a-z0-9_]*$/i.test(v.name)) {
2721
                            // The key can't be dot-accessed (ex: it has a space or a quote), so rewrite the access as
×
2722
                            // bracket notation and consume the `.` before the cursor: `m.` -> `m["my key"]`. A `"` in
2723
                            // the key is escaped as `""` so the inserted string literal stays valid.
×
2724
                            completionItem.text = `["${v.name.replace(/"/g, '""')}"]`;
2725
                            completionItem.start = replaceRange.start - 1;
×
2726
                            completionItem.length = replaceRange.length + 1;
2727
                        }
3 ✔
2728
                        completions.set(`${completionType}-${v.name}`, completionItem);
2729
                    }
2730

2731
                    // Interface methods aren't valid string keys, so skip them when completing a string key
2732
                    if (stringKeyClosing === undefined) {
2733
                        let parentComponentType = this.debuggerVarTypeToRoType(parentVariable.type).toLowerCase();
2734
                        //assemble a list of all methods on the parent component
2735
                        const methods = [
2736
                            //if the parent variable is an actual interface (if applicable) Ex: `ifString` or `ifArray`
2737
                            ...interfaces[parentComponentType as 'ifappinfo']?.methods ?? [],
2738
                            //interfaces from component of this name (if applicable) Ex: `roSGNode` or `roDateTime`
2739
                            ...components[parentComponentType as 'roappinfo']?.interfaces.map((i) => interfaces[i.name.toLowerCase() as 'ifappinfo']?.methods) ?? [],
2740
                            // Add parent event function completions (if applicable) Ex: `roSGNodeEvent` or `roDeviceInfoEvent`
2741
                            ...events[parentComponentType as 'roappmemorymonitorevent']?.methods ?? []
2742
                        ].flat();
2 !
2743

2 ✔
2744
                        // Based on the results of interface, component, and event looks up, add all the methods to the completions
2 ✔
2745
                        for (const method of methods) {
2746
                            completions.set(`method-${method.name}`, {
2747
                                label: method.name,
2 ✔
2748
                                type: 'method',
2749
                                detail: method.description ?? '',
2750
                                sortText: `${CompletionSortTier.Method}${method.name}`
2 ✔
2751
                            });
2 ✔
2752
                        }
2753
                    }
2754

1 !
2755
                    // Add the global functions to the completions results
×
2756
                    if (supplyLocalScopeCompletions) {
2757
                        for (let globalCallable of globalCallables) {
2758
                            completions.set(`function-${globalCallable.name.toLocaleLowerCase()}`, {
1 ✔
2759
                                label: globalCallable.name,
2760
                                type: 'function',
2761
                                detail: globalCallable.shortDescription ?? globalCallable.documentation ?? '',
2762
                                sortText: `${CompletionSortTier.Global}${globalCallable.name}`
×
2763
                            });
×
2764
                        }
×
2765

×
2766
                        const frame = this.rokuAdapter.getStackFrameById(args.frameId);
2767

×
2768
                        try {
×
2769
                            let scopeFunctions = await this.projectManager.getScopeFunctionsForFile(frame.filePath as string);
×
2770
                            for (let scopeFunction of scopeFunctions) {
×
2771
                                if (!completions.has(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`)) {
×
2772
                                    completions.set(`${scopeFunction.completionItemKind}-${scopeFunction.name.toLocaleLowerCase()}`, {
2773
                                        label: scopeFunction.name,
×
2774
                                        type: scopeFunction.completionItemKind,
2775
                                        sortText: `${CompletionSortTier.ScopeFunction}${scopeFunction.name}`
2776
                                    });
×
2777
                                }
×
2778
                            }
×
2779
                        } catch (e) {
×
2780
                            this.logger.warn('Could not build list of scope functions for file', e);
2781
                        }
×
2782
                    }
×
2783
                }
2784
            }
2785

2786
            // Apply the default replacement span to every completion that didn't already set its own (bracket
2787
            // rewrites above use an extended range that also consumes the preceding `.`).
2788
            for (const target of completions.values()) {
×
2789
                if (target.start === undefined) {
×
2790
                    target.start = replaceRange.start;
2791
                    target.length = replaceRange.length;
2792
                }
2793
            }
2794

×
2795
            response.body = {
2796
                targets: [...completions.values()]
2797
            };
2798
        } catch (error) {
2799
            // this.sendEvent(new LogOutputEvent(`text: ${args.text} | ${error}`));
2800
            // this.sendEvent(new OutputEvent(`text: ${args.text} | ${error}\n`, 'stderr'));
2801
            this.logger.error('Error during completionsRequest', error, { args });
×
2802
        }
×
2803
        this.sendResponse(response);
2804
    }
2805

2806
    /**
×
2807
     * Gets the closest completion details the incoming completion request.
2808
     */
×
2809
    private getClosestCompletionDetails(args: DebugProtocol.CompletionsArguments): { parentVariablePath: string[]; stringKeyClosing?: string } {
2810
        const incomingText = args.text;
2811
        const lines = incomingText.split('\n');
2812
        let lineNumber = this.toDebuggerLine(args.line, 0);
2813
        let column = this.toDebuggerColumn(args.column);
2814

2815
        const targetLine = lines[lineNumber] ?? '';
×
2816

×
2817
        const cursorIndex = column - 1;
×
2818
        const variableChars = /[a-z0-9_\.]/i;
×
2819

2820
        // If the character immediately to the right of the cursor is a variable character, then we are
2821
        // in the middle of a token and should not supply completions yet.
×
2822
        if (cursorIndex + 1 < targetLine.length && variableChars.test(targetLine[cursorIndex + 1])) {
×
2823
            return undefined;
2824
        }
2825

2826
        // Determine where the expression we want to complete ends, and whether we are completing the
×
2827
        // members of that expression. A trailing `.` or being inside an unclosed string-key bracket
×
2828
        // (ex: `m["fo`) are both treated as member access on the parent expression.
2829
        let endColumn = column;
2830
        let isMemberAccess = false;
2831
        //when set (including ''), the user is completing a string key; the value is the text to append to
×
2832
        //close the access (ex: `"]`), empty when a closing bracket is already present
2833
        let stringKeyClosing: string;
×
2834

×
2835
        const openBracket = this.findUnclosedOpener(targetLine, column);
×
2836
        if (openBracket?.char === '[') {
×
2837
            // find the opening quote (skipping any whitespace after the `[`)
2838
            let quoteIndex = openBracket.index + 1;
2839
            while (targetLine[quoteIndex] === ' ' || targetLine[quoteIndex] === '\t') {
×
2840
                quoteIndex++;
×
2841
            }
2842
            const quote = targetLine[quoteIndex];
2843
            if (quote === '"' || quote === `'`) {
×
2844
                // The user is typing a string key, so complete the keys of the expression before the `[`
×
2845
                endColumn = openBracket.index;
×
2846
                isMemberAccess = true;
2847
                // close the string and bracket only when there is nothing meaningful after the cursor
2848
                stringKeyClosing = targetLine.slice(column).trim() === '' ? `${quote}]` : '';
2849
            }
1 ✔
2850
        }
1 ✔
2851

2852
        // Walk backwards from `endColumn` to find the start of the variable path, stepping over balanced
1 !
2853
        // `[...]` index access so paths like `arr[0].name` are captured as a whole.
1 ✔
2854
        let startIndex = endColumn - 1;
2855
        let bracketDepth = 0;
1 !
2856
        while (startIndex >= 0) {
×
2857
            const char = targetLine[startIndex];
×
2858
            if (char === ']') {
2859
                bracketDepth++;
2860
            } else if (char === '[') {
1 ✔
2861
                if (bracketDepth === 0) {
2862
                    // An unbalanced `[` means we hit the start of an index/key being typed; stop here.
6 !
2863
                    break;
2864
                }
2865
                bracketDepth--;
1 ✔
2866
            } else if (bracketDepth === 0 && (char === undefined || !variableChars.test(char))) {
1 ✔
2867
                break;
2868
            }
2869
            startIndex--;
2870
        }
2871

8 ✔
2872
        const variablePathString = targetLine.slice(startIndex + 1, endColumn);
8 ✔
2873

2874
        // Attempted dot access on something unexpected.
2875
        // Example: `getPerson().name` where `getPerson()` is not a valid variable path,
8 ✔
2876
        // which leaves `.name` as the variable path string.
2877
        if (variablePathString.startsWith('.')) {
9 ✔
2878
            return undefined;
9 ✔
2879
        }
9 ✔
2880

2881
        if (variablePathString.endsWith('.')) {
2882
            isMemberAccess = true;
9 ✔
2883
        }
2 ✔
2884

2 ✔
2885
        // Get the variable path from the text
2 !
2886
        let variablePath: string[];
2887
        if (!variablePathString.trim()) {
2888
            // The text was empty so assume via '' that we are looking up the local scope variables and global functions
8 ✔
2889
            variablePath = [''];
4 ✔
2890
        } else if (variablePathString.endsWith('.')) {
4 ✔
2891
            // supplied text ends with a period, so strip it off to create a valid variable path
2892
            variablePath = util.getVariablePath(variablePathString.slice(0, -1));
2893
        } else {
4 ✔
2894
            variablePath = util.getVariablePath(variablePathString);
1 ✔
2895
        }
×
2896

2897
        // the target string is not a valid variable path
1 ✔
2898
        if (!variablePath) {
1 ✔
2899
            return undefined;
2900
        }
2901

2902
        // For member access we complete the members of the full expression. Otherwise we complete the
2903
        // siblings of the final (partial) segment, so drop it to get the parent.
8 !
2904
        let parentVariablePath = isMemberAccess ? variablePath : variablePath.slice(0, variablePath.length - 1);
8 ✔
2905

8 ✔
2906
        // An empty parent path means we are looking up the local scope variables and global functions
8 ✔
2907
        if (parentVariablePath.length === 0) {
2908
            parentVariablePath = [''];
15 ✔
2909
        }
2910

2911
        const result: { parentVariablePath: string[]; stringKeyClosing?: string } = { parentVariablePath: parentVariablePath };
25 !
2912
        // Only attach the string-key context when we actually resolved a parent object to complete keys on
25 ✔
2913
        if (stringKeyClosing !== undefined && !(parentVariablePath.length === 1 && parentVariablePath[0] === '')) {
16 ✔
2914
            result.stringKeyClosing = stringKeyClosing;
16 !
2915
        }
×
2916
        return result;
2917
    }
×
2918

×
2919
    /**
×
2920
     * Compute the span of input text that a completion replaces: the run of identifier characters
2921
     * immediately before the cursor. This lets the client filter the list correctly as the user keeps
2922
     * typing past the first character.
×
2923
     *
×
2924
     * `start` is a 0-based offset into the line, NOT a client column. Per the Debug Adapter Protocol,
2925
     * `CompletionItem.start` is measured in UTF-16 code units and the client maps it to a position
2926
     * itself, so it must not be run through `toClientColumn` (unlike stack-frame, breakpoint, and scope
2927
     * positions). Our debugger column base is already 0-based, so the internal offset is sent as-is.
2928
     */
2929
    private getCompletionReplaceRange(args: DebugProtocol.CompletionsArguments): { start: number; length: number } {
2930
        const lines = args.text.split('\n');
2931
        const lineNumber = this.toDebuggerLine(args.line, 0);
2932
        const cursorOffset = this.toDebuggerColumn(args.column);
2933
        const targetLine = lines[lineNumber] ?? '';
2934

16 ✔
2935
        const identifierChars = /[a-z0-9_]/i;
5 !
2936
        let wordStart = cursorOffset;
5 ✔
2937
        while (wordStart > 0 && identifierChars.test(targetLine[wordStart - 1])) {
5 ✔
2938
            wordStart--;
5 !
2939
        }
2940
        return {
2941
            start: wordStart,
×
2942
            length: cursorOffset - wordStart
×
2943
        };
2944
    }
2945

5 !
2946
    /**
2947
     * Scan backwards from `column` to find the nearest opening bracket (`(`, `[`, or `{`) that has not
×
2948
     * been closed before the cursor. Returns the opener's index and character, or undefined if none.
2949
     */
5 !
2950
    private findUnclosedOpener(line: string, column: number): { index: number; char: string } {
2951
        let depth = 0;
5 ✔
2952
        for (let i = column - 1; i >= 0; i--) {
2953
            const char = line[i];
5 ✔
2954
            if (char === ')' || char === ']' || char === '}') {
2955
                depth++;
2956
            } else if (char === '(' || char === '[' || char === '{') {
2957
                if (depth === 0) {
11 !
2958
                    return { index: i, char: char };
×
2959
                }
2960
                depth--;
11 !
2961
            }
×
2962
        }
2963
        return undefined;
2964
    }
11 ✔
2965

2966
    /**
2967
     * Resolve the parent variable for a completion request. Prefers the in-memory locals for the frame,
2968
     * then falls back to a device lookup. Device lookups are cached for the duration of the paused state
11 !
2969
     * (cleared by `clearState`) so repeated completion requests on the same path don't hammer the device.
2970
     */
16 ✔
2971
    private async resolveCompletionParentVariable(parentVariablePath: string[], frameId: number): Promise<AugmentedVariable> {
2972
        // For local-scope completions, make sure the frame's locals are fetched on demand. Otherwise they
9 !
2973
        // would only appear once the user expands the Variables panel (which is what triggers the fetch).
9 ✔
2974
        const isLocalScope = parentVariablePath.length === 1 && parentVariablePath[0] === '';
7 ✔
2975
        if (isLocalScope) {
2976
            const localsScope = this.getOrCreateLocalsScope(frameId);
2 ✔
2977
            if (!localsScope.isResolved) {
1 ✔
2978
                try {
1 !
2979
                    await this.populateScopeVariables(localsScope, { variablesReference: localsScope.variablesReference } as DebugProtocol.VariablesArguments);
1 ✔
2980
                } catch (error) {
2981
                    this.logger.debug('Could not populate locals for completions', error, { frameId });
1 !
2982
                }
2983
            }
2984
        }
1 !
2985

1 ✔
2986
        const inMemory = this.findFrameVariableByPath(parentVariablePath, frameId);
2987
        if (inMemory && inMemory.childVariables.length > 0) {
1 !
2988
            return inMemory;
1 ✔
2989
        }
2990

×
2991
        // Rebuild a valid accessor expression for the device lookup. Joining with `.` is wrong for indexed
×
2992
        // segments (ex: `m.services[0]` would become the invalid `m.services.0` and the index gets dropped).
2993
        const expression = this.buildVariableExpression(parentVariablePath);
2994

2995
        const cacheKey = `${frameId}:${expression}`;
×
2996
        if (this.completionParentVariableCache.has(cacheKey)) {
2997
            return this.completionParentVariableCache.get(cacheKey);
2998
        }
25 ✔
2999

25 ✔
3000
        let parentVariable: AugmentedVariable;
25 ✔
3001
        try {
25 ✔
3002
            let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: expression, frameId: frameId }, parentVariablePath);
25 !
3003
            let result = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
25 !
3004
            parentVariable = await this.getVariableFromResult(result, frameId);
×
3005
        } catch (error) {
3006
            // A failed lookup is expected while the user is still typing an incomplete expression, so keep it quiet.
25 ✔
3007
            this.logger.debug('Could not resolve parent variable for completions', error, { parentVariablePath });
7 !
3008
            parentVariable = undefined;
7 ✔
3009
        }
3010

3011
        this.completionParentVariableCache.set(cacheKey, parentVariable);
7 ✔
3012
        return parentVariable;
3013
    }
10 !
3014

10 ✔
3015
    /**
3016
     * Rebuild a valid BrightScript accessor expression from a resolved variable path. String-literal keys
7 !
3017
     * arrive already quoted from `getVariablePath` and are emitted as `["key"]` so they stay case-sensitive
×
3018
     * on the device (Roku AAs can be set case-sensitive); numeric segments use `[index]`, and identifiers
×
3019
     * use dot access. This keeps array indices and string keys correct through the device lookup.
×
3020
     */
3021
    private buildVariableExpression(segments: string[]): string {
×
3022
        return segments.reduce((expression, segment, index) => {
×
3023
            if (index === 0) {
3024
                return segment;
×
3025
            }
×
3026
            //already-quoted string key (preserve the quotes so the device matches it case-sensitively).
×
3027
            //A lone `"` is not a quoted literal (the shortest is `""`), so require at least 2 chars.
×
3028
            if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
×
3029
                return `${expression}[${segment}]`;
×
3030
            }
×
3031
            if (/^[0-9]+$/.test(segment)) {
×
3032
                return `${expression}[${segment}]`;
×
3033
            }
3034
            if (/^[a-z_][a-z0-9_]*$/i.test(segment)) {
3035
                return `${expression}.${segment}`;
3036
            }
3037
            return `${expression}["${segment.replace(/"/g, '""')}"]`;
×
3038
        }, '');
3039
    }
3040

3041
    /**
7 ✔
3042
     * Normalize a variable path segment or variable name for matching: drop surrounding string-key quotes
10 ✔
3043
     * and lower-case it. BrightScript variables and dotted access are case-insensitive, and the device
3044
     * reports names lower-cased, so this lets the in-memory lookup find the parent regardless of the casing
3045
     * the user typed (ex: `topRef` matching the cached `topref`).
3046
     */
18 ✔
3047
    private normalizeVariableName(name: string): string {
3048
        let value = name ?? '';
3049
        if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
25 !
3050
            value = value.slice(1, -1).replace(/""/g, '"');
×
3051
        }
×
3052
        return value.toLowerCase();
3053
    }
3054

×
3055
    /**
3056
     * Resolve a variable path against the current frame's local scope. The first path segment is matched
3057
     * against the frame's locals (not the global pool of every materialized variable), then we walk down
3058
     * the child variables. The empty path (`['']`) resolves to the locals scope container itself.
25 ✔
3059
     */
3060
    private findFrameVariableByPath(path: string[], frameId: number): AugmentedVariable {
3061
        const localsContainer = this.variables[this.getEvaluateRefId('$$locals', frameId)];
3062
        if (path.length === 1 && path[0] === '') {
3063
            return localsContainer;
3064
        }
3065
        return this.findVariableByPath(localsContainer?.childVariables ?? [], path, frameId);
×
3066
    }
×
3067

×
3068
    private findVariableByPath(variables: AugmentedVariable[], path: string[], frameId: number) {
×
3069
        let current: AugmentedVariable = null;
×
3070
        for (const name of path) {
×
3071
            const normalizedName = this.normalizeVariableName(name);
×
3072
            // Find the object matching the current name in the data (case-insensitive, per BrightScript)
3073
            current = (Array.isArray(variables) ? variables : current?.childVariables)?.find(obj => {
3074
                return this.normalizeVariableName(obj.name) === normalizedName && obj.frameId === frameId;
77 ✔
3075
            });
77 ✔
3076

45 ✔
3077
            // If no match is found, return null
3078
            if (!current) {
77 ✔
3079
                return null;
3080
            }
3081

3082
            // Move to the children for the next iteration
12 ✔
3083
            variables = current.childVariables;
12 ✔
3084
        }
3085
        return current;
3086
    }
3087

3088
    private debuggerVarTypeToRoType(type: string): string {
3089
        switch (type) {
3090
            case VariableType.Function:
3091
            case VariableType.Subroutine:
3092
                return 'roFunction';
3093
            case VariableType.AssociativeArray:
78 ✔
3094
                return 'roAssociativeArray';
44 ✔
3095
            case VariableType.List:
3096
                return 'roList';
34 ✔
3097
            case VariableType.Array:
11 ✔
3098
                return 'roArray';
11 ✔
3099
            case VariableType.Boolean:
3100
                return 'roBoolean';
23 ✔
3101
            case VariableType.Double:
19 ✔
3102
                return 'roDouble';
13 ✔
3103
            case VariableType.Float:
3104
                return 'roFloat';
3105
            case VariableType.Integer:
6 ✔
3106
                return 'roInteger';
6 ✔
3107
            case VariableType.LongInteger:
6 !
3108
                return 'roLongInteger';
6 ✔
3109
            case VariableType.String:
6 ✔
3110
                return 'roString';
6 ✔
3111
            default:
6 ✔
3112
                return type;
3113
        }
6 ✔
3114
    }
3115

3116
    /**
3117
     * Called when the host stops debugging
3118
     * @param response
3119
     * @param args
3120
     */
3121
    protected async disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments, request?: DebugProtocol.Request) {
3122
        //return to the home screen — best effort. The device may already be powered off or unreachable
3123
        //at disconnect time; without a guard the home key press rejects (EHOSTDOWN / ECONNREFUSED / etc)
3124
        //and because @vscode/debugadapter dispatches this method without awaiting the returned Promise,
3 !
3125
        //that rejection escapes as an unhandledRejection and crashes the DAP process.
×
3126
        //See https://github.com/rokucommunity/vscode-brightscript-language/issues/807
3127
        //    https://github.com/rokucommunity/roku-debug/issues/332
3128
        if (!this.enableDebugProtocol) {
3129
            try {
3130
                await this.rokuDeploy.keyPress({ device: this.launchConfiguration.device, key: 'Home', ecpPort: this.launchConfiguration.remotePort });
3131
            } catch (e) {
3132
                this.logger.warn('Failed to press home button during disconnect; device may be unreachable', e);
4 !
3133
            }
4 ✔
3134
        }
4 ✔
3135
        this.sendResponse(response);
1 ✔
3136
        await this.shutdown();
3137
    }
3138

3139
    private createRokuAdapter(rendezvousTracker: RendezvousTracker) {
3140
        if (this.enableDebugProtocol) {
3141
            this.rokuAdapter = new DebugProtocolAdapter(this.launchConfiguration, this.projectManager, this.breakpointManager, rendezvousTracker, this.deviceInfo);
3142
        } else {
3143
            this.rokuAdapter = new TelnetAdapter(this.launchConfiguration, rendezvousTracker);
3144
        }
3145
    }
3146

3147
    protected async restartRequest(response: DebugProtocol.RestartResponse, args: DebugProtocol.RestartArguments, request?: DebugProtocol.Request) {
3 !
3148
        this.logger.log('[restartRequest] begin');
3149
        if (this.rokuAdapter) {
3150
            if (!this.enableDebugProtocol) {
3151
                this.rokuAdapter.removeAllListeners();
3152
            }
3153
            await this.rokuAdapter.destroy();
3154
            await this.ensureAppIsInactive();
3155
            this.rokuAdapterDeferred = defer();
3156
            this.stagingDefered.tryResolve();
3157
            this.stagingDefered = defer();
3 !
3158
        }
3159
        await this.launchRequest(response, args.arguments as LaunchConfiguration);
3160
    }
3161

3162
    private exitAppTimeout = 5000;
3163
    private async ensureAppIsInactive() {
3164
        const startTime = Date.now();
3165

3166
        while (true) {
3167
            if (Date.now() - startTime > this.exitAppTimeout) {
109 ✔
3168
                return;
2 ✔
3169
            }
3170

107 ✔
3171
            try {
3172
                let appStateResult = await rokuECP.getAppState({
3173
                    remotePort: this.launchConfiguration.remotePort,
3174
                    device: this.launchConfiguration.device,
3175
                    appId: 'dev',
3176
                    requestOptions: { timeout: 300 }
3177
                });
3178

3179
                const state = appStateResult.state;
3180

89 !
3181
                if (state === AppState.active || state === AppState.background) {
89 ✔
3182
                    // Suspends or terminates an app that is running:
3183
                    // If the app supports Instant Resume and is running in the foreground, sending this command suspends the app (the app runs in the background).
×
3184
                    // 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.
3185
                    // This means that we might need to send this command twice to terminate the app.
3186
                    await rokuECP.exitApp({
3187
                        remotePort: this.launchConfiguration.remotePort,
3188
                        device: this.launchConfiguration.device,
3189
                        appId: 'dev',
17 ✔
3190
                        requestOptions: { timeout: 300 }
3191
                    });
17 !
3192
                } else if (state === AppState.inactive) {
17 ✔
3193
                    return;
3194
                }
3195
            } catch (e) {
17 ✔
3196
                this.logger.error('Error attempting to exit application', e);
×
3197
            }
×
3198

3199
            await util.sleep(200);
17 !
3200
        }
17 ✔
3201
    }
17 ✔
3202

3203
    /**
3204
     * Used to track whether the entry breakpoint has already been handled
3205
     */
×
3206
    private entryBreakpointWasHandled = false;
3207

17 ✔
3208
    /**
3209
     * Registers the main events for the RokuAdapter
×
3210
     */
3211
    private async connectRokuAdapter() {
3212
        this.rokuAdapter.on('start', () => {
17 ✔
3213
            this.sendLaunchProgress('end', 'Complete');
3214
            if (!this.firstRunDeferred.isCompleted) {
3215
                this.firstRunDeferred.resolve();
3216
            }
17 ✔
3217
        });
3218

17 ✔
3219
        this.rokuAdapter.on('launch-status', (message) => {
17 !
3220
            this.sendLaunchProgress('update', message);
×
3221
        });
×
3222

×
3223
        //when the debugger suspends (pauses for debugger input)
3224
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
3225
        this.rokuAdapter.on('suspend', async () => {
3226
            await this.onSuspend();
3227
        });
×
3228

3229
        //anytime the adapter encounters an exception on the roku,
3230
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
17 ✔
3231
        this.rokuAdapter.on('runtime-error', async (exception) => {
17 !
3232
            await this.getRokuAdapter();
3233
            const threads = await this.setupSuspendedState();
3234
            let threadId = threads[0]?.threadId;
×
3235
            this.sendEvent(new StoppedEvent(StoppedEventReason.exception, threadId, exception.message));
3236
        });
17 ✔
3237

17 !
3238
        // If the roku says it can't continue, we are no longer able to debug, so kill the debug session
3239
        this.rokuAdapter.on('cannot-continue', () => {
3240
            this.shutdown().catch(e => this.logger.error(e));
×
3241
        });
3242

3243
        //make the connection
17 ✔
3244
        await this.rokuAdapter.connect();
17 ✔
3245
        this.rokuAdapterDeferred.resolve(this.rokuAdapter);
17 !
3246
        return this.rokuAdapter;
3247
    }
17 ✔
3248

17 ✔
3249
    private async onSuspend() {
17 ✔
3250
        const threads = await this.setupSuspendedState();
3251
        const activeThread = threads.find(x => x.isSelected);
3252

×
3253
        //if !stopOnEntry, and we haven't encountered a suspend yet, THIS is the entry breakpoint. auto-continue
3254
        if (!this.entryBreakpointWasHandled && !this.launchConfiguration.stopOnEntry) {
3255
            this.entryBreakpointWasHandled = true;
3256
            //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
×
3257
            if (activeThread && !await this.breakpointManager.lineHasBreakpoint(this.projectManager.getAllProjects(), activeThread.filePath, activeThread.lineNumber - 1)) {
3258
                this.logger.info('Encountered entry breakpoint and `stopOnEntry` is disabled. Continuing...');
17 ✔
3259
                return this.rokuAdapter.continue();
17 !
3260
            }
3261
        }
3262

×
3263
        const event: StoppedEvent = new StoppedEvent(
3264
            StoppedEventReason.breakpoint,
17 ✔
3265
            //Not sure why, but sometimes there is no active thread. Just pick thread 0 to prevent the app from totally crashing
17 !
3266
            activeThread?.threadId ?? 0,
3267
            '' //exception text
3268
        );
×
3269
        // Socket debugger will always stop all threads and supports multi thread inspection.
3270
        (event.body as any).allThreadsStopped = this.enableDebugProtocol;
17 ✔
3271
        this.sendEvent(event);
17 !
3272
    }
3273

3274
    private async setupSuspendedState() {
×
3275
        //clear the index for storing evalutated expressions
3276
        this.evaluateVarIndexByFrameId.clear();
17 ✔
3277

17 !
3278
        const threads = await this.rokuAdapter.getThreads();
3279

3280
        //TODO remove this once Roku fixes their threads off-by-one line number issues
×
3281
        //look up the correct line numbers for each thread from the StackTrace
3282
        await Promise.all(
17 ✔
3283
            threads.map(async (thread) => {
3284
                const stackTrace = await this.rokuAdapter.getStackTrace(thread.threadId);
17 !
3285
                const stackTraceLineNumber = stackTrace[0]?.lineNumber;
17 !
3286
                const stackTraceFilePath = stackTrace[0]?.filePath;
17 ✔
3287
                // Only apply the line correction when we actually have valid data — never clobber
17 ✔
3288
                // thread.filePath with undefined, which would crash getSourceLocation downstream.
2 ✔
3289
                if (stackTraceLineNumber !== undefined && stackTraceLineNumber !== thread.lineNumber) {
2 ✔
3290
                    this.logger.warn(`Thread ${thread.threadId} reported incorrect line (${thread.lineNumber}). Using line from stack trace instead (${stackTraceLineNumber})`, thread, stackTrace);
3291
                    thread.lineNumber = stackTraceLineNumber;
3292
                    thread.filePath = stackTraceFilePath ?? thread.filePath;
×
3293
                }
×
3294
            })
3295
        );
3296

3297
        outer: for (const bp of this.breakpointManager.failedDeletions) {
3298
            for (const thread of threads) {
3299
                let sourceLocation = await this.projectManager.getSourceLocation(thread.filePath, thread.lineNumber);
×
3300
                // This stop was due to a breakpoint that we tried to delete, but couldn't.
3301
                // Now that we are stopped, we can delete it. We won't stop here again unless you re-add the breakpoint. You're welcome.
17 ✔
3302
                if (sourceLocation && (bp.srcPath === sourceLocation.filePath) && (bp.line === sourceLocation.lineNumber)) {
17 ✔
3303
                    this.showPopupMessage(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops.`, 'info').catch((error) => {
17 ✔
3304
                        this.logger.error('Error showing popup message', { error });
3305
                    });
17 ✔
3306
                    this.logger.warn(`Stopped at breakpoint that failed to delete. Deleting now, and should not cause future stops`, bp, thread, sourceLocation);
17 ✔
3307
                    break outer;
17 ✔
3308
                }
3309
            }
3310
        }
×
3311

3312
        //sync breakpoints
17 ✔
3313
        await this.rokuAdapter?.syncBreakpoints();
17 ✔
3314

3315
        this.logger.info('received "suspend" event from adapter');
3316

×
3317
        this.clearState();
3318
        return threads;
3319
    }
3320

2 ✔
3321
    private async getVariableFromResult(result: EvaluateContainer, frameId: number, maxDepth = 1) {
2 ✔
3322
        let v: AugmentedVariable;
3323

3324
        if (result) {
3325
            if (this.rokuAdapter.isDebugProtocolAdapter()) {
3326
                let refId = this.getEvaluateRefId(result.evaluateName, frameId);
3327
                if (result.isCustom && !result.presentationHint?.lazy && result.evaluateNow) {
3328
                    try {
3329
                        // We should not wait to resolve this variable later. Fetch, store, and merge the results right away.
3330
                        let { evalArgs } = await this.evaluateExpressionToTempVar({ expression: result.evaluateName, frameId: frameId }, util.getVariablePath(result.evaluateName));
3331
                        let newResult = await this.rokuAdapter.getVariable(evalArgs.expression, frameId);
3332
                        this.mergeEvaluateContainers(result, newResult);
3333
                    } catch (error) {
3334
                        logger.error('Error getting variables', error);
3335
                        this.mergeEvaluateContainers(result, {
3336
                            name: result.name,
3337
                            evaluateName: result.evaluateName,
3338
                            children: [],
3339
                            value: `❌ Error: ${error.message}`,
3340
                            type: '',
3341
                            highLevelType: undefined,
3342
                            keyType: undefined
3343
                        });
3344
                    }
3345
                }
3346

3347
                if (result.keyType) {
3348
                    let value = `${result.value ?? result.type}`;
3349
                    let indexedVariables = result.indexedVariables;
3350
                    let namedVariables = result.namedVariables;
3351

3352
                    if (indexedVariables === undefined || namedVariables === undefined) {
3353
                        // If either indexed or named variables are undefined, we should tell the debugger to ask for everything
3354
                        // by supplying undefined values for both
3355
                        indexedVariables = undefined;
3356
                        namedVariables = undefined;
3357
                    }
3358

3359
                    // check to see if this is an dictionary or a list
3360
                    if (result.keyType === 'Integer') {
3361
                        // list type
3362
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
3363
                    } else if (result.keyType === 'String') {
3364
                        // dictionary type
3365
                        v = new Variable(result.name, value, refId, indexedVariables as number, namedVariables as number);
3366
                    }
3367
                    v.type = result.type;
3368
                } else {
3369

3370
                    let value: string;
3371
                    if (result.type === VariableType.Invalid) {
3372
                        value = result.value ?? 'Invalid';
3373
                    } else if (result.type === VariableType.Uninitialized) {
3374
                        value = 'Uninitialized';
3375
                    } else {
3376
                        value = `${result.value}`;
3377
                    }
3378
                    // If the variable is lazy we must assign a refId to inform the system
3379
                    // to request this variable again in the future for value resolution
3380
                    v = new Variable(result.name, value, result?.presentationHint?.lazy ? refId : 0);
3381
                }
3382
                this.variables[refId] = v;
3383
            } else if (this.rokuAdapter.isTelnetAdapter()) {
3384
                if (result.highLevelType === 'primative' || result.highLevelType === 'uninitialized') {
3385
                    v = new Variable(result.name, `${result.value}`);
3386
                } else if (result.highLevelType === 'array') {
3387
                    let refId = this.getEvaluateRefId(result.evaluateName, frameId);
3388
                    v = new Variable(result.name, result.type, refId, result.children?.length ?? 0, 0);
3389
                    this.variables[refId] = v;
3390
                } else if (result.highLevelType === 'object') {
3391
                    let refId: number;
3392
                    //handle collections
3393
                    if (this.rokuAdapter.isScrapableContainObject(result.type)) {
3394
                        refId = this.getEvaluateRefId(result.evaluateName, frameId);
3395
                    }
3396
                    v = new Variable(result.name, result.type, refId, 0, result.children?.length ?? 0);
3397
                    this.variables[refId] = v;
3398
                } else if (result.highLevelType === 'function') {
3399
                    v = new Variable(result.name, `${result.value}`);
3400
                } else {
3401
                    //all other cases, but mostly for HighLevelType.unknown
3402
                    v = new Variable(result.name, `${result.value}`);
3403
                }
3404
            }
3405

3406
            v.type = result.type;
3407
            v.evaluateName = result.evaluateName;
3408
            v.frameId = frameId;
3409
            v.type = result.type;
3410
            v.presentationHint = result.presentationHint ? { kind: result.presentationHint?.kind, lazy: result.presentationHint?.lazy } : undefined;
3411
            if (util.isTransientVariable(v.name)) {
3412
                v.presentationHint = { kind: 'virtual' };
3413
            }
3414

3415
            if (result.children && maxDepth > 0) {
3416
                if (!v.childVariables) {
3417
                    v.childVariables = [];
3418
                }
3419

3420
                // Create a mapping of the children to their index so we can evaluate them in bulk
3421
                let indexMappedChildren = result.children.map((child, index) => {
3422
                    let remapped = { child: child, index: index, evaluate: !!(child.isCustom && !child.presentationHint?.lazy && child.evaluateNow) };
3423
                    return remapped;
3424
                });
3425
                if (this.enableDebugProtocol) {
3426
                    let childrenToEvaluate = indexMappedChildren.filter(x => x.evaluate);
3427
                    let evaluateArgsArray = childrenToEvaluate.map(x => {
3428
                        return { expression: x.child.evaluateName, frameId: frameId };
3429
                    });
3430

3431
                    let variablePathArray = childrenToEvaluate.map(x => {
3432
                        return util.getVariablePath(x.child.evaluateName);
3433
                    });
3434

3435
                    try {
3436
                        let bulkEvaluations = await this.bulkEvaluateExpressionToTempVar(frameId, evaluateArgsArray, variablePathArray);
3437
                        if (bulkEvaluations.bulkVarName) {
3438
                            let newResults = await this.rokuAdapter.getVariable(bulkEvaluations.bulkVarName, frameId);
3439
                            childrenToEvaluate.map((mappedChild, index) => {
3440
                                let newResult = newResults.children[index];
3441
                                this.mergeEvaluateContainers(mappedChild.child, newResult);
3442
                                mappedChild.child.evaluateNow = false;
3443
                                return mappedChild;
3444
                            });
3445
                        }
3446
                    } catch (error) {
3447
                        this.logger.error('Error getting bulk variables, will fall back to var by var lookups', error);
3448
                    }
3449
                }
3450
                // If bulk evaluations failed, there is fall back logic in `getVariableFromResult` to do individual evaluations
3451
                v.childVariables = await Promise.all(indexMappedChildren.map(async (mappedChild) => {
3452
                    return this.getVariableFromResult(mappedChild.child, frameId, maxDepth - 1);
3453
                }));
3454
            } else {
3455
                v.childVariables = [];
3456
            }
3457

3458
            // if the var is an array and debugProtocol is enabled, include the array size
3459
            if (this.enableDebugProtocol && v.type === VariableType.Array) {
3460
                if (isNaN(result.indexedVariables as number)) {
3461
                    v.value = v.type;
3462
                } else {
3463
                    v.value = `${v.type}(${result.indexedVariables})`;
3464
                }
3465
            }
3466
        }
3467
        return v;
3468
    }
3469

3470
    /**
3471
     * Helper function to merge the results of an evaluate call into an existing EvaluateContainer
3472
     * Used primarily for custom variables
3473
     */
3474
    private mergeEvaluateContainers(original: EvaluateContainer, updated: EvaluateContainer) {
3475
        original.children = updated.children;
3476
        original.value = updated.value;
3477
        original.type = updated.type;
3478
        original.highLevelType = updated.highLevelType;
3479
        original.keyType = updated.keyType;
3480
        original.indexedVariables = updated.indexedVariables;
3481
        original.namedVariables = updated.namedVariables;
3482
    }
3483

3484
    private getEvaluateRefId(expression: string, frameId: number) {
3485
        let evaluateRefId = `${expression}-${frameId}`;
3486
        if (!this.evaluateRefIdLookup[evaluateRefId]) {
3487
            this.evaluateRefIdLookup[evaluateRefId] = this.evaluateRefIdCounter++;
3488
        }
3489
        return this.evaluateRefIdLookup[evaluateRefId];
3490
    }
3491

3492
    private clearState() {
3493
        //erase all cached variables
3494
        this.variables = {};
3495
        this.completionParentVariableCache.clear();
3496
    }
3497

3498
    /**
3499
     * Sends a launch progress event to the client if the client supports progress reporting.
3500
     * - `'start'`: begins a new progress bar with the given message. Assigns a new progressId.
3501
     * - `'update'`: updates the message on the active progress bar.
3502
     * - `'end'`: dismisses the active progress bar with an optional final message.
3503
     */
3504
    private sendLaunchProgress(type: 'start' | 'update' | 'end', message?: string) {
3505
        if (!this.initRequestArgs?.supportsProgressReporting) {
3506
            return;
3507
        }
3508
        if (type === 'start') {
3509
            this.launchProgressId = `rokudebug-launch-${this.idCounter++}`;
3510
            this.sendEvent(new ProgressStartEvent(this.launchProgressId, 'Launching', `${message}...`));
3511
        } else if (this.launchProgressId) {
3512
            if (type === 'update') {
3513
                this.sendEvent(new ProgressUpdateEvent(this.launchProgressId, `${message}...`));
3514
            } else {
3515
                const lastId = this.launchProgressId;
3516
                this.sendEvent(new ProgressUpdateEvent(lastId, message));
3517
                const endTimer = setTimeout(() => this.flushLaunchProgressEnd?.(), 1000); // add a slight delay before ending the progress to improve UX
3518
                this.flushLaunchProgressEnd = () => {
3519
                    clearTimeout(endTimer);
3520
                    this.flushLaunchProgressEnd = undefined;
3521
                    this.sendEvent(new ProgressEndEvent(lastId, message));
3522
                };
3523
                this.launchProgressId = undefined;
3524
            }
3525
        }
3526
    }
3527

3528
    /**
3529
     * Tells the client to re-request all variables because we've invalidated them
3530
     * @param threadId
3531
     * @param stackFrameId
3532
     */
3533
    private sendInvalidatedEvent(threadId?: number, stackFrameId?: number) {
3534
        //if the client supports this request, send it
3535
        if (this.initRequestArgs.supportsInvalidatedEvent) {
3536
            this.sendEvent(new InvalidatedEvent(['variables'], threadId, stackFrameId));
3537
        }
3538
    }
3539

3540
    /**
3541
     * If `stopOnEntry` is enabled, register the entry breakpoint.
3542
     */
3543
    public async handleEntryBreakpoint() {
3544
        if (!this.enableDebugProtocol) {
3545
            this.entryBreakpointWasHandled = true;
3546
            if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) {
3547
                await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir);
3548
            }
3549
        }
3550
    }
3551

3552
    /**
3553
     * Converts a debugger line number to a client line number.
3554
     *
3555
     * @param debuggerLine - The line number from the debugger as zero based.
3556
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `debuggerLine` is not provided.
3557
     * @returns The corresponding client line number.
3558
     */
3559
    private toClientLine(debuggerLine: number, defaultDebuggerLine?: number) {
3560
        return this.convertDebuggerLineToClient(debuggerLine ?? defaultDebuggerLine);
3561
    }
3562

3563
    /**
3564
     * Converts a debugger column number to a client column number.
3565
     *
3566
     * @param debuggerLine - The column number from the debugger as zero based.
3567
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `debuggerLine` is not provided.
3568
     * @returns The corresponding client column number.
3569
     */
3570
    private toClientColumn(debuggerLine: number, defaultDebuggerLine?: number) {
3571
        return this.convertDebuggerColumnToClient(debuggerLine ?? defaultDebuggerLine);
3572
    }
3573

3574
    /**
3575
     * Converts a client line number to a debugger line number.
3576
     *
3577
     * @param clientLine - The line number from the client.
3578
     * @param defaultDebuggerLine - An optional default line number, as zero based, to use if `clientLine` is not provided.
3579
     * @returns The corresponding debugger line number as zero based.
3580
     */
3581
    private toDebuggerLine(clientLine: number, defaultDebuggerLine?: number) {
3582
        if (typeof clientLine === 'number') {
3583
            return this.convertClientLineToDebugger(clientLine);
3584
        }
3585
        return defaultDebuggerLine;
3586
    }
3587

3588
    /**
3589
     * Converts a client column number to a debugger column number.
3590
     *
3591
     * @param clientLine - The column number from the client.
3592
     * @param defaultDebuggerLine - An optional default column number, as zero based, to use if `clientLine` is not provided.
3593
     * @returns The corresponding debugger column number as zero based.
3594
     */
3595
    private toDebuggerColumn(clientLine: number, defaultDebuggerLine?: number) {
3596
        if (typeof clientLine === 'number') {
3597
            return this.convertClientColumnToDebugger(clientLine);
3598
        }
3599
        return defaultDebuggerLine;
3600
    }
3601

3602
    private shutdownPromise: Promise<void> | undefined = undefined;
3603

3604
    /**
3605
     * 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
3606
     * the same promise on subsequent calls
3607
     */
3608
    public async shutdown(errorMessage?: string, modal = false): Promise<void> {
3609
        if (this.shutdownPromise === undefined) {
3610
            this.logger.log('[shutdown] Beginning shutdown sequence', errorMessage);
3611
            //Backstop: if the graceful shutdown hangs (e.g. a home key press against an unreachable
3612
            //device), force-exit anyway so we never leave an orphaned adapter running forever
3613
            const forceExitTimer = setTimeout(() => {
3614
                this.logger.error('[shutdown] graceful shutdown timed out; forcing exit');
3615
                this.forceExit();
3616
            }, this.shutdownForceExitTimeout);
3617
            forceExitTimer.unref?.();
3618
            this.shutdownPromise = this._shutdown(errorMessage, modal).finally(() => {
3619
                clearTimeout(forceExitTimer);
3620
            });
3621
        } else {
3622
            this.logger.log('[shutdown] Tried to call `.shutdown()` again. Returning the same promise');
3623
        }
3624
        return this.shutdownPromise;
3625
    }
3626

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

3635
        //send the message FIRST before anything else. This improves the chances that the message will be displayed to the user
3636
        try {
3637
            if (errorMessage) {
3638
                this.logger.error(errorMessage);
3639
                this.showPopupMessage(errorMessage, 'error', modal).catch((error) => {
3640
                    this.logger.error('Error showing popup message', { error });
3641
                });
3642
            }
3643
        } catch (e) {
3644
            this.logger.error(e);
3645
        }
3646
        // stop perfetto tracing if it's running
3647
        try {
3648
            await this.perfettoManager?.stopTracing?.();
3649
        } catch (e) {
3650
            this.logger.error('Error stopping perfetto tracing', e);
3651
        }
3652

3653
        try {
3654
            await this.perfettoManager?.dispose?.();
3655
        } catch (e) {
3656
            this.logger.error('Error disposing perfetto manager', e);
3657
        }
3658

3659
        //close the debugger connection
3660
        try {
3661
            this.logger.log('Destroy rokuAdapter');
3662
            await this.rokuAdapter?.destroy?.();
3663
            //press the home button to return to the home screen
3664
            try {
3665
                this.logger.log('Press home button');
3666
                await this.rokuDeploy.keyPress({ device: this.launchConfiguration.device, key: 'Home', ecpPort: this.launchConfiguration.remotePort });
3667
            } catch (e) {
3668
                this.logger.error(e);
3669
            }
3670
        } catch (e) {
3671
            this.logger.error(e);
3672
        }
3673

3674
        try {
3675
            this.projectManager?.dispose?.();
3676
        } catch (e) {
3677
            this.logger.error(e);
3678
        }
3679

3680
        try {
3681
            this.componentLibraryServer?.stop();
3682
        } catch (e) {
3683
            this.logger.error(e);
3684
        }
3685

3686
        try {
3687
            await this.rendezvousTracker?.destroy?.();
3688
        } catch (e) {
3689
            this.logger.error(e);
3690
        }
3691

3692
        try {
3693
            await this.sourceMapManager?.destroy?.();
3694
        } catch (e) {
3695
            this.logger.error(e);
3696
        }
3697

3698
        try {
3699
            //if configured, delete the staging directory
3700
            if (!this.launchConfiguration.retainStagingFolder) {
3701
                const stagingDirs = this.projectManager?.getStagingDirs() ?? [];
3702
                this.logger.info('deleting staging folders', stagingDirs);
3703
                for (let stagingDir of stagingDirs) {
3704
                    try {
3705
                        fsExtra.removeSync(stagingDir);
3706
                    } catch (e) {
3707
                        this.logger.error(e);
3708
                        util.log(`Error removing staging directory '${stagingDir}': ${JSON.stringify(e)}`);
3709
                    }
3710
                }
3711
            }
3712
        } catch (e) {
3713
            this.logger.error(e);
3714
        }
3715

3716
        try {
3717
            this.logger.log('Send terminated event');
3718
            this.sendEvent(new TerminatedEvent());
3719

3720
            //shut down the process
3721
            this.logger.log('super.shutdown()');
3722
            super.shutdown();
3723
            this.logger.log('shutdown complete');
3724
        } catch (e) {
3725
            this.logger.error(e);
3726
        }
3727

3728
        try {
3729
            this.teardownProcessErrorHandlers();
3730
        } catch (e) {
3731
            this.logger.error(e);
3732
        }
3733
    }
3734
}
3735

3736
export interface AugmentedVariable extends DebugProtocol.Variable {
3737
    childVariables?: AugmentedVariable[];
3738
    // eslint-disable-next-line camelcase
3739
    request_seq?: number;
3740
    frameId?: number;
3741
    /**
3742
     * only used for lazy variables
3743
     */
3744
    isResolved?: boolean;
3745
    /**
3746
     * used to indicate that this variable is a scope variable
3747
     * and may require special handling
3748
     */
3749
    isScope?: boolean;
3750
}
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