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

rokucommunity / roku-debug / 39450157926

20 Oct 2026 03:37PM UTC coverage: 74.198% (+0.6%) from 73.647%
39450157926

Pull #435

github

web-flow
Merge 2ccf50e6b into aec6474ed
Pull Request #435: chore: updated roku-deploy and brighterscript

3933 of 5575 branches covered (70.55%)

Branch coverage included in aggregate %.

6428 of 8389 relevant lines covered (76.62%)

49.78 hits per line

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

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

82 ✔
53
export class DebugProtocolClient {
82 ✔
54

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

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

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

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

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

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

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

107
    /**
108
     * A collection of plugins that can interact with the client at lifecycle points
109
     */
110
    public plugins = new PluginInterface<DebugProtocolClientPlugin>();
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
309 !
115
     */
116
    private controlSocket: RokuDeploySocket;
117
    /**
59 ✔
118
     * Promise that is resolved when the control socket is closed
59 ✔
119
     */
59 ✔
120
    private controlSocketClosed = defer<void>();
59 ✔
121
    /**
122
     * A socket where the debug server will send stdio
123
     */
124
    private ioSocket: RokuDeploySocket;
125
    /**
126
     * Resolves when the ioSocket has closed
127
     */
379 ✔
128
    private ioSocketClosed = defer<void>();
379 ✔
129
    /**
223 ✔
130
     * The buffer where all unhandled data will be stored until successfully consumed
131
     */
132
    private buffer = Buffer.alloc(0);
133
    /**
134
     * Is the debugger currently stopped at a line of code in the program
712 ✔
135
     */
136
    public isStopped = false;
712 ✔
137
    private requestIdSequence = 1;
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>;
145
    public once(eventName: 'breakpoints-verified'): Promise<BreakpointsVerifiedEvent>;
73 ✔
146
    public once<T = AllThreadsStoppedUpdate | ThreadAttachedUpdate>(eventName: 'runtime-error' | 'suspend'): Promise<T>;
147
    public once(eventName: 'io-output'): Promise<string>;
148
    public once(eventName: 'data'): Promise<Buffer>;
149
    public once(eventName: 'response'): Promise<ProtocolResponse>;
150
    public once(eventName: 'update'): Promise<ProtocolUpdate>;
151
    public once(eventName: 'protocol-version'): Promise<ProtocolVersionDetails>;
152
    public once(eventName: 'handshake-verified'): Promise<HandshakeResponse>;
70 ✔
153
    public once(eventName: string) {
70 ✔
154
        return new Promise((resolve) => {
155
            const disconnect = this.on(eventName as Parameters<DebugProtocolClient['on']>[0], (...args) => {
156
                disconnect();
157
                resolve(...args);
70 ✔
158
            });
70 ✔
159
        });
70 ✔
160
    }
161

162
    public on(eventName: 'compile-error', handler: (event: CompileErrorUpdate) => void);
70 ✔
163
    public on(eventName: 'app-exit' | 'cannot-continue' | 'close' | 'start', handler: () => void);
164
    public on(eventName: 'breakpoints-verified', handler: (event: BreakpointsVerifiedEvent) => void);
165
    public on(eventName: 'response', handler: (response: ProtocolResponse) => void);
166
    public on(eventName: 'update', handler: (update: ProtocolUpdate) => void);
70 ✔
167
    /**
168
     * The raw data from the server socket. You probably don't need this...
169
     */
170
    public on(eventName: 'data', handler: (data: Buffer) => void);
171
    public on<T = AllThreadsStoppedUpdate | ThreadAttachedUpdate>(eventName: 'runtime-error' | 'suspend', handler: (data: T) => void);
172
    public on(eventName: 'io-output', handler: (output: string) => void);
70 ✔
173
    public on(eventName: 'protocol-version', handler: (data: ProtocolVersionDetails) => void);
70 ✔
174
    public on(eventName: 'handshake-verified', handler: (data: HandshakeResponse) => void);
175
    // public on(eventname: 'rendezvous', handler: (output: RendezvousHistory) => void);
70 ✔
176
    // public on(eventName: 'runtime-error', handler: (error: BrightScriptRuntimeError) => void);
70 ✔
177
    public on(eventName: string, handler: (payload: any) => void) {
239 ✔
178
        this.emitter.on(eventName, handler);
239 ✔
179
        return () => {
180
            this.emitter.removeListener(eventName, handler);
239 ✔
181
        };
239 ✔
182
    }
239 ✔
183

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

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

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

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

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

236
    /**
237
     * Connect to the debug server.
408 ✔
238
     * @param sendHandshake should the handshake be sent as part of this connect process. If false, `.sendHandshake()` will need to be called before a session can begin
3 ✔
239
     */
240
    public async connect(sendHandshake = true): Promise<boolean> {
408 ✔
241
        this.logger.log('connect', this.options);
242

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

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

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

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

3 ✔
278
        if (sendHandshake) {
4 ✔
279
            await this.sendHandshake();
280
        }
281
        return true;
8 ✔
282
    }
283

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

467
    /**
468
     * @param variablePathEntries One or more path entries to the variable to be inspected. E.g., m.top.myObj["someKey"] can be accessed with ["m","top","myobj","\"someKey\""].
469
     *
3 ✔
470
     *                            If no path is specified, the variables accessible from the specified stack frame are returned.
471
     *
3 !
472
     *                            Starting in protocol v3.1.0, The keys for indexed gets (i.e. obj["key"]) should be wrapped in quotes so they can be handled in a case-sensitive fashion (if applicable on device).
×
473
     *                            All non-quoted keys (i.e. strings without leading and trailing quotes inside them) will be treated as case-insensitive).
474
     * @param getChildKeys  If set, VARIABLES response include the child keys for container types like lists and associative arrays
475
     * @param stackFrameIndex 0 = first function called, nframes-1 = last function. This indexing does not match the order of the frames returned from the STACKTRACE command
476
     * @param threadIndex the index (or perhaps ID?) of the thread to get variables for
3 !
477
     */
478
    public async getVariables(variablePathEntries: Array<string> = [], stackFrameIndex: number = this.stackFrameIndex, threadIndex: number = this.primaryThread) {
×
479
        const response = await this.processVariablesRequest(
×
480
            VariablesRequest.fromJson({
×
481
                requestId: this.requestIdSequence++,
×
482
                threadIndex: threadIndex,
483
                stackFrameIndex: stackFrameIndex,
484
                getChildKeys: true,
485
                getVirtualKeys: this.capabilities?.supportsVirtualVariables,
486
                variablePathEntries: variablePathEntries.map(x => ({
4 ✔
487
                    //strip the surrounding quotes from a string key and un-escape doubled quotes (`""` -> `"`).
488
                    //BrightScript escapes a `"` inside a string as `""`, so the key `"` arrives as the token `""""`.
489
                    name: x.length >= 2 && x.startsWith('"') && x.endsWith('"')
13 ✔
490
                        ? x.slice(1, -1).replace(/""/g, '"')
491
                        : x.replace(/^"/, '').replace(/"$/, ''),
492
                    forceCaseInsensitive: !x.startsWith('"') && !x.endsWith('"'),
24 ✔
493
                    //vars that start with `'$'` are virtual (AA keys will wrapped in quotes so would start with `"$`
23 ✔
494
                    isVirtual: x.startsWith('$') // || x.startsWith('"$')
495
                })),
496
                //starting in protocol v3.1.0, it supports marking certain path items as case-insensitive (i.e. parts of DottedGet expressions)
2 ✔
497
                enableForceCaseInsensitivity: semver.satisfies(this.protocolVersion, '>=3.1.0') && variablePathEntries.length > 0
2 ✔
498
            })
499
        );
500

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

796
    /**
797
     * Sometimes a request arrives that we don't understand. If that's the case, this function can be used
798
     * to discard that entire response by discarding `packet_length` number of bytes
799
     * @returns true if bytes were actually discarded, false if the buffer was left untouched
800
     */
81 ✔
801
    private discardNextResponseOrUpdate(): boolean {
802
        //an unparseable pre-handshake buffer is a fragmented handshake still accumulating, not an
81 !
803
        //unknown message, so there's no packet length to recover with yet. never discard here
81 ✔
804
        if (!this.isHandshakeComplete) {
81 !
805
            return false;
81 !
806
        }
807
        const response = GenericV3Response.fromBuffer(this.buffer);
808
        if (response.success && response.data.packetLength > 0 && response.data.packetLength <= this.buffer.length) {
3 ✔
809
            this.logger.warn(`Unsupported response or updated encountered. Discarding ${response.data.packetLength} bytes:`, JSON.stringify(
810
                this.buffer.slice(0, response.data.packetLength + 1).toJSON().data
70 ✔
811
            ));
70 ✔
812
            //we have a valid event. Clear the buffer of this data
813
            this.buffer = this.buffer.slice(response.data.packetLength);
3 ✔
814
            return true;
3 ✔
815
        }
816
        return false;
817
    }
×
818

819
    /**
×
820
     * A counter to help give a unique id to each update (mostly just for logging purposes)
×
821
     */
×
822
    private updateSequence = 1;
823

×
824
    private logEvent(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate) {
825
        const [, eventName, eventType] = /(.+?)((?:v\d+_?\d*)?(?:request|response|update))/ig.exec(event?.constructor.name) ?? [];
1 ✔
826
        if (isProtocolRequest(event)) {
1 !
827
            this.logger.log(`${eventName} ${event.data.requestId} (${eventType})`, event, `(${event?.constructor.name})`);
1 ✔
828
        } else if (isProtocolUpdate(event)) {
829
            this.logger.log(`${eventName} ${this.updateSequence++} (${eventType})`, event, `(${event?.constructor.name})`);
1 ✔
830
        } else {
831
            if (event.data.errorCode === ErrorCode.OK) {
832
                this.logger.log(`${eventName} ${event.data.requestId} (${eventType})`, event, `(${event?.constructor.name})`);
×
833
            } else {
×
834
                this.logger.log(`[error] ${eventName} ${event.data.requestId} (${eventType})`, event, `(${event?.constructor.name})`);
835
            }
4 ✔
836
        }
837
    }
838

839
    private async process(): Promise<boolean> {
840
        try {
841
            this.logger.info('[process()]: buffer=', this.buffer.toJSON());
842

77 ✔
843
            let { responseOrUpdate } = await this.plugins.emit('provideResponseOrUpdate', {
77 ✔
844
                client: this,
845
                activeRequests: this.activeRequests,
846
                buffer: this.buffer
847
            });
77 ✔
848

73 ✔
849
            if (!responseOrUpdate) {
850
                responseOrUpdate = await this.getResponseOrUpdate(this.buffer);
851
            }
73 !
852

×
853
            //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)
854
            if (!responseOrUpdate) {
855
                this.logger.info('Unable to convert buffer into anything meaningful', this.buffer);
73 ✔
856
                //if we have packet length, and we have at least that many bytes, throw out this message so we can hopefully recover.
857
                //if we actually discarded bytes, there may be another full message right behind it in the same buffer, so keep looping
73 ✔
858
                return this.discardNextResponseOrUpdate();
73 ✔
859
            }
70 ✔
860
            if (!responseOrUpdate.success || responseOrUpdate.data.packetLength > this.buffer.length) {
70 ✔
861
                this.logger.log(`event parse failed. ${responseOrUpdate?.data?.packetLength} bytes required, ${this.buffer.length} bytes available`);
70 ✔
862
                return false;
863
            }
3 !
864

3 ✔
865
            //we have a valid event. Remove this data from the buffer
3 ✔
866
            this.buffer = this.buffer.slice(responseOrUpdate.readOffset);
867

868
            if (responseOrUpdate.data.errorCode !== ErrorCode.OK) {
4 ✔
869
                this.logEvent(responseOrUpdate);
3 ✔
870
            }
871

77 ✔
872
            //we got a result
873
            if (responseOrUpdate) {
874
                //emit the corresponding event
875
                if (isProtocolUpdate(responseOrUpdate)) {
876
                    this.logEvent(responseOrUpdate);
877
                    this.emit('update', responseOrUpdate);
878
                    await this.plugins.emit('onUpdate', {
71 !
879
                        client: this,
71 ✔
880
                        update: responseOrUpdate
71 ✔
881
                    });
71 ✔
882
                } else {
71 ✔
883
                    this.logEvent(responseOrUpdate);
71 ✔
884
                    this.emit('response', responseOrUpdate);
71 ✔
885
                    await this.plugins.emit('onResponse', {
71 !
886
                        client: this,
71 ✔
887
                        response: responseOrUpdate as any
71 ✔
888
                    });
889
                }
890
                return true;
891
            }
892
        } catch (e) {
×
893
            this.logger.error(`process() failed:`, e);
×
894
        }
×
895
    }
896

897
    /**
898
     * Given a buffer, try to parse into a specific ProtocolResponse or ProtocolUpdate
899
     */
900
    public async getResponseOrUpdate(buffer: Buffer): Promise<ProtocolResponse | ProtocolUpdate> {
×
901
        //if we haven't seen a handshake yet, try to convert the buffer into a handshake
×
902
        if (!this.isHandshakeComplete) {
903
            let handshake: HandshakeV3Response | HandshakeResponse;
904
            //try building the v3 handshake response first
905
            handshake = HandshakeV3Response.fromBuffer(buffer);
×
906
            //we didn't get a v3 handshake. try building an older handshake response
×
907
            if (!handshake.success) {
908
                handshake = HandshakeResponse.fromBuffer(buffer);
71 ✔
909
            }
71 ✔
910
            if (handshake.success) {
911
                await this.verifyHandshake(handshake);
912
                return handshake;
×
913
            }
×
914
            return;
×
915
        }
×
916

917
        let genericResponse = this.watchPacketLength ? GenericV3Response.fromBuffer(buffer) : GenericResponse.fromBuffer(buffer);
918

919
        //if the response has a non-OK error code, we won't receive the expected response type,
920
        //so return the generic response
921
        if (genericResponse.success && genericResponse.data.errorCode !== ErrorCode.OK) {
922
            return genericResponse;
4 ✔
923
        }
924
        // a nonzero requestId means this is a response to a request that we sent
3 ✔
925
        if (genericResponse.data.requestId !== 0) {
926
            //requestId 0 means this is an update
927
            const request = this.activeRequests.get(genericResponse.data.requestId);
928
            if (request) {
3 ✔
929
                return DebugProtocolClient.getResponse(this.buffer, request.data.command);
930
            }
3 ✔
931
        } else {
932
            return this.getUpdate(this.buffer);
3 ✔
933
        }
3 ✔
934
    }
935

3 ✔
936
    public static getResponse(buffer: Buffer, command: Command) {
3 ✔
937
        switch (command) {
3 ✔
938
            case Command.Stop:
3 ✔
939
            case Command.Continue:
3 ✔
940
            case Command.Step:
3 ✔
941
            case Command.ExitChannel:
942
                return GenericV3Response.fromBuffer(buffer);
3 ✔
943
            case Command.Execute:
3 ✔
944
                return ExecuteV3Response.fromBuffer(buffer);
945
            case Command.AddBreakpoints:
2 ✔
946
            case Command.AddConditionalBreakpoints:
947
                return AddBreakpointsResponse.fromBuffer(buffer);
948
            case Command.ListBreakpoints:
1 ✔
949
                return ListBreakpointsResponse.fromBuffer(buffer);
950
            case Command.RemoveBreakpoints:
951
                return RemoveBreakpointsResponse.fromBuffer(buffer);
3 ✔
952
            case Command.Variables:
3 ✔
953
                return VariablesResponse.fromBuffer(buffer);
3 ✔
954
            case Command.StackTrace:
955
                return StackTraceV3Response.fromBuffer(buffer);
956
            case Command.Threads:
3 ✔
957
                return ThreadsResponse.fromBuffer(buffer);
×
958
            case Command.SetExceptionBreakpoints:
×
959
                return SetExceptionBreakpointsResponse.fromBuffer(buffer);
960
            default:
961
                return undefined;
3 ✔
962
        }
963
    }
964

×
965
    public getUpdate(buffer: Buffer): ProtocolUpdate {
×
966
        //read the update_type from the buffer (save some buffer parsing time by narrowing to the exact update type)
967
        const updateTypeCode = buffer.readUInt32LE(
968
            // if the protocol supports packet length, then update_type is bytes 12-16. Otherwise, it's bytes 8-12
1 ✔
969
            this.watchPacketLength ? 12 : 8
970
        );
971
        const updateType = UpdateTypeCode[updateTypeCode] as UpdateType;
972

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

1008
    private handleUpdateQueue = new ActionQueue();
1009

1010
    /**
1011
     * Handle/process any received updates from the debug protocol
1012
     */
80 ✔
1013
    private async handleUpdate(update: ProtocolUpdate) {
1014
        return this.handleUpdateQueue.run(async () => {
1015
            update = (await this.plugins.emit('beforeHandleUpdate', {
1016
                client: this,
80 !
1017
                update: update
80 ✔
1018
            })).update;
80 ✔
1019

1020
            if (update instanceof AllThreadsStoppedUpdate || update instanceof ThreadAttachedUpdate) {
1021
                this.isStopped = true;
1022

80 ✔
1023
                let eventName: 'runtime-error' | 'suspend';
69 ✔
1024
                //TODO should caught runtime error remap to runtime error?
1025
                if (update.data.stopReason === StopReason.RuntimeError || update.data.stopReason === StopReason.CaughtRuntimeError) {
69 ✔
1026
                    eventName = 'runtime-error';
1027
                } else {
1028
                    eventName = 'suspend';
1029
                }
69 !
1030

1031
                const isValidStopReason = [StopReason.RuntimeError, StopReason.Break, StopReason.StopStatement, StopReason.CaughtRuntimeError].includes(update.data.stopReason);
69 !
1032

69 !
1033
                if (update instanceof AllThreadsStoppedUpdate && isValidStopReason) {
69 ✔
1034
                    this.primaryThread = update.data.threadIndex;
69 ✔
1035
                    this.stackFrameIndex = 0;
1036
                    this.emit(eventName, update);
1037
                } else if (update instanceof ThreadAttachedUpdate && isValidStopReason) {
1038
                    this.primaryThread = update.data.threadIndex;
1039
                    this.emit(eventName, update);
1040
                }
×
1041

1042
            } else if (isIOPortOpenedUpdate(update)) {
80 ✔
1043
                this.connectToIoPort(update);
3 ✔
1044
            }
1045
            return true;
3 ✔
1046
        });
3 ✔
1047
    }
1048

1049
    /**
1050
     * Verify all the handshake data
3 !
1051
     */
×
1052
    private async verifyHandshake(response: HandshakeResponse | HandshakeV3Response): Promise<boolean> {
1053
        if (DebugProtocolClient.DEBUGGER_MAGIC === response.data.magic) {
×
1054
            this.logger.log('Magic is valid.');
×
1055

1056
            this.capabilities = new ProtocolCapabilities(response.data.protocolVersion);
1057
            this.logger.log('Protocol Version:', this.protocolVersion);
×
1058

×
1059
            this.watchPacketLength = semver.satisfies(this.protocolVersion, '>=3.0.0');
1060
            this.isHandshakeComplete = true;
1061

3 !
1062
            let handshakeVerified = true;
1063

3 !
1064
            if (semver.satisfies(this.protocolVersion, this.supportedVersionRange)) {
3 !
1065
                this.logger.log('supported');
3 ✔
1066
                this.emit('protocol-version', {
3 ✔
1067
                    message: `Protocol Version ${this.protocolVersion} is supported!`,
1068
                    errorCode: PROTOCOL_ERROR_CODES.SUPPORTED
1069
                });
1070
            } else if (semver.gtr(this.protocolVersion, this.supportedVersionRange)) {
2 ✔
1071
                this.logger.log('roku-debug has not been tested against protocol version', this.protocolVersion);
2 ✔
1072
                this.emit('protocol-version', {
1073
                    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`,
1074
                    errorCode: PROTOCOL_ERROR_CODES.NOT_TESTED
1075
                });
1076
            } else {
1077
                this.logger.log('not supported');
425 !
1078
                this.emit('protocol-version', {
1079
                    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`,
2 ✔
1080
                    errorCode: PROTOCOL_ERROR_CODES.NOT_SUPPORTED
1081
                });
1082
                await this.emit('close');
1083
                handshakeVerified = false;
1084
            }
1085

×
1086
            this.emit('handshake-verified', handshakeVerified);
1087
            return handshakeVerified;
2 ✔
1088
        } else {
1089
            this.logger.log('Closing connection due to bad debugger magic', response.data.magic);
1090
            this.emit('handshake-verified', false);
1091
            await this.emit('close');
1092
            return false;
1093
        }
665 !
1094
    }
1095

2 ✔
1096
    /**
1097
     * When the debugger emits the IOPortOpenedUpdate, we need to immediately connect to the IO port to start receiving that data
1098
     */
1099
    private connectToIoPort(update: IOPortOpenedUpdate) {
1100
        if (update.success) {
1101
            // Create a new client socket to the io port the device just opened
1102
            this.ioSocket = this.createRokuDeploySocket({
1103
                device: this.options.device,
1104
                port: update.data.port
1105
            });
1106
            util.registerSocketLogging(this.ioSocket, this.logger, 'IoSocket');
1107

1108
            // Send a connection request to the server.
1109
            this.logger.log(`Connect to IO Port ${update.data.port}`);
1110

1111
            //sometimes the server shuts down before we had a chance to connect, so recover more gracefully
1112
            try {
1113
                this.ioSocket.connect(() => {
1114
                    // If there is no error, the server has accepted the request
1115
                    this.logger.log('TCP connection established with the IO Port.');
1116
                    this.connectedToIoPort = true;
1117

1118
                    let lastPartialLine = '';
1119
                    this.ioSocket.on('data', (buffer: Buffer) => {
1120
                        this.writeToBufferLog('io', buffer);
1121
                        let logResult = util.handleLogFragments(lastPartialLine, buffer.toString());
1122

1123
                        // Save any remaining partial line for the next event
1124
                        lastPartialLine = logResult.remaining;
1125
                        if (logResult.completed) {
1126
                            // Emit the completed io string.
1127
                            this.emit('io-output', logResult.completed);
1128
                        } else {
1129
                            this.logger.debug('Buffer was split', lastPartialLine);
1130
                        }
1131
                    });
1132

1133
                    this.ioSocket.on('close', () => {
1134
                        this.logger.log('IO socket closed');
1135
                        this.ioSocketClosed.tryResolve();
1136
                    });
1137

1138
                    // Don't forget to catch error, for your own sake.
1139
                    this.ioSocket.once('error', (err) => {
1140
                        this.ioSocket.end();
1141
                        this.logger.error(err);
1142
                    });
1143
                });
1144
                return true;
1145
            } catch (e) {
1146
                this.logger.error(`Failed to connect to IO socket at port ${update.data.port}`, e);
1147
                this.emit('app-exit');
1148
            }
1149
        }
1150
        return false;
1151
    }
1152

1153
    /**
1154
     * Destroy this instance, shutting down any sockets or other long-running items and cleaning up.
1155
     * @param immediate if true, all sockets are immediately closed and do not gracefully shut down
1156
     */
1157
    public async destroy(immediate = false) {
1158
        await this.shutdown(immediate);
1159
    }
1160

1161
    private shutdownPromise: Promise<void>;
1162
    private async shutdown(immediate = false) {
1163
        if (this.shutdownPromise === undefined) {
1164
            this.logger.log('[shutdown] shutting down');
1165
            this.shutdownPromise = this._shutdown(immediate);
1166
        } else {
1167
            this.logger.log(`[shutdown] Tried to call .shutdown() again. Returning the same promise`);
1168
        }
1169
        return this.shutdownPromise;
1170
    }
1171

1172
    private async _shutdown(immediate = false) {
1173
        let exitChannelTimeout = this.options?.exitChannelTimeout ?? 30_000;
1174
        let shutdownTimeMax = this.options?.shutdownTimeout ?? 10_000;
1175
        //if immediate is true, this is an instant shutdown force. don't wait for anything
1176
        if (immediate) {
1177
            exitChannelTimeout = 0;
1178
            shutdownTimeMax = 0;
1179
        }
1180

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

1197
        await Promise.all([
1198
            this.destroyControlSocket(shutdownTimeMax),
1199
            this.destroyIOSocket(shutdownTimeMax, immediate)
1200
        ]);
1201
        this.emitter?.removeAllListeners();
1202
        this.buffer = Buffer.alloc(0);
1203
        this.bufferQueue.destroy();
1204
    }
1205

1206
    private isDestroyingControlSocket = false;
1207

1208
    private async destroyControlSocket(timeout: number) {
1209
        if (this.controlSocket && !this.isDestroyingControlSocket) {
1210
            this.isDestroyingControlSocket = true;
1211

1212
            //wait for the controlSocket to be closed
1213
            await Promise.race([
1214
                this.controlSocketClosed.promise,
1215
                util.sleep(timeout)
1216
            ]);
1217

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

1220
            //destroy the controlSocket
1221
            this.controlSocket?.removeAllListeners();
1222
            this.controlSocket?.destroy();
1223
            this.controlSocket = undefined;
1224
            this.isDestroyingControlSocket = false;
1225
        }
1226
    }
1227

1228
    private isDestroyingIOSocket = false;
1229

1230
    /**
1231
     * @param immediate if true, force close immediately instead of waiting for it to settle
1232
     */
1233
    private async destroyIOSocket(timeout: number, immediate = false) {
1234
        if (this.ioSocket && !this.isDestroyingIOSocket) {
1235
            this.isDestroyingIOSocket = true;
1236
            //wait for the ioSocket to be closed
1237
            await Promise.race([
1238
                this.ioSocketClosed.promise.then(() => this.logger.log('IO socket closed')),
1239
                util.sleep(timeout)
1240
            ]);
1241

1242
            //if the io socket is not closed, wait for it to at least settle
1243
            if (!this.ioSocketClosed.isCompleted && !immediate) {
1244
                await new Promise<void>((resolve) => {
1245
                    const callback = debounce(() => {
1246
                        resolve();
1247
                    }, 250);
1248
                    //trigger the current callback once.
1249
                    callback();
1250
                    this.ioSocket?.on('drain', callback as () => void);
1251
                });
1252
            }
1253

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

1256
            //destroy the ioSocket
1257
            this.ioSocket?.removeAllListeners?.();
1258
            this.ioSocket?.destroy?.();
1259
            this.ioSocket = undefined;
1260
            this.isDestroyingIOSocket = false;
1261
        }
1262
    }
1263
}
1264

1265
export interface ProtocolVersionDetails {
1266
    message: string;
1267
    errorCode: PROTOCOL_ERROR_CODES;
1268
}
1269

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

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

1319
    /**
1320
     * The number of milliseconds that the client should wait during a shutdown request before forcefully terminating the sockets
1321
     */
1322
    shutdownTimeout?: number;
1323

1324
    /**
1325
     * The max time the client will wait for the `exit channel` response before forcefully terminating the sockets
1326
     */
1327
    exitChannelTimeout?: number;
1328
}
1329

1330
/**
1331
 * Is the event a ProtocolRequest
1332
 */
1333
export function isProtocolRequest(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate): event is ProtocolRequest {
1334
    return event?.constructor?.name.endsWith('Request') && event?.data?.requestId > 0;
1335
}
1336

1337
/**
1338
 * Is the event a ProtocolResponse
1339
 */
1340
export function isProtocolResponse(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate): event is ProtocolResponse {
1341
    return event?.constructor?.name.endsWith('Response') && event?.data?.requestId !== 0;
1342
}
1343

1344
/**
1345
 * Is the event a ProtocolUpdate update
1346
 */
1347
export function isProtocolUpdate(event: ProtocolRequest | ProtocolResponse | ProtocolUpdate): event is ProtocolUpdate {
1348
    return event?.constructor?.name.endsWith('Update') && event?.data?.requestId === 0;
1349
}
1350

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

© 2026 Coveralls, Inc