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

rokucommunity / roku-debug / #2026

02 Nov 2022 02:41PM UTC coverage: 56.194% (+7.0%) from 49.18%
#2026

push

TwitchBronBron
0.17.0

997 of 1898 branches covered (52.53%)

Branch coverage included in aggregate %.

2074 of 3567 relevant lines covered (58.14%)

15.56 hits per line

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

39.3
/src/debugProtocol/Debugger.ts
1
import * as Net from 'net';
1✔
2
import * as EventEmitter from 'eventemitter3';
1✔
3
import * as semver from 'semver';
1✔
4
import type {
5
    ThreadAttached,
6
    ThreadsStopped
7
} from './responses';
8
import {
1✔
9
    ConnectIOPortResponse,
10
    HandshakeResponse,
11
    HandshakeResponseV3,
12
    ProtocolEvent,
13
    ProtocolEventV3,
14
    StackTraceResponse,
15
    StackTraceResponseV3,
16
    ThreadsResponse,
17
    UndefinedResponse,
18
    UpdateThreadsResponse,
19
    VariableResponse
20
} from './responses';
21
import { PROTOCOL_ERROR_CODES, COMMANDS, STEP_TYPE, STOP_REASONS, VARIABLE_REQUEST_FLAGS, ERROR_CODES, UPDATE_TYPES } from './Constants';
1✔
22
import { SmartBuffer } from 'smart-buffer';
1✔
23
import { logger } from '../logging';
1✔
24
import { ExecuteResponseV3 } from './responses/ExecuteResponseV3';
1✔
25
import { ListBreakpointsResponse } from './responses/ListBreakpointsResponse';
1✔
26
import { AddBreakpointsResponse } from './responses/AddBreakpointsResponse';
1✔
27
import { RemoveBreakpointsResponse } from './responses/RemoveBreakpointsResponse';
1✔
28
import { util } from '../util';
1✔
29
import { BreakpointErrorUpdateResponse } from './responses/BreakpointErrorUpdateResponse';
1✔
30

31
export class Debugger {
1✔
32

33
    private logger = logger.createLogger(`[${Debugger.name}]`);
10✔
34

35
    public get isStopped(): boolean {
36
        return this.stopped;
2✔
37
    }
38

39
    // The highest tested version of the protocol we support.
40
    public supportedVersionRange = '<=3.0.0';
10✔
41

42
    constructor(
43
        options: ConstructorOptions
44
    ) {
45
        this.options = {
10✔
46
            controllerPort: 8081,
47
            host: undefined,
48
            //override the defaults with the options from parameters
49
            ...options ?? {}
30✔
50
        };
51
    }
52
    public static DEBUGGER_MAGIC = 'bsdebug'; // 64-bit = [b'bsdebug\0' little-endian]
1✔
53

54
    public scriptTitle: string;
55
    public handshakeComplete = false;
10✔
56
    public connectedToIoPort = false;
10✔
57
    public watchPacketLength = false;
10✔
58
    public protocolVersion: string;
59
    public primaryThread: number;
60
    public stackFrameIndex: number;
61

62
    private emitter = new EventEmitter();
10✔
63
    private controllerClient: Net.Socket;
64
    private ioClient: Net.Socket;
65
    private unhandledData: Buffer;
66
    private stopped = false;
10✔
67
    private totalRequests = 0;
10✔
68
    private activeRequests = {};
10✔
69
    private options: ConstructorOptions;
70

71
    /**
72
     * Prior to protocol v3.1.0, the Roku device would regularly set the wrong thread as "active",
73
     * so this flag lets us know if we should use our better-than-nothing workaround
74
     */
75
    private get enableThreadHoppingWorkaround() {
76
        return semver.satisfies(this.protocolVersion, '<3.1.0');
×
77
    }
78

79
    /**
80
     * Starting in protocol v3.1.0, component libary breakpoints must be added in the format `lib:/<library_name>/<filepath>`, but prior they didn't require this.
81
     * So this flag tells us which format to support
82
     */
83
    private get enableComponentLibrarySpecificBreakpoints() {
84
        return semver.satisfies(this.protocolVersion, '>=3.1.0');
×
85
    }
86

87
    /**
88
     * Starting in protocol v3.1.0, breakpoints can support conditional expressions. This flag indicates whether the current sessuion supports that functionality.
89
     */
90
    private get supportsConditionalBreakpoints() {
91
        return semver.satisfies(this.protocolVersion, '>=3.1.0');
×
92
    }
93

94
    /**
95
     * Get a promise that resolves after an event occurs exactly once
96
     */
97
    public once(eventName: 'app-exit' | 'cannot-continue' | 'close' | 'start'): Promise<void>;
98
    public once(eventName: 'data'): Promise<any>;
99
    public once(eventName: 'runtime-error' | 'suspend'): Promise<UpdateThreadsResponse>;
100
    public once(eventName: 'connected'): Promise<boolean>;
101
    public once(eventName: 'io-output'): Promise<string>;
102
    public once(eventName: 'protocol-version'): Promise<ProtocolVersionDetails>;
103
    public once(eventName: 'handshake-verified'): Promise<HandshakeResponse>;
104
    public once(eventName: string) {
105
        return new Promise((resolve) => {
5✔
106
            const disconnect = this.on(eventName as Parameters<Debugger['on']>[0], (...args) => {
5✔
107
                disconnect();
4✔
108
                resolve(...args);
4✔
109
            });
110
        });
111
    }
112

113
    /**
114
     * Subscribe to various events
115
     */
116
    public on(eventName: 'app-exit' | 'cannot-continue' | 'close' | 'start', handler: () => void);
117
    public on(eventName: 'data', handler: (data: any) => void);
118
    public on(eventName: 'runtime-error' | 'suspend', handler: (data: UpdateThreadsResponse) => void);
119
    public on(eventName: 'connected', handler: (connected: boolean) => void);
120
    public on(eventName: 'io-output', handler: (output: string) => void);
121
    public on(eventName: 'protocol-version', handler: (data: ProtocolVersionDetails) => void);
122
    public on(eventName: 'handshake-verified', handler: (data: HandshakeResponse) => void);
123
    // public on(eventname: 'rendezvous', handler: (output: RendezvousHistory) => void);
124
    // public on(eventName: 'runtime-error', handler: (error: BrightScriptRuntimeError) => void);
125
    public on(eventName: string, handler: (payload: any) => void) {
126
        this.emitter.on(eventName, handler);
5✔
127
        return () => {
5✔
128
            this.emitter?.removeListener(eventName, handler);
4!
129
        };
130
    }
131

132
    private emit(eventName: 'suspend' | 'runtime-error', data: UpdateThreadsResponse);
133
    private emit(eventName: 'app-exit' | 'cannot-continue' | 'close' | 'connected' | 'data' | 'handshake-verified' | 'io-output' | 'protocol-version' | 'start', data?);
134
    private emit(eventName: string, data?) {
135
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
136
        setTimeout(() => {
29✔
137
            //in rare cases, this event is fired after the debugger has closed, so make sure the event emitter still exists
138
            this.emitter?.emit(eventName, data);
29!
139
        }, 0);
140
    }
141

142
    private async establishControllerConnection() {
143
        const pendingSockets = new Set<Net.Socket>();
3✔
144
        const connection = await new Promise<Net.Socket>((resolve) => {
3✔
145
            util.setInterval((cancelInterval) => {
3✔
146
                const socket = new Net.Socket();
3✔
147
                pendingSockets.add(socket);
3✔
148
                socket.on('error', (error) => {
3✔
149
                    console.debug(Date.now(), 'Encountered an error connecting to the debug protocol socket. Ignoring and will try again soon', error);
×
150
                });
151
                socket.connect({ port: this.options.controllerPort, host: this.options.host }, () => {
3✔
152
                    cancelInterval();
3✔
153

154
                    this.logger.debug(`Connected to debug protocol controller port. Socket ${[...pendingSockets].indexOf(socket)} of ${pendingSockets.size} was the winner`);
3✔
155
                    //clean up all remaining pending sockets
156
                    for (const pendingSocket of pendingSockets) {
3✔
157
                        pendingSocket.removeAllListeners();
3✔
158
                        //cleanup and destroy all other sockets
159
                        if (pendingSocket !== socket) {
3!
160
                            pendingSocket.end();
×
161
                            pendingSocket?.destroy();
×
162
                        }
163
                    }
164
                    pendingSockets.clear();
3✔
165
                    resolve(socket);
3✔
166
                });
167
            }, this.options.controllerConnectInterval ?? 250);
9!
168
        });
169
        return connection;
3✔
170
    }
171

172
    public async connect(): Promise<boolean> {
173
        this.logger.log('connect', this.options);
3✔
174

175
        // If there is no error, the server has accepted the request and created a new dedicated control socket
176
        this.controllerClient = await this.establishControllerConnection();
3✔
177

178
        this.controllerClient.on('data', (buffer) => {
3✔
179
            if (this.unhandledData) {
2!
180
                this.unhandledData = Buffer.concat([this.unhandledData, buffer]);
×
181
            } else {
182
                this.unhandledData = buffer;
2✔
183
            }
184

185
            this.logger.debug(`on('data'): incoming bytes`, buffer.length);
2✔
186
            const startBufferSize = this.unhandledData.length;
2✔
187

188
            this.parseUnhandledData(this.unhandledData);
2✔
189

190
            const endBufferSize = this.unhandledData?.length ?? 0;
2!
191
            this.logger.debug(`buffer size before:`, startBufferSize, ', buffer size after:', endBufferSize, ', bytes consumed:', startBufferSize - endBufferSize);
2✔
192
        });
193

194
        this.controllerClient.on('end', () => {
3✔
195
            this.logger.log('TCP connection closed');
×
196
            this.shutdown('app-exit');
×
197
        });
198

199
        // Don't forget to catch error, for your own sake.
200
        this.controllerClient.once('error', (error) => {
3✔
201
            //the Roku closed the connection for some unknown reason...
202
            console.error(`TCP connection error on control port`, error);
×
203
            this.shutdown('close');
×
204
        });
205

206
        //send the magic, which triggers the debug session
207
        this.sendMagic();
3✔
208

209
        //wait for the handshake response from the device
210
        const isConnected = await this.once('connected');
3✔
211
        return isConnected;
2✔
212
    }
213

214
    private sendMagic() {
215
        let buffer = new SmartBuffer({ size: Buffer.byteLength(Debugger.DEBUGGER_MAGIC) + 1 }).writeStringNT(Debugger.DEBUGGER_MAGIC).toBuffer();
3✔
216
        this.logger.log('Sending magic to server');
3✔
217
        this.controllerClient.write(buffer);
3✔
218
    }
219

220
    public async continue() {
221
        if (this.stopped) {
×
222
            this.stopped = false;
×
223
            return this.makeRequest<ProtocolEvent>(new SmartBuffer({ size: 12 }), COMMANDS.CONTINUE);
×
224
        }
225
    }
226

227
    public async pause(force = false) {
×
228
        if (!this.stopped || force) {
×
229
            return this.makeRequest<ProtocolEvent>(new SmartBuffer({ size: 12 }), COMMANDS.STOP);
×
230
        }
231
    }
232

233
    public async exitChannel() {
234
        return this.makeRequest<ProtocolEvent>(new SmartBuffer({ size: 12 }), COMMANDS.EXIT_CHANNEL);
×
235
    }
236

237
    public async stepIn(threadId: number = this.primaryThread) {
×
238
        return this.step(STEP_TYPE.STEP_TYPE_LINE, threadId);
×
239
    }
240

241
    public async stepOver(threadId: number = this.primaryThread) {
×
242
        return this.step(STEP_TYPE.STEP_TYPE_OVER, threadId);
×
243
    }
244

245
    public async stepOut(threadId: number = this.primaryThread) {
×
246
        return this.step(STEP_TYPE.STEP_TYPE_OUT, threadId);
×
247
    }
248

249
    private async step(stepType: STEP_TYPE, threadId: number): Promise<ProtocolEvent> {
250
        this.logger.log('[step]', { stepType: STEP_TYPE[stepType], threadId, stopped: this.stopped });
×
251
        let buffer = new SmartBuffer({ size: 17 });
×
252
        buffer.writeUInt32LE(threadId); // thread_index
×
253
        buffer.writeUInt8(stepType); // step_type
×
254
        if (this.stopped) {
×
255
            this.stopped = false;
×
256
            let stepResult = await this.makeRequest<ProtocolEvent>(buffer, COMMANDS.STEP);
×
257
            if (stepResult.errorCode === ERROR_CODES.OK) {
×
258
                // this.stopped = true;
259
                // this.emit('suspend');
260
            } else {
261
                // there is a CANT_CONTINUE error code but we can likely treat all errors like a CANT_CONTINUE
262
                this.emit('cannot-continue');
×
263
            }
264
            return stepResult;
×
265
        }
266
    }
267

268
    public async threads() {
269
        if (this.stopped) {
×
270
            let result = await this.makeRequest<ThreadsResponse>(new SmartBuffer({ size: 12 }), COMMANDS.THREADS);
×
271

272
            if (result.errorCode === ERROR_CODES.OK) {
×
273
                //older versions of the debug protocol had issues with maintaining the active thread, so our workaround is to keep track of it elsewhere
274
                if (this.enableThreadHoppingWorkaround) {
×
275
                    //ignore the `isPrimary` flag on threads
276
                    this.logger.debug(`Ignoring the 'isPrimary' flag from threads because protocol version ${this.protocolVersion} and lower has a bug`);
×
277
                } else {
278
                    //trust the debug protocol's `isPrimary` flag on threads
279
                    for (let i = 0; i < result.threadsCount; i++) {
×
280
                        let thread = result.threads[i];
×
281
                        if (thread.isPrimary) {
×
282
                            this.primaryThread = i;
×
283
                            break;
×
284
                        }
285
                    }
286
                }
287
            }
288
            return result;
×
289
        }
290
    }
291

292
    public async stackTrace(threadIndex: number = this.primaryThread) {
×
293
        let buffer = new SmartBuffer({ size: 16 });
×
294
        buffer.writeUInt32LE(threadIndex); // thread_index
×
295
        if (this.stopped && threadIndex > -1) {
×
296
            return this.makeRequest<StackTraceResponse>(buffer, COMMANDS.STACKTRACE);
×
297
        }
298
    }
299

300
    /**
301
     * @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\""].
302
     *
303
     *                            If no path is specified, the variables accessible from the specified stack frame are returned.
304
     *
305
     *                            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).
306
     *                            All non-quoted keys (i.e. strings without leading and trailing quotes inside them) will be treated as case-insensitive).
307
     * @param getChildKeys  If set, VARIABLES response include the child keys for container types like lists and associative arrays
308
     * @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
309
     * @param threadIndex the index (or perhaps ID?) of the thread to get variables for
310
     */
311
    public async getVariables(variablePathEntries: Array<string> = [], getChildKeys = true, stackFrameIndex: number = this.stackFrameIndex, threadIndex: number = this.primaryThread) {
×
312
        if (this.stopped && threadIndex > -1) {
2!
313
            //starting in protocol v3.1.0, it supports marking certain path items as case-insensitive (i.e. parts of DottedGet expressions)
314
            const sendCaseInsensitiveData = semver.satisfies(this.protocolVersion, '>=3.1.0') && variablePathEntries.length > 0;
2✔
315
            let buffer = new SmartBuffer({ size: 17 });
2✔
316
            let flags = 0;
2✔
317
            if (getChildKeys) {
2✔
318
                // eslint-disable-next-line no-bitwise
319
                flags |= VARIABLE_REQUEST_FLAGS.GET_CHILD_KEYS;
1✔
320
            }
321
            if (sendCaseInsensitiveData) {
2✔
322
                // eslint-disable-next-line no-bitwise
323
                flags |= VARIABLE_REQUEST_FLAGS.CASE_SENSITIVITY_OPTIONS;
1✔
324
            }
325
            buffer.writeUInt8(flags); // variable_request_flags
2✔
326
            buffer.writeUInt32LE(threadIndex); // thread_index
2✔
327
            buffer.writeUInt32LE(stackFrameIndex); // stack_frame_index
2✔
328
            buffer.writeUInt32LE(variablePathEntries.length); // variable_path_len
2✔
329
            variablePathEntries.forEach(entry => {
2✔
330
                if (entry.startsWith('"') && entry.endsWith('"')) {
6✔
331
                    //remove leading and trailing quotes
332
                    entry = entry.substring(1, entry.length - 1);
2✔
333
                }
334
                buffer.writeStringNT(entry); // variable_path_entries - optional
6✔
335
            });
336
            if (sendCaseInsensitiveData) {
2✔
337
                variablePathEntries.forEach(entry => {
1✔
338
                    buffer.writeUInt8(
4✔
339
                        //0 means case SENSITIVE lookup, 1 means case INsensitive lookup
340
                        entry.startsWith('"') ? 0 : 1
4✔
341
                    );
342
                });
343
            }
344
            return this.makeRequest<VariableResponse>(buffer, COMMANDS.VARIABLES, variablePathEntries);
2✔
345
        }
346
    }
347

348
    public async executeCommand(sourceCode: string, stackFrameIndex: number = this.stackFrameIndex, threadIndex: number = this.primaryThread) {
×
349
        if (this.stopped && threadIndex > -1) {
×
350
            console.log(sourceCode);
×
351
            let buffer = new SmartBuffer({ size: 8 });
×
352
            buffer.writeUInt32LE(threadIndex); // thread_index
×
353
            buffer.writeUInt32LE(stackFrameIndex); // stack_frame_index
×
354
            buffer.writeStringNT(sourceCode); // source_code
×
355
            return this.makeRequest<ExecuteResponseV3>(buffer, COMMANDS.EXECUTE, sourceCode);
×
356
        }
357
    }
358

359
    public async addBreakpoints(breakpoints: Array<BreakpointSpec & { componentLibraryName: string }>): Promise<AddBreakpointsResponse> {
360
        const { enableComponentLibrarySpecificBreakpoints } = this;
×
361
        if (breakpoints?.length > 0) {
×
362
            let buffer = new SmartBuffer();
×
363
            //set the `FLAGS` value if supported
364
            if (this.supportsConditionalBreakpoints) {
×
365
                buffer.writeUInt32LE(0); // flags - Should always be passed as 0. Unused, reserved for future use.
×
366
            }
367
            buffer.writeUInt32LE(breakpoints.length); // num_breakpoints - The number of breakpoints in the breakpoints array.
×
368
            breakpoints.forEach((breakpoint) => {
×
369
                let { filePath } = breakpoint;
×
370
                //protocol >= v3.1.0 requires complib breakpoints have a special prefix
371
                if (enableComponentLibrarySpecificBreakpoints) {
×
372
                    if (breakpoint.componentLibraryName) {
×
373
                        filePath = filePath.replace(/^pkg:\//i, `lib:/${breakpoint.componentLibraryName}/`);
×
374
                    }
375
                }
376

377
                buffer.writeStringNT(filePath); // file_path - The path of the source file where the breakpoint is to be inserted.
×
378
                buffer.writeUInt32LE(breakpoint.lineNumber); // line_number - The line number in the channel application code where the breakpoint is to be executed.
×
379
                buffer.writeUInt32LE(breakpoint.hitCount ?? 0); // ignore_count - 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.
×
380
                //if the protocol supports conditional breakpoints, add any present condition
381
                if (this.supportsConditionalBreakpoints) {
×
382
                    //There's a bug in 3.1 where empty conditional expressions would crash the breakpoints, so just default to `true` which always succeeds
383
                    buffer.writeStringNT(breakpoint.conditionalExpression ?? 'true'); // cond_expr - the condition that must evaluate to `true` in order to hit the breakpoint
×
384
                }
385
            });
386
            return this.makeRequest<AddBreakpointsResponse>(buffer,
×
387
                this.supportsConditionalBreakpoints ? COMMANDS.ADD_CONDITIONAL_BREAKPOINTS : COMMANDS.ADD_BREAKPOINTS
×
388
                //COMMANDS.ADD_BREAKPOINTS
389
            );
390
        }
391
        return new AddBreakpointsResponse(null);
×
392
    }
393

394
    public async listBreakpoints(): Promise<ListBreakpointsResponse> {
395
        return this.makeRequest<ListBreakpointsResponse>(new SmartBuffer({ size: 12 }), COMMANDS.LIST_BREAKPOINTS);
×
396
    }
397

398
    public async removeBreakpoints(breakpointIds: number[]): Promise<RemoveBreakpointsResponse> {
399
        if (breakpointIds?.length > 0) {
×
400
            let buffer = new SmartBuffer();
×
401
            buffer.writeUInt32LE(breakpointIds.length); // num_breakpoints - The number of breakpoints in the breakpoints array.
×
402
            breakpointIds.forEach((breakpointId) => {
×
403
                buffer.writeUInt32LE(breakpointId); // breakpoint_ids - An array of breakpoint IDs representing the breakpoints to be removed.
×
404
            });
405
            return this.makeRequest<RemoveBreakpointsResponse>(buffer, COMMANDS.REMOVE_BREAKPOINTS);
×
406
        }
407
        return new RemoveBreakpointsResponse(null);
×
408
    }
409

410
    private async makeRequest<T>(buffer: SmartBuffer, command: COMMANDS, extraData?) {
411
        this.totalRequests++;
×
412
        let requestId = this.totalRequests;
×
413
        buffer.insertUInt32LE(command, 0); // command_code - An enum representing the debugging command being sent. See the COMMANDS enum
×
414
        buffer.insertUInt32LE(requestId, 0); // request_id - The ID of the debugger request (must be >=1). This ID is included in the debugger response.
×
415
        buffer.insertUInt32LE(buffer.writeOffset + 4, 0); // packet_length - The size of the packet to be sent.
×
416

417
        this.activeRequests[requestId] = {
×
418
            commandType: command,
419
            commandTypeText: COMMANDS[command],
420
            extraData: extraData
421
        };
422

423
        return new Promise<T>((resolve, reject) => {
×
424
            let unsubscribe = this.on('data', (data) => {
×
425
                if (data.requestId === requestId) {
×
426
                    unsubscribe();
×
427
                    resolve(data as T);
×
428
                }
429
            });
430

431
            this.logger.debug('makeRequest', `requestId=${requestId}`, this.activeRequests[requestId]);
×
432
            if (this.controllerClient) {
×
433
                this.controllerClient.write(buffer.toBuffer());
×
434
            } else {
435
                throw new Error(`Controller connection was closed - Command: ${COMMANDS[command]}`);
×
436
            }
437
        });
438
    }
439

440
    private parseUnhandledData(buffer: Buffer): boolean {
441
        if (buffer.length < 1) {
11✔
442
            // short circuit if the buffer is empty
443
            return false;
5✔
444
        }
445

446
        if (this.handshakeComplete) {
6✔
447
            let debuggerRequestResponse = this.watchPacketLength ? new ProtocolEventV3(buffer) : new ProtocolEvent(buffer);
1!
448
            let packetLength = debuggerRequestResponse.packetLength;
1✔
449
            let slicedBuffer = packetLength ? buffer.slice(4) : buffer;
1!
450

451
            this.logger.log(`incoming bytes: ${buffer.length}`, debuggerRequestResponse);
1✔
452
            if (debuggerRequestResponse.success) {
1!
453
                if (debuggerRequestResponse.requestId > this.totalRequests) {
1!
454
                    this.removedProcessedBytes(debuggerRequestResponse, slicedBuffer, packetLength);
×
455
                    return true;
×
456
                }
457

458
                if (debuggerRequestResponse.errorCode !== ERROR_CODES.OK) {
1!
459
                    this.logger.error(debuggerRequestResponse.errorCode, debuggerRequestResponse);
1✔
460
                    this.removedProcessedBytes(debuggerRequestResponse, buffer, packetLength);
1✔
461
                    return true;
1✔
462
                }
463

464
                if (debuggerRequestResponse.updateType > 0) {
×
465
                    this.logger.log('Update Type:', UPDATE_TYPES[debuggerRequestResponse.updateType]);
×
466
                    switch (debuggerRequestResponse.updateType) {
×
467
                        case UPDATE_TYPES.IO_PORT_OPENED:
468
                            return this.connectToIoPort(new ConnectIOPortResponse(slicedBuffer), buffer, packetLength);
×
469
                        case UPDATE_TYPES.ALL_THREADS_STOPPED:
470
                        case UPDATE_TYPES.THREAD_ATTACHED:
471
                            let debuggerUpdateThreads = new UpdateThreadsResponse(slicedBuffer);
×
472
                            if (debuggerUpdateThreads.success) {
×
473
                                this.handleThreadsUpdate(debuggerUpdateThreads);
×
474
                                this.removedProcessedBytes(debuggerUpdateThreads, slicedBuffer, packetLength);
×
475
                                return true;
×
476
                            }
477
                            return false;
×
478
                        case UPDATE_TYPES.UNDEF:
479
                            return this.checkResponse(new UndefinedResponse(slicedBuffer), buffer, packetLength);
×
480
                        case UPDATE_TYPES.BREAKPOINT_ERROR:
481
                            const response = new BreakpointErrorUpdateResponse(slicedBuffer);
×
482
                            //we do nothing with breakpoint errors at this time.
483
                            return this.checkResponse(response, buffer, packetLength);
×
484
                        case UPDATE_TYPES.COMPILE_ERROR:
485
                            return this.checkResponse(new UndefinedResponse(slicedBuffer), buffer, packetLength);
×
486
                        default:
487
                            return this.checkResponse(new UndefinedResponse(slicedBuffer), buffer, packetLength);
×
488
                    }
489
                } else {
490
                    this.logger.log('Command Type:', COMMANDS[this.activeRequests[debuggerRequestResponse.requestId].commandType]);
×
491
                    switch (this.activeRequests[debuggerRequestResponse.requestId].commandType) {
×
492
                        case COMMANDS.STOP:
493
                        case COMMANDS.CONTINUE:
494
                        case COMMANDS.STEP:
495
                        case COMMANDS.EXIT_CHANNEL:
496
                            this.removedProcessedBytes(debuggerRequestResponse, buffer, packetLength);
×
497
                            return true;
×
498
                        case COMMANDS.EXECUTE:
499
                            return this.checkResponse(new ExecuteResponseV3(slicedBuffer), buffer, packetLength);
×
500
                        case COMMANDS.ADD_BREAKPOINTS:
501
                        case COMMANDS.ADD_CONDITIONAL_BREAKPOINTS:
502
                            return this.checkResponse(new AddBreakpointsResponse(slicedBuffer), buffer, packetLength);
×
503
                        case COMMANDS.LIST_BREAKPOINTS:
504
                            return this.checkResponse(new ListBreakpointsResponse(slicedBuffer), buffer, packetLength);
×
505
                        case COMMANDS.REMOVE_BREAKPOINTS:
506
                            return this.checkResponse(new RemoveBreakpointsResponse(slicedBuffer), buffer, packetLength);
×
507
                        case COMMANDS.VARIABLES:
508
                            return this.checkResponse(new VariableResponse(slicedBuffer), buffer, packetLength);
×
509
                        case COMMANDS.STACKTRACE:
510
                            return this.checkResponse(
×
511
                                packetLength ? new StackTraceResponseV3(slicedBuffer) : new StackTraceResponse(slicedBuffer),
×
512
                                buffer,
513
                                packetLength);
514
                        case COMMANDS.THREADS:
515
                            return this.checkResponse(new ThreadsResponse(slicedBuffer), buffer, packetLength);
×
516
                        default:
517
                            return this.checkResponse(debuggerRequestResponse, buffer, packetLength);
×
518
                    }
519
                }
520
            }
521
        } else {
522
            let debuggerHandshake: HandshakeResponse | HandshakeResponseV3;
523
            debuggerHandshake = new HandshakeResponseV3(buffer);
5✔
524
            this.logger.log(`incoming bytes: ${buffer.length}`, debuggerHandshake);
5✔
525

526
            if (!debuggerHandshake.success) {
5✔
527
                debuggerHandshake = new HandshakeResponse(buffer);
3✔
528
            }
529

530
            if (debuggerHandshake.success) {
5!
531
                this.handshakeComplete = true;
5✔
532
                this.verifyHandshake(debuggerHandshake);
5✔
533
                this.removedProcessedBytes(debuggerHandshake, buffer);
5✔
534
                //once the handshake is complete, we have successfully "connected"
535
                this.emit('connected', true);
5✔
536
                return true;
5✔
537
            }
538
        }
539

540
        return false;
×
541
    }
542

543
    private checkResponse(responseClass: { requestId: number; readOffset: number; success: boolean }, unhandledData: Buffer, packetLength = 0) {
×
544
        if (responseClass.success) {
×
545
            this.removedProcessedBytes(responseClass, unhandledData, packetLength);
×
546
            return true;
×
547
        } else if (packetLength > 0 && unhandledData.length >= packetLength) {
×
548
            this.removedProcessedBytes(responseClass, unhandledData, packetLength);
×
549
        }
550
        return false;
×
551
    }
552

553
    private removedProcessedBytes(responseHandler: { requestId: number; readOffset: number }, unhandledData: Buffer, packetLength = 0) {
5✔
554
        const activeRequest = this.activeRequests[responseHandler.requestId];
6✔
555
        if (responseHandler.requestId > 0 && this.activeRequests[responseHandler.requestId]) {
6!
556
            delete this.activeRequests[responseHandler.requestId];
×
557
        }
558

559
        this.emit('data', responseHandler);
6✔
560

561
        this.unhandledData = unhandledData.slice(packetLength ? packetLength : responseHandler.readOffset);
6✔
562
        this.logger.debug('[raw]', `requestId=${responseHandler?.requestId}`, activeRequest, (responseHandler as any)?.constructor?.name ?? '', responseHandler);
6!
563
        this.parseUnhandledData(this.unhandledData);
6✔
564
    }
565

566
    private verifyHandshake(debuggerHandshake: HandshakeResponse): boolean {
567
        const magicIsValid = (Debugger.DEBUGGER_MAGIC === debuggerHandshake.magic);
5✔
568
        if (magicIsValid) {
5✔
569
            this.logger.log('Magic is valid.');
4✔
570
            this.protocolVersion = [debuggerHandshake.majorVersion, debuggerHandshake.minorVersion, debuggerHandshake.patchVersion].join('.');
4✔
571
            this.logger.log('Protocol Version:', this.protocolVersion);
4✔
572

573
            this.watchPacketLength = debuggerHandshake.watchPacketLength;
4✔
574

575
            let handshakeVerified = true;
4✔
576

577
            if (semver.satisfies(this.protocolVersion, this.supportedVersionRange)) {
4!
578
                this.logger.log('supported');
4✔
579
                this.emit('protocol-version', {
4✔
580
                    message: `Protocol Version ${this.protocolVersion} is supported!`,
581
                    errorCode: PROTOCOL_ERROR_CODES.SUPPORTED
582
                });
583
            } else if (semver.gtr(this.protocolVersion, this.supportedVersionRange)) {
×
584
                this.logger.log('roku-debug has not been tested against protocol version', this.protocolVersion);
×
585
                this.emit('protocol-version', {
×
586
                    message: `Protocol Version ${this.protocolVersion} has not been tested and my not work as intended.\nPlease open any issues you have with this version to https://github.com/rokucommunity/roku-debug/issues`,
587
                    errorCode: PROTOCOL_ERROR_CODES.NOT_TESTED
588
                });
589
            } else {
590
                this.logger.log('not supported');
×
591
                this.emit('protocol-version', {
×
592
                    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`,
593
                    errorCode: PROTOCOL_ERROR_CODES.NOT_SUPPORTED
594
                });
595
                this.shutdown('close');
×
596
                handshakeVerified = false;
×
597
            }
598

599
            this.emit('handshake-verified', handshakeVerified);
4✔
600
            return handshakeVerified;
4✔
601
        } else {
602
            this.logger.log('Closing connection due to bad debugger magic', debuggerHandshake.magic);
1✔
603
            this.emit('handshake-verified', false);
1✔
604
            this.shutdown('close');
1✔
605
            return false;
1✔
606
        }
607
    }
608

609
    private connectToIoPort(connectIoPortResponse: ConnectIOPortResponse, unhandledData: Buffer, packetLength = 0) {
×
610
        this.logger.log('Connecting to IO port. response status success =', connectIoPortResponse.success);
×
611
        if (connectIoPortResponse.success) {
×
612
            // Create a new TCP client.
613
            this.ioClient = new Net.Socket();
×
614
            // Send a connection request to the server.
615
            this.logger.log('Connect to IO Port: port', connectIoPortResponse.data, 'host', this.options.host);
×
616
            this.ioClient.connect({ port: connectIoPortResponse.data, host: this.options.host }, () => {
×
617
                // If there is no error, the server has accepted the request
618
                this.logger.log('TCP connection established with the IO Port.');
×
619
                this.connectedToIoPort = true;
×
620

621
                let lastPartialLine = '';
×
622
                this.ioClient.on('data', (buffer) => {
×
623
                    let responseText = buffer.toString();
×
624
                    if (!responseText.endsWith('\n')) {
×
625
                        // buffer was split, save the partial line
626
                        lastPartialLine += responseText;
×
627
                    } else {
628
                        if (lastPartialLine) {
×
629
                            // there was leftover lines, join the partial lines back together
630
                            responseText = lastPartialLine + responseText;
×
631
                            lastPartialLine = '';
×
632
                        }
633
                        // Emit the completed io string.
634
                        this.emit('io-output', responseText.trim());
×
635
                    }
636
                });
637

638
                this.ioClient.on('end', () => {
×
639
                    this.ioClient.end();
×
640
                    this.logger.log('Requested an end to the IO connection');
×
641
                });
642

643
                // Don't forget to catch error, for your own sake.
644
                this.ioClient.once('error', (err) => {
×
645
                    this.ioClient.end();
×
646
                    this.logger.error(err);
×
647
                });
648
            });
649

650
            this.removedProcessedBytes(connectIoPortResponse, unhandledData, packetLength);
×
651
            return true;
×
652
        }
653
        return false;
×
654
    }
655

656
    private handleThreadsUpdate(update: UpdateThreadsResponse) {
657
        this.stopped = true;
×
658
        let stopReason = update.data.stopReason;
×
659
        let eventName: 'runtime-error' | 'suspend' = stopReason === STOP_REASONS.RUNTIME_ERROR ? 'runtime-error' : 'suspend';
×
660

661
        if (update.updateType === UPDATE_TYPES.ALL_THREADS_STOPPED) {
×
662
            if (stopReason === STOP_REASONS.RUNTIME_ERROR || stopReason === STOP_REASONS.BREAK || stopReason === STOP_REASONS.STOP_STATEMENT) {
×
663
                this.primaryThread = (update.data as ThreadsStopped).primaryThreadIndex;
×
664
                this.stackFrameIndex = 0;
×
665
                this.emit(eventName, update);
×
666
            }
667
        } else if (stopReason === STOP_REASONS.RUNTIME_ERROR || stopReason === STOP_REASONS.BREAK || stopReason === STOP_REASONS.STOP_STATEMENT) {
×
668
            this.primaryThread = (update.data as ThreadAttached).threadIndex;
×
669
            this.emit(eventName, update);
×
670
        }
671
    }
672

673
    public destroy() {
674
        this.shutdown('close');
8✔
675
    }
676

677
    private shutdown(eventName: 'app-exit' | 'close') {
678
        if (this.controllerClient) {
9✔
679
            this.controllerClient.removeAllListeners();
3✔
680
            this.controllerClient.destroy();
3✔
681
            this.controllerClient = undefined;
3✔
682
        }
683

684
        if (this.ioClient) {
9!
685
            this.ioClient.removeAllListeners();
×
686
            this.ioClient.destroy();
×
687
            this.ioClient = undefined;
×
688
        }
689

690
        this.emit(eventName);
9✔
691
    }
692
}
693

694
export interface ProtocolVersionDetails {
695
    message: string;
696
    errorCode: PROTOCOL_ERROR_CODES;
697
}
698

699
export interface BreakpointSpec {
700
    /**
701
     * The path of the source file where the breakpoint is to be inserted.
702
     */
703
    filePath: string;
704
    /**
705
     * The (1-based) line number in the channel application code where the breakpoint is to be executed.
706
     */
707
    lineNumber: number;
708
    /**
709
     * 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.
710
     */
711
    hitCount?: number;
712
    /**
713
     * BrightScript code that evaluates to a boolean value. The expression is compiled and executed in
714
     * the context where the breakpoint is located. If specified, the hitCount is only be
715
     * updated if this evaluates to true.
716
     * @avaiable since protocol version 3.1.0
717
     */
718
    conditionalExpression?: string;
719
}
720

721
export interface ConstructorOptions {
722
    /**
723
     * The host/ip address of the Roku
724
     */
725
    host: string;
726
    /**
727
     * The port number used to send all debugger commands. This is static/unchanging for Roku devices,
728
     * but is configurable here to support unit testing or alternate runtimes (i.e. https://www.npmjs.com/package/brs)
729
     */
730
    controllerPort?: number;
731
    /**
732
     * The interval (in milliseconds) for how frequently the `connect`
733
     * call should retry connecting to the controller port. At the start of a debug session,
734
     * the protocol debugger will start trying to connect the moment the channel is sideloaded,
735
     * and keep trying until a successful connection is established or the debug session is terminated
736
     * @default 250
737
     */
738
    controllerConnectInterval?: number;
739
    /**
740
     * The maximum time (in milliseconds) the debugger will keep retrying connections.
741
     * This is here to prevent infinitely pinging the Roku device.
742
     */
743
    controllerConnectMaxTime?: number;
744
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc