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

rokucommunity / roku-debug / #2010

pending completion
#2010

push

web-flow
Merge bb51a5c85 into f30a7deaa

1834 of 2724 branches covered (67.33%)

Branch coverage included in aggregate %.

1866 of 1866 new or added lines in 41 files covered. (100.0%)

3396 of 4571 relevant lines covered (74.29%)

25.55 hits per line

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

81.04
/src/debugProtocol/server/DebugProtocolServer.ts
1
import { EventEmitter } from 'eventemitter3';
1✔
2
import * as Net from 'net';
1✔
3
import { ActionQueue } from '../../managers/ActionQueue';
1✔
4
import { Command, CommandCode } from '../Constants';
1✔
5
import type { ProtocolRequest, ProtocolResponse } from '../events/ProtocolEvent';
6
import { AddBreakpointsRequest } from '../events/requests/AddBreakpointsRequest';
1✔
7
import { AddConditionalBreakpointsRequest } from '../events/requests/AddConditionalBreakpointsRequest';
1✔
8
import { ContinueRequest } from '../events/requests/ContinueRequest';
1✔
9
import { ExecuteRequest } from '../events/requests/ExecuteRequest';
1✔
10
import { ExitChannelRequest } from '../events/requests/ExitChannelRequest';
1✔
11
import { HandshakeRequest } from '../events/requests/HandshakeRequest';
1✔
12
import { ListBreakpointsRequest } from '../events/requests/ListBreakpointsRequest';
1✔
13
import { RemoveBreakpointsRequest } from '../events/requests/RemoveBreakpointsRequest';
1✔
14
import { StackTraceRequest } from '../events/requests/StackTraceRequest';
1✔
15
import { StepRequest } from '../events/requests/StepRequest';
1✔
16
import { StopRequest } from '../events/requests/StopRequest';
1✔
17
import { ThreadsRequest } from '../events/requests/ThreadsRequest';
1✔
18
import { VariablesRequest } from '../events/requests/VariablesRequest';
1✔
19
import { HandshakeResponse } from '../events/responses/HandshakeResponse';
1✔
20
import { HandshakeV3Response } from '../events/responses/HandshakeV3Response';
1✔
21
import PluginInterface from '../PluginInterface';
1✔
22
import type { ProtocolServerPlugin } from './DebugProtocolServerPlugin';
23
import { logger } from '../../logging';
1✔
24
import { defer, util } from '../../util';
1✔
25
import { protocolUtil } from '../ProtocolUtil';
1✔
26
import { SmartBuffer } from 'smart-buffer';
1✔
27

28
export const DEBUGGER_MAGIC = 'bsdebug';
1✔
29

30
/**
31
 * A class that emulates the way a Roku's DebugProtocol debug session/server works. This is mostly useful for unit testing,
32
 * but might eventually be helpful for an off-device emulator as well
33
 */
