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

rokucommunity / roku-debug / 29091048042

10 Jul 2026 11:57AM UTC coverage: 73.586% (+0.8%) from 72.802%
29091048042

Pull #323

github

web-flow
Merge a7a0b7642 into 7aa945dd8
Pull Request #323: Add component library postfix to `library` statements

3842 of 5489 branches covered (69.99%)

Branch coverage included in aggregate %.

66 of 102 new or added lines in 2 files covered. (64.71%)

1442 existing lines in 106 files now uncovered.

6296 of 8288 relevant lines covered (75.97%)

45.79 hits per line

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

79.39
/src/debugProtocol/client/DebugProtocolClient.ts
1
import * as Net from 'net';
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 } from '../events/requests/ThreadsRequest';
2✔
23
import type { ExceptionBreakpoint } from '../events/requests/SetExceptionBreakpointsRequest';
2✔
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';
2✔
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';
2✔
37
import { ThreadsResponse } from '../events/responses/ThreadsResponse';
2✔
38
import { SetExceptionBreakpointsResponse } from '../events/responses/SetExceptionBreakpointsResponse';
2✔
39
import type { Variable } from '../events/responses/VariablesResponse';
2✔
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';
2✔
46
import PluginInterface from '../PluginInterface';
2✔
47
import type { VerifiedBreakpoint } from '../events/updates/BreakpointVerifiedUpdate';
48
import { BreakpointVerifiedUpdate } from '../events/updates/BreakpointVerifiedUpdate';
49
import type { AddConditionalBreakpointsResponse } from '../events/responses/AddConditionalBreakpointsResponse';
76✔
50
import { ExceptionBreakpointErrorUpdate } from '../events/updates/ExceptionBreakpointErrorUpdate';
51

