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

rokucommunity / roku-debug / 31207253445

07 Aug 2026 06:30PM UTC coverage: 73.212%. First build
31207253445

Pull #399

github

web-flow
Merge 4acc3ec47 into f0c56045c
Pull Request #399: Roku Cloud Emulator support

3901 of 5576 branches covered (69.96%)

Branch coverage included in aggregate %.

147 of 165 new or added lines in 11 files covered. (89.09%)

6069 of 8042 relevant lines covered (75.47%)

49.25 hits per line

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

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

53
export class DebugProtocolClient {
2✔
54

55
    public logger = logger.createLogger(`[dpclient]`);
77✔
56

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

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

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

73
    private addCorePlugin() {
74
        this.plugins.add({
77✔
75
            onUpdate: (event) => {
76
                return this.handleUpdate(event.update);
75✔
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;
77✔
85
    public connectedToIoPort = false;
77✔
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;
77✔
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;
300!
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>();
77✔
111

112
    private emitter = new EventEmitter();
77✔
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: RokuDeploySocket;
117
    /**
118
     * Promise that is resolved when the control socket is closed
119
     */
120
    private controlSocketClosed = defer<void>();
77✔
121
    /**
122
     * A socket where the debug server will send stdio
123
     */
124
    private ioSocket: RokuDeploySocket;
125
    /**
126
     * Resolves when the ioSocket has closed
127
     */
128
    private ioSocketClosed = defer<void>();
77✔
129
    /**
130
     * The buffer where all unhandled data will be stored until successfully consumed
131
     */
132
    private buffer = Buffer.alloc(0);
77✔
133
    /**
134
     * Is the debugger currently stopped at a line of code in the program
135
     */
136
    public isStopped = false;
77✔
137
    private requestIdSequence = 1;
77✔
138
    private activeRequests = new Map<number, ProtocolRequest>();
77✔
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) => {
57✔
155
            const disconnect = this.on(eventName as Parameters<DebugProtocolClient['on']>[0], (...args) => {
57✔
156
                disconnect();
57✔
157
                resolve(...args);
57✔
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);
376✔
179
        return () => {
376✔
180
            this.emitter.removeListener(eventName, handler);
220✔
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);
700✔
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);
700✔
196
    }
197

198
    /**
199
     * Create the transport used to reach one of the device's debug protocol ports (the control port
200
     * or the io port): a raw tcp socket for a local device, or the RCE instance api's
201
     * `/api/v0/ports/<port>` WebSocket for a cloud device. Extracted to a protected method so tests
202
     * can substitute a fake socket.
203
     */
204
    protected createRokuDeploySocket(options: SocketOptions): RokuDeploySocket {
205
        return createRokuDeploySocket(options);
72✔
206
    }
207

208
    /**
209
     * A collection of sockets created when trying to connect to the debug protocol's control socket. We keep these around for quicker tear-down
210
     * whenever there is an early-terminated debug session
211
     */
212
    private async establishControlConnection() {
213
        const connection = await new Promise<RokuDeploySocket>((resolve) => {
69✔
214
            const socket = this.createRokuDeploySocket({
69✔
215
                device: this.options.device,
216
                port: this.options.controlPort
217
            });
218
            util.registerSocketLogging(socket, this.logger, 'ControlSocket');
69✔
219

220
            socket.connect(() => {
69✔
221
                resolve(socket);
69✔
222
            });
223
        });
224
        await this.plugins.emit('onServerConnected', {
69✔
225
            client: this,
226
            server: connection
227
        });
228
        return connection;
69✔
229
    }
230

231
    /**
232
     * A queue for processing the incoming buffer, every transmission at a time
233
     */
234
    private bufferQueue = new ActionQueue();
77✔
235

236
    /**
237
     * Connect to the debug server.
238
     * @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
239
     */
240
    public async connect(sendHandshake = true): Promise<boolean> {
69✔
241
        this.logger.log('connect', this.options);
69✔
242

243
        // If there is no error, the server has accepted the request and created a new dedicated control socket
244
        this.controlSocket = await this.establishControlConnection();
69✔
245

246
        this.controlSocket.on('data', (data: Buffer) => {
69✔
247
            this.writeToBufferLog('server-to-client', data);
237✔
248
            this.emit('data', data);
237✔
249
            //queue up processing the new data, chunk by chunk
250
            void this.bufferQueue.run(async () => {
237✔
251
                this.buffer = Buffer.concat([this.buffer, data] as any[]);
237✔
252
                while (this.buffer.length > 0 && await this.process()) {
237✔
253
                    //the loop condition is the actual work
254
                }
255
                return true;
237✔
256
            });
257
        });
258

259
        this.controlSocket.on('close', () => {
69✔
260
            this.logger.log('Control socket closed');
1✔
261
            this.controlSocketClosed.tryResolve();
1✔
262
            //destroy the control socket since it just closed on us...
263
            this.controlSocket?.destroy?.();
1!
264
            this.controlSocket = undefined;
1✔
265
            this.emit('app-exit');
1✔
266
        });
267

268
        // Don't forget to catch error, for your own sake.
269
        this.controlSocket.once('error', (error) => {
69✔
270
            //the Roku closed the connection for some unknown reason...
271
            this.logger.error(`error on control port`, error);
×
272
            //destroy the control socket since it errored
273
            this.controlSocket?.destroy?.();
×
274
            this.controlSocket = undefined;
×
275
            this.emit('close');
×
276
        });
277

278
        if (sendHandshake) {
69!
279
            await this.sendHandshake();
69✔
280
        }
281
        return true;
69✔
282
    }
283

284
    /**
285
     * Send the initial handshake request, and wait for the handshake response
286
     */
287
    public async sendHandshake(): Promise<HandshakeV3Response | HandshakeResponse> {
288
        const response = await this.processHandshakeRequest(
69✔
289
            HandshakeRequest.fromJson({
290
                magic: DebugProtocolClient.DEBUGGER_MAGIC
291
            })
292
        );
293
        return response;
69✔
294
    }
295

296
    private async processHandshakeRequest(request: HandshakeRequest): Promise<HandshakeV3Response | HandshakeResponse> {
297
        //send the magic, which triggers the debug session
298
        this.logger.log('Sending magic to server');
69✔
299

300
        //send the handshake request, and wait for the handshake response from the device
301
        return this.sendRequest<HandshakeV3Response | HandshakeResponse>(request);
69✔
302
    }
303

304
    /**
305
     * Write a specific buffer log entry to the logger, which, when file logging is enabled
306
     * can be extracted and processed through the DebugProtocolClientReplaySession
307
     */
308
    private writeToBufferLog(type: 'server-to-client' | 'client-to-server' | 'io', buffer: Buffer) {
309
        let obj = {
405✔
310
            type: type,
311
            timestamp: new Date().toISOString(),
312
            buffer: buffer.toJSON()
313
        };
314
        if (type === 'io') {
405✔
315
            (obj as any).text = buffer.toString();
3✔
316
        }
317
        this.logger.log('[[bufferLog]]:', JSON.stringify(obj));
405✔
318
    }
319

320
    public continue() {
321
        return this.processContinueRequest(
2✔
322
            ContinueRequest.fromJson({
323
                requestId: this.requestIdSequence++
324
            })
325
        );
326
    }
327

328
    private async processContinueRequest(request: ContinueRequest) {
329
        if (this.isStopped) {
2✔
330
            this.isStopped = false;
1✔
331
            return this.sendRequest<GenericResponse>(request);
1✔
332
        }
333
    }
334

335
    public pause(force = false) {
2✔
336
        return this.processStopRequest(
2✔
337
            StopRequest.fromJson({
338
                requestId: this.requestIdSequence++
339
            }),
340
            force
341
        );
342
    }
343

344
    private async processStopRequest(request: StopRequest, force = false) {
×
345
        if (this.isStopped === false || force) {
2✔
346
            return this.sendRequest<GenericResponse>(request);
1✔
347
        }
348
    }
349

350
    /**
351
     * Send the "exit channel" command, which will tell the debug session to immediately quit
352
     */
353
    public async exitChannel() {
354
        return this.sendRequest<GenericResponse>(
2✔
355
            ExitChannelRequest.fromJson({
356
                requestId: this.requestIdSequence++
357
            })
358
        );
359
    }
360

361
    public async stepIn(threadIndex: number = this.primaryThread) {
1✔
362
        return this.step(StepType.Line, threadIndex);
2✔
363
    }
364

365
    public async stepOver(threadIndex: number = this.primaryThread) {
1✔
366
        return this.step(StepType.Over, threadIndex);
2✔
367
    }
368

369
    public async stepOut(threadIndex: number = this.primaryThread) {
3✔
370
        return this.step(StepType.Out, threadIndex);
4✔
371
    }
372

373
    private async step(stepType: StepType, threadIndex: number): Promise<GenericResponse> {
374
        return this.processStepRequest(
8✔
375
            StepRequest.fromJson({
376
                requestId: this.requestIdSequence++,
377
                stepType: stepType,
378
                threadIndex: threadIndex
379
            })
380
        );
381
    }
382

383
    private async processStepRequest(request: StepRequest) {
384
        if (this.isStopped) {
8✔
385
            this.isStopped = false;
7✔
386
            let stepResult = await this.sendRequest<GenericResponse>(request);
7✔
387
            if (stepResult.data.errorCode === ErrorCode.OK) {
7✔
388
                //Step command received and will recieve a separate update when threads have reattached
389
            } else if (stepResult.data.errorCode === ErrorCode.CANT_CONTINUE) {
1!
390
                // there is a CANT_CONTINUE error code but we can likely treat all errors like a CANT_CONTINUE
391
                this.emit('cannot-continue');
1✔
392
            }
393
            return stepResult;
7✔
394
        } else {
395
            this.logger.log('[processStepRequest] skipped because debugger is not paused');
1✔
396
        }
397
    }
398

399
    public async threads() {
400
        const result = await this.processThreadsRequest(
23✔
401
            ThreadsRequest.fromJson({
402
                requestId: this.requestIdSequence++,
403
                //only ask the device for per-thread identity info on firmware that supports it
404
                includeIdentityInfo: this.capabilities?.supportsThreadIdentityInfo
69✔
405
            })
406
        );
407
        return result;
23✔
408
    }
409

410
    public async processThreadsRequest(request: ThreadsRequest) {
411
        if (this.isStopped) {
23✔
412
            let result = await this.sendRequest<ThreadsResponse>(request);
22✔
413

414
            if (result.data.errorCode === ErrorCode.OK) {
22✔
415
                //older versions of the debug protocol had issues with maintaining the active thread, so our workaround is to keep track of it elsewhere
416
                if (this.capabilities?.enableThreadHoppingWorkaround) {
21✔
417
                    //ignore the `isPrimary` flag on threads
418
                    this.logger.debug(`Ignoring the 'isPrimary' flag from threads because protocol version 3.0.0 and lower has a bug`);
1✔
419
                } else {
420
                    //trust the debug protocol's `isPrimary` flag on threads
421
                    for (let i = 0; i < result.data.threads.length; i++) {
20✔
422
                        let thread = result.data.threads[i];
21✔
423
                        if (thread.isPrimary) {
21✔
424
                            this.primaryThread = i;
20✔
425
                            break;
20✔
426
                        }
427
                    }
428
                }
429
            }
430
            return result;
22✔
431
        } else {
432
            this.logger.log('[processThreadsRequest] skipped because not stopped');
1✔
433
        }
434
    }
435

436
    public async setExceptionBreakpoints(filters: ExceptionBreakpoint[]): Promise<SetExceptionBreakpointsResponse> {
437
        return this.processRequest<SetExceptionBreakpointsResponse>(
×
438
            SetExceptionBreakpointsRequest.fromJson({
439
                requestId: this.requestIdSequence++,
440
                breakpoints: filters
441
            })
442
        );
443
    }
444

445
    /**
446
     * Get the stackTrace from the device IF currently stopped
447
     */
448
    public async getStackTrace(threadIndex: number = this.primaryThread) {
2✔
449
        return this.processStackTraceRequest(
21✔
450
            StackTraceRequest.fromJson({
451
                requestId: this.requestIdSequence++,
452
                threadIndex: threadIndex
453
            })
454
        );
455
    }
456

457
    private async processStackTraceRequest(request: StackTraceRequest) {
458
        if (!this.isStopped) {
21✔
459
            this.logger.log('[getStackTrace] skipped because debugger is not paused');
1✔
460
        } else if (request?.data?.threadIndex > -1) {
20!
461
            return this.sendRequest<StackTraceResponse>(request);
19✔
462
        } else {
463
            this.logger.log(`[getStackTrace] skipped because ${request?.data?.threadIndex} is not valid threadIndex`);
1!
464
        }
465
    }
466

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

501
        //if there was an issue, build a "fake" variables response for several known situationsm or throw nicer errors
502
        if (util.hasNonNullishProperty(response?.data.errorData)) {
24✔
503
            let variable = {
11✔
504
                value: null,
505
                isContainer: false,
506
                isConst: false,
507
                refCount: 0,
508
                childCount: 0
509
            } as Variable;
510
            const simulatedResponse = VariablesResponse.fromJson({
11✔
511
                ...response.data,
512
                variables: [variable]
513
            });
514

515
            let parentVarType: VariableType;
516
            let parentVarTypeText: string;
517
            const loadParentVarInfo = async (index: number) => {
11✔
518
                //fetch the variable one level back from the bad one to get its type
519
                const parentVar = await this.getVariables(
7✔
520
                    variablePathEntries.slice(0, index),
521
                    stackFrameIndex,
522
                    threadIndex
523
                );
524
                parentVarType = parentVar?.data?.variables?.[0]?.type;
7!
525
                parentVarTypeText = parentVarType;
7✔
526
                //convert `roSGNode; Node` to `roSGNode (Node)`
527
                if (parentVarType === VariableType.SubtypedObject) {
7✔
528
                    const chunks = parentVar?.data?.variables?.[0]?.value?.toString().split(';').map(x => x.trim());
2!
529
                    parentVarTypeText = `${chunks[0]} (${chunks[1]})`;
1✔
530
                }
531
            };
532

533
            if (!util.isNullish(response.data.errorData.missingKeyIndex)) {
11✔
534
                const { missingKeyIndex } = response.data.errorData;
6✔
535
                //leftmost var is uninitialized, and we tried to read it
536
                //ex: variablePathEntries = [`notThere`]
537
                if (variablePathEntries.length === 1 && missingKeyIndex === 0) {
6✔
538
                    variable.name = variablePathEntries[0];
1✔
539
                    variable.type = VariableType.Uninitialized;
1✔
540
                    return simulatedResponse;
1✔
541
                }
542

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

549
                if (variablePathEntries.length > 1 && missingKeyIndex > 0) {
4!
550
                    await loadParentVarInfo(missingKeyIndex);
4✔
551

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

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

573
                //leftmost var is literal `invalid`, tried to read it
574
                if (variablePathEntries.length === 1 && invalidPathIndex === 0) {
5✔
575
                    variable.name = variablePathEntries[variablePathEntries.length - 1];
1✔
576
                    variable.type = VariableType.Invalid;
1✔
577
                    return simulatedResponse;
1✔
578
                }
579

580
                if (
4✔
581
                    variablePathEntries.length > 1 &&
11✔
582
                    invalidPathIndex > 0 &&
583
                    //only do this logic if the invalid item is not the last item
584
                    invalidPathIndex < variablePathEntries.length - 1
585
                ) {
586
                    await loadParentVarInfo(invalidPathIndex + 1);
3✔
587

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

593
                    // prop at the end doesn't exist. Treat like `invalid`.
594
                    // ex: variablePathEntries = ['there', 'notThere']
595
                    if (
3!
596
                        invalidPathIndex === variablePathEntries.length - 1 &&
3!
597
                        [VariableType.AssociativeArray, VariableType.SubtypedObject].includes(parentVarType)
598
                    ) {
599
                        variable.name = variablePathEntries[variablePathEntries.length - 1];
×
600
                        variable.type = VariableType.Invalid;
×
601
                        variable.value = 'Invalid (not defined)';
×
602
                        return simulatedResponse;
×
603
                    }
604
                }
605
                //prop in the middle is missing, tried reading a prop on it
606
                // ex: variablePathEntries = ["there", "thereButSetToInvalid", "definitelyNotThere"]
607
                throw new Error(`Cannot read '${variablePathEntries[invalidPathIndex + 1]}'${parentVarType ? ` on type '${parentVarTypeText}'` : ''}`);
4✔
608
            }
609
        }
610
        return response;
13✔
611
    }
612

613
    private async processVariablesRequest(request: VariablesRequest) {
614
        if (this.isStopped && request.data.threadIndex > -1) {
24✔
615
            return this.sendRequest<VariablesResponse>(request);
23✔
616
        }
617
    }
618

619
    public async executeCommand(sourceCode: string, stackFrameIndex: number = this.stackFrameIndex, threadIndex: number = this.primaryThread) {
2✔
620
        return this.processExecuteRequest(
2✔
621
            ExecuteRequest.fromJson({
622
                requestId: this.requestIdSequence++,
623
                threadIndex: threadIndex,
624
                stackFrameIndex: stackFrameIndex,
625
                sourceCode: sourceCode
626
            })
627
        );
628
    }
629

630
    private async processExecuteRequest(request: ExecuteRequest) {
631
        if (this.isStopped && request.data.threadIndex > -1) {
2✔
632
            return this.sendRequest<ExecuteV3Response>(request);
1✔
633
        }
634
    }
635

636
    public async addBreakpoints(breakpoints: Array<BreakpointSpec & { componentLibraryName?: string }>): Promise<AddBreakpointsResponse> {
637
        const enableComponentLibrarySpecificBreakpoints = this.capabilities?.enableComponentLibrarySpecificBreakpoints;
19!
638
        if (breakpoints?.length > 0) {
19!
639
            const json = {
12✔
640
                requestId: this.requestIdSequence++,
641
                breakpoints: breakpoints.map(x => {
642
                    let breakpoint = {
15✔
643
                        ...x,
644
                        ignoreCount: x.hitCount
645
                    };
646
                    if (enableComponentLibrarySpecificBreakpoints && breakpoint.componentLibraryName) {
15✔
647
                        breakpoint.filePath = breakpoint.filePath.replace(/^pkg:\//i, `lib:/${breakpoint.componentLibraryName}/`);
1✔
648
                    }
649
                    return breakpoint;
15✔
650
                })
651
            };
652

653
            const useConditionalBreakpoints = (
654
                //does this protocol version support conditional breakpoints?
655
                this.capabilities?.supportsConditionalBreakpoints &&
12!
656
                //is there at least one conditional breakpoint present?
657
                !!breakpoints.find(x => !!x?.conditionalExpression?.trim())
13!
658
            );
659

660
            let response: AddBreakpointsResponse | AddConditionalBreakpointsResponse;
661
            if (useConditionalBreakpoints) {
12✔
662
                response = await this.sendRequest<AddBreakpointsResponse>(
2✔
663
                    AddConditionalBreakpointsRequest.fromJson(json)
664
                );
665
            } else {
666
                response = await this.sendRequest<AddBreakpointsResponse>(
10✔
667
                    AddBreakpointsRequest.fromJson(json)
668
                );
669
            }
670

671
            //if the device does not support breakpoint verification, then auto-mark all of these as verified
672
            if (!this.capabilities?.supportsBreakpointVerification) {
12!
673
                this.emit('breakpoints-verified', {
10✔
674
                    breakpoints: response.data.breakpoints
675
                });
676
            }
677
            return response;
12✔
678
        }
679
        return AddBreakpointsResponse.fromBuffer(null);
7✔
680
    }
681

682
    public async listBreakpoints(): Promise<ListBreakpointsResponse> {
683
        return this.processRequest<ListBreakpointsResponse>(
4✔
684
            ListBreakpointsRequest.fromJson({
685
                requestId: this.requestIdSequence++
686
            })
687
        );
688
    }
689

690
    /**
691
     * Remove breakpoints having the specified IDs
692
     */
693
    public async removeBreakpoints(breakpointIds: number[]) {
694
        return this.processRemoveBreakpointsRequest(
7✔
695
            RemoveBreakpointsRequest.fromJson({
696
                requestId: this.requestIdSequence++,
697
                breakpointIds: breakpointIds
698
            })
699
        );
700
    }
701

702
    private async processRemoveBreakpointsRequest(request: RemoveBreakpointsRequest) {
703
        //throw out null breakpoints
704
        request.data.breakpointIds = request.data.breakpointIds?.filter(x => typeof x === 'number') ?? [];
9✔
705

706
        if (request.data.breakpointIds?.length > 0) {
7!
707
            return this.sendRequest<RemoveBreakpointsResponse>(request);
5✔
708
        }
709
        return RemoveBreakpointsResponse.fromJson(null);
2✔
710
    }
711

712
    /**
713
     * Given a request, process it in the proper fashion. This is mostly used for external mocking/testing of
714
     * this client, but it should force the client to flow in the same fashion as a live debug session
715
     */
716
    public async processRequest<TResponse extends ProtocolResponse>(request: ProtocolRequest): Promise<TResponse> {
717
        switch (request?.constructor.name) {
4!
718
            case ContinueRequest.name:
719
                return this.processContinueRequest(request as ContinueRequest) as any;
×
720

721
            case ExecuteRequest.name:
722
                return this.processExecuteRequest(request as ExecuteRequest) as any;
×
723

724
            case HandshakeRequest.name:
725
                return this.processHandshakeRequest(request as HandshakeRequest) as any;
×
726

727
            case RemoveBreakpointsRequest.name:
728
                return this.processRemoveBreakpointsRequest(request as RemoveBreakpointsRequest) as any;
×
729

730
            case StackTraceRequest.name:
731
                return this.processStackTraceRequest(request as StackTraceRequest) as any;
×
732

733
            case StepRequest.name:
734
                return this.processStepRequest(request as StepRequest) as any;
×
735

736
            case StopRequest.name:
737
                return this.processStopRequest(request as StopRequest) as any;
×
738

739
            case ThreadsRequest.name:
740
                return this.processThreadsRequest(request as ThreadsRequest) as any;
×
741

742
            case VariablesRequest.name:
743
                return this.processVariablesRequest(request as VariablesRequest) as any;
×
744

745
            //for all other request types, there's no custom business logic, so just pipe them through manually
746
            case AddBreakpointsRequest.name:
747
            case AddConditionalBreakpointsRequest.name:
748
            case ExitChannelRequest.name:
749
            case ListBreakpointsRequest.name:
750
            case SetExceptionBreakpointsRequest.name:
751
                return this.sendRequest(request);
4✔
752
            default:
753
                this.logger.log('Unknown request type. Sending anyway...', request);
×
754
                //unknown request type. try sending it as-is
755
                return this.sendRequest(request);
×
756
        }
757
    }
758

759
    /**
760
     * Send a request to the roku device, and get a promise that resolves once we have received the response
761
     */
762
    private async sendRequest<T extends ProtocolResponse | ProtocolUpdate>(request: ProtocolRequest) {
763
        request = (await this.plugins.emit('beforeSendRequest', {
166✔
764
            client: this,
765
            request: request
766
        })).request;
767

768
        this.activeRequests.set(request.data.requestId, request);
166✔
769

770
        return new Promise<T>((resolve, reject) => {
166✔
771
            let unsubscribe = this.on('response', (response) => {
166✔
772
                if (response.data.requestId === request.data.requestId) {
164✔
773
                    unsubscribe();
163✔
774
                    this.activeRequests.delete(request.data.requestId);
163✔
775
                    resolve(response as T);
163✔
776
                }
777
            });
778

779
            this.logEvent(request);
166✔
780
            if (this.controlSocket) {
166✔
781
                const buffer = request.toBuffer();
165✔
782
                this.writeToBufferLog('client-to-server', buffer);
165✔
783
                this.controlSocket.write(buffer);
165✔
784
                void this.plugins.emit('afterSendRequest', {
165✔
785
                    client: this,
786
                    request: request
787
                });
788
            } else {
789
                reject(
1✔
790
                    new Error(`Control socket was closed - Command: ${Command[request.data.command]}`)
791
                );
792
            }
793
        });
794
    }
795

796
    /**
797
     * Sometimes a request arrives that we don't understand. If that's the case, this function can be used
798
     * to discard that entire response by discarding `packet_length` number of bytes
799
     */
800
    private discardNextResponseOrUpdate() {
801
        const response = GenericV3Response.fromBuffer(this.buffer);
1✔
802
        if (response.success && response.data.packetLength > 0) {
1!
803
            this.logger.warn(`Unsupported response or updated encountered. Discarding ${response.data.packetLength} bytes:`, JSON.stringify(
×
804
                this.buffer.slice(0, response.data.packetLength + 1).toJSON().data
805
            ));
806
            //we have a valid event. Clear the buffer of this data
807
            this.buffer = this.buffer.slice(response.data.packetLength);
×
808
        }
809
    }
810

811
    /**
812
     * A counter to help give a unique id to each update (mostly just for logging purposes)
813
     */
814
    private updateSequence = 1;
77✔
815

816
    private logEvent(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate) {
817
        const [, eventName, eventType] = /(.+?)((?:v\d+_?\d*)?(?:request|response|update))/ig.exec(event?.constructor.name) ?? [];
420!
818
        if (isProtocolRequest(event)) {
420✔
819
            this.logger.log(`${eventName} ${event.data.requestId} (${eventType})`, event, `(${event?.constructor.name})`);
166!
820
        } else if (isProtocolUpdate(event)) {
254✔
821
            this.logger.log(`${eventName} ${this.updateSequence++} (${eventType})`, event, `(${event?.constructor.name})`);
75!
822
        } else {
823
            if (event.data.errorCode === ErrorCode.OK) {
179✔
824
                this.logger.log(`${eventName} ${event.data.requestId} (${eventType})`, event, `(${event?.constructor.name})`);
147!
825
            } else {
826
                this.logger.log(`[error] ${eventName} ${event.data.requestId} (${eventType})`, event, `(${event?.constructor.name})`);
32!
827
            }
828
        }
829
    }
830

831
    private async process(): Promise<boolean> {
832
        try {
239✔
833
            this.logger.info('[process()]: buffer=', this.buffer.toJSON());
239✔
834

835
            let { responseOrUpdate } = await this.plugins.emit('provideResponseOrUpdate', {
239✔
836
                client: this,
837
                activeRequests: this.activeRequests,
838
                buffer: this.buffer
839
            });
840

841
            if (!responseOrUpdate) {
239!
842
                responseOrUpdate = await this.getResponseOrUpdate(this.buffer);
239✔
843
            }
844

845
            //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)
846
            if (!responseOrUpdate) {
239✔
847
                this.logger.info('Unable to convert buffer into anything meaningful', this.buffer);
1✔
848
                //if we have packet length, and we have at least that many bytes, throw out this message so we can hopefully recover
849
                this.discardNextResponseOrUpdate();
1✔
850
                return false;
1✔
851
            }
852
            if (!responseOrUpdate.success || responseOrUpdate.data.packetLength > this.buffer.length) {
238!
853
                this.logger.log(`event parse failed. ${responseOrUpdate?.data?.packetLength} bytes required, ${this.buffer.length} bytes available`);
×
854
                return false;
×
855
            }
856

857
            //we have a valid event. Remove this data from the buffer
858
            this.buffer = this.buffer.slice(responseOrUpdate.readOffset);
238✔
859

860
            if (responseOrUpdate.data.errorCode !== ErrorCode.OK) {
238✔
861
                this.logEvent(responseOrUpdate);
16✔
862
            }
863

864
            //we got a result
865
            if (responseOrUpdate) {
238!
866
                //emit the corresponding event
867
                if (isProtocolUpdate(responseOrUpdate)) {
238✔
868
                    this.logEvent(responseOrUpdate);
75✔
869
                    this.emit('update', responseOrUpdate);
75✔
870
                    await this.plugins.emit('onUpdate', {
75✔
871
                        client: this,
872
                        update: responseOrUpdate
873
                    });
874
                } else {
875
                    this.logEvent(responseOrUpdate);
163✔
876
                    this.emit('response', responseOrUpdate);
163✔
877
                    await this.plugins.emit('onResponse', {
163✔
878
                        client: this,
879
                        response: responseOrUpdate as any
880
                    });
881
                }
882
                return true;
238✔
883
            }
884
        } catch (e) {
885
            this.logger.error(`process() failed:`, e);
×
886
        }
887
    }
888

889
    /**
890
     * Given a buffer, try to parse into a specific ProtocolResponse or ProtocolUpdate
891
     */
892
    public async getResponseOrUpdate(buffer: Buffer): Promise<ProtocolResponse | ProtocolUpdate> {
893
        //if we haven't seen a handshake yet, try to convert the buffer into a handshake
894
        if (!this.isHandshakeComplete) {
239✔
895
            let handshake: HandshakeV3Response | HandshakeResponse;
896
            //try building the v3 handshake response first
897
            handshake = HandshakeV3Response.fromBuffer(buffer);
69✔
898
            //we didn't get a v3 handshake. try building an older handshake response
899
            if (!handshake.success) {
69✔
900
                handshake = HandshakeResponse.fromBuffer(buffer);
1✔
901
            }
902
            if (handshake.success) {
69!
903
                await this.verifyHandshake(handshake);
69✔
904
                return handshake;
69✔
905
            }
906
            return;
×
907
        }
908

909
        let genericResponse = this.watchPacketLength ? GenericV3Response.fromBuffer(buffer) : GenericResponse.fromBuffer(buffer);
170!
910

911
        //if the response has a non-OK error code, we won't receive the expected response type,
912
        //so return the generic response
913
        if (genericResponse.success && genericResponse.data.errorCode !== ErrorCode.OK) {
170✔
914
            return genericResponse;
16✔
915
        }
916
        // a nonzero requestId means this is a response to a request that we sent
917
        if (genericResponse.data.requestId !== 0) {
154✔
918
            //requestId 0 means this is an update
919
            const request = this.activeRequests.get(genericResponse.data.requestId);
79✔
920
            if (request) {
79✔
921
                return DebugProtocolClient.getResponse(this.buffer, request.data.command);
78✔
922
            }
923
        } else {
924
            return this.getUpdate(this.buffer);
75✔
925
        }
926
    }
927

928
    public static getResponse(buffer: Buffer, command: Command) {
929
        switch (command) {
78!
930
            case Command.Stop:
931
            case Command.Continue:
932
            case Command.Step:
933
            case Command.ExitChannel:
934
                return GenericV3Response.fromBuffer(buffer);
9✔
935
            case Command.Execute:
936
                return ExecuteV3Response.fromBuffer(buffer);
1✔
937
            case Command.AddBreakpoints:
938
            case Command.AddConditionalBreakpoints:
939
                return AddBreakpointsResponse.fromBuffer(buffer);
12✔
940
            case Command.ListBreakpoints:
941
                return ListBreakpointsResponse.fromBuffer(buffer);
3✔
942
            case Command.RemoveBreakpoints:
943
                return RemoveBreakpointsResponse.fromBuffer(buffer);
3✔
944
            case Command.Variables:
945
                return VariablesResponse.fromBuffer(buffer);
12✔
946
            case Command.StackTrace:
947
                return StackTraceV3Response.fromBuffer(buffer);
17✔
948
            case Command.Threads:
949
                return ThreadsResponse.fromBuffer(buffer);
21✔
950
            case Command.SetExceptionBreakpoints:
951
                return SetExceptionBreakpointsResponse.fromBuffer(buffer);
×
952
            default:
953
                return undefined;
×
954
        }
955
    }
956

957
    public getUpdate(buffer: Buffer): ProtocolUpdate {
958
        //read the update_type from the buffer (save some buffer parsing time by narrowing to the exact update type)
959
        const updateTypeCode = buffer.readUInt32LE(
75✔
960
            // if the protocol supports packet length, then update_type is bytes 12-16. Otherwise, it's bytes 8-12
961
            this.watchPacketLength ? 12 : 8
75!
962
        );
963
        const updateType = UpdateTypeCode[updateTypeCode] as UpdateType;
75✔
964

965
        this.logger?.log('getUpdate(): update Type:', updateType);
75!
966
        switch (updateType) {
75!
967
            case UpdateType.IOPortOpened:
968
                //TODO handle this
969
                return IOPortOpenedUpdate.fromBuffer(buffer);
3✔
970
            case UpdateType.AllThreadsStopped:
971
                const allThreadsStoppedResponse = AllThreadsStoppedUpdate.fromBuffer(buffer);
68✔
972
                return allThreadsStoppedResponse;
68✔
973
            case UpdateType.ThreadAttached:
974
                const threadAttachedResponse = ThreadAttachedUpdate.fromBuffer(buffer);
3✔
975
                return threadAttachedResponse;
3✔
976
            case UpdateType.BreakpointError:
977
                //we do nothing with breakpoint errors at this time.
978
                return BreakpointErrorUpdate.fromBuffer(buffer);
×
979
            case UpdateType.CompileError:
980
                let compileErrorUpdate = CompileErrorUpdate.fromBuffer(buffer);
×
981
                if (compileErrorUpdate?.data?.errorMessage !== '') {
×
982
                    this.emit('compile-error', compileErrorUpdate);
×
983
                }
984
                return compileErrorUpdate;
×
985
            case UpdateType.BreakpointVerified:
986
                let response = BreakpointVerifiedUpdate.fromBuffer(buffer);
1✔
987
                if (response?.data?.breakpoints?.length > 0) {
1!
988
                    this.emit('breakpoints-verified', response.data);
1✔
989
                }
990
                return response;
1✔
991
            case UpdateType.ExceptionBreakpointError:
992
                //we do nothing with exception breakpoint errors at this time.
993
                const exceptionBreakpointErrorUpdate = ExceptionBreakpointErrorUpdate.fromBuffer(buffer);
×
994
                return exceptionBreakpointErrorUpdate;
×
995
            default:
996
                return undefined;
×
997
        }
998
    }
999

1000
    private handleUpdateQueue = new ActionQueue();
77✔
1001

1002
    /**
1003
     * Handle/process any received updates from the debug protocol
1004
     */
1005
    private async handleUpdate(update: ProtocolUpdate) {
1006
        return this.handleUpdateQueue.run(async () => {
75✔
1007
            update = (await this.plugins.emit('beforeHandleUpdate', {
75✔
1008
                client: this,
1009
                update: update
1010
            })).update;
1011

1012
            if (update instanceof AllThreadsStoppedUpdate || update instanceof ThreadAttachedUpdate) {
75✔
1013
                this.isStopped = true;
71✔
1014

1015
                let eventName: 'runtime-error' | 'suspend';
1016
                //TODO should caught runtime error remap to runtime error?
1017
                if (update.data.stopReason === StopReason.RuntimeError || update.data.stopReason === StopReason.CaughtRuntimeError) {
71!
1018
                    eventName = 'runtime-error';
×
1019
                } else {
1020
                    eventName = 'suspend';
71✔
1021
                }
1022

1023
                const isValidStopReason = [StopReason.RuntimeError, StopReason.Break, StopReason.StopStatement, StopReason.CaughtRuntimeError].includes(update.data.stopReason);
71✔
1024

1025
                if (update instanceof AllThreadsStoppedUpdate && isValidStopReason) {
71✔
1026
                    this.primaryThread = update.data.threadIndex;
68✔
1027
                    this.stackFrameIndex = 0;
68✔
1028
                    this.emit(eventName, update);
68✔
1029
                } else if (update instanceof ThreadAttachedUpdate && isValidStopReason) {
3!
1030
                    this.primaryThread = update.data.threadIndex;
3✔
1031
                    this.emit(eventName, update);
3✔
1032
                }
1033

1034
            } else if (isIOPortOpenedUpdate(update)) {
4✔
1035
                this.connectToIoPort(update);
3✔
1036
            }
1037
            return true;
75✔
1038
        });
1039
    }
1040

1041
    /**
1042
     * Verify all the handshake data
1043
     */
1044
    private async verifyHandshake(response: HandshakeResponse | HandshakeV3Response): Promise<boolean> {
1045
        if (DebugProtocolClient.DEBUGGER_MAGIC === response.data.magic) {
69!
1046
            this.logger.log('Magic is valid.');
69✔
1047

1048
            this.capabilities = new ProtocolCapabilities(response.data.protocolVersion);
69✔
1049
            this.logger.log('Protocol Version:', this.protocolVersion);
69✔
1050

1051
            this.watchPacketLength = semver.satisfies(this.protocolVersion, '>=3.0.0');
69✔
1052
            this.isHandshakeComplete = true;
69✔
1053

1054
            let handshakeVerified = true;
69✔
1055

1056
            if (semver.satisfies(this.protocolVersion, this.supportedVersionRange)) {
69!
1057
                this.logger.log('supported');
69✔
1058
                this.emit('protocol-version', {
69✔
1059
                    message: `Protocol Version ${this.protocolVersion} is supported!`,
1060
                    errorCode: PROTOCOL_ERROR_CODES.SUPPORTED
1061
                });
1062
            } else if (semver.gtr(this.protocolVersion, this.supportedVersionRange)) {
×
1063
                this.logger.log('roku-debug has not been tested against protocol version', this.protocolVersion);
×
1064
                this.emit('protocol-version', {
×
1065
                    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`,
1066
                    errorCode: PROTOCOL_ERROR_CODES.NOT_TESTED
1067
                });
1068
            } else {
1069
                this.logger.log('not supported');
×
1070
                this.emit('protocol-version', {
×
1071
                    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`,
1072
                    errorCode: PROTOCOL_ERROR_CODES.NOT_SUPPORTED
1073
                });
1074
                await this.emit('close');
×
1075
                handshakeVerified = false;
×
1076
            }
1077

1078
            this.emit('handshake-verified', handshakeVerified);
69✔
1079
            return handshakeVerified;
69✔
1080
        } else {
1081
            this.logger.log('Closing connection due to bad debugger magic', response.data.magic);
×
1082
            this.emit('handshake-verified', false);
×
1083
            await this.emit('close');
×
1084
            return false;
×
1085
        }
1086
    }
1087

1088
    /**
1089
     * When the debugger emits the IOPortOpenedUpdate, we need to immediately connect to the IO port to start receiving that data
1090
     */
1091
    private connectToIoPort(update: IOPortOpenedUpdate) {
1092
        if (update.success) {
4✔
1093
            // Create a new client socket to the io port the device just opened
1094
            this.ioSocket = this.createRokuDeploySocket({
3✔
1095
                device: this.options.device,
1096
                port: update.data.port
1097
            });
1098
            util.registerSocketLogging(this.ioSocket, this.logger, 'IoSocket');
3✔
1099

1100
            // Send a connection request to the server.
1101
            this.logger.log(`Connect to IO Port ${update.data.port}`);
3✔
1102

1103
            //sometimes the server shuts down before we had a chance to connect, so recover more gracefully
1104
            try {
3✔
1105
                this.ioSocket.connect(() => {
3✔
1106
                    // If there is no error, the server has accepted the request
1107
                    this.logger.log('TCP connection established with the IO Port.');
3✔
1108
                    this.connectedToIoPort = true;
3✔
1109

1110
                    let lastPartialLine = '';
3✔
1111
                    this.ioSocket.on('data', (buffer: Buffer) => {
3✔
1112
                        this.writeToBufferLog('io', buffer);
3✔
1113
                        let logResult = util.handleLogFragments(lastPartialLine, buffer.toString());
3✔
1114

1115
                        // Save any remaining partial line for the next event
1116
                        lastPartialLine = logResult.remaining;
3✔
1117
                        if (logResult.completed) {
3✔
1118
                            // Emit the completed io string.
1119
                            this.emit('io-output', logResult.completed);
2✔
1120
                        } else {
1121
                            this.logger.debug('Buffer was split', lastPartialLine);
1✔
1122
                        }
1123
                    });
1124

1125
                    this.ioSocket.on('close', () => {
3✔
1126
                        this.logger.log('IO socket closed');
3✔
1127
                        this.ioSocketClosed.tryResolve();
3✔
1128
                    });
1129

1130
                    // Don't forget to catch error, for your own sake.
1131
                    this.ioSocket.once('error', (err) => {
3✔
1132
                        this.ioSocket.end();
×
1133
                        this.logger.error(err);
×
1134
                    });
1135
                });
1136
                return true;
3✔
1137
            } catch (e) {
NEW
1138
                this.logger.error(`Failed to connect to IO socket at port ${update.data.port}`, e);
×
1139
                this.emit('app-exit');
×
1140
            }
1141
        }
1142
        return false;
1✔
1143
    }
1144

1145
    /**
1146
     * Destroy this instance, shutting down any sockets or other long-running items and cleaning up.
1147
     * @param immediate if true, all sockets are immediately closed and do not gracefully shut down
1148
     */
1149
    public async destroy(immediate = false) {
1✔
1150
        await this.shutdown(immediate);
79✔
1151
    }
1152

1153
    private shutdownPromise: Promise<void>;
1154
    private async shutdown(immediate = false) {
×
1155
        if (this.shutdownPromise === undefined) {
79✔
1156
            this.logger.log('[shutdown] shutting down');
75✔
1157
            this.shutdownPromise = this._shutdown(immediate);
75✔
1158
        } else {
1159
            this.logger.log(`[shutdown] Tried to call .shutdown() again. Returning the same promise`);
4✔
1160
        }
1161
        return this.shutdownPromise;
79✔
1162
    }
1163

1164
    private async _shutdown(immediate = false) {
×
1165
        let exitChannelTimeout = this.options?.exitChannelTimeout ?? 30_000;
75!
1166
        let shutdownTimeMax = this.options?.shutdownTimeout ?? 10_000;
75!
1167
        //if immediate is true, this is an instant shutdown force. don't wait for anything
1168
        if (immediate) {
75✔
1169
            exitChannelTimeout = 0;
74✔
1170
            shutdownTimeMax = 0;
74✔
1171
        }
1172

1173
        //tell the device to exit the channel (only if the device is still listening...)
1174
        if (this.controlSocket) {
75✔
1175
            try {
68✔
1176
                //ask the device to terminate the debug session. We have to wait for this to come back.
1177
                //The device might be running unstoppable code, so this might take a while. Wait for the device to send back
1178
                //the response before we continue with the teardown process
1179
                await Promise.race([
68✔
1180
                    immediate
68✔
1181
                        ? Promise.resolve(null)
1182
                        : this.exitChannel().finally(() => this.logger.log('exit channel completed')),
×
1183
                    //if the exit channel request took this long to finish, something's terribly wrong
1184
                    util.sleep(exitChannelTimeout)
1185
                ]);
1186
            } finally { }
1187
        }
1188

1189
        await Promise.all([
75✔
1190
            this.destroyControlSocket(shutdownTimeMax),
1191
            this.destroyIOSocket(shutdownTimeMax, immediate)
1192
        ]);
1193
        this.emitter?.removeAllListeners();
75!
1194
        this.buffer = Buffer.alloc(0);
75✔
1195
        this.bufferQueue.destroy();
75✔
1196
    }
1197

1198
    private isDestroyingControlSocket = false;
77✔
1199

1200
    private async destroyControlSocket(timeout: number) {
1201
        if (this.controlSocket && !this.isDestroyingControlSocket) {
75✔
1202
            this.isDestroyingControlSocket = true;
68✔
1203

1204
            //wait for the controlSocket to be closed
1205
            await Promise.race([
68✔
1206
                this.controlSocketClosed.promise,
1207
                util.sleep(timeout)
1208
            ]);
1209

1210
            this.logger.log('[destroy] controlSocket is: ', this.controlSocketClosed.isResolved ? 'closed' : 'not closed');
68!
1211

1212
            //destroy the controlSocket
1213
            this.controlSocket?.removeAllListeners();
68!
1214
            this.controlSocket?.destroy();
68!
1215
            this.controlSocket = undefined;
68✔
1216
            this.isDestroyingControlSocket = false;
68✔
1217
        }
1218
    }
1219

1220
    private isDestroyingIOSocket = false;
77✔
1221

1222
    /**
1223
     * @param immediate if true, force close immediately instead of waiting for it to settle
1224
     */
1225
    private async destroyIOSocket(timeout: number, immediate = false) {
×
1226
        if (this.ioSocket && !this.isDestroyingIOSocket) {
75✔
1227
            this.isDestroyingIOSocket = true;
3✔
1228
            //wait for the ioSocket to be closed
1229
            await Promise.race([
3✔
1230
                this.ioSocketClosed.promise.then(() => this.logger.log('IO socket closed')),
3✔
1231
                util.sleep(timeout)
1232
            ]);
1233

1234
            //if the io socket is not closed, wait for it to at least settle
1235
            if (!this.ioSocketClosed.isCompleted && !immediate) {
3!
1236
                await new Promise<void>((resolve) => {
×
1237
                    const callback = debounce(() => {
×
1238
                        resolve();
×
1239
                    }, 250);
1240
                    //trigger the current callback once.
1241
                    callback();
×
1242
                    this.ioSocket?.on('drain', callback as () => void);
×
1243
                });
1244
            }
1245

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

1248
            //destroy the ioSocket
1249
            this.ioSocket?.removeAllListeners?.();
3!
1250
            this.ioSocket?.destroy?.();
3!
1251
            this.ioSocket = undefined;
3✔
1252
            this.isDestroyingIOSocket = false;
3✔
1253
        }
1254
    }
1255
}
1256

1257
export interface ProtocolVersionDetails {
1258
    message: string;
1259
    errorCode: PROTOCOL_ERROR_CODES;
1260
}
1261

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

1284
export interface ConstructorOptions {
1285
    /**
1286
     * The roku-deploy device config for the target device. This is the only way this client
1287
     * addresses the device: a local device connects raw tcp sockets to its debug protocol ports,
1288
     * and a Roku Cloud Emulator device reaches the same ports through its instance api's
1289
     * `/api/v0/ports/<port>` WebSocket routes.
1290
     */
1291
    device: DeviceConfig;
1292
    /**
1293
     * The port number used to send all debugger commands. This is static/unchanging for Roku devices,
1294
     * but is configurable here to support unit testing or alternate runtimes (i.e. https://www.npmjs.com/package/brs)
1295
     */
1296
    controlPort?: number;
1297
    /**
1298
     * The interval (in milliseconds) for how frequently the `connect`
1299
     * call should retry connecting to the control port. At the start of a debug session,
1300
     * the protocol debugger will start trying to connect the moment the channel is sideloaded,
1301
     * and keep trying until a successful connection is established or the debug session is terminated
1302
     * @default 250
1303
     */
1304
    controlConnectInterval?: number;
1305
    /**
1306
     * The maximum time (in milliseconds) the debugger will keep retrying connections.
1307
     * This is here to prevent infinitely pinging the Roku device.
1308
     */
1309
    controlConnectMaxTime?: number;
1310

1311
    /**
1312
     * The number of milliseconds that the client should wait during a shutdown request before forcefully terminating the sockets
1313
     */
1314
    shutdownTimeout?: number;
1315

1316
    /**
1317
     * The max time the client will wait for the `exit channel` response before forcefully terminating the sockets
1318
     */
1319
    exitChannelTimeout?: number;
1320
}
1321

1322
/**
1323
 * Is the event a ProtocolRequest
1324
 */
1325
export function isProtocolRequest(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate): event is ProtocolRequest {
2✔
1326
    return event?.constructor?.name.endsWith('Request') && event?.data?.requestId > 0;
420!
1327
}
1328

1329
/**
1330
 * Is the event a ProtocolResponse
1331
 */
1332
export function isProtocolResponse(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate): event is ProtocolResponse {
2✔
1333
    return event?.constructor?.name.endsWith('Response') && event?.data?.requestId !== 0;
×
1334
}
1335

1336
/**
1337
 * Is the event a ProtocolUpdate update
1338
 */
1339
export function isProtocolUpdate(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate): event is ProtocolUpdate {
2✔
1340
    return event?.constructor?.name.endsWith('Update') && event?.data?.requestId === 0;
656!
1341
}
1342

1343
export interface BreakpointsVerifiedEvent {
1344
    breakpoints: VerifiedBreakpoint[];
1345
}
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