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

rokucommunity / roku-debug / 31208378856

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

push

github

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

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

3901 of 5576 branches covered (69.96%)

Branch coverage included in aggregate %.

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

3 existing lines in 2 files now uncovered.

6069 of 8042 relevant lines covered (75.47%)

49.26 hits per line

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

60.3
/src/adapters/DebugProtocolAdapter.ts
1
import * as EventEmitter from 'events';
2✔
2
import { createRokuDeploySocket } from 'roku-deploy';
2✔
3
import type { RokuDeploySocket, SocketOptions } from 'roku-deploy';
4
import { DiagnosticSeverity, util as bscUtil } from 'brighterscript';
2✔
5
import type { BSDebugDiagnostic } from '../CompileErrorProcessor';
6
import { CompileErrorProcessor } from '../CompileErrorProcessor';
2✔
7
import type { RendezvousHistory, RendezvousTracker } from '../RendezvousTracker';
8
import type { ChanperfData } from '../ChanperfTracker';
9
import { ChanperfTracker } from '../ChanperfTracker';
2✔
10
import { ErrorCode, PROTOCOL_ERROR_CODES, UpdateType } from '../debugProtocol/Constants';
2✔
11
import { defer, util } from '../util';
2✔
12
import { logger } from '../logging';
2✔
13
import type { AdapterOptions, HighLevelType, RokuAdapterEvaluateResponse } from '../interfaces';
14
import type { BreakpointManager } from '../managers/BreakpointManager';
15
import type { ProjectManager } from '../managers/ProjectManager';
16
import type { BreakpointsVerifiedEvent, ConstructorOptions, ProtocolVersionDetails } from '../debugProtocol/client/DebugProtocolClient';
17
import { DebugProtocolClient } from '../debugProtocol/client/DebugProtocolClient';
2✔
18
import { ProtocolCapabilities } from '../debugProtocol/client/ProtocolCapabilities';
2✔
19
import type { Variable } from '../debugProtocol/events/responses/VariablesResponse';
20
import { VariableType } from '../debugProtocol/events/responses/VariablesResponse';
2✔
21
import type { TelnetAdapter } from './TelnetAdapter';
22
import type { DeviceInfo } from 'roku-deploy';
23
import type { ThreadsResponse } from '../debugProtocol/events/responses/ThreadsResponse';
24
import type { ExceptionBreakpoint } from '../debugProtocol/events/requests/SetExceptionBreakpointsRequest';
25
import { insertCustomVariables, overrideKeyTypesForCustomVariables } from './customVariableUtils';
2✔
26
import type { DebugProtocol } from '@vscode/debugprotocol';
27
import { SocketConnectionInUseError } from '../Exceptions';
2✔
28

29
/**
30
 * A class that connects to a Roku device over telnet debugger port and provides a standardized way of interacting with it.
31
 */
32
export class DebugProtocolAdapter {
2✔
33
    constructor(
34
        private options: AdapterOptions & ConstructorOptions,
22✔
35
        private projectManager: ProjectManager,
22✔
36
        private breakpointManager: BreakpointManager,
22✔
37
        private rendezvousTracker: RendezvousTracker,
22✔
38
        private deviceInfo: DeviceInfo
22✔
39
    ) {
40
        util.normalizeAdapterOptions(this.options);
22✔
41
        this.emitter = new EventEmitter();
22✔
42
        this.chanperfTracker = new ChanperfTracker();
22✔
43
        this.compileErrorProcessor = new CompileErrorProcessor();
22✔
44
        this.connected = false;
22✔
45
        //capabilities derived from device-info; used to answer questions before the debug
46
        //protocol client has connected and completed its handshake
47
        this.fallbackCapabilities = new ProtocolCapabilities(this.deviceInfo?.brightscriptDebuggerVersion, this.deviceInfo?.softwareVersion);
22!
48

49
        // watch for chanperf events
50
        this.chanperfTracker.on('chanperf', (output) => {
22✔
51
            this.emit('chanperf', output);
×
52
        });
53
    }
54

55
    private logger = logger.createLogger(`[padapter]`);
22✔
56

57
    /**
58
     * Capabilities seeded from the device-info `brightscript-debugger-version`. Used as the
59
     * source of truth for protocol capability questions before the debug protocol client has
60
     * connected and completed its handshake.
61
     */
62
    private fallbackCapabilities: ProtocolCapabilities;
63

64
    /**
65
     * The current authoritative capabilities for protocol-version-driven questions: the live
66
     * client's capabilities once it exists, otherwise the device-info-seeded fallback.
67
     */
68
    private get capabilities(): ProtocolCapabilities {
69
        return this.client?.capabilities ?? this.fallbackCapabilities;
50✔
70
    }
71

72
    /**
73
     * Indicates whether the adapter has successfully established a connection with the device
74
     */
75
    public connected: boolean;
76

77
    private compileClient: RokuDeploySocket;
78
    private compileErrorProcessor: CompileErrorProcessor;
79
    private emitter: EventEmitter;
80
    private chanperfTracker: ChanperfTracker;
81
    private client: DebugProtocolClient;
82
    private nextFrameId = 1;
22✔
83

84
    private stackFramesCache: Record<number, StackFrame> = {};
22✔
85
    private cache = {};
22✔
86

87
    /**
88
     * Get the version of the protocol for the Roku device we're currently connected to.
89
     */
90
    public get activeProtocolVersion() {
91
        return this.client?.protocolVersion;
×
92
    }
93

94
    /**
95
     * Subscribe to an event exactly once
96
     * @param eventName
97
     */
98
    public once(eventName: 'cannot-continue'): Promise<void>;
99
    public once(eventname: 'chanperf'): Promise<ChanperfData>;
100
    public once(eventName: 'close'): Promise<void>;
101
    public once(eventName: 'app-exit'): Promise<void>;
102
    public once(eventName: 'app-ready'): Promise<void>;
103
    public once(eventName: 'diagnostics'): Promise<BSDebugDiagnostic>;
104
    public once(eventName: 'connected'): Promise<boolean>;
105
    public once(eventname: 'console-output'): Promise<string>; // TODO: might be able to remove this at some point
106
    public once(eventname: 'protocol-version'): Promise<ProtocolVersionDetails>;
107
    public once(eventname: 'rendezvous'): Promise<RendezvousHistory>;
108
    public once(eventName: 'runtime-error'): Promise<BrightScriptRuntimeError>;
109
    public once(eventName: 'suspend'): Promise<void>;
110
    public once(eventName: 'start'): Promise<void>;
111
    public once(eventname: 'device-unresponsive'): Promise<void>;
112
    public once(eventname: 'unhandled-console-output'): Promise<string>;
113
    public once(eventName: string) {
114
        return new Promise((resolve) => {
16✔
115
            const disconnect = this.on(eventName as Parameters<DebugProtocolAdapter['on']>[0], (...args) => {
16✔
116
                disconnect();
16✔
117
                resolve(...args);
16✔
118
            });
119
        });
120
    }
121

122
    /**
123
     * Subscribe to various events
124
     * @param eventName
125
     * @param handler
126
     */
127
    public on(eventName: 'breakpoints-verified', handler: (event: BreakpointsVerifiedEvent) => any);
128
    public on(eventName: 'cannot-continue', handler: () => any);
129
    public on(eventname: 'chanperf', handler: (output: ChanperfData) => any);
130
    public on(eventName: 'close', handler: () => any);
131
    public on(eventName: 'app-exit', handler: () => any);
132
    public on(eventName: 'diagnostics', handler: (params: BSDebugDiagnostic[]) => any);
133
    public on(eventName: 'launch-status', handler: (message: string) => any);
134
    public on(eventName: 'connected', handler: (params: boolean) => any);
135
    public on(eventname: 'console-output', handler: (output: string) => any); // TODO: might be able to remove this at some point.
136
    public on(eventname: 'protocol-version', handler: (output: ProtocolVersionDetails) => any);
137
    public on(eventName: 'runtime-error', handler: (error: BrightScriptRuntimeError) => any);
138
    public on(eventName: 'suspend', handler: () => any);
139
    public on(eventName: 'start', handler: () => any);
140
    public on(eventName: 'waiting-for-debugger', handler: () => any);
141
    public on(eventName: 'device-unresponsive', handler: (data: { lastCommand: string }) => any);
142
    public on(eventname: 'unhandled-console-output', handler: (output: string) => any);
143
    public on(eventName: string, handler: (payload: any) => any) {
144
        this.emitter?.on(eventName, handler);
34!
145
        return () => {
34✔
146
            this.emitter?.removeListener(eventName, handler);
16!
147
        };
148
    }
149

150
    private emit(eventName: 'suspend');
151
    private emit(eventName: 'breakpoints-verified', event: BreakpointsVerifiedEvent);
152
    private emit(eventName: 'diagnostics', data: BSDebugDiagnostic[]);
153
    private emit(eventName: 'launch-status', message: string);
154
    private emit(eventName: 'app-exit' | 'app-ready' | 'cannot-continue' | 'chanperf' | 'close' | 'connected' | 'console-output' | 'protocol-version' | 'rendezvous' | 'runtime-error' | 'start' | 'unhandled-console-output' | 'waiting-for-debugger' | 'device-unresponsive', data?);
155
    private emit(eventName: string, data?) {
156
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
157
        setTimeout(() => {
74✔
158
            //in rare cases, this event is fired after the debugger has closed, so make sure the event emitter still exists
159
            if (!this.emitter) {
74!
160
                return;
×
161
            }
162
            //drop stale 'suspend'/'runtime-error' events when the debugger has already resumed.
163
            //emit() defers via setTimeout, so isStopped can flip false between queue and fire (e.g. auto-continue on entry breakpoint),
164
            //causing downstream handlers to call getThreads() against a running debugger.
165
            //See https://github.com/rokucommunity/vscode-brightscript-language/issues/798
166
            if ((eventName === 'suspend' || eventName === 'runtime-error') && !this.isAtDebuggerPrompt) {
74✔
167
                this.logger.warn(`Dropping stale "${eventName}" event because debugger is no longer paused`);
1✔
168
                return;
1✔
169
            }
170
            this.emitter.emit(eventName, data);
73✔
171
        }, 0);
172
    }
173

174
    /**
175
     * Does the current client support exception breakpoints? Resolved via the live client's
176
     * capabilities when connected, otherwise the device-info-seeded fallback.
177
     */
178
    public get supportsExceptionBreakpoints(): boolean {
179
        return this.capabilities.supportsExceptionBreakpoints;
×
180
    }
181

182
    /**
183
     * Does the current client support conditional breakpoints? Same fallback semantics as
184
     * `supportsExceptionBreakpoints`.
185
     */
186
    public get supportsConditionalBreakpoints(): boolean {
187
        return this.capabilities.supportsConditionalBreakpoints;
×
188
    }
189

190
    /**
191
     * Does the current client support hit-count breakpoints?
192
     */
193
    public get supportsHitConditionalBreakpoints(): boolean {
194
        return this.capabilities.supportsHitConditionalBreakpoints;
×
195
    }
196

197
    /**
198
     * The debugger needs to tell us when to be active (i.e. when the package was deployed)
199
     */
200
    public isActivated = false;
22✔
201

202
    /**
203
     * This will be set to true When the roku emits the [scrpt.ctx.run.enter] text,
204
     * which indicates that the app is running on the Roku
205
     */
206
    public isAppRunning = false;
22✔
207

208
    public activate() {
209
        this.isActivated = true;
×
210
        this.handleStartupIfReady();
×
211
    }
212

213
    public async sendErrors() {
214
        await this.compileErrorProcessor.sendErrors();
×
215
    }
216

217
    private handleStartupIfReady() {
218
        if (this.isActivated && this.isAppRunning) {
16!
219
            this.emit('start');
×
220

221
            //if we are already sitting at a debugger prompt, we need to emit the first suspend event.
222
            //If not, then there are probably still messages being received, so let the normal handler
223
            //emit the suspend event when it's ready
224
            if (this.isAtDebuggerPrompt === true) {
×
225
                this.emit('suspend');
×
226
            }
227
        }
228
    }
229

230
    /**
231
     * Wait until the client has stopped sending messages. This is used mainly during .connect so we can ignore all old messages from the server
232
     * @param client
233
     * @param maxWaitMilliseconds
234
     */
235
    private settleCompileClient(client: RokuDeploySocket, maxWaitMilliseconds = 400) {
×
236
        return new Promise<string>((resolve) => {
×
237
            let timeoutStarted = false;
×
238
            let callCount = -1;
×
239
            let logs = '';
×
240

241
            function handler(buffer) {
242
                callCount++;
×
243
                logs += buffer.toString();
×
244
                let myCallCount = callCount;
×
245
                timeoutStarted = true;
×
246
                setTimeout(() => {
×
247
                    if (myCallCount === callCount) {
×
248
                        // stop listening for data events
249
                        client.removeListener('data', handler);
×
250
                        resolve(logs);
×
251
                    }
252
                }, maxWaitMilliseconds);
253
            }
254

255
            const startTimeout = () => {
×
256
                if (timeoutStarted === false) {
×
257
                    handler(Buffer.from(''));
×
258
                }
259
            };
260

261
            // watch for data events
262
            client.on('data', handler);
×
263

264
            // watch for different connection related events to start the timeout logic
265
            client.on('ready', startTimeout);
×
266
            client.on('end', startTimeout);
×
267
            client.on('closed', startTimeout);
×
268
        });
269
    }
270

271
    public get isAtDebuggerPrompt() {
272
        return this.client?.isStopped ?? false;
81!
273
    }
274

275
    private firstConnectDeferred = defer<void>();
22✔
276

277
    /**
278
     * Resolves when the first connection to the client is established
279
     */
280
    public onReady() {
281
        return this.firstConnectDeferred.promise;
×
282
    }
283

284
    /**
285
     * Create the transport used to reach the device's BrightScript console. Extracted to a
286
     * protected method so tests can substitute a fake socket.
287
     */
288
    protected createRokuDeploySocket(options: SocketOptions): RokuDeploySocket {
NEW
289
        return createRokuDeploySocket(options);
×
290
    }
291

292
    /**
293
     * Connect to the telnet session. This should be called before the channel is launched.
294
     */
295
    public async connect(): Promise<void> {
296
        //Start processing telnet output to look for compile errors or the debugger prompt
297
        await this.processTelnetOutput();
17✔
298

299
        this.on('waiting-for-debugger', async () => { // eslint-disable-line @typescript-eslint/no-misused-promises
17✔
300
            await this.createDebugProtocolClient();
×
301

302
            //if this is the first time we are connecting, resolve the promise.
303
            //(future events fire for "reconnect" situations, we don't need to resolve again for those)
304
            if (!this.firstConnectDeferred.isCompleted) {
×
305
                this.firstConnectDeferred.resolve();
×
306
            }
307
        });
308
    }
309

310
    public async createDebugProtocolClient() {
311
        let deferred = defer();
17✔
312
        if (this.client) {
17!
313
            await Promise.race([
×
314
                util.sleep(2000),
315
                await this.client.destroy()
316
            ]);
317
            this.client = undefined;
×
318
            //keep `connected` in sync with `client` so the _syncBreakpoints entry guard
319
            //(and similar checks elsewhere) reflects the actual state. Restored to true below
320
            //once the new client finishes connecting.
321
            this.connected = false;
×
322
        }
323
        this.client = new DebugProtocolClient(this.options);
17✔
324
        try {
17✔
325
            // Emit IO from the debugger.
326
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
327
            this.client.on('io-output', async (responseText) => {
17✔
328
                if (typeof responseText === 'string') {
×
329
                    responseText = this.chanperfTracker.processLog(responseText);
×
330
                    responseText = await this.rendezvousTracker.processLog(responseText);
×
331
                    this.emit('unhandled-console-output', responseText);
×
332
                    this.emit('console-output', responseText);
×
333
                }
334
            });
335

336
            // Emit IO from the debugger.
337
            this.client.on('protocol-version', (data: ProtocolVersionDetails) => {
17✔
338
                if (data.errorCode === PROTOCOL_ERROR_CODES.SUPPORTED) {
16!
339
                    this.emit('console-output', data.message);
16✔
340
                } else if (data.errorCode === PROTOCOL_ERROR_CODES.NOT_TESTED) {
×
341
                    this.emit('unhandled-console-output', data.message);
×
342
                    this.emit('console-output', data.message);
×
343
                } else if (data.errorCode === PROTOCOL_ERROR_CODES.NOT_SUPPORTED) {
×
344
                    this.emit('unhandled-console-output', data.message);
×
345
                    this.emit('console-output', data.message);
×
346
                }
347

348
            });
349

350
            // Listen for the close event
351
            this.client.on('close', () => {
17✔
352
                this.emit('close');
2✔
353
                this.beginAppExit();
2✔
354
                void this.client?.destroy();
2!
355
                this.client = undefined;
2✔
356
                //the protocol client is gone — keep `connected` in sync so any subsequent
357
                //syncBreakpoints / setExceptionBreakpoints calls during the close→app-exit
358
                //window early-return instead of dereferencing the undefined client.
359
                //See https://github.com/rokucommunity/vscode-brightscript-language/issues/811
360
                this.connected = false;
2✔
361
            });
362

363
            // Listen for the app exit event
364
            this.client.on('app-exit', () => {
17✔
365
                this.emit('app-exit');
×
366
                void this.client?.destroy();
×
367
                this.client = undefined;
×
368
            });
369

370
            this.client.on('suspend', (data) => {
17✔
371
                this.clearCache();
17✔
372
                this.emit('suspend');
17✔
373
            });
374

375
            this.client.on('runtime-error', (data) => {
17✔
376
                console.debug('hasRuntimeError!!', data);
×
377
                this.emit('runtime-error', <BrightScriptRuntimeError>{
×
378
                    message: data.data.stopReasonDetail,
379
                    errorCode: data.data.stopReason
380
                });
381
            });
382

383
            this.client.on('cannot-continue', () => {
17✔
384
                this.emit('cannot-continue');
×
385
            });
386

387
            //handle when the device verifies breakpoints
388
            this.client.on('breakpoints-verified', (event) => {
17✔
389
                let unverifiableDeviceIds = [] as number[];
6✔
390

391
                //mark the breakpoints as verified
392
                for (let breakpoint of event?.breakpoints ?? []) {
6!
393
                    const success = this.breakpointManager.verifyBreakpoint(breakpoint.id, true);
5✔
394
                    if (!success) {
5✔
395
                        unverifiableDeviceIds.push(breakpoint.id);
3✔
396
                    }
397
                }
398
                //if there were any unsuccessful breakpoint verifications, we need to ask the device to delete those breakpoints as they've gone missing on our side
399
                if (unverifiableDeviceIds.length > 0) {
6✔
400
                    this.logger.warn('Could not find breakpoints to verify. Removing from device:', { deviceBreakpointIds: unverifiableDeviceIds });
3✔
401
                    void this.client?.removeBreakpoints(unverifiableDeviceIds);
3!
402
                }
403
                this.emit('breakpoints-verified', event);
5✔
404
            });
405

406
            this.client.on('compile-error', (update) => {
17✔
407
                let diagnostics: BSDebugDiagnostic[] = [];
×
408
                diagnostics.push({
×
409
                    path: update.data.filePath,
410
                    range: bscUtil.createRange(update.data.lineNumber - 1, 0, update.data.lineNumber - 1, 999),
411
                    message: update.data.errorMessage,
412
                    severity: DiagnosticSeverity.Error,
413
                    code: undefined
414
                });
415
                this.emit('diagnostics', diagnostics);
×
416
            });
417

418
            await this.client.connect();
17✔
419

420
            //the client can be torn down while the connect above is still settling (its 'close'
421
            //handler clears `this.client` - for example the device immediately killing the session
422
            //it accepted). Everything below configures a client that no longer exists, so bail and
423
            //leave the queued breakpoint state for the next connection instead of crashing.
424
            if (!this.client) {
17✔
425
                this.logger.warn('Debug protocol client closed before setup completed; waiting for a new connection');
1✔
426
                deferred.resolve();
1✔
427
                return await deferred.promise;
1✔
428
            }
429

430
            this.logger.log(`Connected to device`, { device: util.getDeviceLabel(this.options.device), connected: this.connected });
16✔
431
            this.connected = true;
16✔
432
            this.isAppRunning = true;
16✔
433
            this.handleStartupIfReady();
16✔
434
            this.emit('connected', this.connected);
16✔
435
            this.emit('app-ready');
16✔
436
            //flush any breakpoints that were queued while we were waiting for the client to connect.
437
            //setBreakpointsRequest is called by VS Code before the channel is uploaded, so the initial
438
            //sync bails out (no client yet); this re-sync pushes those queued breakpoints to the device.
439
            void this.syncBreakpoints();
16✔
440
            //also replay any queued exception breakpoint filters
441
            if (this.pendingExceptionBreakpointFilters) {
16!
442
                const queuedFilters = this.pendingExceptionBreakpointFilters;
×
443
                this.pendingExceptionBreakpointFilters = undefined;
×
444
                void this.client.setExceptionBreakpoints(queuedFilters);
×
445
            }
446

447
            //the adapter is connected and running smoothly. resolve the promise
448
            deferred.resolve();
16✔
449
        } catch (e) {
450
            deferred.reject(e);
×
451
        }
452
        return deferred.promise;
16✔
453
    }
454

455
    private beginAppExit() {
456
        this.compileErrorProcessor.compileErrorTimer = setTimeout(() => {
2✔
457
            this.isAppRunning = false;
2✔
458
            this.emit('app-exit');
2✔
459
        }, 200);
460
    }
461

462
    /**
463
     * Determines if the current version of the debug protocol supports emitting compile error updates.
464
     */
465
    public get supportsCompileErrorReporting() {
466
        return this.capabilities.supportsCompileErrorReporting;
×
467
    }
468

469
    /**
470
     * Indicate if virtual variables should be auto resolved when they are encountered.
471
     */
472
    public get autoResolveVirtualVariables() {
473
        return this.options.autoResolveVirtualVariables;
1✔
474
    }
475

476
    private processingTelnetOutput = false;
22✔
477
    public async processTelnetOutput() {
478
        if (this.processingTelnetOutput) {
1!
479
            return;
×
480
        }
481
        this.processingTelnetOutput = true;
1✔
482

483
        let deferred = defer();
1✔
484
        try {
1✔
485
            //normalizeAdapterOptions guarantees `device` is a concrete device config
486
            const device = this.options.device;
1✔
487
            const deviceLabel = util.getDeviceLabel(device);
1✔
488

489
            this.compileClient = this.createRokuDeploySocket({ device: device, port: this.options.brightScriptConsolePort });
1✔
490
            util.registerSocketLogging(this.compileClient, this.logger, 'CompileClient');
1✔
491

492
            this.compileErrorProcessor.on('diagnostics', (errors) => {
1✔
493
                this.compileClient.end();
×
494
                this.emit('diagnostics', errors);
×
495
            });
496

497
            this.compileErrorProcessor.on('launch-status', (message) => {
1✔
498
                this.emit('launch-status', message);
×
499
            });
500

501
            //if the connection fails, reject the connect promise.
502
            //Use tryReject (not reject) because this handler persists for the socket's lifetime.
503
            //After a successful connection the deferred is already resolved, so a post-connection
504
            //socket error (e.g. ECONNRESET on device disconnect) must not crash the process.
505
            this.compileClient.on('error', (err) => {
1✔
506
                deferred.tryReject(new Error(`Error with connection to: ${deviceLabel}:${this.options.brightScriptConsolePort} \n\n ${err.message} `));
1✔
507
            });
508
            this.logger.info('Connecting via telnet to gather compile info', { device: deviceLabel, port: this.options.brightScriptConsolePort });
1✔
509
            this.compileClient.connect(() => {
1✔
510
                this.logger.log(`CONNECTED via telnet to gather compile info`, { device: deviceLabel, port: this.options.brightScriptConsolePort });
1✔
511
            });
512

513
            this.logger.debug('Waiting for the compile client to settle');
1✔
514
            const settledLogs = await this.settleCompileClient(this.compileClient);
1✔
515
            this.logger.debug('Compile client has settled');
1✔
516
            this.logger.trace('Settled logs:', settledLogs);
1✔
517

518
            if (settledLogs.trim().startsWith('Console connection is already in use.')) {
1!
NEW
519
                throw new SocketConnectionInUseError(`Telnet connection ${deviceLabel}:${this.options.brightScriptConsolePort} already is use`, {
×
520
                    port: this.options.brightScriptConsolePort,
521
                    host: deviceLabel
522
                });
523
            }
524

525
            let lastPartialLine = '';
1✔
526
            this.compileClient.on('data', (buffer: Buffer) => {
1✔
527
                let responseText = buffer.toString();
×
528
                this.logger.info('CompileClient received data', { responseText });
×
529

530
                let logResult = util.handleLogFragments(lastPartialLine, buffer.toString());
×
531

532
                // Save any remaining partial line for the next event
533
                lastPartialLine = logResult.remaining;
×
534
                if (logResult.completed) {
×
535
                    // Emit the completed io string.
536
                    this.findWaitForDebuggerPrompt(logResult.completed);
×
537
                    this.compileErrorProcessor.processUnhandledLines(logResult.completed);
×
538
                    this.logger.debug('CompileClient data:', logResult.completed);
×
539
                    this.emit('unhandled-console-output', logResult.completed);
×
540
                } else {
541
                    this.logger.debug('CompileClient buffer was split:', lastPartialLine);
×
542
                }
543
            });
544

545
            this.compileClient.on('close', () => {
1✔
546
                this.logger.log('compileClient socket closed');
1✔
547
                this.compileClientClosed.tryResolve();
1✔
548
            });
549

550
            // connected to telnet. resolve the promise
551
            deferred.resolve();
1✔
552
        } catch (e) {
553
            deferred.reject(e);
×
554
        }
555
        return deferred.promise;
1✔
556
    }
557

558
    private findWaitForDebuggerPrompt(responseText: string) {
559
        let lines = responseText.split(/\r?\n/g);
×
560
        for (const line of lines) {
×
561
            if (/Waiting for debugger on \d+\.\d+\.\d+\.\d+:8081/g.exec(line)) {
×
562
                this.emit('waiting-for-debugger');
×
563
            }
564
        }
565
    }
566

567
    /**
568
     * Send command to step over
569
     */
570
    public async stepOver(threadId: number) {
571
        this.clearCache();
×
572
        return this.client.stepOver(threadId);
×
573
    }
574

575
    public async stepInto(threadId: number) {
576
        this.clearCache();
×
577
        return this.client.stepIn(threadId);
×
578
    }
579

580
    public async stepOut(threadId: number) {
581
        this.clearCache();
×
582
        return this.client.stepOut(threadId);
×
583
    }
584

585
    /**
586
     * Tell the brightscript program to continue (i.e. resume program)
587
     */
588
    public async continue() {
589
        this.clearCache();
×
590
        return this.client.continue();
×
591
    }
592

593
    /**
594
     * Tell the brightscript program to pause (fall into debug mode)
595
     */
596
    public async pause() {
597
        this.clearCache();
×
598
        //send the kill signal, which breaks into debugger mode
599
        return this.client.pause();
×
600
    }
601

602
    /**
603
     * Clears the state, which means that everything will be retrieved fresh next time it is requested
604
     */
605
    public clearCache() {
606
        this.cache = {};
17✔
607
        this.stackFramesCache = {};
17✔
608
    }
609

610
    /**
611
     * Execute a command directly on the roku. Returns the output of the command
612
     * @param command
613
     * @returns the output of the command (if possible)
614
     */
615
    public async evaluate(command: string, frameId: number = this.client.primaryThread): Promise<RokuAdapterEvaluateResponse> {
×
616
        if (this.capabilities.supportsExecuteCommand) {
×
617
            if (!this.isAtDebuggerPrompt) {
×
618
                throw new Error('Cannot run evaluate: debugger is not paused');
×
619
            }
620

621
            let stackFrame = this.getStackFrameById(frameId);
×
622
            if (!stackFrame) {
×
623
                throw new Error('Cannot execute command without a corresponding frame');
×
624
            }
625
            this.logger.log('evaluate ', { command, frameId });
×
626

627
            const response = await this.client.executeCommand(command, stackFrame.frameIndex, stackFrame.threadIndex);
×
628
            this.logger.info('evaluate response', { command, response });
×
629
            if (response.data.executeSuccess) {
×
630
                return {
×
631
                    message: undefined,
632
                    type: 'message'
633
                };
634
            } else {
635
                const messages = [
×
636
                    ...response?.data?.compileErrors ?? [],
×
637
                    ...response?.data?.runtimeErrors ?? [],
×
638
                    ...response?.data?.otherErrors ?? []
×
639
                ];
640
                return {
×
641
                    message: messages[0] ?? 'Unknown error executing command',
×
642
                    type: 'error'
643
                };
644
            }
645
        } else {
646
            return {
×
647
                message: `Execute commands are not supported on debug protocol: ${this.activeProtocolVersion}, v3.0.0 or greater is required.`,
648
                type: 'error'
649
            };
650
        }
651
    }
652

653
    public async getStackTrace(threadIndex: number = this.client.primaryThread) {
×
654
        if (!this.isAtDebuggerPrompt) {
20!
655
            throw new Error('Cannot get stack trace: debugger is not paused');
×
656
        }
657
        return this.resolve(`stack trace for thread ${threadIndex}`, async () => {
20✔
658
            let thread = await this.getThreadByThreadId(threadIndex);
19✔
659
            let frames: StackFrame[] = [];
19✔
660
            let stackTraceData = await this.client.getStackTrace(threadIndex);
19✔
661

662
            // Non-OK error code (e.g. THREAD_DETACHED) means we can not provide the stack trace
663
            if (stackTraceData?.data?.errorCode !== undefined && stackTraceData.data.errorCode !== ErrorCode.OK) {
19✔
664
                this.logger.warn(`getStackTrace for thread ${threadIndex} failed with errorCode ${stackTraceData.data.errorCode}`);
2✔
665
                return frames;
2✔
666
            }
667
            for (let i = 0; i < (stackTraceData?.data?.entries?.length ?? 0); i++) {
17✔
668
                let frameData = stackTraceData.data.entries[i];
16✔
669
                let stackFrame: StackFrame = {
16✔
670
                    frameId: this.nextFrameId++,
671
                    // frame index is the reverse of the returned order.
672
                    frameIndex: stackTraceData.data.entries.length - i - 1,
673
                    threadIndex: threadIndex,
674
                    filePath: frameData.filePath,
675
                    lineNumber: frameData.lineNumber,
676
                    // eslint-disable-next-line no-nested-ternary
677
                    functionIdentifier: this.cleanUpFunctionName(i === 0 ? (frameData.functionName) ? frameData.functionName : thread.functionName : frameData.functionName)
32!
678
                };
679
                this.stackFramesCache[stackFrame.frameId] = stackFrame;
16✔
680
                frames.push(stackFrame);
16✔
681
            }
682
            //if the first frame is missing any data, supplement with thread information
683
            if (frames[0]) {
17✔
684
                frames[0].filePath ??= thread.filePath;
16!
685
                frames[0].lineNumber ??= thread.lineNumber;
16!
686
            }
687

688
            return frames;
17✔
689
        });
690
    }
691

692
    public getStackFrameById(frameId: number): StackFrame {
693
        return this.stackFramesCache[frameId];
2✔
694
    }
695

696
    private cleanUpFunctionName(functionName): string {
697
        return functionName.substring(functionName.lastIndexOf('@') + 1);
16✔
698
    }
699

700
    /**
701
     * Get info about the specified variable.
702
     * @param expression the expression for the specified variable (i.e. `m`, `someVar.value`, `arr[1][2].three`). If empty string/undefined is specified, all local variables are retrieved instead
703
     */
704
    private async getVariablesResponse(expression: string, frameId: number) {
705
        const logger = this.logger.createLogger('[getVariable]');
2✔
706
        logger.info('begin', { expression });
2✔
707
        if (!this.isAtDebuggerPrompt) {
2!
708
            throw new Error('Cannot resolve variable: debugger is not paused');
×
709
        }
710

711
        let frame = this.getStackFrameById(frameId);
2✔
712
        if (!frame) {
2!
713
            throw new Error('Cannot request variable without a corresponding frame');
×
714
        }
715

716
        logger.info(`Expression:`, JSON.stringify(expression));
2✔
717
        let variablePath = expression === '' ? [] : util.getVariablePath(expression);
2✔
718

719
        // Temporary workaround related to casing issues over the protocol
720
        if (this.capabilities.enableVariablesLowerCaseRetry && variablePath?.length > 0) {
2!
721
            variablePath[0] = variablePath[0].toLowerCase();
×
722
        }
723

724
        let response = await this.client.getVariables(variablePath, frame.frameIndex, frame.threadIndex);
2✔
725

726
        if (this.capabilities.enableVariablesLowerCaseRetry && response.data.errorCode !== ErrorCode.OK) {
2!
727
            // Temporary workaround related to casing issues over the protocol
728
            logger.log(`Retrying expression as lower case:`, expression);
×
729
            variablePath = expression === '' ? [] : util.getVariablePath(expression?.toLowerCase());
×
730
            response = await this.client.getVariables(variablePath, frame.frameIndex, frame.threadIndex);
×
731
        }
732
        return response;
2✔
733
    }
734

735
    /**
736
     * Get the variable for the specified expression.
737
     */
738
    public async getVariable(expression: string, frameId: number): Promise<EvaluateContainer> {
739
        const response = await this.getVariablesResponse(expression, frameId);
1✔
740

741
        if (Array.isArray(response?.data?.variables)) {
1!
742
            const container = this.createEvaluateContainer(
1✔
743
                response.data.variables[0],
744
                //the name of the top container is the expression itself
745
                expression,
746
                //this is the top-level container, so there are no parent keys to this entry
747
                undefined
748
            );
749
            await insertCustomVariables(this, expression, container);
1✔
750
            container.namedVariables = container.children.length - container.indexedVariables;
1✔
751
            return container;
1✔
752
        }
753
    }
754

755
    /**
756
     * Get the list of local variables
757
     */
758
    public async getLocalVariables(frameId: number) {
759
        const response = await this.getVariablesResponse('', frameId);
1✔
760

761
        if (response?.data?.errorCode === ErrorCode.OK && Array.isArray(response?.data?.variables)) {
1!
762
            //create a top-level container to hold all the local vars
763
            const container = this.createEvaluateContainer(
1✔
764
                //dummy data
765
                {
766
                    isConst: false,
767
                    isContainer: true,
768
                    keyType: VariableType.String,
769
                    refCount: undefined,
770
                    type: VariableType.AssociativeArray,
771
                    value: undefined,
772
                    children: response.data.variables
773
                },
774
                //no name, this is a dummy container
775
                undefined,
776
                //there's no parent path
777
                undefined
778
            );
779
            container.indexedVariables = 0;
1✔
780
            container.namedVariables = container.children.length;
1✔
781
            return container;
1✔
782
        }
783
    }
784

785
    /**
786
     * Create an EvaluateContainer for the given variable. If the variable has children, those are created and attached as well
787
     * @param variable a Variable object from the debug protocol debugger
788
     * @param name the name of this variable. For example, `alpha.beta.charlie`, this value would be `charlie`. For local vars, this is the root variable name (i.e. `alpha`)
789
     * @param parentEvaluateName the string used to derive the parent, _excluding_ this variable's name (i.e. `alpha.beta` or `alpha[0]`)
790
     */
791
    private createEvaluateContainer(variable: Variable, name: string | number, parentEvaluateName: string): EvaluateContainer {
792
        let value;
793
        let variableType = variable.type;
10✔
794
        if (variable.value === null) {
10!
795
            value = 'roInvalid';
×
796
        } else if (variableType === VariableType.String) {
10✔
797
            value = `\"${variable.value}\"`;
2✔
798
        } else {
799
            value = variable.value;
8✔
800
        }
801

802
        if (variableType === VariableType.SubtypedObject) {
10!
803
            //subtyped objects can only have string values
804
            let parts = (variable.value as string).split('; ');
×
805
            // Pull the primary type from the value.
806
            (variableType as string) = parts[0];
×
807

808
            // Format the value to be more readable in the UI.
809
            // Example: `roSGNode; Group` = `roSGNode (Group)`
810
            (value as string) = `${parts[0]}(${parts[1]})`;
×
811
        } else if (variableType === VariableType.Object || variableType === VariableType.Interface) {
10!
812
            // We want the type to reflect `roAppInfo` or `roDeviceInfo` for example in the UI
813
            // so set the type to be the value from the device
814
            variableType = value;
×
815
        } else if (variableType === VariableType.AssociativeArray) {
10✔
816
            // We want the type to reflect `function` in the UI
817
            value = VariableType.AssociativeArray;
7✔
818
        }
819

820
        //build full evaluate name for this var. (i.e. `alpha["beta"]` + ["charlie"]` === `alpha["beta"]["charlie"]`)
821
        let evaluateName: string;
822
        if (!parentEvaluateName?.trim()) {
10✔
823
            evaluateName = name?.toString();
6✔
824
        } else if (variable.isVirtual) {
4!
825
            evaluateName = `${parentEvaluateName}.${name}`;
×
826
        } else if (typeof name === 'string') {
4✔
827
            evaluateName = `${parentEvaluateName}["${name}"]`;
3✔
828
        } else if (typeof name === 'number') {
1!
829
            evaluateName = `${parentEvaluateName}[${name}]`;
1✔
830
        }
831

832
        let container: EvaluateContainer = {
10✔
833
            name: name?.toString() ?? '',
60✔
834
            evaluateName: evaluateName ?? '',
30✔
835
            type: variableType ?? '',
30!
836
            value: value ?? null,
30!
837
            highLevelType: undefined,
838
            //non object/array variables don't have a key type
839
            keyType: variable.keyType as unknown as KeyType,
840
            namedVariables: 0,
841
            indexedVariables: 0,
842
            //non object/array variables still need to have an empty `children` array to help upstream logic. The `keyType` being null is how we know it doesn't actually have children
843
            children: []
844
        };
845

846
        // In preparation for adding custom variables some variables need to be marked
847
        // as keyable/container like even thought they are not on device.
848
        overrideKeyTypesForCustomVariables(this, container);
10✔
849

850
        if (container.keyType === KeyType.integer) {
10✔
851
            container.indexedVariables = variable.childCount ?? variable.children?.length ?? undefined;
2!
852
            // We do not know how many named variables there are, if any, so we will always tell the DAP client to ask for them
853
            container.namedVariables = 1;
2✔
854
        } else if (container.keyType === KeyType.string) {
8✔
855
            // container.namedVariables = variable.childCount ?? variable.children?.length ?? undefined;
856
            // Force one so DAP client always asks for all named vars
857
            container.namedVariables = 1;
5✔
858
        }
859

860
        //recursively generate children containers
861
        if ([KeyType.integer, KeyType.string].includes(container.keyType) && Array.isArray(variable.children)) {
10✔
862
            container.children = [];
4✔
863

864
            container.namedVariables = 0;
4✔
865
            container.indexedVariables = 0;
4✔
866

867
            for (let i = 0; i < variable.children.length; i++) {
4✔
868
                const childVariable = variable.children[i];
6✔
869
                if (childVariable.name === undefined) {
6!
870
                    container.indexedVariables++;
×
871
                }
872
                const childContainer = this.createEvaluateContainer(
6✔
873
                    childVariable,
874
                    container.keyType === KeyType.integer && !childVariable.isVirtual ? i : childVariable.name,
13✔
875
                    container.evaluateName
876
                );
877
                container.children.push(childContainer);
6✔
878
            }
879
        }
880

881
        //show virtual variables in the UI
882
        if (variable.isVirtual) {
10!
883
            if (!container.presentationHint) {
×
884
                container.presentationHint = {};
×
885
            }
886
            container.presentationHint.kind = 'virtual';
×
887
        }
888

889
        return container;
10✔
890
    }
891

892
    /**
893
     * Cache items by a unique key
894
     * @param expression
895
     * @param factory
896
     */
897
    private resolve<T>(key: string, factory: () => T | Thenable<T>): Promise<T> {
898
        if (this.cache[key]) {
39✔
899
            this.logger.log('return cashed response', key, this.cache[key]);
4✔
900
            return this.cache[key];
4✔
901
        }
902
        this.cache[key] = Promise.resolve<T>(factory());
35✔
903
        return this.cache[key];
35✔
904
    }
905

906
    /**
907
     * Get a list of threads. The active thread will always be first in the list.
908
     */
909
    public async getThreads() {
910
        if (!this.isAtDebuggerPrompt) {
19!
911
            throw new Error('Cannot get threads: debugger is not paused');
×
912
        }
913
        return this.resolve('threads', async () => {
19✔
914
            let threads: Thread[] = [];
16✔
915
            let threadsResponse: ThreadsResponse;
916
            // sometimes roku threads are stubborn and haven't stopped yet, causing our ThreadsRequest to fail with "not stopped".
917
            // A nice simple fix for this is to just send a "pause" request again, which seems to fix the issue.
918
            // we'll do this a few times just to make sure we've tried our best to get the list of threads.
919
            for (let i = 0; i < 3; i++) {
16✔
920
                threadsResponse = await this.client.threads();
16✔
921
                if (threadsResponse.data.errorCode === ErrorCode.NOT_STOPPED) {
16!
922
                    this.logger.log(`Threads request retrying... ${i}:\n`, threadsResponse);
×
923
                    threadsResponse = undefined;
×
924
                    const pauseResponse = await this.client.pause(true);
×
925
                    await util.sleep(100);
×
926
                } else {
927
                    break;
16✔
928
                }
929
            }
930
            if (!threadsResponse) {
16!
931
                return [];
×
932
            }
933

934
            for (let i = 0; i < (threadsResponse.data?.threads?.length ?? 0); i++) {
16!
935
                let threadInfo = threadsResponse.data.threads[i];
16✔
936
                let thread = <Thread>{
16✔
937
                    // NOTE: On THREAD_ATTACHED events the threads request is marking the wrong thread as primary.
938
                    // NOTE: Rely on the thead index from the threads update event.
939
                    isSelected: this.client.primaryThread === i,
940
                    // isSelected: threadInfo.isPrimary,
941
                    isDetached: threadInfo.isDetached,
942
                    filePath: threadInfo.filePath,
943
                    functionName: threadInfo.functionName,
944
                    lineNumber: threadInfo.lineNumber, //threadInfo.lineNumber is 1-based. Thread requires 1-based line numbers
945
                    lineContents: threadInfo.codeSnippet,
946
                    threadId: i,
947
                    osThreadId: threadInfo.osThreadId,
948
                    name: threadInfo.name,
949
                    type: threadInfo.type
950
                };
951
                threads.push(thread);
16✔
952
            }
953
            //make sure the selected thread is at the top
954
            threads.sort((a, b) => {
16✔
955
                return a.isSelected ? -1 : 1;
×
956
            });
957

958
            return threads;
16✔
959
        });
960
    }
961

962
    private async getThreadByThreadId(threadId: number) {
963
        let threads = await this.getThreads();
19✔
964
        for (let thread of threads) {
19✔
965
            if (thread.threadId === threadId) {
19✔
966
                return thread;
18✔
967
            }
968
        }
969
    }
970

971
    public removeAllListeners() {
972
        if (this.emitter) {
×
973
            this.emitter.removeAllListeners();
×
974
        }
975
    }
976

977
    /**
978
     * Indicates whether this class had `.destroy()` called at least once. Mostly used for checking externally to see if
979
     * the whole debug session has been terminated or is in a bad state.
980
     */
981
    public isDestroyed = false;
22✔
982
    /**
983
     * Disconnect from the telnet session and unset all objects
984
     */
985
    public async destroy() {
986
        this.isDestroyed = true;
×
987

988
        // destroy the debug client if it's defined
989
        if (this.client) {
×
990
            try {
×
991
                await this.client.destroy();
×
992
            } catch (e) {
993
                this.logger.error(e);
×
994
            }
995
        }
996

997
        try {
×
998
            let shutdownTimeMax = this.options?.shutdownTimeout ?? 10_000;
×
999
            await this.destroyCompileClient(shutdownTimeMax);
×
1000
        } catch (e) {
1001
            this.logger.error(e);
×
1002
        }
1003

1004
        this.cache = undefined;
×
1005
        this.removeAllListeners();
×
1006
        this.emitter = undefined;
×
1007
    }
1008

1009
    /**
1010
     * Promise that is resolved when the compile client socket is closed
1011
     */
1012
    private compileClientClosed = defer<void>();
22✔
1013
    private isDestroyingCompileClient = false;
22✔
1014

1015
    private async destroyCompileClient(timeout: number) {
1016
        if (this.compileClient && !this.isDestroyingCompileClient) {
×
1017
            this.isDestroyingCompileClient = true;
×
1018
            this.compileClient?.end();
×
1019

1020
            //wait for the compileClient to be closed
1021
            await Promise.race([
×
1022
                this.compileClientClosed.promise,
1023
                util.sleep(timeout)
1024
            ]);
1025

1026
            this.logger.log('[destroy] compileClient is: ', this.compileClientClosed.isResolved ? 'closed' : 'not closed');
×
1027

1028
            //destroy the compileClient
1029
            this.compileClient?.removeAllListeners();
×
1030
            this.compileClient?.destroy();
×
1031
            this.compileClient = undefined;
×
1032
            this.isDestroyingCompileClient = false;
×
1033
        }
1034
    }
1035

1036
    /**
1037
     * Passes the log level down to the RendezvousTracker and ChanperfTracker
1038
     * @param outputLevel the consoleOutput from the launch config
1039
     */
1040
    public setConsoleOutput(outputLevel: string) {
1041
        this.chanperfTracker.setConsoleOutput(outputLevel);
×
1042
        this.rendezvousTracker.setConsoleOutput(outputLevel);
×
1043
    }
1044

1045
    /**
1046
     * Sends a call to the RendezvousTracker to clear the current rendezvous history
1047
     */
1048
    public clearRendezvousHistory() {
1049
        this.rendezvousTracker.clearHistory();
×
1050
    }
1051

1052
    /**
1053
     * Sends a call to the ChanperfTracker to clear the current chanperf history
1054
     */
1055
    public clearChanperfHistory() {
1056
        this.chanperfTracker.clearHistory();
×
1057
    }
1058

1059
    /**
1060
     * The most recently requested exception breakpoint filters. Stored so we can replay them
1061
     * to the debug protocol client once it connects (the session can send these before the
1062
     * device has launched and the client has finished its handshake).
1063
     */
1064
    private pendingExceptionBreakpointFilters: ExceptionBreakpoint[] | undefined;
1065

1066
    public async setExceptionBreakpoints(filters: ExceptionBreakpoint[]) {
1067
        if (!this.capabilities.supportsExceptionBreakpoints) {
×
1068
            return undefined;
×
1069
        }
1070
        //if the client isn't connected yet, queue the filters for replay on connect
1071
        if (!this.connected) {
×
1072
            this.pendingExceptionBreakpointFilters = filters;
×
1073
            return undefined;
×
1074
        }
1075
        return this.client.setExceptionBreakpoints(filters);
×
1076
    }
1077

1078
    private syncBreakpointsPromise = Promise.resolve();
22✔
1079
    public async syncBreakpoints() {
1080
        this.logger.log('syncBreakpoints()');
42✔
1081
        //wait for the previous sync to finish
1082
        this.syncBreakpointsPromise = this.syncBreakpointsPromise
42✔
1083
            //ignore any errors
1084
            .catch(() => { })
1085
            //run the next sync
1086
            .then(() => this._syncBreakpoints());
42✔
1087

1088
        //return the new promise, which will resolve once our latest `syncBreakpoints()` call is finished
1089
        return this.syncBreakpointsPromise;
42✔
1090
    }
1091

1092
    public async _syncBreakpoints() {
1093
        //we need to actually be connected to the device before we can push breakpoints. We'll get
1094
        //called again once the debug protocol client has connected.
1095
        if (!this.connected) {
32✔
1096
            this.logger.info('Cannot sync breakpoints because the debug protocol client has not connected yet');
2✔
1097
            return;
2✔
1098
        }
1099
        //we can't send breakpoints unless we're stopped (or in a protocol version that supports sending them while running).
1100
        //So...if we're not stopped, quit now. (we'll get called again when the stop event happens)
1101
        if (!this.capabilities.supportsBreakpointRegistrationWhileRunning && !this.isAtDebuggerPrompt) {
30✔
1102
            this.logger.info('Cannot sync breakpoints because the debugger', this.capabilities.supportsBreakpointRegistrationWhileRunning ? 'does not support sending breakpoints while running' : 'is not paused');
16!
1103
            return;
16✔
1104
        }
1105

1106
        //compute breakpoint changes since last sync
1107
        const diff = await this.breakpointManager.getDiff(this.projectManager.getAllProjects());
14✔
1108
        this.logger.log('Syncing breakpoints', diff);
14✔
1109

1110
        if (diff.added.length === 0 && diff.removed.length === 0) {
14✔
1111
            this.logger.debug('No breakpoints to sync');
2✔
1112
            return;
2✔
1113
        }
1114

1115
        //getDiff above can yield to other microtasks. If the protocol client closed while we were
1116
        //awaiting (mid-sync TOCTOU), bail out before dereferencing this.client. The app-exit handler
1117
        //resets the breakpoint baseline so the next reconnect re-pushes any pending changes.
1118
        //See https://github.com/rokucommunity/vscode-brightscript-language/issues/811
1119
        if (!this.client) {
12✔
1120
            this.logger.info('Skipping breakpoint sync because the protocol client closed mid-sync');
2✔
1121
            return;
2✔
1122
        }
1123

1124
        // REMOVE breakpoints (delete these breakpoints from the device)
1125
        if (diff.removed.length > 0) {
10✔
1126
            const response = await this.client.removeBreakpoints(
3✔
1127
                //TODO handle retrying to remove breakpoints that don't have deviceIds yet but might get one in the future
1128
                diff.removed.map(x => x.deviceId).filter(x => typeof x === 'number')
5✔
1129
            );
1130

1131
            if (response.data?.errorCode === ErrorCode.NOT_STOPPED) {
3!
1132
                this.breakpointManager.failedDeletions.push(...diff.removed);
1✔
1133
            }
1134
        }
1135

1136
        if (diff.added.length > 0) {
10✔
1137
            //the removeBreakpoints await above can also yield; re-check before attempting the add
1138
            if (!this.client) {
8!
1139
                this.logger.info('Skipping breakpoint add because the protocol client closed mid-sync');
×
1140
                return;
×
1141
            }
1142
            const breakpointsToSendToDevice = diff.added.map(breakpoint => {
8✔
1143
                const hitCount = parseInt(breakpoint.hitCondition);
11✔
1144
                return {
11✔
1145
                    filePath: breakpoint.pkgPath,
1146
                    lineNumber: breakpoint.line,
1147
                    hitCount: !isNaN(hitCount) ? hitCount : undefined,
11!
1148
                    conditionalExpression: breakpoint.condition,
1149
                    srcHash: breakpoint.srcHash,
1150
                    destHash: breakpoint.destHash,
1151
                    componentLibraryName: breakpoint.componentLibraryName
1152
                };
1153
            });
1154

1155
            //split the list into conditional and non-conditional breakpoints.
1156
            //(TODO we can eliminate this splitting logic once the conditional breakpoints "continue" bug in protocol is fixed)
1157
            const standardBreakpoints: typeof breakpointsToSendToDevice = [];
8✔
1158
            const conditionalBreakpoints: typeof breakpointsToSendToDevice = [];
8✔
1159
            for (const breakpoint of breakpointsToSendToDevice) {
8✔
1160
                if (breakpoint?.conditionalExpression?.trim()) {
11!
1161
                    conditionalBreakpoints.push(breakpoint);
1✔
1162
                } else {
1163
                    standardBreakpoints.push(breakpoint);
10✔
1164
                }
1165
            }
1166
            for (const breakpoints of [standardBreakpoints, conditionalBreakpoints]) {
8✔
1167
                const response = await this.client.addBreakpoints(breakpoints);
16✔
1168

1169
                //if the response was successful, and we have the correct number of breakpoints in the response
1170
                if (response.data.errorCode === ErrorCode.OK && response?.data?.breakpoints?.length === breakpoints.length) {
16!
1171
                    for (let i = 0; i < (response?.data?.breakpoints?.length ?? 0); i++) {
14!
1172
                        const deviceBreakpoint = response.data.breakpoints[i];
9✔
1173

1174
                        if (typeof deviceBreakpoint?.id === 'number') {
9!
1175
                            //sync this breakpoint's deviceId with the roku-assigned breakpoint ID
1176
                            this.breakpointManager.setBreakpointDeviceId(
9✔
1177
                                breakpoints[i].srcHash,
1178
                                breakpoints[i].destHash,
1179
                                deviceBreakpoint.id
1180
                            );
1181
                        }
1182

1183
                        //this breakpoint had an issue. remove it from the client
1184
                        if (deviceBreakpoint.errorCode !== ErrorCode.OK) {
9✔
1185
                            this.breakpointManager.deleteBreakpoint(breakpoints[i].srcHash);
1✔
1186
                        }
1187
                    }
1188
                    //the entire response was bad. delete these breakpoints from the client
1189
                } else {
1190
                    this.breakpointManager.deleteBreakpoints(
2✔
1191
                        breakpoints.map(x => x.srcHash)
2✔
1192
                    );
1193
                }
1194
            }
1195
        }
1196
    }
1197

1198
    public isTelnetAdapter(): this is TelnetAdapter {
1199
        return false;
×
1200
    }
1201

1202
    public isDebugProtocolAdapter(): this is DebugProtocolAdapter {
1203
        return true;
×
1204
    }
1205
}
1206

1207
export interface StackFrame {
1208
    frameId: number;
1209
    frameIndex: number;
1210
    threadIndex: number;
1211
    filePath: string;
1212
    lineNumber: number;
1213
    functionIdentifier: string;
1214
}
1215

1216
export enum EventName {
2✔
1217
    suspend = 'suspend'
2✔
1218
}
1219

1220
export interface EvaluateContainer {
1221
    name: string;
1222
    evaluateName: string;
1223
    type: string;
1224
    value?: any;
1225
    keyType?: KeyType;
1226
    namedVariables?: number;
1227
    indexedVariables?: number;
1228
    highLevelType?: HighLevelType;
1229
    children: EvaluateContainer[];
1230
    isCustom?: boolean;
1231
    evaluateNow?: boolean;
1232
    presentationHint?: DebugProtocol.VariablePresentationHint;
1233
}
1234

1235
export enum KeyType {
2✔
1236
    string = 'String',
2✔
1237
    integer = 'Integer',
2✔
1238
    legacy = 'Legacy'
2✔
1239
}
1240

1241
export interface Thread {
1242
    isSelected: boolean;
1243
    isDetached?: boolean;
1244
    /**
1245
     * The 1-based line number for the thread
1246
     */
1247
    lineNumber: number;
1248
    filePath: string;
1249
    functionName: string;
1250
    lineContents: string;
1251
    threadId: number;
1252
    osThreadId?: string;
1253
    name?: string;
1254
    type?: string;
1255
}
1256

1257
interface BrightScriptRuntimeError {
1258
    message: string;
1259
    errorCode: string;
1260
}
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