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

rokucommunity / roku-debug / #2017

pending completion
#2017

push

web-flow
Merge 4961675eb into f30a7deaa

1876 of 2742 branches covered (68.42%)

Branch coverage included in aggregate %.

1876 of 1876 new or added lines in 41 files covered. (100.0%)

3422 of 4578 relevant lines covered (74.75%)

27.43 hits per line

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

80.69
/src/debugProtocol/client/DebugProtocolClient.ts
1
import * as Net from 'net';
1✔
2
import * as EventEmitter from 'eventemitter3';
1✔
3
import * as semver from 'semver';
1✔
4
import { PROTOCOL_ERROR_CODES, Command, StepType, ErrorCode, UpdateType, UpdateTypeCode, StopReason } from '../Constants';
1✔
5
import { logger } from '../../logging';
1✔
6
import { ExecuteV3Response } from '../events/responses/ExecuteV3Response';
1✔
7
import { ListBreakpointsResponse } from '../events/responses/ListBreakpointsResponse';
1✔
8
import { AddBreakpointsResponse } from '../events/responses/AddBreakpointsResponse';
1✔
9
import { RemoveBreakpointsResponse } from '../events/responses/RemoveBreakpointsResponse';
1✔
10
import { defer, util } from '../../util';
1✔
11
import { BreakpointErrorUpdate } from '../events/updates/BreakpointErrorUpdate';
1✔
12
import { ContinueRequest } from '../events/requests/ContinueRequest';
1✔
13
import { StopRequest } from '../events/requests/StopRequest';
1✔
14
import { ExitChannelRequest } from '../events/requests/ExitChannelRequest';
1✔
15
import { StepRequest } from '../events/requests/StepRequest';
1✔
16
import { RemoveBreakpointsRequest } from '../events/requests/RemoveBreakpointsRequest';
1✔
17
import { ListBreakpointsRequest } from '../events/requests/ListBreakpointsRequest';
1✔
18
import { VariablesRequest } from '../events/requests/VariablesRequest';
1✔
19
import { StackTraceRequest } from '../events/requests/StackTraceRequest';
1✔
20
import { ThreadsRequest } from '../events/requests/ThreadsRequest';
1✔
21
import { ExecuteRequest } from '../events/requests/ExecuteRequest';
1✔
22
import { AddBreakpointsRequest } from '../events/requests/AddBreakpointsRequest';
1✔
23
import { AddConditionalBreakpointsRequest } from '../events/requests/AddConditionalBreakpointsRequest';
1✔
24
import type { ProtocolRequest, ProtocolResponse, ProtocolUpdate } from '../events/ProtocolEvent';
25
import { HandshakeResponse } from '../events/responses/HandshakeResponse';
1✔
26
import { HandshakeV3Response } from '../events/responses/HandshakeV3Response';
1✔
27
import { HandshakeRequest } from '../events/requests/HandshakeRequest';
1✔
28
import { GenericV3Response } from '../events/responses/GenericV3Response';
1✔
29
import { AllThreadsStoppedUpdate } from '../events/updates/AllThreadsStoppedUpdate';
1✔
30
import { CompileErrorUpdate } from '../events/updates/CompileErrorUpdate';
1✔
31
import { GenericResponse } from '../events/responses/GenericResponse';
1✔
32
import type { StackTraceResponse } from '../events/responses/StackTraceResponse';
33
import { ThreadsResponse } from '../events/responses/ThreadsResponse';
1✔
34
import type { Variable } from '../events/responses/VariablesResponse';
35
import { VariablesResponse, VariablesResponseData, VariableType } from '../events/responses/VariablesResponse';
1✔
36
import { IOPortOpenedUpdate, isIOPortOpenedUpdate } from '../events/updates/IOPortOpenedUpdate';
1✔
37
import { ThreadAttachedUpdate } from '../events/updates/ThreadAttachedUpdate';
1✔
38
import { StackTraceV3Response } from '../events/responses/StackTraceV3Response';
1✔
39
import { ActionQueue } from '../../managers/ActionQueue';
1✔
40
import type { DebugProtocolClientPlugin } from './DebugProtocolClientPlugin';
41
import PluginInterface from '../PluginInterface';
1✔
42
import type { VerifiedBreakpoint } from '../events/updates/BreakpointVerifiedUpdate';
43
import { BreakpointVerifiedUpdate } from '../events/updates/BreakpointVerifiedUpdate';
1✔
44
import type { AddConditionalBreakpointsResponse } from '../events/responses/AddConditionalBreakpointsResponse';
45