34
export class DebugProtocolServer {
1✔
35
    constructor(
36
        public options?: DebugProtocolServerOptions
58✔
37
    ) {
38

39
    }
40

41
    private logger = logger.createLogger(`[${DebugProtocolServer.name}]`);
58✔
42

43
    /**
44
     * Indicates whether the client has sent the magic string to kick off the debug session.
45
     */
46
    private isHandshakeComplete = false;
58✔
47

48
    private buffer = Buffer.alloc(0);
58✔
49

50
    /**
51
     * The server
52
     */
53
    private server: Net.Server;
54
    /**
55
     * Once a client connects, this is a reference to that client
56
     */
57
    private client: Net.Socket;
58

59
    /**
60
     * A collection of plugins that can interact with the server at lifecycle points
61
     */
62
    public plugins = new PluginInterface<ProtocolServerPlugin>();
58✔
63

64
    /**
65
     * A queue for processing the incoming buffer, every transmission at a time
66
     */
67
    private bufferQueue = new ActionQueue();
58✔
68

69
    public get controlPort() {
70
        return this.options.controlPort ?? this._port;
58✔
71
    }
72
    private _port: number;
73

74
    /**
75
     * Run the server. This opens a socket and listens for a connection.
76
     * The promise resolves when the server has started listening. It does NOT wait for a client to connect
77
     */
78
    public async start() {
79
        const deferred = defer();
58✔
80
        try {
58✔
81
            this.server = new Net.Server({});
58✔
82
            //Roku only allows 1 connection, so we should too.
83
            this.server.maxConnections = 1;
58✔
84

85
            //whenever a client makes a connection
86
            // eslint-disable-next-line @typescript-eslint/no-misused-promises
87
            this.server.on('connection', async (socket: Net.Socket) => {
58✔
88
                const event = await this.plugins.emit('onClientConnected', {
52✔
89
                    server: this,
90
                    client: socket
91
                });
92
                this.client = event.client;
52✔
93

94
                //anytime we receive incoming data from the client
95
                this.client.on('data', (data) => {
52✔
96
                    //queue up processing the new data, chunk by chunk
97
                    void this.bufferQueue.run(async () => {
104✔
98
                        this.buffer = Buffer.concat([this.buffer, data]);
104✔
99
                        while (this.buffer.length > 0 && await this.process()) {
104✔
100
                            //the loop condition is the actual work
101
                        }
102
                        return true;
104✔
103
                    });
104
                });
105
                //handle connection errors
106
                this.client.on('error', (e) => {
52✔
107
                    this.logger.error(e);
×
108
                });
109
            });
110
            this._port = this.controlPort ?? await util.getPort();
58✔
111

112
            //handle connection errors
113
            this.server.on('error', (e) => {
58✔
114
                this.logger.error(e);
×
115
            });
116

117
            this.server.listen({
58✔
118
                port: this.options.controlPort ?? 8081,
174✔
119
                hostName: this.options.host ?? '0.0.0.0'
174✔
120
            }, () => {
121
                void this.plugins.emit('onServerStart', { server: this });
58✔
122
                deferred.resolve();
58✔
123
            });
124
        } catch (e) {
125
            deferred.reject(e);
×
126
        }
127
        return deferred.promise;
58✔
128
    }
129

130
    public async stop() {
131
        //close the client socket
132
        this.client?.destroy();
57✔
133

134

135
        //now close the server
136
        try {
57✔
137
            await new Promise<void>((resolve, reject) => {
57✔
138
                this.server.close((err) => {
57✔
139
                    if (err) {
57!
140
                        reject(err);
×
141
                    } else {
142
                        resolve();
57✔
143
                    }
144
                });
145
            });
146
        } finally {
147
            this.client?.removeAllListeners();
57✔
148
            delete this.client;
57✔
149
            this.server?.removeAllListeners();
57!
150
            delete this.server;
57✔
151
        }
152
    }
153

154
    public async destroy() {
155
        await this.stop();
51✔
156
    }
157

158
    /**
159
     * Given a buffer, find the request that matches it
160
     */
161
    public static getRequest(buffer: Buffer, allowHandshake: boolean): ProtocolRequest {
162
        //when enabled, look at the start of the buffer for the exact DEBUGGER_MAGIC text. This is a boolean because
163
        //there could be cases where binary data looks similar to this structure, so the caller must opt-in to this logic
164
        if (allowHandshake && buffer.length >= 8 && protocolUtil.readStringNT(SmartBuffer.fromBuffer(buffer)) === DEBUGGER_MAGIC) {
53!
165
            return HandshakeRequest.fromBuffer(buffer);
×
166
        }
167
        // if we don't have enough buffer data, skip this
168
        if (buffer.length < 12) {
53!
169
            return;
×
170
        }
171
        //the client may only send commands to the server, so extract the command type from the known byte position
172
        const command = CommandCode[buffer.readUInt32LE(8)] as Command; // command_code
53✔
173
        switch (command) {
53✔
174
            case Command.AddBreakpoints:
175
                return AddBreakpointsRequest.fromBuffer(buffer);
5✔
176
            case Command.Stop:
177
                return StopRequest.fromBuffer(buffer);
1✔
178
            case Command.Continue:
179
                return ContinueRequest.fromBuffer(buffer);
1✔
180
            case Command.Threads:
181
                return ThreadsRequest.fromBuffer(buffer);
9✔
182
            case Command.StackTrace:
183
                return StackTraceRequest.fromBuffer(buffer);
7✔
184
            case Command.Variables:
185
                return VariablesRequest.fromBuffer(buffer);
15✔
186
            case Command.Step:
187
                return StepRequest.fromBuffer(buffer);
7✔
188
            case Command.ListBreakpoints:
189
                return ListBreakpointsRequest.fromBuffer(buffer);
3✔
190
            case Command.RemoveBreakpoints:
191
                return RemoveBreakpointsRequest.fromBuffer(buffer);
1✔
192
            case Command.Execute:
193
                return ExecuteRequest.fromBuffer(buffer);
1✔
194
            case Command.AddConditionalBreakpoints:
195
                return AddConditionalBreakpointsRequest.fromBuffer(buffer);
2✔
196
            case Command.ExitChannel:
197
                return ExitChannelRequest.fromBuffer(buffer);
1✔
198
        }
199
    }
200

201
    private getResponse(request: ProtocolRequest) {
202
        if (request instanceof HandshakeRequest) {
50!
203
            return HandshakeV3Response.fromJson({
50✔
204
                magic: this.magic,
205
                protocolVersion: '3.1.0',
206
                //TODO update this to an actual date from the device
207
                revisionTimestamp: new Date(2022, 1, 1)
208
            });
209
        }
210
    }
211

212
    /**
213
     * Process a single request.
214
     * @returns true if successfully processed a request, and false if not
215
     */
216
    private async process() {
217
        try {
105✔
218
            this.logger.log('process() start', { buffer: this.buffer.toJSON() });
105✔
219

220
            //at this point, there is an active debug session. The plugin must provide us all the real-world data
221
            let { buffer, request } = await this.plugins.emit('provideRequest', {
105✔
222
                server: this,
223
                buffer: this.buffer,
224
                request: undefined
225
            });
226

227
            //we must build the request if the plugin didn't supply one (most plugins won't provide a request...)
228
            if (!request) {
105!
229
                //if we haven't seen the handshake yet, look for the handshake first
230
                if (!this.isHandshakeComplete) {
105✔
231
                    request = HandshakeRequest.fromBuffer(buffer);
52✔
232
                } else {
233
                    request = DebugProtocolServer.getRequest(buffer, false);
53✔
234
                }
235
            }
236

237
            //if we couldn't construct a request this request, hard-fail
238
            if (!request || !request.success) {
105!
239
                this.logger.error('process() invalid request', { request });
×
240
                throw new Error(`Unable to parse request: ${JSON.stringify(this.buffer.toJSON().data)}`);
×
241
            }
242

243
            this.logger.log('process() constructed request', { request });
105✔
244

245
            //trim the buffer now that the request has been processed
246
            this.buffer = buffer.slice(request.readOffset);
105✔
247

248
            this.logger.log('process() buffer sliced', { buffer: this.buffer.toJSON() });
105✔
249

250
            //now ask the plugin to provide a response for the given request
251
            let { response } = await this.plugins.emit('provideResponse', {
105✔
252
                server: this,
253
                request: request,
254
                response: undefined
255
            });
256

257

258
            //if the plugin didn't provide a response, we need to try our best to make one (we only support a few...plugins should provide most of them)
259
            if (!response) {
105✔
260
                response = this.getResponse(request);
50✔
261
            }
262

263
            if (!response) {
105!
264
                this.logger.error('process() invalid response', { request, response });
×
265
                throw new Error(`Server was unable to provide a response for ${JSON.stringify(request.data)}`);
×
266
            }
267

268

269
            //if this is part of the handshake flow, the client should have sent a magic string to kick off the debugger. If it matches, set `isHandshakeComplete = true`
270
            if ((response instanceof HandshakeResponse || response instanceof HandshakeV3Response) && response.data.magic === this.magic) {
105✔
271
                this.isHandshakeComplete = true;
51✔
272
            }
273

274
            //send the response to the client. (TODO handle when the response is missing)
275
            await this.sendResponse(response);
105✔
276
            return true;
105✔
277
        } catch (e) {
278
            this.logger.error('process() error', e);
×
279
        }
280
        return false;
×
281
    }
282

283
    /**
284
     * Send a response from the server to the client. This involves writing the response buffer to the client socket
285
     */
286
    private async sendResponse(response: ProtocolResponse) {
287
        const event = await this.plugins.emit('beforeSendResponse', {
158✔
288
            server: this,
289
            response: response
290
        });
291

292
        this.logger.log('sendResponse()', { response });
158✔
293
        this.client.write(event.response.toBuffer());
158✔
294

295
        await this.plugins.emit('afterSendResponse', {
158✔
296
            server: this,
297
            response: event.response
298
        });
299
        return event.response;
158✔
300
    }
301

302
    /**
303
     * Send an update from the server to the client. This can be things like ALL_THREADS_STOPPED
304
     */
305
    public sendUpdate(update: ProtocolResponse) {
306
        return this.sendResponse(update);
53✔
307
    }
308

309
    /**
310
     * An event emitter used for all of the events this server emitts
311
     */
312
    private emitter = new EventEmitter();
58✔
313

314
    public on<T = { response: Response }>(eventName: 'before-send-response', callback: (event: T) => void);
315
    public on<T = { response: Response }>(eventName: 'after-send-response', callback: (event: T) => void);
316
    public on<T = { client: Net.Socket }>(eventName: 'client-connected', callback: (event: T) => void);
317
    public on<T = any>(eventName: string, callback: (data: T) => void)
318
    public on<T = any>(eventName: string, callback: (data: T) => void) {
319
        this.emitter.on(eventName, callback);
×
320
        return () => {
×
321
            this.emitter?.removeListener(eventName, callback);
×
322
        };
323
    }
324

325
    /**
326
     * Subscribe to an event exactly one time. This will fire the very next time an event happens,
327
     * and then immediately unsubscribe
328
     */
329
    public once<T>(eventName: string): Promise<T> {
330
        return new Promise<T>((resolve) => {
×
331
            const off = this.on<T>(eventName, (event) => {
×
332
                off();
×
333
                resolve(event);
×
334
            });
335
        });
336
    }
337

338
    public emit<T = { response: Response }>(eventName: 'before-send-response', event: T): T;
339
    public emit<T = { response: Response }>(eventName: 'after-send-response', event: T): T;
340
    public emit<T = { client: Net.Socket }>(eventName: 'client-connected', event: T): T;
341
    public emit<T>(eventName: string, event: any): T {
342
        this.emitter?.emit(eventName, event);
×
343
        return event;
×
344
    }
345

346
    /**
347
     * The magic string used to kick off the debug session.
348
     * @default "bsdebug"
349
     */
350
    private get magic() {
351
        return this.options.magic ?? DEBUGGER_MAGIC;
102!
352
    }
353
}
354

355
export interface DebugProtocolServerOptions {
356
    /**
357
     * The magic that is sent as part of the handshake
358
     */
359
    magic?: string;
360
    /**
361
     * The port to use for the primary communication between this server and a client
362
     */
363
    controlPort?: number;
364
    /**
365
     * A specific host to listen on. If not specified, all hosts are used
366
     */
367
    host?: string;
368
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc