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

rokucommunity / roku-debug / 26120672946

19 May 2026 07:37PM UTC coverage: 70.727% (+0.7%) from 70.049%
26120672946

Pull #351

github

web-flow
Merge eb0b2e542 into 5bbd82240
Pull Request #351: 0.23.8

3328 of 5046 branches covered (65.95%)

Branch coverage included in aggregate %.

5834 of 7908 relevant lines covered (73.77%)

35.01 hits per line

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

78.8
/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';
67✔
50
import { ExceptionBreakpointErrorUpdate } from '../events/updates/ExceptionBreakpointErrorUpdate';
51

67✔
52
export class DebugProtocolClient {
67✔
53

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

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

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

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

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

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

83
    public scriptTitle: string;
84
    public isHandshakeComplete = false;
85
    public connectedToIoPort = false;
67✔
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
     */
67✔
90
    public watchPacketLength = false;
67✔
91
    /**
67✔
92
     * Capability flags derived from the negotiated protocol version. Undefined until the
67✔
93
     * handshake completes, then assigned a fresh `ProtocolCapabilities` keyed off the version
67✔
94
     * the device reported.
95
     */
96
    public capabilities: ProtocolCapabilities | undefined;
97
    /**
201✔
98
     * The protocol version negotiated with the device during the handshake. Undefined until
99
     * the handshake has completed.
100
     */
67✔
101
    public get protocolVersion(): string | undefined {
102
        return this.capabilities?.protocolVersion;
103
    }
67✔
104
    public primaryThread: number;
105
    public stackFrameIndex: number;
67✔
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
     */
267!
116
    private controlSocket: Net.Socket;
117
    /**
118
     * Promise that is resolved when the control socket is closed
53✔
119
     */
53✔
120
    private controlSocketClosed = defer<void>();
53✔
121
    /**
53✔
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>();
305✔
129
    /**
305✔
130
     * The buffer where all unhandled data will be stored until successfully consumed
195✔
131
     */
132
    private buffer = Buffer.alloc(0);
133
    /**
134
     * Is the debugger currently stopped at a line of code in the program
135
     */
616✔
136
    public isStopped = false;
137
    private requestIdSequence = 1;
616✔
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>;
61✔
145
    public once(eventName: 'breakpoints-verified'): Promise<BreakpointsVerifiedEvent>;
61✔
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>;
61✔
149
    public once(eventName: 'response'): Promise<ProtocolResponse>;
61✔
150
    public once(eventName: 'update'): Promise<ProtocolUpdate>;
61✔
151
    public once(eventName: 'protocol-version'): Promise<ProtocolVersionDetails>;
152
    public once(eventName: 'handshake-verified'): Promise<HandshakeResponse>;
153
    public once(eventName: string) {
61✔
154
        return new Promise((resolve) => {
155
            const disconnect = this.on(eventName as Parameters<DebugProtocolClient['on']>[0], (...args) => {
156
                disconnect();
157
                resolve(...args);
61✔
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);
61✔
164
    public on(eventName: 'breakpoints-verified', handler: (event: BreakpointsVerifiedEvent) => void);
61✔
165
    public on(eventName: 'response', handler: (response: ProtocolResponse) => void);
166
    public on(eventName: 'update', handler: (update: ProtocolUpdate) => void);
61✔
167
    /**
61✔
168
     * The raw data from the server socket. You probably don't need this...
208✔
169
     */
208✔
170
    public on(eventName: 'data', handler: (data: Buffer) => void);
171
    public on<T = AllThreadsStoppedUpdate | ThreadAttachedUpdate>(eventName: 'runtime-error' | 'suspend', handler: (data: T) => void);
208✔
172
    public on(eventName: 'io-output', handler: (output: string) => void);
208✔
173
    public on(eventName: 'protocol-version', handler: (data: ProtocolVersionDetails) => void);
208✔
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);
208✔
177
    public on(eventName: string, handler: (payload: any) => void) {
178
        this.emitter.on(eventName, handler);
179
        return () => {
61✔
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);
61✔
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);
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);
×
196
    }
×
197

198
    /**
61!
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
61✔
200
     * whenever there is an early-terminated debug session
201
     */
61✔
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');
61✔
208

209
            socket.connect({ port: this.options.controlPort, host: this.options.host }, () => {
210
                resolve(socket);
61✔
211
            });
212
        });
213
        await this.plugins.emit('onServerConnected', {
214
            client: this,
61✔
215
            server: connection
216
        });
61✔
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();
354✔
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
     */
