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

rokucommunity / roku-debug / 27221406363

09 Jun 2026 04:43PM UTC coverage: 70.865% (+0.2%) from 70.676%
27221406363

Pull #303

github

web-flow
Merge 76f4861a5 into dd544af05
Pull Request #303: Enhanced thread names

3495 of 5208 branches covered (67.11%)

Branch coverage included in aggregate %.

44 of 44 new or added lines in 4 files covered. (100.0%)

44 existing lines in 1 file now uncovered.

5692 of 7756 relevant lines covered (73.39%)

45.05 hits per line

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

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

52
export class DebugProtocolClient {
2✔
53

54
    public logger = logger.createLogger(`[dpclient]`);
71✔
55

56
    // The highest tested version of the protocol we support.
57
    public supportedVersionRange = '<=3.5.0';
71✔
58

59
    constructor(
60
        options?: ConstructorOptions
61
    ) {
62
        this.options = {
71✔
63
            controlPort: 8081,
64
            host: undefined,
65
            //override the defaults with the options from parameters
66
            ...options ?? {}
213✔
67
        };
68

69
        //add the internal plugin last, so it's the final plugin to handle the events
70
        this.addCorePlugin();
71✔
71
    }
72

73
    private addCorePlugin() {
74
        this.plugins.add({
71✔
75
            onUpdate: (event) => {
76
                return this.handleUpdate(event.update);
71✔
77
            }
78
        }, 999);
79
    }
80

81
    public static DEBUGGER_MAGIC = 'bsdebug'; // 64-bit = [b'bsdebug\0' little-endian]
2✔
82

83
    public scriptTitle: string;
84
    public isHandshakeComplete = false;
71✔
85
    public connectedToIoPort = false;
71✔
86
    /**
87
     * Debug protocol version 3.0.0 introduced a packet_length to all responses. Prior to that, most responses had no packet length at all.
88
     * This field indicates whether we should be looking for packet_length or not in the responses we get from the device
89
     */
90
    public watchPacketLength = false;
71✔
91
    /**
92
     * Capability flags derived from the negotiated protocol version. Undefined until the
93
     * handshake completes, then assigned a fresh `ProtocolCapabilities` keyed off the version
94
     * the device reported.
95
     */
96
    public capabilities: ProtocolCapabilities | undefined;
97
    /**
98
     * The protocol version negotiated with the device during the handshake. Undefined until
99
     * the handshake has completed.
100
     */
101
    public get protocolVersion(): string | undefined {
102
        return this.capabilities?.protocolVersion;
283!
103
    }
104
    public primaryThread: number;
105
    public stackFrameIndex: number;
106

107
    /**
108
     * A collection of plugins that can interact with the client at lifecycle points
109
     */
110
    public plugins = new PluginInterface<DebugProtocolClientPlugin>();
71✔
111

112
    private emitter = new EventEmitter();
71✔
113
    /**
114
     * The primary socket for this session. It's used to communicate with the debugger by sending commands and receives responses or updates
115
     */
116
    private controlSocket: Net.Socket;
117
    /**
118
     * Promise that is resolved when the control socket is closed
119
     */
120
    private controlSocketClosed = defer<void>();
71✔
121
    /**
122
     * A socket where the debug server will send stdio
123
     */
124
    private ioSocket: Net.Socket;
125
    /**
126
     * Resolves when the ioSocket has closed
127
     */
128
    private ioSocketClosed = defer<void>();
71✔
129
    /**
130
     * The buffer where all unhandled data will be stored until successfully consumed
131
     */
132
    private buffer = Buffer.alloc(0);
71✔
133
    /**
134
     * Is the debugger currently stopped at a line of code in the program
135
     */
136
    public isStopped = false;
71✔
137
    private requestIdSequence = 1;
71✔
138
    private activeRequests = new Map<number, ProtocolRequest>();
71✔
139
    private options: ConstructorOptions;
140

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

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

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

198
    /**
199
     * A collection of sockets created when trying to connect to the debug protocol's control socket. We keep these around for quicker tear-down
200
     * whenever there is an early-terminated debug session
201
     */
202
    private async establishControlConnection() {
203
        const connection = await new Promise<Net.Socket>((resolve) => {
65✔
204
            const socket = new Net.Socket({
65✔
205
                allowHalfOpen: false
206
            });
207
            util.registerSocketLogging(socket, this.logger, 'ControlSocket');
65✔
208

209
            socket.connect({ port: this.options.controlPort, host: this.options.host }, () => {
65✔
210
                resolve(socket);
65✔
211
            });
212
        });
213
        await this.plugins.emit('onServerConnected', {
65✔
214
            client: this,
215
            server: connection
216
        });
217
        return connection;
65✔
218
    }
219

220
    /**
221
     * A queue for processing the incoming buffer, every transmission at a time
222
     */
223
    private bufferQueue = new ActionQueue();
71✔
224

225
    /**
226
     * Connect to the debug server.
227
     * @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
228
     */
229
    public async connect(sendHandshake = true): Promise<boolean> {
65✔
230
        this.logger.log('connect', this.options);
65✔
231

232
        // If there is no error, the server has accepted the request and created a new dedicated control socket
233
        this.controlSocket = await this.establishControlConnection();
65✔
234

235
        this.controlSocket.on('data', (data) => {
65✔
236
            this.writeToBufferLog('server-to-client', data);
225✔
237
            this.emit('data', data);
225✔
238
            //queue up processing the new data, chunk by chunk
239
            void this.bufferQueue.run(async () => {
225✔
240
                this.buffer = Buffer.concat([this.buffer, data] as any[]);
225✔
241
                while (this.buffer.length > 0 && await this.process()) {
225✔
242
                    //the loop condition is the actual work
243
                }
244
                return true;
225✔
245
            });
246
        });
247

248
        this.controlSocket.on('close', () => {
65✔
249
            this.logger.log('Control socket closed');
1✔
250
            this.controlSocketClosed.tryResolve();
1✔
251
            //destroy the control socket since it just closed on us...
252
            this.controlSocket?.destroy?.();
1!
253
            this.controlSocket = undefined;
1✔
254
            this.emit('app-exit');
1✔
255
        });
256

257
        // Don't forget to catch error, for your own sake.
258
        this.controlSocket.once('error', (error) => {
65✔
259
            //the Roku closed the connection for some unknown reason...
260
            this.logger.error(`error on control port`, error);
×
261
            //destroy the control socket since it errored
262
            this.controlSocket?.destroy?.();
×
263
            this.controlSocket = undefined;
×
264
            this.emit('close');
×
265
        });
266

267
        if (sendHandshake) {
65!
268
            await this.sendHandshake();
65✔
269
        }
270
        return true;
65✔
271
    }
272

273
    /**
274
     * Send the initial handshake request, and wait for the handshake response
275
     */
276
    public async sendHandshake(): Promise<HandshakeV3Response | HandshakeResponse> {
277
        const response = await this.processHandshakeRequest(
65✔
278
            HandshakeRequest.fromJson({
279
                magic: DebugProtocolClient.DEBUGGER_MAGIC
280
            })
281
        );
282
        return response;
65✔
283
    }
284

285
    private async processHandshakeRequest(request: HandshakeRequest): Promise<HandshakeV3Response | HandshakeResponse> {
286
        //send the magic, which triggers the debug session
287
        this.logger.log('Sending magic to server');
65✔
288

289
        //send the handshake request, and wait for the handshake response from the device
290
        return this.sendRequest<HandshakeV3Response | HandshakeResponse>(request);
65✔
291
    }
292

293
    /**
294
     * Write a specific buffer log entry to the logger, which, when file logging is enabled
295
     * can be extracted and processed through the DebugProtocolClientReplaySession
296
     */
297
    private writeToBufferLog(type: 'server-to-client' | 'client-to-server' | 'io', buffer: Buffer) {
298
        let obj = {
385✔
299
            type: type,
300
            timestamp: new Date().toISOString(),
301
            buffer: buffer.toJSON()
302
        };
303
        if (type === 'io') {
385✔
304
            (obj as any).text = buffer.toString();
3✔
305
        }
306
        this.logger.log('[[bufferLog]]:', JSON.stringify(obj));
385✔
307
    }
308

309
    public continue() {
310
        return this.processContinueRequest(
2✔
311
            ContinueRequest.fromJson({
312
                requestId: this.requestIdSequence++
313
            })
314
        );
315
    }
316

317
    private async processContinueRequest(request: ContinueRequest) {
318
        if (this.isStopped) {
2✔
319
            this.isStopped = false;
1✔
320
            return this.sendRequest<GenericResponse>(request);
1✔
321
        }
322
    }
323

324
    public pause(force = false) {
2✔
325
        return this.processStopRequest(
2✔
326
            StopRequest.fromJson({
327
                requestId: this.requestIdSequence++
328
            }),
329
            force
330
        );
331
    }
332

333
    private async processStopRequest(request: StopRequest, force = false) {
×
334
        if (this.isStopped === false || force) {
2✔
335
            return this.sendRequest<GenericResponse>(request);
1✔
336
        }
337
    }
338

339
    /**
340
     * Send the "exit channel" command, which will tell the debug session to immediately quit
341
     */
342
    public async exitChannel() {
343
        return this.sendRequest<GenericResponse>(
2✔
344
            ExitChannelRequest.fromJson({
345
                requestId: this.requestIdSequence++
346
            })
347
        );
348
    }
349

350
    public async stepIn(threadIndex: number = this.primaryThread) {
1✔
351
        return this.step(StepType.Line, threadIndex);
2✔
352
    }
353

354
    public async stepOver(threadIndex: number = this.primaryThread) {
1✔
355
        return this.step(StepType.Over, threadIndex);
2✔
356
    }
357

358
    public async stepOut(threadIndex: number = this.primaryThread) {
3✔
359
        return this.step(StepType.Out, threadIndex);
4✔
360
    }
361

362
    private async step(stepType: StepType, threadIndex: number): Promise<GenericResponse> {
363
        return this.processStepRequest(
8✔
364
            StepRequest.fromJson({
365
                requestId: this.requestIdSequence++,
366
                stepType: stepType,
367
                threadIndex: threadIndex
368
            })
369
        );
370
    }
371

372
    private async processStepRequest(request: StepRequest) {
373
        if (this.isStopped) {
8✔
374
            this.isStopped = false;
7✔
375
            let stepResult = await this.sendRequest<GenericResponse>(request);
7✔
376
            if (stepResult.data.errorCode === ErrorCode.OK) {
7✔
377
                //Step command received and will recieve a separate update when threads have reattached
378
            } else if (stepResult.data.errorCode === ErrorCode.CANT_CONTINUE) {
1!
379
                // there is a CANT_CONTINUE error code but we can likely treat all errors like a CANT_CONTINUE
380
                this.emit('cannot-continue');
1✔
381
            }
382
            return stepResult;
7✔
383
        } else {
384
            this.logger.log('[processStepRequest] skipped because debugger is not paused');
1✔
385
        }
386
    }
387

388
    public async threads() {
389
        const result = await this.processThreadsRequest(
20✔
390
            ThreadsRequest.fromJson({
391
                requestId: this.requestIdSequence++,
392
                threadsRequestFlags: ThreadRequestFlags.includeIdentityInfo
393
            })
394
        );
395
        return result;
20✔
396
    }
397
    public async processThreadsRequest(request: ThreadsRequest) {
398
        if (this.isStopped) {
20✔
399
            let result = await this.sendRequest<ThreadsResponse>(request);
19✔
400

401
            if (result.data.errorCode === ErrorCode.OK) {
19✔
402
                //older versions of the debug protocol had issues with maintaining the active thread, so our workaround is to keep track of it elsewhere
403
                if (this.capabilities?.enableThreadHoppingWorkaround) {
18!
404
                    //ignore the `isPrimary` flag on threads
405
                    this.logger.debug(`Ignoring the 'isPrimary' flag from threads because protocol version 3.0.0 and lower has a bug`);
1✔
406
                } else {
407
                    //trust the debug protocol's `isPrimary` flag on threads
408
                    for (let i = 0; i < result.data.threads.length; i++) {
17✔
409
                        let thread = result.data.threads[i];
18✔
410
                        if (thread.isPrimary) {
18✔
411
                            this.primaryThread = i;
17✔
412
                            break;
17✔
413
                        }
414
                    }
415
                }
416
            }
417
            return result;
19✔
418
        } else {
419
            this.logger.log('[processThreadsRequest] skipped because not stopped');
1✔
420
        }
421
    }
422

423
    public async setExceptionBreakpoints(filters: ExceptionBreakpoint[]): Promise<SetExceptionBreakpointsResponse> {
UNCOV
424
        return this.processRequest<SetExceptionBreakpointsResponse>(
×
425
            SetExceptionBreakpointsRequest.fromJson({
426
                requestId: this.requestIdSequence++,
427
                breakpoints: filters
428
            })
429
        );
430
    }
431

432
    /**
433
     * Get the stackTrace from the device IF currently stopped
434
     */
435
    public async getStackTrace(threadIndex: number = this.primaryThread) {
2✔
436
        return this.processStackTraceRequest(
21✔
437
            StackTraceRequest.fromJson({
438
                requestId: this.requestIdSequence++,
439
                threadIndex: threadIndex
440
            })
441
        );
442
    }
443

444
    private async processStackTraceRequest(request: StackTraceRequest) {
445
        if (!this.isStopped) {
21✔
446
            this.logger.log('[getStackTrace] skipped because debugger is not paused');
1✔
447
        } else if (request?.data?.threadIndex > -1) {
20!
448
            return this.sendRequest<StackTraceResponse>(request);
19✔
449
        } else {
450
            this.logger.log(`[getStackTrace] skipped because ${request?.data?.threadIndex} is not valid threadIndex`);
1!
451
        }
452
    }
453

454
    /**
455
     * @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\""].
456
     *
457
     *                            If no path is specified, the variables accessible from the specified stack frame are returned.
458
     *
459
     *                            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).
460
     *                            All non-quoted keys (i.e. strings without leading and trailing quotes inside them) will be treated as case-insensitive).
461
     * @param getChildKeys  If set, VARIABLES response include the child keys for container types like lists and associative arrays
462
     * @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
463
     * @param threadIndex the index (or perhaps ID?) of the thread to get variables for
464
     */
465
    public async getVariables(variablePathEntries: Array<string> = [], stackFrameIndex: number = this.stackFrameIndex, threadIndex: number = this.primaryThread) {
25✔
466
        const response = await this.processVariablesRequest(
23✔
467
            VariablesRequest.fromJson({
468
                requestId: this.requestIdSequence++,
469
                threadIndex: threadIndex,
470
                stackFrameIndex: stackFrameIndex,
471
                getChildKeys: true,
472
                getVirtualKeys: this.capabilities?.supportsVirtualVariables,
69!
473
                variablePathEntries: variablePathEntries.map(x => ({
43✔
474
                    //remove leading and trailing quotes
475
                    name: x.replace(/^"/, '').replace(/"$/, ''),
476
                    forceCaseInsensitive: !x.startsWith('"') && !x.endsWith('"'),
84✔
477
                    //vars that start with `'$'` are virtual (AA keys will wrapped in quotes so would start with `"$`
478
                    isVirtual: x.startsWith('$') // || x.startsWith('"$')
479
                })),
480
                //starting in protocol v3.1.0, it supports marking certain path items as case-insensitive (i.e. parts of DottedGet expressions)
481
                enableForceCaseInsensitivity: semver.satisfies(this.protocolVersion, '>=3.1.0') && variablePathEntries.length > 0
45✔
482
            })
483
        );
484

485
        //if there was an issue, build a "fake" variables response for several known situationsm or throw nicer errors
486
        if (util.hasNonNullishProperty(response?.data.errorData)) {
23✔
487
            let variable = {
11✔
488
                value: null,
489
                isContainer: false,
490
                isConst: false,
491
                refCount: 0,
492
                childCount: 0
493
            } as Variable;
494
            const simulatedResponse = VariablesResponse.fromJson({
11✔
495
                ...response.data,
496
                variables: [variable]
497
            });
498

499
            let parentVarType: VariableType;
500
            let parentVarTypeText: string;
501
            const loadParentVarInfo = async (index: number) => {
11✔
502
                //fetch the variable one level back from the bad one to get its type
503
                const parentVar = await this.getVariables(
7✔
504
                    variablePathEntries.slice(0, index),
505
                    stackFrameIndex,
506
                    threadIndex
507
                );
508
                parentVarType = parentVar?.data?.variables?.[0]?.type;
7!
509
                parentVarTypeText = parentVarType;
7✔
510
                //convert `roSGNode; Node` to `roSGNode (Node)`
511
                if (parentVarType === VariableType.SubtypedObject) {
7✔
512
                    const chunks = parentVar?.data?.variables?.[0]?.value?.toString().split(';').map(x => x.trim());
2!
513
                    parentVarTypeText = `${chunks[0]} (${chunks[1]})`;
1✔
514
                }
515
            };
516

517
            if (!util.isNullish(response.data.errorData.missingKeyIndex)) {
11✔
518
                const { missingKeyIndex } = response.data.errorData;
6✔
519
                //leftmost var is uninitialized, and we tried to read it
520
                //ex: variablePathEntries = [`notThere`]
521
                if (variablePathEntries.length === 1 && missingKeyIndex === 0) {
6✔
522
                    variable.name = variablePathEntries[0];
1✔
523
                    variable.type = VariableType.Uninitialized;
1✔
524
                    return simulatedResponse;
1✔
525
                }
526

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

533
                if (variablePathEntries.length > 1 && missingKeyIndex > 0) {
4!
534
                    await loadParentVarInfo(missingKeyIndex);
4✔
535

536
                    // prop at the end of Node or AA doesn't exist. Treat like `invalid`.
537
                    // ex: variablePathEntries = ['there', 'notThere']
538
                    if (
4✔
539
                        missingKeyIndex === variablePathEntries.length - 1 &&
5✔
540
                        [VariableType.AssociativeArray, VariableType.SubtypedObject].includes(parentVarType)
541
                    ) {
542
                        variable.name = variablePathEntries[variablePathEntries.length - 1];
1✔
543
                        variable.type = VariableType.Invalid;
1✔
544
                        variable.value = 'Invalid (not defined)';
1✔
545
                        return simulatedResponse;
1✔
546
                    }
547
                }
548
                //prop in the middle is missing, tried reading a prop on it
549
                // ex: variablePathEntries = ["there", "notThere", "definitelyNotThere"]
550
                throw new Error(`Cannot read '${variablePathEntries[missingKeyIndex]}'${parentVarType ? ` on type '${parentVarTypeText}'` : ''}`);
3!
551
            }
552

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

557
                //leftmost var is literal `invalid`, tried to read it
558
                if (variablePathEntries.length === 1 && invalidPathIndex === 0) {
5✔
559
                    variable.name = variablePathEntries[variablePathEntries.length - 1];
1✔
560
                    variable.type = VariableType.Invalid;
1✔
561
                    return simulatedResponse;
1✔
562
                }
563

564
                if (
4✔
565
                    variablePathEntries.length > 1 &&
11✔
566
                    invalidPathIndex > 0 &&
567
                    //only do this logic if the invalid item is not the last item
568
                    invalidPathIndex < variablePathEntries.length - 1
569
                ) {
570
                    await loadParentVarInfo(invalidPathIndex + 1);
3✔
571

572
                    //leftmost var is set to literal `invalid`, tried to read prop
573
                    if (invalidPathIndex === 0 && variablePathEntries.length > 1) {
3!
UNCOV
574
                        throw new Error(`Cannot read '${variablePathEntries[invalidPathIndex + 1]}' on type '${parentVarTypeText}'`);
×
575
                    }
576

577
                    // prop at the end doesn't exist. Treat like `invalid`.
578
                    // ex: variablePathEntries = ['there', 'notThere']
579
                    if (
3!
580
                        invalidPathIndex === variablePathEntries.length - 1 &&
3!
581
                        [VariableType.AssociativeArray, VariableType.SubtypedObject].includes(parentVarType)
582
                    ) {
UNCOV
583
                        variable.name = variablePathEntries[variablePathEntries.length - 1];
×
UNCOV
584
                        variable.type = VariableType.Invalid;
×
585
                        variable.value = 'Invalid (not defined)';
×
586
                        return simulatedResponse;
×
587
                    }
588
                }
589
                //prop in the middle is missing, tried reading a prop on it
590
                // ex: variablePathEntries = ["there", "thereButSetToInvalid", "definitelyNotThere"]
591
                throw new Error(`Cannot read '${variablePathEntries[invalidPathIndex + 1]}'${parentVarType ? ` on type '${parentVarTypeText}'` : ''}`);
4✔
592
            }
593
        }
594
        return response;
12✔
595
    }
596

597
    private async processVariablesRequest(request: VariablesRequest) {
598
        if (this.isStopped && request.data.threadIndex > -1) {
23✔
599
            return this.sendRequest<VariablesResponse>(request);
22✔
600
        }
601
    }
602

603
    public async executeCommand(sourceCode: string, stackFrameIndex: number = this.stackFrameIndex, threadIndex: number = this.primaryThread) {
2✔
604
        return this.processExecuteRequest(
2✔
605
            ExecuteRequest.fromJson({
606
                requestId: this.requestIdSequence++,
607
                threadIndex: threadIndex,
608
                stackFrameIndex: stackFrameIndex,
609
                sourceCode: sourceCode
610
            })
611
        );
612
    }
613

614
    private async processExecuteRequest(request: ExecuteRequest) {
615
        if (this.isStopped && request.data.threadIndex > -1) {
2✔
616
            return this.sendRequest<ExecuteV3Response>(request);
1✔
617
        }
618
    }
619

620
    public async addBreakpoints(breakpoints: Array<BreakpointSpec & { componentLibraryName?: string }>): Promise<AddBreakpointsResponse> {
621
        const enableComponentLibrarySpecificBreakpoints = this.capabilities?.enableComponentLibrarySpecificBreakpoints;
19!
622
        if (breakpoints?.length > 0) {
19!
623
            const json = {
12✔
624
                requestId: this.requestIdSequence++,
625
                breakpoints: breakpoints.map(x => {
626
                    let breakpoint = {
15✔
627
                        ...x,
628
                        ignoreCount: x.hitCount
629
                    };
630
                    if (enableComponentLibrarySpecificBreakpoints && breakpoint.componentLibraryName) {
15✔
631
                        breakpoint.filePath = breakpoint.filePath.replace(/^pkg:\//i, `lib:/${breakpoint.componentLibraryName}/`);
1✔
632
                    }
633
                    return breakpoint;
15✔
634
                })
635
            };
636

637
            const useConditionalBreakpoints = (
638
                //does this protocol version support conditional breakpoints?
639
                this.capabilities?.supportsConditionalBreakpoints &&
12!
640
                //is there at least one conditional breakpoint present?
641
                !!breakpoints.find(x => !!x?.conditionalExpression?.trim())
13!
642
            );
643

644
            let response: AddBreakpointsResponse | AddConditionalBreakpointsResponse;
645
            if (useConditionalBreakpoints) {
12✔
646
                response = await this.sendRequest<AddBreakpointsResponse>(
2✔
647
                    AddConditionalBreakpointsRequest.fromJson(json)
648
                );
649
            } else {
650
                response = await this.sendRequest<AddBreakpointsResponse>(
10✔
651
                    AddBreakpointsRequest.fromJson(json)
652
                );
653
            }
654

655
            //if the device does not support breakpoint verification, then auto-mark all of these as verified
656
            if (!this.capabilities?.supportsBreakpointVerification) {
12!
657
                this.emit('breakpoints-verified', {
10✔
658
                    breakpoints: response.data.breakpoints
659
                });
660
            }
661
            return response;
12✔
662
        }
663
        return AddBreakpointsResponse.fromBuffer(null);
7✔
664
    }
665

666
    public async listBreakpoints(): Promise<ListBreakpointsResponse> {
667
        return this.processRequest<ListBreakpointsResponse>(
4✔
668
            ListBreakpointsRequest.fromJson({
669
                requestId: this.requestIdSequence++
670
            })
671
        );
672
    }
673

674
    /**
675
     * Remove breakpoints having the specified IDs
676
     */
677
    public async removeBreakpoints(breakpointIds: number[]) {
678
        return this.processRemoveBreakpointsRequest(
7✔
679
            RemoveBreakpointsRequest.fromJson({
680
                requestId: this.requestIdSequence++,
681
                breakpointIds: breakpointIds
682
            })
683
        );
684
    }
685

686
    private async processRemoveBreakpointsRequest(request: RemoveBreakpointsRequest) {
687
        //throw out null breakpoints
688
        request.data.breakpointIds = request.data.breakpointIds?.filter(x => typeof x === 'number') ?? [];
9✔
689

690
        if (request.data.breakpointIds?.length > 0) {
7!
691
            return this.sendRequest<RemoveBreakpointsResponse>(request);
5✔
692
        }
693
        return RemoveBreakpointsResponse.fromJson(null);
2✔
694
    }
695

696
    /**
697
     * Given a request, process it in the proper fashion. This is mostly used for external mocking/testing of
698
     * this client, but it should force the client to flow in the same fashion as a live debug session
699
     */
700
    public async processRequest<TResponse extends ProtocolResponse>(request: ProtocolRequest): Promise<TResponse> {
701
        switch (request?.constructor.name) {
4!
702
            case ContinueRequest.name:
UNCOV
703
                return this.processContinueRequest(request as ContinueRequest) as any;
×
704

705
            case ExecuteRequest.name:
UNCOV
706
                return this.processExecuteRequest(request as ExecuteRequest) as any;
×
707

708
            case HandshakeRequest.name:
UNCOV
709
                return this.processHandshakeRequest(request as HandshakeRequest) as any;
×
710

711
            case RemoveBreakpointsRequest.name:
UNCOV
712
                return this.processRemoveBreakpointsRequest(request as RemoveBreakpointsRequest) as any;
×
713

714
            case StackTraceRequest.name:
UNCOV
715
                return this.processStackTraceRequest(request as StackTraceRequest) as any;
×
716

717
            case StepRequest.name:
UNCOV
718
                return this.processStepRequest(request as StepRequest) as any;
×
719

720
            case StopRequest.name:
UNCOV
721
                return this.processStopRequest(request as StopRequest) as any;
×
722

723
            case ThreadsRequest.name:
UNCOV
724
                return this.processThreadsRequest(request as ThreadsRequest) as any;
×
725

726
            case VariablesRequest.name:
UNCOV
727
                return this.processVariablesRequest(request as VariablesRequest) as any;
×
728

729
            //for all other request types, there's no custom business logic, so just pipe them through manually
730
            case AddBreakpointsRequest.name:
731
            case AddConditionalBreakpointsRequest.name:
732
            case ExitChannelRequest.name:
733
            case ListBreakpointsRequest.name:
734
            case SetExceptionBreakpointsRequest.name:
735
                return this.sendRequest(request);
4✔
736
            default:
UNCOV
737
                this.logger.log('Unknown request type. Sending anyway...', request);
×
738
                //unknown request type. try sending it as-is
739
                return this.sendRequest(request);
×
740
        }
741
    }
742

743
    /**
744
     * Send a request to the roku device, and get a promise that resolves once we have received the response
745
     */
746
    private async sendRequest<T extends ProtocolResponse | ProtocolUpdate>(request: ProtocolRequest) {
747
        request = (await this.plugins.emit('beforeSendRequest', {
158✔
748
            client: this,
749
            request: request
750
        })).request;
751

752
        this.activeRequests.set(request.data.requestId, request);
158✔
753

754
        return new Promise<T>((resolve, reject) => {
158✔
755
            let unsubscribe = this.on('response', (response) => {
158✔
756
                if (response.data.requestId === request.data.requestId) {
156✔
757
                    unsubscribe();
155✔
758
                    this.activeRequests.delete(request.data.requestId);
155✔
759
                    resolve(response as T);
155✔
760
                }
761
            });
762

763
            this.logEvent(request);
158✔
764
            if (this.controlSocket) {
158✔
765
                const buffer = request.toBuffer();
157✔
766
                this.writeToBufferLog('client-to-server', buffer);
157✔
767
                this.controlSocket.write(buffer);
157✔
768
                void this.plugins.emit('afterSendRequest', {
157✔
769
                    client: this,
770
                    request: request
771
                });
772
            } else {
773
                reject(
1✔
774
                    new Error(`Control socket was closed - Command: ${Command[request.data.command]}`)
775
                );
776
            }
777
        });
778
    }
779

780
    /**
781
     * Sometimes a request arrives that we don't understand. If that's the case, this function can be used
782
     * to discard that entire response by discarding `packet_length` number of bytes
783
     */
784
    private discardNextResponseOrUpdate() {
785
        const response = GenericV3Response.fromBuffer(this.buffer);
1✔
786
        if (response.success && response.data.packetLength > 0) {
1!
UNCOV
787
            this.logger.warn(`Unsupported response or updated encountered. Discarding ${response.data.packetLength} bytes:`, JSON.stringify(
×
788
                this.buffer.slice(0, response.data.packetLength + 1).toJSON().data
789
            ));
790
            //we have a valid event. Clear the buffer of this data
UNCOV
791
            this.buffer = this.buffer.slice(response.data.packetLength);
×
792
        }
793
    }
794

795
    /**
796
     * A counter to help give a unique id to each update (mostly just for logging purposes)
797
     */
798
    private updateSequence = 1;
71✔
799

800
    private logEvent(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate) {
801
        const [, eventName, eventType] = /(.+?)((?:v\d+_?\d*)?(?:request|response|update))/ig.exec(event?.constructor.name) ?? [];
400!
802
        if (isProtocolRequest(event)) {
400✔
803
            this.logger.log(`${eventName} ${event.data.requestId} (${eventType})`, event, `(${event?.constructor.name})`);
158!
804
        } else if (isProtocolUpdate(event)) {
242✔
805
            this.logger.log(`${eventName} ${this.updateSequence++} (${eventType})`, event, `(${event?.constructor.name})`);
71!
806
        } else {
807
            if (event.data.errorCode === ErrorCode.OK) {
171✔
808
                this.logger.log(`${eventName} ${event.data.requestId} (${eventType})`, event, `(${event?.constructor.name})`);
139!
809
            } else {
810
                this.logger.log(`[error] ${eventName} ${event.data.requestId} (${eventType})`, event, `(${event?.constructor.name})`);
32!
811
            }
812
        }
813
    }
814

815
    private async process(): Promise<boolean> {
816
        try {
227✔
817
            this.logger.info('[process()]: buffer=', this.buffer.toJSON());
227✔
818

819
            let { responseOrUpdate } = await this.plugins.emit('provideResponseOrUpdate', {
227✔
820
                client: this,
821
                activeRequests: this.activeRequests,
822
                buffer: this.buffer
823
            });
824

825
            if (!responseOrUpdate) {
227!
826
                responseOrUpdate = await this.getResponseOrUpdate(this.buffer);
227✔
827
            }
828

829
            //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)
830
            if (!responseOrUpdate) {
227✔
831
                this.logger.info('Unable to convert buffer into anything meaningful', this.buffer);
1✔
832
                //if we have packet length, and we have at least that many bytes, throw out this message so we can hopefully recover
833
                this.discardNextResponseOrUpdate();
1✔
834
                return false;
1✔
835
            }
836
            if (!responseOrUpdate.success || responseOrUpdate.data.packetLength > this.buffer.length) {
226!
UNCOV
837
                this.logger.log(`event parse failed. ${responseOrUpdate?.data?.packetLength} bytes required, ${this.buffer.length} bytes available`);
×
UNCOV
838
                return false;
×
839
            }
840

841
            //we have a valid event. Remove this data from the buffer
842
            this.buffer = this.buffer.slice(responseOrUpdate.readOffset);
226✔
843

844
            if (responseOrUpdate.data.errorCode !== ErrorCode.OK) {
226✔
845
                this.logEvent(responseOrUpdate);
16✔
846
            }
847

848
            //we got a result
849
            if (responseOrUpdate) {
226!
850
                //emit the corresponding event
851
                if (isProtocolUpdate(responseOrUpdate)) {
226✔
852
                    this.logEvent(responseOrUpdate);
71✔
853
                    this.emit('update', responseOrUpdate);
71✔
854
                    await this.plugins.emit('onUpdate', {
71✔
855
                        client: this,
856
                        update: responseOrUpdate
857
                    });
858
                } else {
859
                    this.logEvent(responseOrUpdate);
155✔
860
                    this.emit('response', responseOrUpdate);
155✔
861
                    await this.plugins.emit('onResponse', {
155✔
862
                        client: this,
863
                        response: responseOrUpdate as any
864
                    });
865
                }
866
                return true;
226✔
867
            }
868
        } catch (e) {
UNCOV
869
            this.logger.error(`process() failed:`, e);
×
870
        }
871
    }
872

873
    /**
874
     * Given a buffer, try to parse into a specific ProtocolResponse or ProtocolUpdate
875
     */
876
    public async getResponseOrUpdate(buffer: Buffer): Promise<ProtocolResponse | ProtocolUpdate> {
877
        //if we haven't seen a handshake yet, try to convert the buffer into a handshake
878
        if (!this.isHandshakeComplete) {
227✔
879
            let handshake: HandshakeV3Response | HandshakeResponse;
880
            //try building the v3 handshake response first
881
            handshake = HandshakeV3Response.fromBuffer(buffer);
65✔
882
            //we didn't get a v3 handshake. try building an older handshake response
883
            if (!handshake.success) {
65✔
884
                handshake = HandshakeResponse.fromBuffer(buffer);
1✔
885
            }
886
            if (handshake.success) {
65!
887
                await this.verifyHandshake(handshake);
65✔
888
                return handshake;
65✔
889
            }
UNCOV
890
            return;
×
891
        }
892

893
        let genericResponse = this.watchPacketLength ? GenericV3Response.fromBuffer(buffer) : GenericResponse.fromBuffer(buffer);
162!
894

895
        //if the response has a non-OK error code, we won't receive the expected response type,
896
        //so return the generic response
897
        if (genericResponse.success && genericResponse.data.errorCode !== ErrorCode.OK) {
162✔
898
            return genericResponse;
16✔
899
        }
900
        // a nonzero requestId means this is a response to a request that we sent
901
        if (genericResponse.data.requestId !== 0) {
146✔
902
            //requestId 0 means this is an update
903
            const request = this.activeRequests.get(genericResponse.data.requestId);
75✔
904
            if (request) {
75✔
905
                return DebugProtocolClient.getResponse(this.buffer, request.data.command);
74✔
906
            }
907
        } else {
908
            return this.getUpdate(this.buffer);
71✔
909
        }
910
    }
911

912
    public static getResponse(buffer: Buffer, command: Command) {
913
        switch (command) {
74!
914
            case Command.Stop:
915
            case Command.Continue:
916
            case Command.Step:
917
            case Command.ExitChannel:
918
                return GenericV3Response.fromBuffer(buffer);
9✔
919
            case Command.Execute:
920
                return ExecuteV3Response.fromBuffer(buffer);
1✔
921
            case Command.AddBreakpoints:
922
            case Command.AddConditionalBreakpoints:
923
                return AddBreakpointsResponse.fromBuffer(buffer);
12✔
924
            case Command.ListBreakpoints:
925
                return ListBreakpointsResponse.fromBuffer(buffer);
3✔
926
            case Command.RemoveBreakpoints:
927
                return RemoveBreakpointsResponse.fromBuffer(buffer);
3✔
928
            case Command.Variables:
929
                return VariablesResponse.fromBuffer(buffer);
11✔
930
            case Command.StackTrace:
931
                return StackTraceV3Response.fromBuffer(buffer);
17✔
932
            case Command.Threads:
933
                return ThreadsResponse.fromBuffer(buffer);
18✔
934
            case Command.SetExceptionBreakpoints:
UNCOV
935
                return SetExceptionBreakpointsResponse.fromBuffer(buffer);
×
936
            default:
937
                return undefined;
×
938
        }
939
    }
940

941
    public getUpdate(buffer: Buffer): ProtocolUpdate {
942
        //read the update_type from the buffer (save some buffer parsing time by narrowing to the exact update type)
943
        const updateTypeCode = buffer.readUInt32LE(
71✔
944
            // if the protocol supports packet length, then update_type is bytes 12-16. Otherwise, it's bytes 8-12
945
            this.watchPacketLength ? 12 : 8
71!
946
        );
947
        const updateType = UpdateTypeCode[updateTypeCode] as UpdateType;
71✔
948

949
        this.logger?.log('getUpdate(): update Type:', updateType);
71!
950
        switch (updateType) {
71!
951
            case UpdateType.IOPortOpened:
952
                //TODO handle this
953
                return IOPortOpenedUpdate.fromBuffer(buffer);
3✔
954
            case UpdateType.AllThreadsStopped:
955
                const allThreadsStoppedResponse = AllThreadsStoppedUpdate.fromBuffer(buffer);
64✔
956
                return allThreadsStoppedResponse;
64✔
957
            case UpdateType.ThreadAttached:
958
                const threadAttachedResponse = ThreadAttachedUpdate.fromBuffer(buffer);
3✔
959
                return threadAttachedResponse;
3✔
960
            case UpdateType.BreakpointError:
961
                //we do nothing with breakpoint errors at this time.
UNCOV
962
                return BreakpointErrorUpdate.fromBuffer(buffer);
×
963
            case UpdateType.CompileError:
964
                let compileErrorUpdate = CompileErrorUpdate.fromBuffer(buffer);
×
UNCOV
965
                if (compileErrorUpdate?.data?.errorMessage !== '') {
×
966
                    this.emit('compile-error', compileErrorUpdate);
×
967
                }
968
                return compileErrorUpdate;
×
969
            case UpdateType.BreakpointVerified:
970
                let response = BreakpointVerifiedUpdate.fromBuffer(buffer);
1✔
971
                if (response?.data?.breakpoints?.length > 0) {
1!
972
                    this.emit('breakpoints-verified', response.data);
1✔
973
                }
974
                return response;
1✔
975
            case UpdateType.ExceptionBreakpointError:
976
                //we do nothing with exception breakpoint errors at this time.
UNCOV
977
                const exceptionBreakpointErrorUpdate = ExceptionBreakpointErrorUpdate.fromBuffer(buffer);
×
UNCOV
978
                return exceptionBreakpointErrorUpdate;
×
979
            default:
980
                return undefined;
×
981
        }
982
    }
983

984
    private handleUpdateQueue = new ActionQueue();
71✔
985

986
    /**
987
     * Handle/process any received updates from the debug protocol
988
     */
989
    private async handleUpdate(update: ProtocolUpdate) {
990
        return this.handleUpdateQueue.run(async () => {
71✔
991
            update = (await this.plugins.emit('beforeHandleUpdate', {
71✔
992
                client: this,
993
                update: update
994
            })).update;
995

996
            if (update instanceof AllThreadsStoppedUpdate || update instanceof ThreadAttachedUpdate) {
71✔
997
                this.isStopped = true;
67✔
998

999
                let eventName: 'runtime-error' | 'suspend';
1000
                //TODO should caught runtime error remap to runtime error?
1001
                if (update.data.stopReason === StopReason.RuntimeError || update.data.stopReason === StopReason.CaughtRuntimeError) {
67!
UNCOV
1002
                    eventName = 'runtime-error';
×
1003
                } else {
1004
                    eventName = 'suspend';
67✔
1005
                }
1006

1007
                const isValidStopReason = [StopReason.RuntimeError, StopReason.Break, StopReason.StopStatement, StopReason.CaughtRuntimeError].includes(update.data.stopReason);
67✔
1008

1009
                if (update instanceof AllThreadsStoppedUpdate && isValidStopReason) {
67✔
1010
                    this.primaryThread = update.data.threadIndex;
64✔
1011
                    this.stackFrameIndex = 0;
64✔
1012
                    this.emit(eventName, update);
64✔
1013
                } else if (update instanceof ThreadAttachedUpdate && isValidStopReason) {
3!
1014
                    this.primaryThread = update.data.threadIndex;
3✔
1015
                    this.emit(eventName, update);
3✔
1016
                }
1017

1018
            } else if (isIOPortOpenedUpdate(update)) {
4✔
1019
                this.connectToIoPort(update);
3✔
1020
            }
1021
            return true;
71✔
1022
        });
1023
    }
1024

1025
    /**
1026
     * Verify all the handshake data
1027
     */
1028
    private async verifyHandshake(response: HandshakeResponse | HandshakeV3Response): Promise<boolean> {
1029
        if (DebugProtocolClient.DEBUGGER_MAGIC === response.data.magic) {
65!
1030
            this.logger.log('Magic is valid.');
65✔
1031

1032
            this.capabilities = new ProtocolCapabilities(response.data.protocolVersion);
65✔
1033
            this.logger.log('Protocol Version:', this.protocolVersion);
65✔
1034

1035
            this.watchPacketLength = semver.satisfies(this.protocolVersion, '>=3.0.0');
65✔
1036
            this.isHandshakeComplete = true;
65✔
1037

1038
            let handshakeVerified = true;
65✔
1039

1040
            if (semver.satisfies(this.protocolVersion, this.supportedVersionRange)) {
65!
1041
                this.logger.log('supported');
65✔
1042
                this.emit('protocol-version', {
65✔
1043
                    message: `Protocol Version ${this.protocolVersion} is supported!`,
1044
                    errorCode: PROTOCOL_ERROR_CODES.SUPPORTED
1045
                });
UNCOV
1046
            } else if (semver.gtr(this.protocolVersion, this.supportedVersionRange)) {
×
UNCOV
1047
                this.logger.log('roku-debug has not been tested against protocol version', this.protocolVersion);
×
1048
                this.emit('protocol-version', {
×
1049
                    message: `Protocol Version ${this.protocolVersion} has not been tested and may not work as intended.\nPlease open any issues you have with this version to https://github.com/rokucommunity/roku-debug/issues`,
1050
                    errorCode: PROTOCOL_ERROR_CODES.NOT_TESTED
1051
                });
1052
            } else {
UNCOV
1053
                this.logger.log('not supported');
×
UNCOV
1054
                this.emit('protocol-version', {
×
1055
                    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`,
1056
                    errorCode: PROTOCOL_ERROR_CODES.NOT_SUPPORTED
1057
                });
UNCOV
1058
                await this.emit('close');
×
UNCOV
1059
                handshakeVerified = false;
×
1060
            }
1061

1062
            this.emit('handshake-verified', handshakeVerified);
65✔
1063
            return handshakeVerified;
65✔
1064
        } else {
UNCOV
1065
            this.logger.log('Closing connection due to bad debugger magic', response.data.magic);
×
UNCOV
1066
            this.emit('handshake-verified', false);
×
1067
            await this.emit('close');
×
1068
            return false;
×
1069
        }
1070
    }
1071

1072
    /**
1073
     * When the debugger emits the IOPortOpenedUpdate, we need to immediately connect to the IO port to start receiving that data
1074
     */
1075
    private connectToIoPort(update: IOPortOpenedUpdate) {
1076
        if (update.success) {
4✔
1077
            // Create a new TCP client.
1078
            this.ioSocket = new Net.Socket({
3✔
1079
                allowHalfOpen: false
1080
            });
1081
            util.registerSocketLogging(this.ioSocket, this.logger, 'IoSocket');
3✔
1082

1083
            // Send a connection request to the server.
1084
            this.logger.log(`Connect to IO Port ${this.options.host}:${update.data.port}`);
3✔
1085

1086
            //sometimes the server shuts down before we had a chance to connect, so recover more gracefully
1087
            try {
3✔
1088
                this.ioSocket.connect({
3✔
1089
                    port: update.data.port,
1090
                    host: this.options.host
1091
                }, () => {
1092
                    // If there is no error, the server has accepted the request
1093
                    this.logger.log('TCP connection established with the IO Port.');
3✔
1094
                    this.connectedToIoPort = true;
3✔
1095

1096
                    let lastPartialLine = '';
3✔
1097
                    this.ioSocket.on('data', (buffer) => {
3✔
1098
                        this.writeToBufferLog('io', buffer);
3✔
1099
                        let logResult = util.handleLogFragments(lastPartialLine, buffer.toString());
3✔
1100

1101
                        // Save any remaining partial line for the next event
1102
                        lastPartialLine = logResult.remaining;
3✔
1103
                        if (logResult.completed) {
3✔
1104
                            // Emit the completed io string.
1105
                            this.emit('io-output', logResult.completed);
2✔
1106
                        } else {
1107
                            this.logger.debug('Buffer was split', lastPartialLine);
1✔
1108
                        }
1109
                    });
1110

1111
                    this.ioSocket.on('close', () => {
3✔
1112
                        this.logger.log('IO socket closed');
3✔
1113
                        this.ioSocketClosed.tryResolve();
3✔
1114
                    });
1115

1116
                    // Don't forget to catch error, for your own sake.
1117
                    this.ioSocket.once('error', (err) => {
3✔
UNCOV
1118
                        this.ioSocket.end();
×
UNCOV
1119
                        this.logger.error(err);
×
1120
                    });
1121
                });
1122
                return true;
3✔
1123
            } catch (e) {
UNCOV
1124
                this.logger.error(`Failed to connect to IO socket at ${this.options.host}:${update.data.port}`, e);
×
UNCOV
1125
                this.emit('app-exit');
×
1126
            }
1127
        }
1128
        return false;
1✔
1129
    }
1130

1131
    /**
1132
     * Destroy this instance, shutting down any sockets or other long-running items and cleaning up.
1133
     * @param immediate if true, all sockets are immediately closed and do not gracefully shut down
1134
     */
1135
    public async destroy(immediate = false) {
1✔
1136
        await this.shutdown(immediate);
74✔
1137
    }
1138

1139
    private shutdownPromise: Promise<void>;
1140
    private async shutdown(immediate = false) {
×
1141
        if (this.shutdownPromise === undefined) {
74✔
1142
            this.logger.log('[shutdown] shutting down');
70✔
1143
            this.shutdownPromise = this._shutdown(immediate);
70✔
1144
        } else {
1145
            this.logger.log(`[shutdown] Tried to call .shutdown() again. Returning the same promise`);
4✔
1146
        }
1147
        return this.shutdownPromise;
74✔
1148
    }
1149

1150
    private async _shutdown(immediate = false) {
×
1151
        let exitChannelTimeout = this.options?.exitChannelTimeout ?? 30_000;
70!
1152
        let shutdownTimeMax = this.options?.shutdownTimeout ?? 10_000;
70!
1153
        //if immediate is true, this is an instant shutdown force. don't wait for anything
1154
        if (immediate) {
70✔
1155
            exitChannelTimeout = 0;
69✔
1156
            shutdownTimeMax = 0;
69✔
1157
        }
1158

1159
        //tell the device to exit the channel (only if the device is still listening...)
1160
        if (this.controlSocket) {
70✔
1161
            try {
64✔
1162
                //ask the device to terminate the debug session. We have to wait for this to come back.
1163
                //The device might be running unstoppable code, so this might take a while. Wait for the device to send back
1164
                //the response before we continue with the teardown process
1165
                await Promise.race([
64✔
1166
                    immediate
64✔
1167
                        ? Promise.resolve(null)
UNCOV
1168
                        : this.exitChannel().finally(() => this.logger.log('exit channel completed')),
×
1169
                    //if the exit channel request took this long to finish, something's terribly wrong
1170
                    util.sleep(exitChannelTimeout)
1171
                ]);
1172
            } finally { }
1173
        }
1174

1175
        await Promise.all([
70✔
1176
            this.destroyControlSocket(shutdownTimeMax),
1177
            this.destroyIOSocket(shutdownTimeMax, immediate)
1178
        ]);
1179
        this.emitter?.removeAllListeners();
70!
1180
        this.buffer = Buffer.alloc(0);
70✔
1181
        this.bufferQueue.destroy();
70✔
1182
    }
1183

1184
    private isDestroyingControlSocket = false;
71✔
1185

1186
    private async destroyControlSocket(timeout: number) {
1187
        if (this.controlSocket && !this.isDestroyingControlSocket) {
70✔
1188
            this.isDestroyingControlSocket = true;
64✔
1189

1190
            //wait for the controlSocket to be closed
1191
            await Promise.race([
64✔
1192
                this.controlSocketClosed.promise,
1193
                util.sleep(timeout)
1194
            ]);
1195

1196
            this.logger.log('[destroy] controlSocket is: ', this.controlSocketClosed.isResolved ? 'closed' : 'not closed');
64!
1197

1198
            //destroy the controlSocket
1199
            this.controlSocket?.removeAllListeners();
64!
1200
            this.controlSocket?.destroy();
64!
1201
            this.controlSocket = undefined;
64✔
1202
            this.isDestroyingControlSocket = false;
64✔
1203
        }
1204
    }
1205

1206
    private isDestroyingIOSocket = false;
71✔
1207

1208
    /**
1209
     * @param immediate if true, force close immediately instead of waiting for it to settle
1210
     */
1211
    private async destroyIOSocket(timeout: number, immediate = false) {
×
1212
        if (this.ioSocket && !this.isDestroyingIOSocket) {
70✔
1213
            this.isDestroyingIOSocket = true;
3✔
1214
            //wait for the ioSocket to be closed
1215
            await Promise.race([
3✔
1216
                this.ioSocketClosed.promise.then(() => this.logger.log('IO socket closed')),
3✔
1217
                util.sleep(timeout)
1218
            ]);
1219

1220
            //if the io socket is not closed, wait for it to at least settle
1221
            if (!this.ioSocketClosed.isCompleted && !immediate) {
3!
UNCOV
1222
                await new Promise<void>((resolve) => {
×
UNCOV
1223
                    const callback = debounce(() => {
×
1224
                        resolve();
×
1225
                    }, 250);
1226
                    //trigger the current callback once.
UNCOV
1227
                    callback();
×
UNCOV
1228
                    this.ioSocket?.on('drain', callback as () => void);
×
1229
                });
1230
            }
1231

1232
            this.logger.log('[destroy] ioSocket is: ', this.ioSocketClosed.isResolved ? 'closed' : 'not closed');
3!
1233

1234
            //destroy the ioSocket
1235
            this.ioSocket?.removeAllListeners?.();
3!
1236
            this.ioSocket?.destroy?.();
3!
1237
            this.ioSocket = undefined;
3✔
1238
            this.isDestroyingIOSocket = false;
3✔
1239
        }
1240
    }
1241
}
1242

1243
export interface ProtocolVersionDetails {
1244
    message: string;
1245
    errorCode: PROTOCOL_ERROR_CODES;
1246
}
1247

1248
export interface BreakpointSpec {
1249
    /**
1250
     * The path of the source file where the breakpoint is to be inserted.
1251
     */
1252
    filePath: string;
1253
    /**
1254
     * The (1-based) line number in the channel application code where the breakpoint is to be executed.
1255
     */
1256
    lineNumber: number;
1257
    /**
1258
     * 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.
1259
     */
1260
    hitCount?: number;
1261
    /**
1262
     * BrightScript code that evaluates to a boolean value. The expression is compiled and executed in
1263
     * the context where the breakpoint is located. If specified, the hitCount is only be
1264
     * updated if this evaluates to true.
1265
     * @avaiable since protocol version 3.1.0
1266
     */
1267
    conditionalExpression?: string;
1268
}
1269

1270
export interface ConstructorOptions {
1271
    /**
1272
     * The host/ip address of the Roku
1273
     */
1274
    host: string;
1275
    /**
1276
     * The port number used to send all debugger commands. This is static/unchanging for Roku devices,
1277
     * but is configurable here to support unit testing or alternate runtimes (i.e. https://www.npmjs.com/package/brs)
1278
     */
1279
    controlPort?: number;
1280
    /**
1281
     * The interval (in milliseconds) for how frequently the `connect`
1282
     * call should retry connecting to the control port. At the start of a debug session,
1283
     * the protocol debugger will start trying to connect the moment the channel is sideloaded,
1284
     * and keep trying until a successful connection is established or the debug session is terminated
1285
     * @default 250
1286
     */
1287
    controlConnectInterval?: number;
1288
    /**
1289
     * The maximum time (in milliseconds) the debugger will keep retrying connections.
1290
     * This is here to prevent infinitely pinging the Roku device.
1291
     */
1292
    controlConnectMaxTime?: number;
1293

1294
    /**
1295
     * The number of milliseconds that the client should wait during a shutdown request before forcefully terminating the sockets
1296
     */
1297
    shutdownTimeout?: number;
1298

1299
    /**
1300
     * The max time the client will wait for the `exit channel` response before forcefully terminating the sockets
1301
     */
1302
    exitChannelTimeout?: number;
1303
}
1304

1305
/**
1306
 * Is the event a ProtocolRequest
1307
 */
1308
export function isProtocolRequest(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate): event is ProtocolRequest {
2✔
1309
    return event?.constructor?.name.endsWith('Request') && event?.data?.requestId > 0;
400!
1310
}
1311

1312
/**
1313
 * Is the event a ProtocolResponse
1314
 */
1315
export function isProtocolResponse(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate): event is ProtocolResponse {
2✔
UNCOV
1316
    return event?.constructor?.name.endsWith('Response') && event?.data?.requestId !== 0;
×
1317
}
1318

1319
/**
1320
 * Is the event a ProtocolUpdate update
1321
 */
1322
export function isProtocolUpdate(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate): event is ProtocolUpdate {
2✔
1323
    return event?.constructor?.name.endsWith('Update') && event?.data?.requestId === 0;
625!
1324
}
1325

1326
export interface BreakpointsVerifiedEvent {
1327
    breakpoints: VerifiedBreakpoint[];
1328
}
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

© 2026 Coveralls, Inc