46
export class DebugProtocolClient {
1✔
47

48
    public logger = logger.createLogger(`[${DebugProtocolClient.name}]`);
70✔
49

50
    // The highest tested version of the protocol we support.
51
    public supportedVersionRange = '<=3.0.0';
70✔
52

53
    constructor(
54
        options?: ConstructorOptions
55
    ) {
56
        this.options = {
70✔
57
            controlPort: 8081,
58
            host: undefined,
59
            //override the defaults with the options from parameters
60
            ...options ?? {}
210✔
61
        };
62

63
        //add the internal plugin last, so it's the final plugin to handle the events
64
        this.addCorePlugin();
70✔
65
    }
66

67
    private addCorePlugin() {
68
        this.plugins.add({
70✔
69
            onUpdate: (event) => {
70
                return this.handleUpdate(event.update);
61✔
71
            }
72
        }, 999);
73
    }
74

75
    public static DEBUGGER_MAGIC = 'bsdebug'; // 64-bit = [b'bsdebug\0' little-endian]
1✔
76

77
    public scriptTitle: string;
78
    public isHandshakeComplete = false;
70✔
79
    public connectedToIoPort = false;
70✔
80
    /**
81
     * Debug protocol version 3.0.0 introduced a packet_length to all responses. Prior to that, most responses had no packet length at all.
82
     * This field indicates whether we should be looking for packet_length or not in the responses we get from the device
83
     */
84
    public watchPacketLength = false;
70✔
85
    public protocolVersion: string;
86
    public primaryThread: number;
87
    public stackFrameIndex: number;
88

89
    /**
90
     * A collection of plugins that can interact with the client at lifecycle points
91
     */
92
    public plugins = new PluginInterface<DebugProtocolClientPlugin>();
70✔
93

94
    private emitter = new EventEmitter();
70✔
95
    /**
96
     * The primary socket for this session. It's used to communicate with the debugger by sending commands and receives responses or updates
97
     */
98
    private controlSocket: Net.Socket;
99
    /**
100
     * A socket where the debug server will send stdio
101
     */
102
    private ioSocket: Net.Socket;
103
    /**
104
     * The buffer where all unhandled data will be stored until successfully consumed
105
     */
106
    private buffer = Buffer.alloc(0);
70✔
107
    /**
108
     * Is the debugger currently stopped at a line of code in the program
109
     */
110
    public isStopped = false;
70✔
111
    private requestIdSequence = 1;
70✔
112
    private activeRequests = new Map<number, ProtocolRequest>();
70✔
113
    private options: ConstructorOptions;
114

115
    /**
116
     * Prior to protocol v3.1.0, the Roku device would regularly set the wrong thread as "active",
117
     * so this flag lets us know if we should use our better-than-nothing workaround
118
     */
119
    private get enableThreadHoppingWorkaround() {
120
        return semver.satisfies(this.protocolVersion, '<3.1.0');
13✔
121
    }
122

123
    /**
124
     * Starting in protocol v3.1.0, component libary breakpoints must be added in the format `lib:/<library_name>/<filepath>`, but prior they didn't require this.
125
     * So this flag tells us which format to support
126
     */
127
    private get enableComponentLibrarySpecificBreakpoints() {
128
        return semver.satisfies(this.protocolVersion, '>=3.1.0');
15✔
129
    }
130

131
    /**
132
     * Starting in protocol v3.1.0, breakpoints can support conditional expressions. This flag indicates whether the current sessuion supports that functionality.
133
     */
134
    private get supportsConditionalBreakpoints() {
135
        return semver.satisfies(this.protocolVersion, '>=3.1.0');
12✔
136
    }
137

138
    public get supportsBreakpointRegistrationWhileRunning() {
139
        return semver.satisfies(this.protocolVersion, '>=3.2.0');
5✔
140
    }
141

142
    public get supportsBreakpointVerification() {
143
        return semver.satisfies(this.protocolVersion, '>=3.2.0');
8✔
144
    }
145

146
    /**
147
     * Get a promise that resolves after an event occurs exactly once
148
     */
149
    public once(eventName: 'app-exit' | 'cannot-continue' | 'close' | 'start'): Promise<void>;
150
    public once(eventName: 'breakpoints-verified'): Promise<BreakpointsVerifiedEvent>;
151
    public once<T = AllThreadsStoppedUpdate | ThreadAttachedUpdate>(eventName: 'runtime-error' | 'suspend'): Promise<T>;
152
    public once(eventName: 'io-output'): Promise<string>;
153
    public once(eventName: 'data'): Promise<Buffer>;
154
    public once(eventName: 'response'): Promise<ProtocolResponse>;
155
    public once(eventName: 'update'): Promise<ProtocolUpdate>;
156
    public once(eventName: 'protocol-version'): Promise<ProtocolVersionDetails>;
157
    public once(eventName: 'handshake-verified'): Promise<HandshakeResponse>;
158
    public once(eventName: string) {
159
        return new Promise((resolve) => {
54✔
160
            const disconnect = this.on(eventName as Parameters<DebugProtocolClient['on']>[0], (...args) => {
54✔
161
                disconnect();
54✔
162
                resolve(...args);
54✔
163
            });
164
        });
165
    }
166

167
    public on(eventName: 'app-exit' | 'cannot-continue' | 'close' | 'start', handler: () => void);
168
    public on(eventName: 'breakpoints-verified', handler: (event: BreakpointsVerifiedEvent) => void);
169
    public on(eventName: 'response', handler: (response: ProtocolResponse) => void);
170
    public on(eventName: 'update', handler: (update: ProtocolUpdate) => void);
171
    /**
172
     * The raw data from the server socket. You probably don't need this...
173
     */
174
    public on(eventName: 'data', handler: (data: Buffer) => void);
175
    public on<T = AllThreadsStoppedUpdate | ThreadAttachedUpdate>(eventName: 'runtime-error' | 'suspend', handler: (data: T) => void);
176
    public on(eventName: 'io-output', handler: (output: string) => void);
177
    public on(eventName: 'protocol-version', handler: (data: ProtocolVersionDetails) => void);
178
    public on(eventName: 'handshake-verified', handler: (data: HandshakeResponse) => void);
179
    // public on(eventname: 'rendezvous', handler: (output: RendezvousHistory) => void);
180
    // public on(eventName: 'runtime-error', handler: (error: BrightScriptRuntimeError) => void);
181
    public on(eventName: string, handler: (payload: any) => void) {
182
        this.emitter.on(eventName, handler);
231✔
183
        return () => {
231✔
184
            this.emitter.removeListener(eventName, handler);
174✔
185
        };
186
    }
187

188
    private emit(eventName: 'response', response: ProtocolResponse);
189
    private emit(eventName: 'update', update: ProtocolUpdate);
190
    private emit(eventName: 'data', update: Buffer);
191
    private emit(eventName: 'breakpoints-verified', event: BreakpointsVerifiedEvent);
192
    private emit(eventName: 'suspend' | 'runtime-error', data: AllThreadsStoppedUpdate | ThreadAttachedUpdate);
193
    private emit(eventName: 'app-exit' | 'cannot-continue' | 'close' | 'handshake-verified' | 'io-output' | 'protocol-version' | 'start', data?);
194
    private emit(eventName: string, data?) {
195
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
196
        setTimeout(() => {
620✔
197
            //in rare cases, this event is fired after the debugger has closed, so make sure the event emitter still exists
198
            this.emitter.emit(eventName, data);
620✔
199
        }, 0);
200
    }
201

202
    private async establishControlConnection() {
203
        const pendingSockets = new Set<Net.Socket>();
57✔
204
        const connection = await new Promise<Net.Socket>((resolve) => {
57✔
205
            util.setInterval((cancelInterval) => {
57✔
206
                const socket = new Net.Socket();
57✔
207
                pendingSockets.add(socket);
57✔
208
                socket.on('error', (error) => {
57✔
209
                    console.debug(Date.now(), 'Encountered an error connecting to the debug protocol socket. Ignoring and will try again soon', error);
×
210
                });
211
                socket.connect({ port: this.options.controlPort, host: this.options.host }, () => {
57✔
212
                    cancelInterval();
57✔
213

214
                    this.logger.debug(`Connected to debug protocol control port. Socket ${[...pendingSockets].indexOf(socket)} of ${pendingSockets.size} was the winner`);
57✔
215
                    //clean up all remaining pending sockets
216
                    for (const pendingSocket of pendingSockets) {
57✔
217
                        pendingSocket.removeAllListeners();
57✔
218
                        //cleanup and destroy all other sockets
219
                        if (pendingSocket !== socket) {
57!
220
                            pendingSocket.end();
×
221
                            pendingSocket?.destroy();
×
222
                        }
223
                    }
224
                    pendingSockets.clear();
57✔
225
                    resolve(socket);
57✔
226
                });
227
            }, this.options.controlConnectInterval ?? 250);
171!
228
        });
229
        await this.plugins.emit('onServerConnected', {
57✔
230
            client: this,
231
            server: connection
232
        });
233
        return connection;
57✔
234
    }
235

236
    /**
237
     * A queue for processing the incoming buffer, every transmission at a time
238
     */
239
    private bufferQueue = new ActionQueue();
70✔
240

241
    /**
242
     * Connect to the debug server.
243
     * @param sendHandshake should the handshake be sent as part of this connect process. If false, `.sendHandshake()` will need to be called before a session can begin
244
     */
245
    public async connect(sendHandshake = true): Promise<boolean> {
57✔
246
        this.logger.log('connect', this.options);
57✔
247

248
        // If there is no error, the server has accepted the request and created a new dedicated control socket
249
        this.controlSocket = await this.establishControlConnection();
57✔
250

251
        this.controlSocket.on('data', (data) => {
57✔
252
            this.emit('data', data);
180✔
253
            this.logger.info('received server-to-client data\n', { type: 'server-to-client', data: data.toJSON() });
180✔
254
            //queue up processing the new data, chunk by chunk
255
            void this.bufferQueue.run(async () => {
180✔
256
                this.buffer = Buffer.concat([this.buffer, data]);
180✔
257
                while (this.buffer.length > 0 && await this.process()) {
180✔
258
                    //the loop condition is the actual work
259
                }
260
                return true;
180✔
261
            });
262
        });
263

264
        this.controlSocket.on('end', () => {
57✔
265
            this.logger.log('TCP connection closed');
8✔
266
            this.shutdown('app-exit');
8✔
267
        });
268

269
        // Don't forget to catch error, for your own sake.
270
        this.controlSocket.once('error', (error) => {
57✔
271
            //the Roku closed the connection for some unknown reason...
272
            console.error(`TCP connection error on control port`, error);
×
273
            this.shutdown('close');
×
274
        });
275

276
        if (sendHandshake) {
57!
277
            await this.sendHandshake();
57✔
278
        }
279
        return true;
57✔
280
    }
281

282
    /**
283
     * Send the initial handshake request, and wait for the handshake response
284
     */
285
    public async sendHandshake() {
286
        return this.processHandshakeRequest(
57✔
287
            HandshakeRequest.fromJson({
288
                magic: DebugProtocolClient.DEBUGGER_MAGIC
289
            })
290
        );
291
    }
292

293
    private async processHandshakeRequest(request: HandshakeRequest) {
294
        //send the magic, which triggers the debug session
295
        this.logger.log('Sending magic to server');
57✔
296

297
        //send the handshake request, and wait for the handshake response from the device
298
        return this.sendRequest<HandshakeV3Response | HandshakeResponse>(request);
57✔
299
    }
300

301
    public continue() {
302
        return this.processContinueRequest(
2✔
303
            ContinueRequest.fromJson({
304
                requestId: this.requestIdSequence++
305
            })
306
        );
307
    }
308

309
    private async processContinueRequest(request: ContinueRequest) {
310
        if (this.isStopped) {
2✔
311
            this.isStopped = false;
1✔
312
            return this.sendRequest<GenericResponse>(request);
1✔
313
        }
314
    }
315

316
    public pause() {
317
        return this.processStopRequest(
2✔
318
            StopRequest.fromJson({
319
                requestId: this.requestIdSequence++
320
            })
321
        );
322
    }
323

324
    private async processStopRequest(request: StopRequest) {
325
        if (this.isStopped === false) {
2✔
326
            return this.sendRequest<GenericResponse>(request);
1✔
327
        }
328
    }
329

330
    public async exitChannel() {
331
        return this.sendRequest<GenericResponse>(
1✔
332
            ExitChannelRequest.fromJson({
333
                requestId: this.requestIdSequence++
334
            })
335
        );
336
    }
337

338
    public async stepIn(threadIndex: number = this.primaryThread) {
1✔
339
        return this.step(StepType.Line, threadIndex);
2✔
340
    }
341

342
    public async stepOver(threadIndex: number = this.primaryThread) {
1✔
343
        return this.step(StepType.Over, threadIndex);
2✔
344
    }
345

346
    public async stepOut(threadIndex: number = this.primaryThread) {
3✔
347
        return this.step(StepType.Out, threadIndex);
4✔
348
    }
349

350
    private async step(stepType: StepType, threadIndex: number): Promise<GenericResponse> {
351
        return this.processStepRequest(
8✔
352
            StepRequest.fromJson({
353
                requestId: this.requestIdSequence++,
354
                stepType: stepType,
355
                threadIndex: threadIndex
356
            })
357
        );
358
    }
359

360
    private async processStepRequest(request: StepRequest) {
361
        if (this.isStopped) {
8✔
362
            this.isStopped = false;
7✔
363
            let stepResult = await this.sendRequest<GenericResponse>(request);
7✔
364
            if (stepResult.data.errorCode === ErrorCode.OK) {
7✔
365
                this.isStopped = true;
6✔
366
                //TODO this is not correct. Do we get a new threads event after a step? Perhaps that should be what triggers the event instead of us?
367
                this.emit('suspend', stepResult as AllThreadsStoppedUpdate);
6✔
368
            } else {
369
                // there is a CANT_CONTINUE error code but we can likely treat all errors like a CANT_CONTINUE
370
                this.emit('cannot-continue');
1✔
371
            }
372
            return stepResult;
7✔
373
        } else {
374
            this.logger.log('[processStepRequest] skipped because debugger is not paused');
1✔
375
        }
376
    }
377

378
    public async threads() {
379
        return this.processThreadsRequest(
11✔
380
            ThreadsRequest.fromJson({
381
                requestId: this.requestIdSequence++
382
            })
383
        );
384
    }
385
    public async processThreadsRequest(request: ThreadsRequest) {
386
        if (this.isStopped) {
11✔
387
            let result = await this.sendRequest<ThreadsResponse>(request);
10✔
388

389
            if (result.data.errorCode === ErrorCode.OK) {
10✔
390
                //older versions of the debug protocol had issues with maintaining the active thread, so our workaround is to keep track of it elsewhere
391
                if (this.enableThreadHoppingWorkaround) {
9✔
392
                    //ignore the `isPrimary` flag on threads
393
                    this.logger.debug(`Ignoring the 'isPrimary' flag from threads because protocol version 3.0.0 and lower has a bug`);
1✔
394
                } else {
395
                    //trust the debug protocol's `isPrimary` flag on threads
396
                    for (let i = 0; i < result.data.threads.length; i++) {
8✔
397
                        let thread = result.data.threads[i];
9✔
398
                        if (thread.isPrimary) {
9✔
399
                            this.primaryThread = i;
8✔
400
                            break;
8✔
401
                        }
402
                    }
403
                }
404
            }
405
            return result;
10✔
406
        } else {
407
            this.logger.log('[processThreadsRequest] skipped because not stopped');
1✔
408
        }
409
    }
410

411
    /**
412
     * Get the stackTrace from the device IF currently stopped
413
     */
414
    public async getStackTrace(threadIndex: number = this.primaryThread) {
2✔
415
        return this.processStackTraceRequest(
10✔
416
            StackTraceRequest.fromJson({
417
                requestId: this.requestIdSequence++,
418
                threadIndex: threadIndex
419
            })
420
        );
421
    }
422

423
    private async processStackTraceRequest(request: StackTraceRequest) {
424
        if (!this.isStopped) {
10✔
425
            this.logger.log('[getStackTrace] skipped because debugger is not paused');
1✔
426
        } else if (request?.data?.threadIndex > -1) {
9!
427
            return this.sendRequest<StackTraceResponse>(request);
8✔
428
        } else {
429
            this.logger.log(`[getStackTrace] skipped because ${request?.data?.threadIndex} is not valid threadIndex`);
1!
430
        }
431
    }
432

433
    /**
434
     * @param variablePathEntries One or more path entries to the variable to be inspected. E.g., m.top.myObj["someKey"] can be accessed with ["m","top","myobj","\"someKey\""].
435
     *
436
     *                            If no path is specified, the variables accessible from the specified stack frame are returned.
437
     *
438
     *                            Starting in protocol v3.1.0, The keys for indexed gets (i.e. obj["key"]) should be wrapped in quotes so they can be handled in a case-sensitive fashion (if applicable on device).
439
     *                            All non-quoted keys (i.e. strings without leading and trailing quotes inside them) will be treated as case-insensitive).
440
     * @param getChildKeys  If set, VARIABLES response include the child keys for container types like lists and associative arrays
441
     * @param stackFrameIndex 0 = first function called, nframes-1 = last function. This indexing does not match the order of the frames returned from the STACKTRACE command
442
     * @param threadIndex the index (or perhaps ID?) of the thread to get variables for
443
     */
444
    public async getVariables(variablePathEntries: Array<string> = [], stackFrameIndex: number = this.stackFrameIndex, threadIndex: number = this.primaryThread) {
25✔
445
        const response = await this.processVariablesRequest(
23✔
446
            VariablesRequest.fromJson({
447
                requestId: this.requestIdSequence++,
448
                threadIndex: threadIndex,
449
                stackFrameIndex: stackFrameIndex,
450
                getChildKeys: true,
451
                variablePathEntries: variablePathEntries.map(x => ({
43✔
452
                    //remove leading and trailing quotes
453
                    name: x.replace(/^"/, '').replace(/"$/, ''),
454
                    forceCaseInsensitive: !x.startsWith('"') && !x.endsWith('"')
84✔
455
                })),
456
                //starting in protocol v3.1.0, it supports marking certain path items as case-insensitive (i.e. parts of DottedGet expressions)
457
                enableForceCaseInsensitivity: semver.satisfies(this.protocolVersion, '>=3.1.0') && variablePathEntries.length > 0
45✔
458
            })
459
        );
460

461
        //if there was an issue, build a "fake" variables response for several known situationsm or throw nicer errors
462
        if (util.hasNonNullishProperty(response?.data.errorData)) {
23✔
463
            let variable = {
11✔
464
                value: null,
465
                isContainer: false,
466
                isConst: false,
467
                refCount: 0,
468
                childCount: 0
469
            } as Variable;
470
            const simulatedResponse = VariablesResponse.fromJson({
11✔
471
                ...response.data,
472
                variables: [variable]
473
            });
474

475
            let parentVarType: VariableType;
476
            let parentVarTypeText: string;
477
            const loadParentVarInfo = async (index: number) => {
11✔
478
                //fetch the variable one level back from the bad one to get its type
479
                const parentVar = await this.getVariables(
7✔
480
                    variablePathEntries.slice(0, index),
481
                    stackFrameIndex,
482
                    threadIndex
483
                );
484
                parentVarType = parentVar?.data?.variables?.[0]?.type;
7!
485
                parentVarTypeText = parentVarType;
7✔
486
                //convert `roSGNode; Node` to `roSGNode (Node)`
487
                if (parentVarType === VariableType.SubtypedObject) {
7✔
488
                    const chunks = parentVar?.data?.variables?.[0]?.value?.toString().split(';').map(x => x.trim());
2!
489
                    parentVarTypeText = `${chunks[0]} (${chunks[1]})`;
1✔
490
                }
491
            };
492

493
            if (!util.isNullish(response.data.errorData.missingKeyIndex)) {
11✔
494
                const { missingKeyIndex } = response.data.errorData;
6✔
495
                //leftmost var is uninitialized, and we tried to read it
496
                //ex: variablePathEntries = [`notThere`]
497
                if (variablePathEntries.length === 1 && missingKeyIndex === 0) {
6✔
498
                    variable.name = variablePathEntries[0];
1✔
499
                    variable.type = VariableType.Uninitialized;
1✔
500
                    return simulatedResponse;
1✔
501
                }
502

503
                //leftmost var was uninitialized, and tried to read a prop on it
504
                //ex: variablePathEntries = ["notThere", "definitelyNotThere"]
505
                if (missingKeyIndex === 0 && variablePathEntries.length > 1) {
5✔
506
                    throw new Error(`Cannot read '${variablePathEntries[missingKeyIndex + 1]}' on type 'Uninitialized'`);
1✔
507
                }
508

509
                if (variablePathEntries.length > 1 && missingKeyIndex > 0) {
4!
510
                    await loadParentVarInfo(missingKeyIndex);
4✔
511

512
                    // prop at the end of Node or AA doesn't exist. Treat like `invalid`.
513
                    // ex: variablePathEntries = ['there', 'notThere']
514
                    if (
4✔
515
                        missingKeyIndex === variablePathEntries.length - 1 &&
5✔
516
                        [VariableType.AssociativeArray, VariableType.SubtypedObject].includes(parentVarType)
517
                    ) {
518
                        variable.name = variablePathEntries[variablePathEntries.length - 1];
1✔
519
                        variable.type = VariableType.Invalid;
1✔
520
                        variable.value = 'Invalid (not defined)';
1✔
521
                        return simulatedResponse;
1✔
522
                    }
523
                }
524
                //prop in the middle is missing, tried reading a prop on it
525
                // ex: variablePathEntries = ["there", "notThere", "definitelyNotThere"]
526
                throw new Error(`Cannot read '${variablePathEntries[missingKeyIndex]}'${parentVarType ? ` on type '${parentVarTypeText}'` : ''}`);
3!
527
            }
528

529
            //this flow is when the item at the index exists, but is set to literally `invalid` or is an unknown value
530
            if (!util.isNullish(response.data.errorData.invalidPathIndex)) {
5!
531
                const { invalidPathIndex } = response.data.errorData;
5✔
532

533
                //leftmost var is literal `invalid`, tried to read it
534
                if (variablePathEntries.length === 1 && invalidPathIndex === 0) {
5✔
535
                    variable.name = variablePathEntries[variablePathEntries.length - 1];
1✔
536
                    variable.type = VariableType.Invalid;
1✔
537
                    return simulatedResponse;
1✔
538
                }
539

540
                if (
4✔
541
                    variablePathEntries.length > 1 &&
11✔
542
                    invalidPathIndex > 0 &&
543
                    //only do this logic if the invalid item is not the last item
544
                    invalidPathIndex < variablePathEntries.length - 1
545
                ) {
546
                    await loadParentVarInfo(invalidPathIndex + 1);
3✔
547

548
                    //leftmost var is set to literal `invalid`, tried to read prop
549
                    if (invalidPathIndex === 0 && variablePathEntries.length > 1) {
3!
550
                        throw new Error(`Cannot read '${variablePathEntries[invalidPathIndex + 1]}' on type '${parentVarTypeText}'`);
×
551
                    }
552

553
                    // prop at the end doesn't exist. Treat like `invalid`.
554
                    // ex: variablePathEntries = ['there', 'notThere']
555
                    if (
3!
556
                        invalidPathIndex === variablePathEntries.length - 1 &&
3!
557
                        [VariableType.AssociativeArray, VariableType.SubtypedObject].includes(parentVarType)
558
                    ) {
559
                        variable.name = variablePathEntries[variablePathEntries.length - 1];
×
560
                        variable.type = VariableType.Invalid;
×
561
                        variable.value = 'Invalid (not defined)';
×
562
                        return simulatedResponse;
×
563
                    }
564
                }
565
                console.log('Bronley');
4✔
566
                //prop in the middle is missing, tried reading a prop on it
567
                // ex: variablePathEntries = ["there", "thereButSetToInvalid", "definitelyNotThere"]
568
                throw new Error(`Cannot read '${variablePathEntries[invalidPathIndex + 1]}'${parentVarType ? ` on type '${parentVarTypeText}'` : ''}`);
4✔
569
            }
570
        }
571
        return response;
12✔
572
    }
573

574
    private async processVariablesRequest(request: VariablesRequest) {
575
        if (this.isStopped && request.data.threadIndex > -1) {
23✔
576
            return this.sendRequest<VariablesResponse>(request);
22✔
577
        }
578
    }
579

580
    public async executeCommand(sourceCode: string, stackFrameIndex: number = this.stackFrameIndex, threadIndex: number = this.primaryThread) {
2✔
581
        return this.processExecuteRequest(
2✔
582
            ExecuteRequest.fromJson({
583
                requestId: this.requestIdSequence++,
584
                threadIndex: threadIndex,
585
                stackFrameIndex: stackFrameIndex,
586
                sourceCode: sourceCode
587
            })
588
        );
589
    }
590

591
    private async processExecuteRequest(request: ExecuteRequest) {
592
        if (this.isStopped && request.data.threadIndex > -1) {
2✔
593
            return this.sendRequest<ExecuteV3Response>(request);
1✔
594
        }
595
    }
596

597
    public async addBreakpoints(breakpoints: Array<BreakpointSpec & { componentLibraryName?: string }>): Promise<AddBreakpointsResponse> {
598
        const { enableComponentLibrarySpecificBreakpoints } = this;
11✔
599
        if (breakpoints?.length > 0) {
11!
600
            const json = {
8✔
601
                requestId: this.requestIdSequence++,
602
                breakpoints: breakpoints.map(x => {
603
                    let breakpoint = {
9✔
604
                        ...x,
605
                        ignoreCount: x.hitCount
606
                    };
607
                    if (enableComponentLibrarySpecificBreakpoints && breakpoint.componentLibraryName) {
9✔
608
                        breakpoint.filePath = breakpoint.filePath.replace(/^pkg:\//i, `lib:/${breakpoint.componentLibraryName}/`);
1✔
609
                    }
610
                    return breakpoint;
9✔
611
                })
612
            };
613

614
            const useConditionalBreakpoints = (
615
                //does this protocol version support conditional breakpoints?
616
                this.supportsConditionalBreakpoints &&
8✔
617
                //is there at least one conditional breakpoint present?
618
                !!breakpoints.find(x => !!x?.conditionalExpression?.trim())
7!
619
            );
620

621
            let response: AddBreakpointsResponse | AddConditionalBreakpointsResponse;
622
            if (useConditionalBreakpoints) {
8✔
623
                response = await this.sendRequest<AddBreakpointsResponse>(
2✔
624
                    AddConditionalBreakpointsRequest.fromJson(json)
625
                );
626
            } else {
627
                response = await this.sendRequest<AddBreakpointsResponse>(
6✔
628
                    AddBreakpointsRequest.fromJson(json)
629
                );
630
            }
631

632
            //if the device does not support breakpoint verification, then auto-mark all of these as verified
633
            if (!this.supportsBreakpointVerification) {
8!
634
                this.emit('breakpoints-verified', {
8✔
635
                    breakpoints: response.data.breakpoints
636
                });
637
            }
638
            return response;
8✔
639
        }
640
        return AddBreakpointsResponse.fromBuffer(null);
3✔
641
    }
642

643
    public async listBreakpoints(): Promise<ListBreakpointsResponse> {
644
        return this.processRequest<ListBreakpointsResponse>(
4✔
645
            ListBreakpointsRequest.fromJson({
646
                requestId: this.requestIdSequence++
647
            })
648
        );
649
    }
650

651
    /**
652
     * Remove breakpoints having the specified IDs
653
     */
654
    public async removeBreakpoints(breakpointIds: number[]) {
655
        return this.processRemoveBreakpointsRequest(
3✔
656
            RemoveBreakpointsRequest.fromJson({
657
                requestId: this.requestIdSequence++,
658
                breakpointIds: breakpointIds
659
            })
660
        );
661
    }
662

663
    private async processRemoveBreakpointsRequest(request: RemoveBreakpointsRequest) {
664
        //throw out null breakpoints
665
        request.data.breakpointIds = request.data.breakpointIds?.filter(x => typeof x === 'number') ?? [];
3✔
666

667
        if (request.data.breakpointIds?.length > 0) {
3!
668
            return this.sendRequest<RemoveBreakpointsResponse>(request);
1✔
669
        }
670
        return RemoveBreakpointsResponse.fromJson(null);
2✔
671
    }
672

673
    /**
674
     * Given a request, process it in the proper fashion. This is mostly used for external mocking/testing of
675
     * this client, but it should force the client to flow in the same fashion as a live debug session
676
     */
677
    public async processRequest<TResponse extends ProtocolResponse>(request: ProtocolRequest): Promise<TResponse> {
678
        switch (request?.constructor.name) {
4!
679
            case ContinueRequest.name:
680
                return this.processContinueRequest(request as ContinueRequest) as any;
×
681

682
            case ExecuteRequest.name:
683
                return this.processExecuteRequest(request as ExecuteRequest) as any;
×
684

685
            case HandshakeRequest.name:
686
                return this.processHandshakeRequest(request as HandshakeRequest) as any;
×
687

688
            case RemoveBreakpointsRequest.name:
689
                return this.processRemoveBreakpointsRequest(request as RemoveBreakpointsRequest) as any;
×
690

691
            case StackTraceRequest.name:
692
                return this.processStackTraceRequest(request as StackTraceRequest) as any;
×
693

694
            case StepRequest.name:
695
                return this.processStepRequest(request as StepRequest) as any;
×
696

697
            case StopRequest.name:
698
                return this.processStopRequest(request as StopRequest) as any;
×
699

700
            case ThreadsRequest.name:
701
                return this.processThreadsRequest(request as ThreadsRequest) as any;
×
702

703
            case VariablesRequest.name:
704
                return this.processVariablesRequest(request as VariablesRequest) as any;
×
705

706
            //for all other request types, there's no custom business logic, so just pipe them through manually
707
            case AddBreakpointsRequest.name:
708
            case AddConditionalBreakpointsRequest.name:
709
            case ExitChannelRequest.name:
710
            case ListBreakpointsRequest.name:
711
                return this.sendRequest(request);
4✔
712
            default:
713
                this.logger.log('Unknown request type. Sending anyway...', request);
×
714
                //unknown request type. try sending it as-is
715
                return this.sendRequest(request);
×
716
        }
717
    }
718

719
    /**
720
     * Send a request to the roku device, and get a promise that resolves once we have received the response
721
     */
722
    private async sendRequest<T extends ProtocolResponse | ProtocolUpdate>(request: ProtocolRequest) {
723
        request = (await this.plugins.emit('beforeSendRequest', {
121✔
724
            client: this,
725
            request: request
726
        })).request;
727

728
        this.activeRequests.set(request.data.requestId, request);
121✔
729

730
        return new Promise<T>((resolve) => {
121✔
731
            let unsubscribe = this.on('response', (response) => {
121✔
732
                if (response.data.requestId === request.data.requestId) {
121✔
733
                    unsubscribe();
120✔
734
                    this.activeRequests.delete(request.data.requestId);
120✔
735
                    resolve(response as T);
120✔
736
                }
737
            });
738

739
            this.logger.log(`Request ${request?.data?.requestId}`, request);
121!
740
            if (this.controlSocket) {
121✔
741
                const buffer = request.toBuffer();
120✔
742
                this.logger.info('received client-to-server data\n', { type: 'client-to-server', data: buffer.toJSON() });
120✔
743
                this.controlSocket.write(buffer);
120✔
744
                void this.plugins.emit('afterSendRequest', {
120✔
745
                    client: this,
746
                    request: request
747
                });
748
            } else {
749
                throw new Error(`Control socket was closed - Command: ${Command[request.data.command]}`);
1✔
750
            }
751
        });
752
    }
753

754
    /**
755
     * Sometimes a request arrives that we don't understand. If that's the case, this function can be used
756
     * to discard that entire response by discarding `packet_length` number of bytes
757
     */
758
    private discardNextResponseOrUpdate() {
759
        const response = GenericV3Response.fromBuffer(this.buffer);
1✔
760
        if (response.success && response.data.packetLength > 0) {
1!
761
            this.logger.warn(`Unsupported response or updated encountered. Discarding ${response.data.packetLength} bytes:`, JSON.stringify(
×
762
                this.buffer.slice(0, response.data.packetLength + 1).toJSON().data
763
            ));
764
            //we have a valid event. Clear the buffer of this data
765
            this.buffer = this.buffer.slice(response.data.packetLength);
×
766
        }
767
    }
768

769
    private async process(): Promise<boolean> {
770
        try {
182✔
771
            this.logger.info('[process()]: buffer=', this.buffer.toJSON());
182✔
772

773
            let { responseOrUpdate } = await this.plugins.emit('provideResponseOrUpdate', {
182✔
774
                client: this,
775
                activeRequests: this.activeRequests,
776
                buffer: this.buffer
777
            });
778

779
            if (!responseOrUpdate) {
182!
780
                responseOrUpdate = this.getResponseOrUpdate(this.buffer);
182✔
781
            }
782

783
            //if the event failed to parse, or the buffer doesn't have enough bytes to satisfy the packetLength, exit here (new data will re-trigger this function)
784
            if (!responseOrUpdate) {
182✔
785
                this.logger.info('Unable to convert buffer into anything meaningful', this.buffer);
1✔
786
                //if we have packet length, and we have at least that many bytes, throw out this message so we can hopefully recover
787
                this.discardNextResponseOrUpdate();
1✔
788
                return false;
1✔
789
            }
790
            if (!responseOrUpdate.success || responseOrUpdate.data.packetLength > this.buffer.length) {
181!
791
                this.logger.log(`event parse failed. ${responseOrUpdate?.data?.packetLength} bytes required, ${this.buffer.length} bytes available`);
×
792
                return false;
×
793
            }
794

795
            //we have a valid event. Remove this data from the buffer
796
            this.buffer = this.buffer.slice(responseOrUpdate.readOffset);
181✔
797

798
            if (responseOrUpdate.data.errorCode !== ErrorCode.OK) {
181✔
799
                this.logger.error(responseOrUpdate.data.errorCode, responseOrUpdate);
13✔
800
            }
801

802
            //we got a result
803
            if (responseOrUpdate) {
181!
804
                //emit the corresponding event
805
                if (isProtocolUpdate(responseOrUpdate)) {
181✔
806
                    this.logger.log(`Update:`, responseOrUpdate);
61✔
807
                    this.emit('update', responseOrUpdate);
61✔
808
                    await this.plugins.emit('onUpdate', {
61✔
809
                        client: this,
810
                        update: responseOrUpdate
811
                    });
812
                } else {
813
                    this.logger.log(`Response ${responseOrUpdate?.data?.requestId}:`, responseOrUpdate);
120!
814
                    this.emit('response', responseOrUpdate);
120✔
815
                    await this.plugins.emit('onResponse', {
120✔
816
                        client: this,
817
                        response: responseOrUpdate as any
818
                    });
819
                }
820
                return true;
181✔
821
            }
822
        } catch (e) {
823
            this.logger.error(`process() failed:`, e);
×
824
        }
825
    }
826

827
    /**
828
     * Given a buffer, try to parse into a specific ProtocolResponse or ProtocolUpdate
829
     */
830
    public getResponseOrUpdate(buffer: Buffer): ProtocolResponse | ProtocolUpdate {
831
        //if we haven't seen a handshake yet, try to convert the buffer into a handshake
832
        if (!this.isHandshakeComplete) {
182✔
833
            let handshake: HandshakeV3Response | HandshakeResponse;
834
            //try building the v3 handshake response first
835
            handshake = HandshakeV3Response.fromBuffer(buffer);
57✔
836
            //we didn't get a v3 handshake. try building an older handshake response
837
            if (!handshake.success) {
57✔
838
                handshake = HandshakeResponse.fromBuffer(buffer);
1✔
839
            }
840
            if (handshake.success) {
57!
841
                this.verifyHandshake(handshake);
57✔
842
                return handshake;
57✔
843
            }
844
            return;
×
845
        }
846

847
        let genericResponse = this.watchPacketLength ? GenericV3Response.fromBuffer(buffer) : GenericResponse.fromBuffer(buffer);
125!
848

849
        //if the response has a non-OK error code, we won't receive the expected response type,
850
        //so return the generic response
851
        if (genericResponse.success && genericResponse.data.errorCode !== ErrorCode.OK) {
125✔
852
            return genericResponse;
13✔
853
        }
854
        // a nonzero requestId means this is a response to a request that we sent
855
        if (genericResponse.data.requestId !== 0) {
112✔
856
            //requestId 0 means this is an update
857
            const request = this.activeRequests.get(genericResponse.data.requestId);
51✔
858
            if (request) {
51✔
859
                return DebugProtocolClient.getResponse(this.buffer, request.data.command);
50✔
860
            }
861
        } else {
862
            return this.getUpdate(this.buffer);
61✔
863
        }
864
    }
865

866
    public static getResponse(buffer: Buffer, command: Command) {
867
        switch (command) {
50!
868
            case Command.Stop:
869
            case Command.Continue:
870
            case Command.Step:
871
            case Command.ExitChannel:
872
                return GenericV3Response.fromBuffer(buffer);
9✔
873
            case Command.Execute:
874
                return ExecuteV3Response.fromBuffer(buffer);
1✔
875
            case Command.AddBreakpoints:
876
            case Command.AddConditionalBreakpoints:
877
                return AddBreakpointsResponse.fromBuffer(buffer);
8✔
878
            case Command.ListBreakpoints:
879
                return ListBreakpointsResponse.fromBuffer(buffer);
3✔
880
            case Command.RemoveBreakpoints:
881
                return RemoveBreakpointsResponse.fromBuffer(buffer);
1✔
882
            case Command.Variables:
883
                return VariablesResponse.fromBuffer(buffer);
11✔
884
            case Command.StackTrace:
885
                return StackTraceV3Response.fromBuffer(buffer);
8✔
886
            case Command.Threads:
887
                return ThreadsResponse.fromBuffer(buffer);
9✔
888
            default:
889
                return undefined;
×
890
        }
891
    }
892

893
    public getUpdate(buffer: Buffer): ProtocolUpdate {
894
        //read the update_type from the buffer (save some buffer parsing time by narrowing to the exact update type)
895
        const updateTypeCode = buffer.readUInt32LE(
61✔
896
            // if the protocol supports packet length, then update_type is bytes 12-16. Otherwise, it's bytes 8-12
897
            this.watchPacketLength ? 12 : 8
61!
898
        );
899
        const updateType = UpdateTypeCode[updateTypeCode] as UpdateType;
61✔
900

901
        this.logger?.log('getUpdate(): update Type:', updateType);
61!
902
        switch (updateType) {
61!
903
            case UpdateType.IOPortOpened:
904
                //TODO handle this
905
                return IOPortOpenedUpdate.fromBuffer(buffer);
3✔
906
            case UpdateType.AllThreadsStopped:
907
                return AllThreadsStoppedUpdate.fromBuffer(buffer);
55✔
908
            case UpdateType.ThreadAttached:
909
                return ThreadAttachedUpdate.fromBuffer(buffer);
3✔
910
            case UpdateType.BreakpointError:
911
                //we do nothing with breakpoint errors at this time.
912
                return BreakpointErrorUpdate.fromBuffer(buffer);
×
913
            case UpdateType.CompileError:
914
                return CompileErrorUpdate.fromBuffer(buffer);
×
915
            case UpdateType.BreakpointVerified:
916
                let response = BreakpointVerifiedUpdate.fromBuffer(buffer);
×
917
                if (response?.data?.breakpoints?.length > 0) {
×
918
                    this.emit('breakpoints-verified', response.data);
×
919
                }
920
                return response;
×
921
            default:
922
                return undefined;
×
923
        }
924
    }
925

926
    private handleUpdateQueue = new ActionQueue();
70✔
927

928
    /**
929
     * Handle/process any received updates from the debug protocol
930
     */
931
    private async handleUpdate(update: ProtocolUpdate) {
932
        return this.handleUpdateQueue.run(async () => {
61✔
933
            update = (await this.plugins.emit('beforeHandleUpdate', {
61✔
934
                client: this,
935
                update: update
936
            })).update;
937

938
            if (update instanceof AllThreadsStoppedUpdate || update instanceof ThreadAttachedUpdate) {
61✔
939
                this.isStopped = true;
58✔
940

941
                let eventName: 'runtime-error' | 'suspend';
942
                if (update.data.stopReason === StopReason.RuntimeError) {
58!
943
                    eventName = 'runtime-error';
×
944
                } else {
945
                    eventName = 'suspend';
58✔
946
                }
947

948
                const isValidStopReason = [StopReason.RuntimeError, StopReason.Break, StopReason.StopStatement].includes(update.data.stopReason);
58✔
949

950
                if (update instanceof AllThreadsStoppedUpdate && isValidStopReason) {
58✔
951
                    this.primaryThread = update.data.threadIndex;
55✔
952
                    this.stackFrameIndex = 0;
55✔
953
                    this.emit(eventName, update);
55✔
954
                } else if (update instanceof ThreadAttachedUpdate && isValidStopReason) {
3!
955
                    this.primaryThread = update.data.threadIndex;
3✔
956
                    this.emit(eventName, update);
3✔
957
                }
958

959
            } else if (isIOPortOpenedUpdate(update)) {
3!
960
                this.connectToIoPort(update);
3✔
961
            }
962
            return true;
61✔
963
        });
964
    }
965

966
    /**
967
     * Verify all the handshake data
968
     */
969
    private verifyHandshake(response: HandshakeResponse | HandshakeV3Response): boolean {
970
        if (DebugProtocolClient.DEBUGGER_MAGIC === response.data.magic) {
57✔
971
            this.logger.log('Magic is valid.');
56✔
972

973
            this.protocolVersion = response.data.protocolVersion;
56✔
974
            this.logger.log('Protocol Version:', this.protocolVersion);
56✔
975

976
            this.watchPacketLength = semver.satisfies(this.protocolVersion, '>=3.0.0');
56✔
977
            this.isHandshakeComplete = true;
56✔
978

979
            let handshakeVerified = true;
56✔
980

981
            if (semver.satisfies(this.protocolVersion, this.supportedVersionRange)) {
56✔
982
                this.logger.log('supported');
1✔
983
                this.emit('protocol-version', {
1✔
984
                    message: `Protocol Version ${this.protocolVersion} is supported!`,
985
                    errorCode: PROTOCOL_ERROR_CODES.SUPPORTED
986
                });
987
            } else if (semver.gtr(this.protocolVersion, this.supportedVersionRange)) {
55!
988
                this.logger.log('roku-debug has not been tested against protocol version', this.protocolVersion);
55✔
989
                this.emit('protocol-version', {
55✔
990
                    message: `Protocol Version ${this.protocolVersion} has not been tested and my not work as intended.\nPlease open any issues you have with this version to https://github.com/rokucommunity/roku-debug/issues`,
991
                    errorCode: PROTOCOL_ERROR_CODES.NOT_TESTED
992
                });
993
            } else {
994
                this.logger.log('not supported');
×
995
                this.emit('protocol-version', {
×
996
                    message: `Protocol Version ${this.protocolVersion} is not supported.\nIf you believe this is an error please open an issue at https://github.com/rokucommunity/roku-debug/issues`,
997
                    errorCode: PROTOCOL_ERROR_CODES.NOT_SUPPORTED
998
                });
999
                this.shutdown('close');
×
1000
                handshakeVerified = false;
×
1001
            }
1002

1003
            this.emit('handshake-verified', handshakeVerified);
56✔
1004
            return handshakeVerified;
56✔
1005
        } else {
1006
            this.logger.log('Closing connection due to bad debugger magic', response.data.magic);
1✔
1007
            this.emit('handshake-verified', false);
1✔
1008
            this.shutdown('close');
1✔
1009
            return false;
1✔
1010
        }
1011
    }
1012

1013
    /**
1014
     * When the debugger emits the IOPortOpenedUpdate, we need to immediately connect to the IO port to start receiving that data
1015
     */
1016
    private connectToIoPort(update: IOPortOpenedUpdate) {
1017
        if (update.success) {
4✔
1018
            // Create a new TCP client.
1019
            this.ioSocket = new Net.Socket();
3✔
1020
            // Send a connection request to the server.
1021
            this.logger.log(`Connect to IO Port ${this.options.host}:${update.data.port}`);
3✔
1022
            this.ioSocket.connect({
3✔
1023
                port: update.data.port,
1024
                host: this.options.host
1025
            }, () => {
1026
                // If there is no error, the server has accepted the request
1027
                this.logger.log('TCP connection established with the IO Port.');
3✔
1028
                this.connectedToIoPort = true;
3✔
1029

1030
                let lastPartialLine = '';
3✔
1031
                this.ioSocket.on('data', (buffer) => {
3✔
1032
                    this.logger.info('received IO data\n', { type: 'io', data: buffer.toJSON() });
3✔
1033
                    let responseText = buffer.toString();
3✔
1034
                    if (!responseText.endsWith('\n')) {
3✔
1035
                        // buffer was split, save the partial line
1036
                        lastPartialLine += responseText;
1✔
1037
                    } else {
1038
                        if (lastPartialLine) {
2✔
1039
                            // there was leftover lines, join the partial lines back together
1040
                            responseText = lastPartialLine + responseText;
1✔
1041
                            lastPartialLine = '';
1✔
1042
                        }
1043
                        // Emit the completed io string.
1044
                        this.emit('io-output', responseText.trim());
2✔
1045
                    }
1046
                });
1047

1048
                this.ioSocket.on('end', () => {
3✔
1049
                    this.ioSocket.end();
3✔
1050
                    this.logger.log('Requested an end to the IO connection');
3✔
1051
                });
1052

1053
                // Don't forget to catch error, for your own sake.
1054
                this.ioSocket.once('error', (err) => {
3✔
1055
                    this.ioSocket.end();
×
1056
                    this.logger.error(err);
×
1057
                });
1058
            });
1059
            return true;
3✔
1060
        }
1061
        return false;
1✔
1062
    }
1063

1064
    public destroy() {
1065
        this.shutdown('close');
62✔
1066
    }
1067

1068
    private shutdown(eventName: 'app-exit' | 'close') {
1069
        if (this.controlSocket) {
71✔
1070
            this.controlSocket.removeAllListeners();
56✔
1071
            this.controlSocket.destroy();
56✔
1072
            this.controlSocket = undefined;
56✔
1073
        }
1074

1075
        if (this.ioSocket) {
71✔
1076
            this.ioSocket.removeAllListeners();
3✔
1077
            this.ioSocket.destroy();
3✔
1078
            this.ioSocket = undefined;
3✔
1079
        }
1080

1081
        this.emit(eventName);
71✔
1082
    }
1083
}
1084

1085
export interface ProtocolVersionDetails {
1086
    message: string;
1087
    errorCode: PROTOCOL_ERROR_CODES;
1088
}
1089

1090
export interface BreakpointSpec {
1091
    /**
1092
     * The path of the source file where the breakpoint is to be inserted.
1093
     */
1094
    filePath: string;
1095
    /**
1096
     * The (1-based) line number in the channel application code where the breakpoint is to be executed.
1097
     */
1098
    lineNumber: number;
1099
    /**
1100
     * The number of times to ignore the breakpoint condition before executing the breakpoint. This number is decremented each time the channel application reaches the breakpoint.
1101
     */
1102
    hitCount?: number;
1103
    /**
1104
     * BrightScript code that evaluates to a boolean value. The expression is compiled and executed in
1105
     * the context where the breakpoint is located. If specified, the hitCount is only be
1106
     * updated if this evaluates to true.
1107
     * @avaiable since protocol version 3.1.0
1108
     */
1109
    conditionalExpression?: string;
1110
}
1111

1112
export interface ConstructorOptions {
1113
    /**
1114
     * The host/ip address of the Roku
1115
     */
1116
    host: string;
1117
    /**
1118
     * The port number used to send all debugger commands. This is static/unchanging for Roku devices,
1119
     * but is configurable here to support unit testing or alternate runtimes (i.e. https://www.npmjs.com/package/brs)
1120
     */
1121
    controlPort?: number;
1122
    /**
1123
     * The interval (in milliseconds) for how frequently the `connect`
1124
     * call should retry connecting to the control port. At the start of a debug session,
1125
     * the protocol debugger will start trying to connect the moment the channel is sideloaded,
1126
     * and keep trying until a successful connection is established or the debug session is terminated
1127
     * @default 250
1128
     */
1129
    controlConnectInterval?: number;
1130
    /**
1131
     * The maximum time (in milliseconds) the debugger will keep retrying connections.
1132
     * This is here to prevent infinitely pinging the Roku device.
1133
     */
1134
    controlConnectMaxTime?: number;
1135
}
1136

1137
/**
1138
 * Is the event a ProtocolUpdate update
1139
 */
1140
export function isProtocolUpdate(event: ProtocolUpdate | ProtocolResponse): event is ProtocolUpdate {
1✔
1141
    return event?.constructor?.name.endsWith('Update') && event?.data?.requestId === 0;
301!
1142
}
1143
/**
1144
 * Is the event a ProtocolResponse
1145
 */
1146
export function isProtocolResponse(event: ProtocolUpdate | ProtocolResponse): event is ProtocolResponse {
1✔
1147
    return event?.constructor?.name.endsWith('Response') && event?.data?.requestId !== 0;
×
1148
}
1149

1150
export interface BreakpointsVerifiedEvent {
1151
    breakpoints: VerifiedBreakpoint[];
1152
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc