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

rokucommunity / roku-debug / #1987

pending completion
#1987

push

web-flow
<a href="https://github.com/rokucommunity/roku-debug/commit/<a class=hub.com/rokucommunity/roku-debug/commit/4c8a6c75baa3b77321e6a97bef6e5259da330770">4c8a6c75b<a href="https://github.com/rokucommunity/roku-debug/commit/4c8a6c75baa3b77321e6a97bef6e5259da330770">">Merge </a><a class="double-link" href="https://github.com/rokucommunity/roku-debug/commit/<a class="double-link" href="https://github.com/rokucommunity/roku-debug/commit/64ebe50a11f971043f7d35142b20a2168365d856">64ebe50a1</a>">64ebe50a1</a><a href="https://github.com/rokucommunity/roku-debug/commit/4c8a6c75baa3b77321e6a97bef6e5259da330770"> into fb162b3d1">fb162b3d1</a>

999 of 1926 branches covered (51.87%)

Branch coverage included in aggregate %.

28 of 28 new or added lines in 2 files covered. (100.0%)

2082 of 3586 relevant lines covered (58.06%)

15.95 hits per line

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

38.17
/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
    public get supportsBreakpointRegistrationWhileRunning() {
95
        return semver.satisfies(this.protocolVersion, '>=3.2.0');
×
96
    }
97

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

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

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

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

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

176
    public async connect(): Promise<boolean> {
177
        this.logger.log('connect', this.options);
3✔
178

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

182
        this.controllerClient.on('data', (buffer) => {
3✔
183
            if (this.unhandledData) {
2!
184
                this.unhandledData = Buffer.concat([this.unhandledData, buffer]);
×
185
            } else {
186
                this.unhandledData = buffer;
2✔
187
            }
188

189
            this.logger.debug(`on('data'): incoming bytes`, buffer.length);
2✔
190
            const startBufferSize = this.unhandledData.length;
2✔
191

192
            this.parseUnhandledData(this.unhandledData);
2✔
193

194
            const endBufferSize = this.unhandledData?.length ?? 0;
2!
195
            this.logger.debug(`buffer size before:`, startBufferSize, ', buffer size after:', endBufferSize, ', bytes consumed:', startBufferSize - endBufferSize);
2✔
196
        });
197

198
        this.controllerClient.on('end', () => {
3✔
199
            this.logger.log('TCP connection closed');
×
200
            this.shutdown('app-exit');
×
201
        });
202

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

210
        //send the magic, which triggers the debug session
211
        this.sendMagic();
3✔
212

213
        //wait for the handshake response from the device
214
        const isConnected = await this.once('connected');
3✔
215
        return isConnected;
2✔
216
    }
217

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

224
    public async continue() {
225
        if (this.stopped) {
×
226
            this.stopped = false;
×
227
            return this.makeRequest<ProtocolEvent>(new SmartBuffer({ size: 12 }), COMMANDS.CONTINUE);
×
228
        }
229
    }
230

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

237
    public async exitChannel() {
238
        return this.makeRequest<ProtocolEvent>(new SmartBuffer({ size: 12 }), COMMANDS.EXIT_CHANNEL);
×
239
    }
240

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

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

249
    public async stepOut(threadId: number = this.primaryThread) {
×
250
        return this.step(STEP_TYPE.STEP_TYPE_OUT, threadId);
×
251
    }
252

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

272
    public async threads() {
273
        if (this.stopped) {
×
274
            let result = await this.makeRequest<ThreadsResponse>(new SmartBuffer({ size: 12 }), COMMANDS.THREADS);
×
275

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

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

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

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

363
    public async addBreakpoints(breakpoints: Array<BreakpointSpec & { componentLibraryName: string }>): Promise<AddBreakpointsResponse> {
364
        const { enableComponentLibrarySpecificBreakpoints } = this;
×
365
        if (breakpoints?.length > 0) {
×
366

367
            const useConditionalBreakpoints = (
368
                //does this protocol version support conditional breakpoints?
369
                this.supportsConditionalBreakpoints &&
×
370
                //is there at least one conditional breakpoint present?
371
                !!breakpoints.find(x => !!x?.conditionalExpression?.trim())
×
372
            );
373

374
            let buffer = new SmartBuffer();
×
375
            //set the `FLAGS` value if supported
376
            if (useConditionalBreakpoints) {
×
377
                buffer.writeUInt32LE(0); // flags - Should always be passed as 0. Unused, reserved for future use.
×
378
            }
379
            buffer.writeUInt32LE(breakpoints.length); // num_breakpoints - The number of breakpoints in the breakpoints array.
×
380
            for (const breakpoint of breakpoints) {
×
381
                let { filePath } = breakpoint;
×
382
                //protocol >= v3.1.0 requires complib breakpoints have a special prefix
383
                if (enableComponentLibrarySpecificBreakpoints) {
×
384
                    if (breakpoint.componentLibraryName) {
×
385
                        filePath = filePath.replace(/^pkg:\//i, `lib:/${breakpoint.componentLibraryName}/`);
×
386
                    }
387
                }
388

389
                buffer.writeStringNT(filePath); // file_path - The path of the source file where the breakpoint is to be inserted.
×
390
                buffer.writeUInt32LE(breakpoint.lineNumber); // line_number - The line number in the channel application code where the breakpoint is to be executed.
×
391
                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.
×
392
                //if the protocol supports conditional breakpoints, add any present condition
393
                if (useConditionalBreakpoints) {
×
394
                    //There's a bug in 3.1 where empty conditional expressions would crash the breakpoints, so just default to `true` which always succeeds
395
                    buffer.writeStringNT(breakpoint.conditionalExpression ?? 'true'); // cond_expr - the condition that must evaluate to `true` in order to hit the breakpoint
×
396
                }
397
            }
398
            return this.makeRequest<AddBreakpointsResponse>(buffer,
×
399
                useConditionalBreakpoints ? COMMANDS.ADD_CONDITIONAL_BREAKPOINTS : COMMANDS.ADD_BREAKPOINTS
×
400
            );
401
        }
402
        const response = new AddBreakpointsResponse(null);
×
403
        response.success = true;
×
404
        response.errorCode = ERROR_CODES.OK;
×
405
        return response;
×
406
    }
407

408
    public async listBreakpoints(): Promise<ListBreakpointsResponse> {
409
        return this.makeRequest<ListBreakpointsResponse>(new SmartBuffer({ size: 12 }), COMMANDS.LIST_BREAKPOINTS);
×
410
    }
411

412
    public async removeBreakpoints(breakpointIds: number[]): Promise<RemoveBreakpointsResponse> {
413
        if (breakpointIds?.length > 0) {
×
414
            let buffer = new SmartBuffer();
×
415
            buffer.writeUInt32LE(breakpointIds.length); // num_breakpoints - The number of breakpoints in the breakpoints array.
×
416
            breakpointIds.forEach((breakpointId) => {
×
417
                buffer.writeUInt32LE(breakpointId); // breakpoint_ids - An array of breakpoint IDs representing the breakpoints to be removed.
×
418
            });
419
            return this.makeRequest<RemoveBreakpointsResponse>(buffer, COMMANDS.REMOVE_BREAKPOINTS);
×
420
        }
421
        return new RemoveBreakpointsResponse(null);
×
422
    }
423

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

431
        this.activeRequests[requestId] = {
×
432
            commandType: command,
433
            commandTypeText: COMMANDS[command],
434
            extraData: extraData
435
        };
436

437
        return new Promise<T>((resolve, reject) => {
×
438
            let unsubscribe = this.on('data', (data) => {
×
439
                if (data.requestId === requestId) {
×
440
                    unsubscribe();
×
441
                    resolve(data as T);
×
442
                }
443
            });
444

445
            this.logger.debug('makeRequest', `requestId=${requestId}`, this.activeRequests[requestId]);
×
446
            if (this.controllerClient) {
×
447
                this.controllerClient.write(buffer.toBuffer());
×
448
            } else {
449
                throw new Error(`Controller connection was closed - Command: ${COMMANDS[command]}`);
×
450
            }
451
        });
452
    }
453

454
    private parseUnhandledData(buffer: Buffer): boolean {
455
        if (buffer.length < 1) {
11✔
456
            // short circuit if the buffer is empty
457
            return false;
5✔
458
        }
459

460
        if (this.handshakeComplete) {
6✔
461
            let debuggerRequestResponse = this.watchPacketLength ? new ProtocolEventV3(buffer) : new ProtocolEvent(buffer);
1!
462
            let packetLength = debuggerRequestResponse.packetLength;
1✔
463
            let slicedBuffer = packetLength ? buffer.slice(4) : buffer;
1!
464

465
            this.logger.log(`incoming bytes: ${buffer.length}`, debuggerRequestResponse);
1✔
466
            if (debuggerRequestResponse.success) {
1!
467
                if (debuggerRequestResponse.requestId > this.totalRequests) {
1!
468
                    this.removedProcessedBytes(debuggerRequestResponse, slicedBuffer, packetLength);
×
469
                    return true;
×
470
                }
471

472
                if (debuggerRequestResponse.errorCode !== ERROR_CODES.OK) {
1!
473
                    this.logger.error(debuggerRequestResponse.errorCode, debuggerRequestResponse);
1✔
474
                    this.removedProcessedBytes(debuggerRequestResponse, buffer, packetLength);
1✔
475
                    return true;
1✔
476
                }
477

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

540
            if (!debuggerHandshake.success) {
5✔
541
                debuggerHandshake = new HandshakeResponse(buffer);
3✔
542
            }
543

544
            if (debuggerHandshake.success) {
5!
545
                this.handshakeComplete = true;
5✔
546
                this.verifyHandshake(debuggerHandshake);
5✔
547
                this.removedProcessedBytes(debuggerHandshake, buffer);
5✔
548
                //once the handshake is complete, we have successfully "connected"
549
                this.emit('connected', true);
5✔
550
                return true;
5✔
551
            }
552
        }
553

554
        return false;
×
555
    }
556

557
    private checkResponse(responseClass: { requestId: number; readOffset: number; success: boolean }, unhandledData: Buffer, packetLength = 0) {
×
558
        if (responseClass.success) {
×
559
            this.removedProcessedBytes(responseClass, unhandledData, packetLength);
×
560
            return true;
×
561
        } else if (packetLength > 0 && unhandledData.length >= packetLength) {
×
562
            this.removedProcessedBytes(responseClass, unhandledData, packetLength);
×
563
        }
564
        return false;
×
565
    }
566

567
    private removedProcessedBytes(responseHandler: { requestId: number; readOffset: number }, unhandledData: Buffer, packetLength = 0) {
5✔
568
        const activeRequest = this.activeRequests[responseHandler.requestId];
6✔
569
        if (responseHandler.requestId > 0 && this.activeRequests[responseHandler.requestId]) {
6!
570
            delete this.activeRequests[responseHandler.requestId];
×
571
        }
572

573
        this.emit('data', responseHandler);
6✔
574

575
        this.unhandledData = unhandledData.slice(packetLength ? packetLength : responseHandler.readOffset);
6✔
576
        this.logger.debug('[raw]', `requestId=${responseHandler?.requestId}`, activeRequest, (responseHandler as any)?.constructor?.name ?? '', responseHandler);
6!
577
        this.parseUnhandledData(this.unhandledData);
6✔
578
    }
579

580
    private verifyHandshake(debuggerHandshake: HandshakeResponse): boolean {
581
        const magicIsValid = (Debugger.DEBUGGER_MAGIC === debuggerHandshake.magic);
5✔
582
        if (magicIsValid) {
5✔
583
            this.logger.log('Magic is valid.');
4✔
584
            this.protocolVersion = [debuggerHandshake.majorVersion, debuggerHandshake.minorVersion, debuggerHandshake.patchVersion].join('.');
4✔
585
            this.logger.log('Protocol Version:', this.protocolVersion);
4✔
586

587
            this.watchPacketLength = debuggerHandshake.watchPacketLength;
4✔
588

589
            let handshakeVerified = true;
4✔
590

591
            if (semver.satisfies(this.protocolVersion, this.supportedVersionRange)) {
4!
592
                this.logger.log('supported');
4✔
593
                this.emit('protocol-version', {
4✔
594
                    message: `Protocol Version ${this.protocolVersion} is supported!`,
595
                    errorCode: PROTOCOL_ERROR_CODES.SUPPORTED
596
                });
597
            } else if (semver.gtr(this.protocolVersion, this.supportedVersionRange)) {
×
598
                this.logger.log('roku-debug has not been tested against protocol version', this.protocolVersion);
×
599
                this.emit('protocol-version', {
×
600
                    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`,
601
                    errorCode: PROTOCOL_ERROR_CODES.NOT_TESTED
602
                });
603
            } else {
604
                this.logger.log('not supported');
×
605
                this.emit('protocol-version', {
×
606
                    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`,
607
                    errorCode: PROTOCOL_ERROR_CODES.NOT_SUPPORTED
608
                });
609
                this.shutdown('close');
×
610
                handshakeVerified = false;
×
611
            }
612

613
            this.emit('handshake-verified', handshakeVerified);
4✔
614
            return handshakeVerified;
4✔
615
        } else {
616
            this.logger.log('Closing connection due to bad debugger magic', debuggerHandshake.magic);
1✔
617
            this.emit('handshake-verified', false);
1✔
618
            this.shutdown('close');
1✔
619
            return false;
1✔
620
        }
621
    }
622

623
    private connectToIoPort(connectIoPortResponse: ConnectIOPortResponse, unhandledData: Buffer, packetLength = 0) {
×
624
        this.logger.log('Connecting to IO port. response status success =', connectIoPortResponse.success);
×
625
        if (connectIoPortResponse.success) {
×
626
            // Create a new TCP client.
627
            this.ioClient = new Net.Socket();
×
628
            // Send a connection request to the server.
629
            this.logger.log('Connect to IO Port: port', connectIoPortResponse.data, 'host', this.options.host);
×
630
            this.ioClient.connect({ port: connectIoPortResponse.data, host: this.options.host }, () => {
×
631
                // If there is no error, the server has accepted the request
632
                this.logger.log('TCP connection established with the IO Port.');
×
633
                this.connectedToIoPort = true;
×
634

635
                let lastPartialLine = '';
×
636
                this.ioClient.on('data', (buffer) => {
×
637
                    let responseText = buffer.toString();
×
638
                    if (!responseText.endsWith('\n')) {
×
639
                        // buffer was split, save the partial line
640
                        lastPartialLine += responseText;
×
641
                    } else {
642
                        if (lastPartialLine) {
×
643
                            // there was leftover lines, join the partial lines back together
644
                            responseText = lastPartialLine + responseText;
×
645
                            lastPartialLine = '';
×
646
                        }
647
                        // Emit the completed io string.
648
                        this.emit('io-output', responseText.trim());
×
649
                    }
650
                });
651

652
                this.ioClient.on('end', () => {
×
653
                    this.ioClient.end();
×
654
                    this.logger.log('Requested an end to the IO connection');
×
655
                });
656

657
                // Don't forget to catch error, for your own sake.
658
                this.ioClient.once('error', (err) => {
×
659
                    this.ioClient.end();
×
660
                    this.logger.error(err);
×
661
                });
662
            });
663

664
            this.removedProcessedBytes(connectIoPortResponse, unhandledData, packetLength);
×
665
            return true;
×
666
        }
667
        return false;
×
668
    }
669

670
    private handleThreadsUpdate(update: UpdateThreadsResponse) {
671
        this.stopped = true;
×
672
        let stopReason = update.data.stopReason;
×
673
        let eventName: 'runtime-error' | 'suspend' = stopReason === STOP_REASONS.RUNTIME_ERROR ? 'runtime-error' : 'suspend';
×
674

675
        if (update.updateType === UPDATE_TYPES.ALL_THREADS_STOPPED) {
×
676
            if (stopReason === STOP_REASONS.RUNTIME_ERROR || stopReason === STOP_REASONS.BREAK || stopReason === STOP_REASONS.STOP_STATEMENT) {
×
677
                this.primaryThread = (update.data as ThreadsStopped).primaryThreadIndex;
×
678
                this.stackFrameIndex = 0;
×
679
                this.emit(eventName, update);
×
680
            }
681
        } else if (stopReason === STOP_REASONS.RUNTIME_ERROR || stopReason === STOP_REASONS.BREAK || stopReason === STOP_REASONS.STOP_STATEMENT) {
×
682
            this.primaryThread = (update.data as ThreadAttached).threadIndex;
×
683
            this.emit(eventName, update);
×
684
        }
685
    }
686

687
    public destroy() {
688
        this.shutdown('close');
8✔
689
    }
690

691
    private shutdown(eventName: 'app-exit' | 'close') {
692
        if (this.controllerClient) {
9✔
693
            this.controllerClient.removeAllListeners();
3✔
694
            this.controllerClient.destroy();
3✔
695
            this.controllerClient = undefined;
3✔
696
        }
697

698
        if (this.ioClient) {
9!
699
            this.ioClient.removeAllListeners();
×
700
            this.ioClient.destroy();
×
701
            this.ioClient = undefined;
×
702
        }
703

704
        this.emit(eventName);
9✔
705
    }
706
}
707

708
export interface ProtocolVersionDetails {
709
    message: string;
710
    errorCode: PROTOCOL_ERROR_CODES;
711
}
712

713
export interface BreakpointSpec {
714
    /**
715
     * The path of the source file where the breakpoint is to be inserted.
716
     */
717
    filePath: string;
718
    /**
719
     * The (1-based) line number in the channel application code where the breakpoint is to be executed.
720
     */
721
    lineNumber: number;
722
    /**
723
     * 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.
724
     */
725
    hitCount?: number;
726
    /**
727
     * BrightScript code that evaluates to a boolean value. The expression is compiled and executed in
728
     * the context where the breakpoint is located. If specified, the hitCount is only be
729
     * updated if this evaluates to true.
730
     * @avaiable since protocol version 3.1.0
731
     */
732
    conditionalExpression?: string;
733
}
734

735
export interface ConstructorOptions {
736
    /**
737
     * The host/ip address of the Roku
738
     */
739
    host: string;
740
    /**
741
     * The port number used to send all debugger commands. This is static/unchanging for Roku devices,
742
     * but is configurable here to support unit testing or alternate runtimes (i.e. https://www.npmjs.com/package/brs)
743
     */
744
    controllerPort?: number;
745
    /**
746
     * The interval (in milliseconds) for how frequently the `connect`
747
     * call should retry connecting to the controller port. At the start of a debug session,
748
     * the protocol debugger will start trying to connect the moment the channel is sideloaded,
749
     * and keep trying until a successful connection is established or the debug session is terminated
750
     * @default 250
751
     */
752
    controllerConnectInterval?: number;
753
    /**
754
     * The maximum time (in milliseconds) the debugger will keep retrying connections.
755
     * This is here to prevent infinitely pinging the Roku device.
756
     */
757
    controllerConnectMaxTime?: number;
758
}
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