76✔
52
export class DebugProtocolClient {
76✔
53

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

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

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

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

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

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

83
    public scriptTitle: string;
84
    public isHandshakeComplete = false;
85
    public connectedToIoPort = false;
76✔
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
     */
76✔
90
    public watchPacketLength = false;
76✔
91
    /**
76✔
92
     * Capability flags derived from the negotiated protocol version. Undefined until the
76✔
93
     * handshake completes, then assigned a fresh `ProtocolCapabilities` keyed off the version
76✔
94
     * the device reported.
95
     */
96
    public capabilities: ProtocolCapabilities | undefined;
97
    /**
228✔
98
     * The protocol version negotiated with the device during the handshake. Undefined until
99
     * the handshake has completed.
100
     */
76✔
101
    public get protocolVersion(): string | undefined {
102
        return this.capabilities?.protocolVersion;
103
    }
76✔
104
    public primaryThread: number;
105
    public stackFrameIndex: number;
75✔
106

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

112
    private emitter = new EventEmitter();
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
     */
300!
116
    private controlSocket: Net.Socket;
117
    /**
118
     * Promise that is resolved when the control socket is closed
57✔
119
     */
57✔
120
    private controlSocketClosed = defer<void>();
57✔
121
    /**
57✔
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>();
367✔
129
    /**
367✔
130
     * The buffer where all unhandled data will be stored until successfully consumed
220✔
131
     */
132
    private buffer = Buffer.alloc(0);
133
    /**
134
     * Is the debugger currently stopped at a line of code in the program
135
     */
699✔
136
    public isStopped = false;
137
    private requestIdSequence = 1;
699✔
138
    private activeRequests = new Map<number, ProtocolRequest>();
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>;
69✔
145
    public once(eventName: 'breakpoints-verified'): Promise<BreakpointsVerifiedEvent>;
69✔
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>;
69✔
149
    public once(eventName: 'response'): Promise<ProtocolResponse>;
69✔
150
    public once(eventName: 'update'): Promise<ProtocolUpdate>;
69✔
151
    public once(eventName: 'protocol-version'): Promise<ProtocolVersionDetails>;
152
    public once(eventName: 'handshake-verified'): Promise<HandshakeResponse>;
153
    public once(eventName: string) {
69✔
154
        return new Promise((resolve) => {
155
            const disconnect = this.on(eventName as Parameters<DebugProtocolClient['on']>[0], (...args) => {
156
                disconnect();
157
                resolve(...args);
69✔
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);
69✔
164
    public on(eventName: 'breakpoints-verified', handler: (event: BreakpointsVerifiedEvent) => void);
69✔
165
    public on(eventName: 'response', handler: (response: ProtocolResponse) => void);
166
    public on(eventName: 'update', handler: (update: ProtocolUpdate) => void);
69✔
167
    /**
69✔
168
     * The raw data from the server socket. You probably don't need this...
237✔
169
     */
237✔
170
    public on(eventName: 'data', handler: (data: Buffer) => void);
171
    public on<T = AllThreadsStoppedUpdate | ThreadAttachedUpdate>(eventName: 'runtime-error' | 'suspend', handler: (data: T) => void);
237✔
172
    public on(eventName: 'io-output', handler: (output: string) => void);
237✔
173
    public on(eventName: 'protocol-version', handler: (data: ProtocolVersionDetails) => void);
237✔
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);
237✔
177
    public on(eventName: string, handler: (payload: any) => void) {
178
        this.emitter.on(eventName, handler);
179
        return () => {
69✔
180
            this.emitter.removeListener(eventName, handler);
181
        };
1✔
182
    }
1✔
183

184
    private emit(eventName: 'compile-error', response: CompileErrorUpdate);
1!
185
    private emit(eventName: 'response', response: ProtocolResponse);
1✔
186
    private emit(eventName: 'update', update: ProtocolUpdate);
1✔
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);
69✔
190
    private emit(eventName: 'app-exit' | 'cannot-continue' | 'close' | 'handshake-verified' | 'io-output' | 'protocol-version' | 'start', data?);
191
    private async emit(eventName: string, data?) {
UNCOV
192
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
×
193
        await util.sleep(0);
UNCOV
194
        //in rare cases, this event is fired after the debugger has closed, so make sure the event emitter still exists
×
UNCOV
195
        this.emitter.emit(eventName, data);
×
UNCOV
196
    }
×
197

198
    /**
69!
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
69✔
200
     * whenever there is an early-terminated debug session
201
     */
69✔
202
    private async establishControlConnection() {
203
        const connection = await new Promise<Net.Socket>((resolve) => {
204
            const socket = new Net.Socket({
205
                allowHalfOpen: false
206
            });
207
            util.registerSocketLogging(socket, this.logger, 'ControlSocket');
69✔
208

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

220
    /**
221
     * A queue for processing the incoming buffer, every transmission at a time
222
     */
223
    private bufferQueue = new ActionQueue();
405✔
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
     */
405✔
229
    public async connect(sendHandshake = true): Promise<boolean> {
3✔
230
        this.logger.log('connect', this.options);
231

405✔
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();
234

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

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

257
        // Don't forget to catch error, for your own sake.
258
        this.controlSocket.once('error', (error) => {
2✔
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?.();
1✔
263
            this.controlSocket = undefined;
2✔
264
            this.emit('close');
265
        });
1✔
266

2✔
267
        if (sendHandshake) {
268
            await this.sendHandshake();
3✔
269
        }
4✔
270
        return true;
271
    }
272

8✔
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(
278
            HandshakeRequest.fromJson({
279
                magic: DebugProtocolClient.DEBUGGER_MAGIC
8✔
280
            })
7✔
281
        );
7✔
282
        return response;
7✔
283
    }
284

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

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

1✔
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) {
23✔
298
        let obj = {
299
            type: type,
300
            timestamp: new Date().toISOString(),
69✔
301
            buffer: buffer.toJSON()
302
        };
23✔
303
        if (type === 'io') {
304
            (obj as any).text = buffer.toString();
305
        }
306
        this.logger.log('[[bufferLog]]:', JSON.stringify(obj));
23✔
307
    }
22✔
308

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

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

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

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

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

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

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

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

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

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

388
    public async threads() {
47✔
389
        const result = await this.processThreadsRequest(
390
            ThreadsRequest.fromJson({
391
                requestId: this.requestIdSequence++,
24✔
392
                //only ask the device for per-thread identity info on firmware that supports it
11✔
393
                includeIdentityInfo: this.capabilities?.supportsThreadIdentityInfo
394
            })
395
        );
396
        return result;
397
    }
398

399
    public async processThreadsRequest(request: ThreadsRequest) {
11✔
400
        if (this.isStopped) {
401
            let result = await this.sendRequest<ThreadsResponse>(request);
402

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

1✔
425
    public async setExceptionBreakpoints(filters: ExceptionBreakpoint[]): Promise<SetExceptionBreakpointsResponse> {
426
        return this.processRequest<SetExceptionBreakpointsResponse>(
427
            SetExceptionBreakpointsRequest.fromJson({
428
                requestId: this.requestIdSequence++,
5✔
429
                breakpoints: filters
1✔
430
            })
431
        );
4!
432
    }
4✔
433

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

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

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

490
        //if there was an issue, build a "fake" variables response for several known situationsm or throw nicer errors
491
        if (util.hasNonNullishProperty(response?.data.errorData)) {
492
            let variable = {
493
                value: null,
494
                isContainer: false,
495
                isConst: false,
496
                refCount: 0,
2✔
497
                childCount: 0
1✔
498
            } as Variable;
499
            const simulatedResponse = VariablesResponse.fromJson({
500
                ...response.data,
501
                variables: [variable]
502
            });
19!
503

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

13!
522
            if (!util.isNullish(response.data.errorData.missingKeyIndex)) {
523
                const { missingKeyIndex } = response.data.errorData;
12✔
524
                //leftmost var is uninitialized, and we tried to read it
2✔
525
                //ex: variablePathEntries = [`notThere`]
526
                if (variablePathEntries.length === 1 && missingKeyIndex === 0) {
527
                    variable.name = variablePathEntries[0];
10✔
528
                    variable.type = VariableType.Uninitialized;
529
                    return simulatedResponse;
530
                }
12!
531

10✔
532
                //leftmost var was uninitialized, and tried to read a prop on it
533
                //ex: variablePathEntries = ["notThere", "definitelyNotThere"]
534
                if (missingKeyIndex === 0 && variablePathEntries.length > 1) {
535
                    throw new Error(`Cannot read '${variablePathEntries[missingKeyIndex + 1]}' on type 'Uninitialized'`);
12✔
536
                }
537

7✔
538
                if (variablePathEntries.length > 1 && missingKeyIndex > 0) {
539
                    await loadParentVarInfo(missingKeyIndex);
540

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

7!
558
            //this flow is when the item at the index exists, but is set to literally `invalid` or is an unknown value
5✔
559
            if (!util.isNullish(response.data.errorData.invalidPathIndex)) {
560
                const { invalidPathIndex } = response.data.errorData;
2✔
561

562
                //leftmost var is literal `invalid`, tried to read it
563
                if (variablePathEntries.length === 1 && invalidPathIndex === 0) {
564
                    variable.name = variablePathEntries[variablePathEntries.length - 1];
565
                    variable.type = VariableType.Invalid;
566
                    return simulatedResponse;
567
                }
4!
568

UNCOV
569
                if (
×
570
                    variablePathEntries.length > 1 &&
UNCOV
571
                    invalidPathIndex > 0 &&
×
572
                    //only do this logic if the invalid item is not the last item
UNCOV
573
                    invalidPathIndex < variablePathEntries.length - 1
×
574
                ) {
UNCOV
575
                    await loadParentVarInfo(invalidPathIndex + 1);
×
576

UNCOV
577
                    //leftmost var is set to literal `invalid`, tried to read prop
×
578
                    if (invalidPathIndex === 0 && variablePathEntries.length > 1) {
579
                        throw new Error(`Cannot read '${variablePathEntries[invalidPathIndex + 1]}' on type '${parentVarTypeText}'`);
×
580
                    }
UNCOV
581

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

602
    private async processVariablesRequest(request: VariablesRequest) {
603
        if (this.isStopped && request.data.threadIndex > -1) {
166✔
604
            return this.sendRequest<VariablesResponse>(request);
605
        }
606
    }
607

166✔
608
    public async executeCommand(sourceCode: string, stackFrameIndex: number = this.stackFrameIndex, threadIndex: number = this.primaryThread) {
166✔
609
        return this.processExecuteRequest(
166✔
610
            ExecuteRequest.fromJson({
164✔
611
                requestId: this.requestIdSequence++,
163✔
612
                threadIndex: threadIndex,
163✔
613
                stackFrameIndex: stackFrameIndex,
163✔
614
                sourceCode: sourceCode
615
            })
616
        );
166✔
617
    }
166✔
618

165✔
619
    private async processExecuteRequest(request: ExecuteRequest) {
165✔
620
        if (this.isStopped && request.data.threadIndex > -1) {
165✔
621
            return this.sendRequest<ExecuteV3Response>(request);
165✔
622
        }
623
    }
624

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

642
            const useConditionalBreakpoints = (
643
                //does this protocol version support conditional breakpoints?
644
                this.capabilities?.supportsConditionalBreakpoints &&
645
                //is there at least one conditional breakpoint present?
420!
646
                !!breakpoints.find(x => !!x?.conditionalExpression?.trim())
420✔
647
            );
166!
648

649
            let response: AddBreakpointsResponse | AddConditionalBreakpointsResponse;
254✔
650
            if (useConditionalBreakpoints) {
75!
651
                response = await this.sendRequest<AddBreakpointsResponse>(
652
                    AddConditionalBreakpointsRequest.fromJson(json)
653
                );
179✔
654
            } else {
147!
655
                response = await this.sendRequest<AddBreakpointsResponse>(
656
                    AddBreakpointsRequest.fromJson(json)
657
                );
32!
658
            }
659

660
            //if the device does not support breakpoint verification, then auto-mark all of these as verified
661
            if (!this.capabilities?.supportsBreakpointVerification) {
662
                this.emit('breakpoints-verified', {
663
                    breakpoints: response.data.breakpoints
239✔
664
                });
239✔
665
            }
239✔
666
            return response;
667
        }
668
        return AddBreakpointsResponse.fromBuffer(null);
669
    }
670

239!
671
    public async listBreakpoints(): Promise<ListBreakpointsResponse> {
239✔
672
        return this.processRequest<ListBreakpointsResponse>(
673
            ListBreakpointsRequest.fromJson({
674
                requestId: this.requestIdSequence++
239✔
675
            })
1✔
676
        );
677
    }
1✔
678

1✔
679
    /**
680
     * Remove breakpoints having the specified IDs
238!
UNCOV
681
     */
×
UNCOV
682
    public async removeBreakpoints(breakpointIds: number[]) {
×
683
        return this.processRemoveBreakpointsRequest(
684
            RemoveBreakpointsRequest.fromJson({
685
                requestId: this.requestIdSequence++,
238✔
686
                breakpointIds: breakpointIds
238✔
687
            })
16✔
688
        );
689
    }
690

238!
691
    private async processRemoveBreakpointsRequest(request: RemoveBreakpointsRequest) {
692
        //throw out null breakpoints
238✔
693
        request.data.breakpointIds = request.data.breakpointIds?.filter(x => typeof x === 'number') ?? [];
75✔
694

75✔
695
        if (request.data.breakpointIds?.length > 0) {
75✔
696
            return this.sendRequest<RemoveBreakpointsResponse>(request);
697
        }
698
        return RemoveBreakpointsResponse.fromJson(null);
699
    }
700

701
    /**
163✔
702
     * Given a request, process it in the proper fashion. This is mostly used for external mocking/testing of
163✔
703
     * this client, but it should force the client to flow in the same fashion as a live debug session
163✔
704
     */
705
    public async processRequest<TResponse extends ProtocolResponse>(request: ProtocolRequest): Promise<TResponse> {
706
        switch (request?.constructor.name) {
707
            case ContinueRequest.name:
708
                return this.processContinueRequest(request as ContinueRequest) as any;
238✔
709

710
            case ExecuteRequest.name:
711
                return this.processExecuteRequest(request as ExecuteRequest) as any;
UNCOV
712

×
713
            case HandshakeRequest.name:
714
                return this.processHandshakeRequest(request as HandshakeRequest) as any;
715

716
            case RemoveBreakpointsRequest.name:
717
                return this.processRemoveBreakpointsRequest(request as RemoveBreakpointsRequest) as any;
718

719
            case StackTraceRequest.name:
720
                return this.processStackTraceRequest(request as StackTraceRequest) as any;
239✔
721

722
            case StepRequest.name:
723
                return this.processStepRequest(request as StepRequest) as any;
69✔
724

725
            case StopRequest.name:
69✔
726
                return this.processStopRequest(request as StopRequest) as any;
1✔
727

728
            case ThreadsRequest.name:
69!
729
                return this.processThreadsRequest(request as ThreadsRequest) as any;
69✔
730

69✔
731
            case VariablesRequest.name:
732
                return this.processVariablesRequest(request as VariablesRequest) as any;
×
733

734
            //for all other request types, there's no custom business logic, so just pipe them through manually
170!
735
            case AddBreakpointsRequest.name:
736
            case AddConditionalBreakpointsRequest.name:
737
            case ExitChannelRequest.name:
170✔
738
            case ListBreakpointsRequest.name:
16✔
739
            case SetExceptionBreakpointsRequest.name:
740
                return this.sendRequest(request);
741
            default:
154✔
742
                this.logger.log('Unknown request type. Sending anyway...', request);
743
                //unknown request type. try sending it as-is
79✔
744
                return this.sendRequest(request);
79✔
745
        }
78✔
746
    }
747

748
    /**
749
     * Send a request to the roku device, and get a promise that resolves once we have received the response
75✔
750
     */
751
    private async sendRequest<T extends ProtocolResponse | ProtocolUpdate>(request: ProtocolRequest) {
752
        request = (await this.plugins.emit('beforeSendRequest', {
753
            client: this,
78!
754
            request: request
755
        })).request;
756

757
        this.activeRequests.set(request.data.requestId, request);
758

9✔
759
        return new Promise<T>((resolve, reject) => {
760
            let unsubscribe = this.on('response', (response) => {
1✔
761
                if (response.data.requestId === request.data.requestId) {
762
                    unsubscribe();
763
                    this.activeRequests.delete(request.data.requestId);
12✔
764
                    resolve(response as T);
765
                }
3✔
766
            });
767

3✔
768
            this.logEvent(request);
769
            if (this.controlSocket) {
12✔
770
                const buffer = request.toBuffer();
771
                this.writeToBufferLog('client-to-server', buffer);
17✔
772
                this.controlSocket.write(buffer);
773
                void this.plugins.emit('afterSendRequest', {
21✔
774
                    client: this,
UNCOV
775
                    request: request
×
776
                });
UNCOV
777
            } else {
×
778
                reject(
779
                    new Error(`Control socket was closed - Command: ${Command[request.data.command]}`)
780
                );
781
            }
782
        });
783
    }
75✔
784

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

UNCOV
800
    /**
×
801
     * A counter to help give a unique id to each update (mostly just for logging purposes)
UNCOV
802
     */
×
UNCOV
803
    private updateSequence = 1;
×
UNCOV
804

×
805
    private logEvent(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate) {
UNCOV
806
        const [, eventName, eventType] = /(.+?)((?:v\d+_?\d*)?(?:request|response|update))/ig.exec(event?.constructor.name) ?? [];
×
807
        if (isProtocolRequest(event)) {
808
            this.logger.log(`${eventName} ${event.data.requestId} (${eventType})`, event, `(${event?.constructor.name})`);
1✔
809
        } else if (isProtocolUpdate(event)) {
1!
810
            this.logger.log(`${eventName} ${this.updateSequence++} (${eventType})`, event, `(${event?.constructor.name})`);
1✔
811
        } else {
812
            if (event.data.errorCode === ErrorCode.OK) {
1✔
813
                this.logger.log(`${eventName} ${event.data.requestId} (${eventType})`, event, `(${event?.constructor.name})`);
814
            } else {
UNCOV
815
                this.logger.log(`[error] ${eventName} ${event.data.requestId} (${eventType})`, event, `(${event?.constructor.name})`);
×
UNCOV
816
            }
×
817
        }
UNCOV
818
    }
×
819

820
    private async process(): Promise<boolean> {
821
        try {
822
            this.logger.info('[process()]: buffer=', this.buffer.toJSON());
823

824
            let { responseOrUpdate } = await this.plugins.emit('provideResponseOrUpdate', {
825
                client: this,
75✔
826
                activeRequests: this.activeRequests,
75✔
827
                buffer: this.buffer
828
            });
829

830
            if (!responseOrUpdate) {
75✔
831
                responseOrUpdate = await this.getResponseOrUpdate(this.buffer);
71✔
832
            }
833

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

846
            //we have a valid event. Remove this data from the buffer
3!
847
            this.buffer = this.buffer.slice(responseOrUpdate.readOffset);
3✔
848

3✔
849
            if (responseOrUpdate.data.errorCode !== ErrorCode.OK) {
850
                this.logEvent(responseOrUpdate);
851
            }
4✔
852

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

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

×
UNCOV
898
        let genericResponse = this.watchPacketLength ? GenericV3Response.fromBuffer(buffer) : GenericResponse.fromBuffer(buffer);
×
899

900
        //if the response has a non-OK error code, we won't receive the expected response type,
901
        //so return the generic response
902
        if (genericResponse.success && genericResponse.data.errorCode !== ErrorCode.OK) {
903
            return genericResponse;
904
        }
905
        // a nonzero requestId means this is a response to a request that we sent
4✔
906
        if (genericResponse.data.requestId !== 0) {
907
            //requestId 0 means this is an update
3✔
908
            const request = this.activeRequests.get(genericResponse.data.requestId);
909
            if (request) {
910
                return DebugProtocolClient.getResponse(this.buffer, request.data.command);
3✔
911
            }
912
        } else {
3✔
913
            return this.getUpdate(this.buffer);
914
        }
3✔
915
    }
3✔
916

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

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

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

68✔
989
    private handleUpdateQueue = new ActionQueue();
UNCOV
990

×
991
    /**
992
     * Handle/process any received updates from the debug protocol
993
     */
994
    private async handleUpdate(update: ProtocolUpdate) {
995
        return this.handleUpdateQueue.run(async () => {
996
            update = (await this.plugins.emit('beforeHandleUpdate', {
997
                client: this,
75✔
998
                update: update
999
            })).update;
1000

1001
            if (update instanceof AllThreadsStoppedUpdate || update instanceof ThreadAttachedUpdate) {
75!
1002
                this.isStopped = true;
75✔
1003

75✔
1004
                let eventName: 'runtime-error' | 'suspend';
1005
                //TODO should caught runtime error remap to runtime error?
1006
                if (update.data.stopReason === StopReason.RuntimeError || update.data.stopReason === StopReason.CaughtRuntimeError) {
1007
                    eventName = 'runtime-error';
75✔
1008
                } else {
68✔
1009
                    eventName = 'suspend';
1010
                }
68✔
1011

1012
                const isValidStopReason = [StopReason.RuntimeError, StopReason.Break, StopReason.StopStatement, StopReason.CaughtRuntimeError].includes(update.data.stopReason);
1013

1014
                if (update instanceof AllThreadsStoppedUpdate && isValidStopReason) {
68!
1015
                    this.primaryThread = update.data.threadIndex;
1016
                    this.stackFrameIndex = 0;
68!
1017
                    this.emit(eventName, update);
68!
1018
                } else if (update instanceof ThreadAttachedUpdate && isValidStopReason) {
68✔
1019
                    this.primaryThread = update.data.threadIndex;
68✔
1020
                    this.emit(eventName, update);
1021
                }
1022

1023
            } else if (isIOPortOpenedUpdate(update)) {
1024
                this.connectToIoPort(update);
1025
            }
×
1026
            return true;
1027
        });
75✔
1028
    }
3✔
1029

1030
    /**
3✔
1031
     * Verify all the handshake data
3✔
1032
     */
1033
    private async verifyHandshake(response: HandshakeResponse | HandshakeV3Response): Promise<boolean> {
1034
        if (DebugProtocolClient.DEBUGGER_MAGIC === response.data.magic) {
1035
            this.logger.log('Magic is valid.');
3!
UNCOV
1036

×
1037
            this.capabilities = new ProtocolCapabilities(response.data.protocolVersion);
UNCOV
1038
            this.logger.log('Protocol Version:', this.protocolVersion);
×
UNCOV
1039

×
1040
            this.watchPacketLength = semver.satisfies(this.protocolVersion, '>=3.0.0');
1041
            this.isHandshakeComplete = true;
UNCOV
1042

×
UNCOV
1043
            let handshakeVerified = true;
×
1044

1045
            if (semver.satisfies(this.protocolVersion, this.supportedVersionRange)) {
1046
                this.logger.log('supported');
3!
1047
                this.emit('protocol-version', {
1048
                    message: `Protocol Version ${this.protocolVersion} is supported!`,
3!
1049
                    errorCode: PROTOCOL_ERROR_CODES.SUPPORTED
3!
1050
                });
3✔
1051
            } else if (semver.gtr(this.protocolVersion, this.supportedVersionRange)) {
3✔
1052
                this.logger.log('roku-debug has not been tested against protocol version', this.protocolVersion);
1053
                this.emit('protocol-version', {
1054
                    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`,
1055
                    errorCode: PROTOCOL_ERROR_CODES.NOT_TESTED
2✔
1056
                });
2✔
1057
            } else {
1058
                this.logger.log('not supported');
1059
                this.emit('protocol-version', {
1060
                    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`,
1061
                    errorCode: PROTOCOL_ERROR_CODES.NOT_SUPPORTED
1062
                });
420!
1063
                await this.emit('close');
1064
                handshakeVerified = false;
2✔
1065
            }
1066

1067
            this.emit('handshake-verified', handshakeVerified);
1068
            return handshakeVerified;
1069
        } else {
1070
            this.logger.log('Closing connection due to bad debugger magic', response.data.magic);
×
1071
            this.emit('handshake-verified', false);
1072
            await this.emit('close');
2✔
1073
            return false;
1074
        }
1075
    }
1076

1077
    /**
1078
     * When the debugger emits the IOPortOpenedUpdate, we need to immediately connect to the IO port to start receiving that data
656!
1079
     */
1080
    private connectToIoPort(update: IOPortOpenedUpdate) {
2✔
1081
        if (update.success) {
1082
            // Create a new TCP client.
1083
            this.ioSocket = new Net.Socket({
1084
                allowHalfOpen: false
1085
            });
1086
            util.registerSocketLogging(this.ioSocket, this.logger, 'IoSocket');
1087

1088
            // Send a connection request to the server.
1089
            this.logger.log(`Connect to IO Port ${this.options.host}:${update.data.port}`);
1090

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

1101
                    let lastPartialLine = '';
1102
                    this.ioSocket.on('data', (buffer) => {
1103
                        this.writeToBufferLog('io', buffer);
1104
                        let logResult = util.handleLogFragments(lastPartialLine, buffer.toString());
1105

1106
                        // Save any remaining partial line for the next event
1107
                        lastPartialLine = logResult.remaining;
1108
                        if (logResult.completed) {
1109
                            // Emit the completed io string.
1110
                            this.emit('io-output', logResult.completed);
1111
                        } else {
1112
                            this.logger.debug('Buffer was split', lastPartialLine);
1113
                        }
1114
                    });
1115

1116
                    this.ioSocket.on('close', () => {
1117
                        this.logger.log('IO socket closed');
1118
                        this.ioSocketClosed.tryResolve();
1119
                    });
1120

1121
                    // Don't forget to catch error, for your own sake.
1122
                    this.ioSocket.once('error', (err) => {
1123
                        this.ioSocket.end();
1124
                        this.logger.error(err);
1125
                    });
1126
                });
1127
                return true;
1128
            } catch (e) {
1129
                this.logger.error(`Failed to connect to IO socket at ${this.options.host}:${update.data.port}`, e);
1130
                this.emit('app-exit');
1131
            }
1132
        }
1133
        return false;
1134
    }
1135

1136
    /**
1137
     * Destroy this instance, shutting down any sockets or other long-running items and cleaning up.
1138
     * @param immediate if true, all sockets are immediately closed and do not gracefully shut down
1139
     */
1140
    public async destroy(immediate = false) {
1141
        await this.shutdown(immediate);
1142
    }
1143

1144
    private shutdownPromise: Promise<void>;
1145
    private async shutdown(immediate = false) {
1146
        if (this.shutdownPromise === undefined) {
1147
            this.logger.log('[shutdown] shutting down');
1148
            this.shutdownPromise = this._shutdown(immediate);
1149
        } else {
1150
            this.logger.log(`[shutdown] Tried to call .shutdown() again. Returning the same promise`);
1151
        }
1152
        return this.shutdownPromise;
1153
    }
1154

1155
    private async _shutdown(immediate = false) {
1156
        let exitChannelTimeout = this.options?.exitChannelTimeout ?? 30_000;
1157
        let shutdownTimeMax = this.options?.shutdownTimeout ?? 10_000;
1158
        //if immediate is true, this is an instant shutdown force. don't wait for anything
1159
        if (immediate) {
1160
            exitChannelTimeout = 0;
1161
            shutdownTimeMax = 0;
1162
        }
1163

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

1180
        await Promise.all([
1181
            this.destroyControlSocket(shutdownTimeMax),
1182
            this.destroyIOSocket(shutdownTimeMax, immediate)
1183
        ]);
1184
        this.emitter?.removeAllListeners();
1185
        this.buffer = Buffer.alloc(0);
1186
        this.bufferQueue.destroy();
1187
    }
1188

1189
    private isDestroyingControlSocket = false;
1190

1191
    private async destroyControlSocket(timeout: number) {
1192
        if (this.controlSocket && !this.isDestroyingControlSocket) {
1193
            this.isDestroyingControlSocket = true;
1194

1195
            //wait for the controlSocket to be closed
1196
            await Promise.race([
1197
                this.controlSocketClosed.promise,
1198
                util.sleep(timeout)
1199
            ]);
1200

1201
            this.logger.log('[destroy] controlSocket is: ', this.controlSocketClosed.isResolved ? 'closed' : 'not closed');
1202

1203
            //destroy the controlSocket
1204
            this.controlSocket?.removeAllListeners();
1205
            this.controlSocket?.destroy();
1206
            this.controlSocket = undefined;
1207
            this.isDestroyingControlSocket = false;
1208
        }
1209
    }
1210

1211
    private isDestroyingIOSocket = false;
1212

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

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

1237
            this.logger.log('[destroy] ioSocket is: ', this.ioSocketClosed.isResolved ? 'closed' : 'not closed');
1238

1239
            //destroy the ioSocket
1240
            this.ioSocket?.removeAllListeners?.();
1241
            this.ioSocket?.destroy?.();
1242
            this.ioSocket = undefined;
1243
            this.isDestroyingIOSocket = false;
1244
        }
1245
    }
1246
}
1247

1248
export interface ProtocolVersionDetails {
1249
    message: string;
1250
    errorCode: PROTOCOL_ERROR_CODES;
1251
}
1252

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

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

1299
    /**
1300
     * The number of milliseconds that the client should wait during a shutdown request before forcefully terminating the sockets
1301
     */
1302
    shutdownTimeout?: number;
1303

1304
    /**
1305
     * The max time the client will wait for the `exit channel` response before forcefully terminating the sockets
1306
     */
1307
    exitChannelTimeout?: number;
1308
}
1309

1310
/**
1311
 * Is the event a ProtocolRequest
1312
 */
1313
export function isProtocolRequest(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate): event is ProtocolRequest {
1314
    return event?.constructor?.name.endsWith('Request') && event?.data?.requestId > 0;
1315
}
1316

1317
/**
1318
 * Is the event a ProtocolResponse
1319
 */
1320
export function isProtocolResponse(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate): event is ProtocolResponse {
1321
    return event?.constructor?.name.endsWith('Response') && event?.data?.requestId !== 0;
1322
}
1323

1324
/**
1325
 * Is the event a ProtocolUpdate update
1326
 */
1327
export function isProtocolUpdate(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate): event is ProtocolUpdate {
1328
    return event?.constructor?.name.endsWith('Update') && event?.data?.requestId === 0;
1329
}
1330

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

© 2026 Coveralls, Inc