354✔
229
    public async connect(sendHandshake = true): Promise<boolean> {
3✔
230
        this.logger.log('connect', this.options);
231

354✔
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) => {
1✔
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
     */
16✔
297
    private writeToBufferLog(type: 'server-to-client' | 'client-to-server' | 'io', buffer: Buffer) {
298
        let obj = {
299
            type: type,
16✔
300
            timestamp: new Date().toISOString(),
301
            buffer: buffer.toJSON()
302
        };
303
        if (type === 'io') {
16✔
304
            (obj as any).text = buffer.toString();
15✔
305
        }
15✔
306
        this.logger.log('[[bufferLog]]:', JSON.stringify(obj));
307
    }
14!
308

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

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

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

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

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

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

354
    public async stepOver(threadIndex: number = this.primaryThread) {
355
        return this.step(StepType.Over, threadIndex);
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,
25✔
367
                threadIndex: threadIndex
368
            })
23✔
369
        );
370
    }
371

372
    private async processStepRequest(request: StepRequest) {
373
        if (this.isStopped) {
69!
374
            this.isStopped = false;
43✔
375
            let stepResult = await this.sendRequest<GenericResponse>(request);
376
            if (stepResult.data.errorCode === ErrorCode.OK) {
377
                //Step command received and will recieve a separate update when threads have reattached
84✔
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');
381
            }
382
            return stepResult;
45✔
383
        } else {
384
            this.logger.log('[processStepRequest] skipped because debugger is not paused');
385
        }
23✔
386
    }
11✔
387

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

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

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

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

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

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

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

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

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

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

6✔
532
                if (variablePathEntries.length > 1 && missingKeyIndex > 0) {
533
                    await loadParentVarInfo(missingKeyIndex);
534

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

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

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

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

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

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

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

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

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

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

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

643
            let response: AddBreakpointsResponse | AddConditionalBreakpointsResponse;
225✔
644
            if (useConditionalBreakpoints) {
67!
645
                response = await this.sendRequest<AddBreakpointsResponse>(
646
                    AddConditionalBreakpointsRequest.fromJson(json)
647
                );
158✔
648
            } else {
126!
649
                response = await this.sendRequest<AddBreakpointsResponse>(
650
                    AddBreakpointsRequest.fromJson(json)
651
                );
32!
652
            }
653

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

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

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

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

67✔
689
        if (request.data.breakpointIds?.length > 0) {
67✔
690
            return this.sendRequest<RemoveBreakpointsResponse>(request);
691
        }
692
        return RemoveBreakpointsResponse.fromJson(null);
693
    }
694

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

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

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

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

713
            case StackTraceRequest.name:
714
                return this.processStackTraceRequest(request as StackTraceRequest) as any;
210✔
715

716
            case StepRequest.name:
717
                return this.processStepRequest(request as StepRequest) as any;
61✔
718

719
            case StopRequest.name:
61✔
720
                return this.processStopRequest(request as StopRequest) as any;
1✔
721

722
            case ThreadsRequest.name:
61!
723
                return this.processThreadsRequest(request as ThreadsRequest) as any;
61✔
724

61✔
725
            case VariablesRequest.name:
726
                return this.processVariablesRequest(request as VariablesRequest) as any;
×
727

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

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

751
        this.activeRequests.set(request.data.requestId, request);
752

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

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

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

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

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

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

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

824
            if (!responseOrUpdate) {
67✔
825
                responseOrUpdate = await this.getResponseOrUpdate(this.buffer);
63✔
826
            }
827

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

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

3✔
843
            if (responseOrUpdate.data.errorCode !== ErrorCode.OK) {
844
                this.logEvent(responseOrUpdate);
845
            }
4✔
846

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

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

×
892
        let genericResponse = this.watchPacketLength ? GenericV3Response.fromBuffer(buffer) : GenericResponse.fromBuffer(buffer);
×
893

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

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

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

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

60!
983
    private handleUpdateQueue = new ActionQueue();
984

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

995
            if (update instanceof AllThreadsStoppedUpdate || update instanceof ThreadAttachedUpdate) {
66!
996
                this.isStopped = true;
66✔
997

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

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

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

1017
            } else if (isIOPortOpenedUpdate(update)) {
1018
                this.connectToIoPort(update);
1019
            }
×
1020
            return true;
1021
        });
66✔
1022
    }
3✔
1023

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

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

×
1034
            this.watchPacketLength = semver.satisfies(this.protocolVersion, '>=3.0.0');
1035
            this.isHandshakeComplete = true;
1036

×
1037
            let handshakeVerified = true;
×
1038

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1183
    private isDestroyingControlSocket = false;
1184

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

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

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

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

1205
    private isDestroyingIOSocket = false;
1206

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

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

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

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

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

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

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

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

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

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

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

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

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

© 2026 Coveralls, Inc