Skip to content

Commit d87b820

Browse files
authored
fix: serialize DVT socket reads through a single receive pump (#300)
* fix: serialize DVT socket reads through a single receive pump Concurrent receivers each attached their own once('data') listener to the shared socket, so both consumed the same chunk and the DTX stream desynchronized. Bytes arriving between reads were dropped. All reads now go through one pump that buffers chunks, parses frames sequentially and resolves per channel waiters. * addressing review comments
1 parent c664c04 commit d87b820

2 files changed

Lines changed: 359 additions & 63 deletions

File tree

src/services/ios/dvt/index.ts

Lines changed: 196 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ const log = getLogger('DVTSecureSocketProxyService');
2222

2323
const MIN_ERROR_DESCRIPTION_LENGTH = 20;
2424

25+
interface ChannelWaiter {
26+
resolve: (message: Buffer) => void;
27+
reject: (err: Error) => void;
28+
signal?: AbortSignal;
29+
onAbort?: () => void;
30+
}
31+
2532
/**
2633
* DVTSecureSocketProxyService provides access to Apple's DTServiceHub functionality
2734
* This service enables various instruments and debugging capabilities through the DTX protocol
@@ -37,8 +44,18 @@ export class DVTSecureSocketProxyService extends BaseService {
3744
private curMessageId: number = 0;
3845
private readonly channelCache: Map<string, Channel> = new Map();
3946
private readonly channelMessages: Map<number, ChannelFragmenter> = new Map();
47+
private readonly channelWaiters: Map<number, ChannelWaiter[]> = new Map();
4048
private isHandshakeComplete: boolean = false;
4149
private readBuffer: Buffer = Buffer.alloc(0);
50+
private readonly pendingChunks: Buffer[] = [];
51+
private bufferedLength: number = 0;
52+
private readonly dataWaiters: Array<() => void> = [];
53+
private socketError: Error | null = null;
54+
private isPumpRunning: boolean = false;
55+
private listeningSocket: net.Socket | null = null;
56+
private readonly boundOnData = (chunk: Buffer): void => this.onSocketData(chunk);
57+
private readonly boundOnError = (err: Error): void => this.onSocketError(err);
58+
private readonly boundOnClose = (): void => this.onSocketClose();
4259

4360
constructor(udid: string) {
4461
super(udid);
@@ -57,6 +74,7 @@ export class DVTSecureSocketProxyService extends BaseService {
5774
this.connection = await this.startLockdownWithoutCheckin(DVTSecureSocketProxyService.RSD_SERVICE_NAME);
5875
this.socket = this.connection.getSocket();
5976
stripSSL(this.socket);
77+
this.attachSocketListeners(this.socket);
6078

6179
await this.performHandshake();
6280
}
@@ -252,19 +270,99 @@ export class DVTSecureSocketProxyService extends BaseService {
252270
}
253271

254272
const socketToDestroy = this.socket;
273+
this.detachSocketListeners();
255274
this.connection.close();
256275
this.connection = null;
257276
this.socket = null;
258277
this.isHandshakeComplete = false;
278+
this.socketError = null;
259279
this.channelCache.clear();
260280
this.channelMessages.clear();
261281
this.channelMessages.set(DVTSecureSocketProxyService.BROADCAST_CHANNEL, new ChannelFragmenter());
282+
this.readBuffer = Buffer.alloc(0);
283+
this.pendingChunks.length = 0;
284+
this.bufferedLength = 0;
285+
this.rejectAllWaiters(new Error('Service closed'));
286+
this.flushDataWaiters();
262287

263-
// Forcibly destroy the socket so any pending readExact calls unblock
264-
// immediately rather than waiting for the remote FIN.
265288
socketToDestroy?.destroy();
266289
}
267290

291+
private attachSocketListeners(socket: net.Socket): void {
292+
if (this.listeningSocket === socket) {
293+
return;
294+
}
295+
this.detachSocketListeners();
296+
this.listeningSocket = socket;
297+
socket.on('data', this.boundOnData);
298+
socket.on('error', this.boundOnError);
299+
socket.on('close', this.boundOnClose);
300+
}
301+
302+
private detachSocketListeners(): void {
303+
if (!this.listeningSocket) {
304+
return;
305+
}
306+
this.listeningSocket.off('data', this.boundOnData);
307+
this.listeningSocket.off('error', this.boundOnError);
308+
this.listeningSocket.off('close', this.boundOnClose);
309+
this.listeningSocket = null;
310+
}
311+
312+
private flushDataWaiters(): void {
313+
for (const waiter of this.dataWaiters.splice(0)) {
314+
waiter();
315+
}
316+
}
317+
318+
private onSocketData(chunk: Buffer): void {
319+
this.pendingChunks.push(chunk);
320+
this.bufferedLength += chunk.length;
321+
if (this.dataWaiters.length > 0) {
322+
this.flushDataWaiters();
323+
} else {
324+
this.listeningSocket?.pause();
325+
}
326+
}
327+
328+
private consolidateReadBuffer(): void {
329+
if (this.pendingChunks.length === 0) {
330+
return;
331+
}
332+
this.readBuffer = Buffer.concat([this.readBuffer, ...this.pendingChunks]);
333+
this.pendingChunks.length = 0;
334+
}
335+
336+
private onSocketError(err: Error): void {
337+
if (!this.socketError) {
338+
this.socketError = err;
339+
}
340+
this.flushDataWaiters();
341+
}
342+
343+
private onSocketClose(): void {
344+
if (!this.socketError) {
345+
this.socketError = new Error('Socket closed');
346+
}
347+
this.flushDataWaiters();
348+
}
349+
350+
private rejectAllWaiters(error: Error): void {
351+
for (const waiters of this.channelWaiters.values()) {
352+
for (const waiter of waiters.splice(0)) {
353+
if (waiter.signal && waiter.onAbort) {
354+
waiter.signal.removeEventListener('abort', waiter.onAbort);
355+
}
356+
waiter.reject(error);
357+
}
358+
}
359+
this.channelWaiters.clear();
360+
}
361+
362+
private hasPendingWaiters(): boolean {
363+
return Array.from(this.channelWaiters.values()).some((waiters) => waiters.length > 0);
364+
}
365+
268366
/**
269367
* Perform DTX protocol handshake to establish connection and retrieve capabilities
270368
*/
@@ -333,6 +431,7 @@ export class DVTSecureSocketProxyService extends BaseService {
333431
* Drain any buffered messages that arrived during handshake
334432
*/
335433
private async drainBufferedMessages(): Promise<void> {
434+
this.consolidateReadBuffer();
336435
if (this.readBuffer.length === 0) {
337436
return;
338437
}
@@ -354,28 +453,81 @@ export class DVTSecureSocketProxyService extends BaseService {
354453
} catch (error) {
355454
log.debug('Error while draining buffer:', error);
356455
}
456+
this.bufferedLength = this.readBuffer.length;
357457
}
358458

359459
/**
360460
* Receive packet fragments until a complete message is available for the specified channel
361461
*/
362462
private async recvPacketFragments(channel: number, signal?: AbortSignal): Promise<Buffer> {
363-
while (true) {
364-
const fragmenter = this.channelMessages.get(channel);
365-
if (!fragmenter) {
366-
throw new Error(`No fragmenter for channel ${channel}`);
367-
}
463+
const fragmenter = this.channelMessages.get(channel);
464+
if (!fragmenter) {
465+
throw new Error(`No fragmenter for channel ${channel}`);
466+
}
467+
468+
const queued = fragmenter.get();
469+
if (queued) {
470+
return queued;
471+
}
472+
473+
signal?.throwIfAborted();
368474

369-
// Check if we have a complete message
370-
const message = fragmenter.get();
371-
if (message) {
372-
return message;
475+
const promise = new Promise<Buffer>((resolve, reject) => {
476+
const waiter: ChannelWaiter = {resolve, reject, signal};
477+
if (signal) {
478+
const onAbort = () => {
479+
const waiters = this.channelWaiters.get(channel);
480+
if (waiters) {
481+
const idx = waiters.indexOf(waiter);
482+
if (idx >= 0) {
483+
waiters.splice(idx, 1);
484+
}
485+
}
486+
reject(signal.reason ?? new DOMException('Receive aborted', 'AbortError'));
487+
};
488+
waiter.onAbort = onAbort;
489+
signal.addEventListener('abort', onAbort, {once: true});
373490
}
374491

375-
// Read next message header
376-
const headerData = await this.readExact(DTX_CONSTANTS.MESSAGE_HEADER_SIZE, signal);
492+
const waiters = this.channelWaiters.get(channel) ?? [];
493+
this.channelWaiters.set(channel, waiters);
494+
waiters.push(waiter);
495+
});
496+
497+
this.ensurePumpRunning();
498+
return promise;
499+
}
500+
501+
private ensurePumpRunning(): void {
502+
if (this.isPumpRunning) {
503+
return;
504+
}
505+
this.isPumpRunning = true;
506+
void this.runPump()
507+
.catch((err: unknown) => {
508+
this.rejectAllWaiters(err instanceof Error ? err : new Error(String(err)));
509+
})
510+
.finally(() => {
511+
this.isPumpRunning = false;
512+
if (this.hasPendingWaiters()) {
513+
this.ensurePumpRunning();
514+
}
515+
});
516+
}
517+
518+
private async runPump(): Promise<void> {
519+
while (this.hasPendingWaiters()) {
520+
const headerData = await this.readExact(DTX_CONSTANTS.MESSAGE_HEADER_SIZE);
377521
const header = DTXMessage.parseMessageHeader(headerData);
378522

523+
if (header.magic !== DTX_CONSTANTS.MESSAGE_HEADER_MAGIC) {
524+
const err = new Error(
525+
`Invalid DTX message header magic: 0x${header.magic.toString(16)} (stream desynchronized)`,
526+
);
527+
this.socketError = err;
528+
throw err;
529+
}
530+
379531
const receivedChannel = Math.abs(header.channelCode);
380532

381533
if (!this.channelMessages.has(receivedChannel)) {
@@ -392,80 +544,61 @@ export class DVTSecureSocketProxyService extends BaseService {
392544
continue;
393545
}
394546

395-
// Read message payload
396547
const messageData = await this.readExact(header.length);
397548

398-
// Add fragment to appropriate channel
399549
const targetFragmenter = this.channelMessages.get(receivedChannel);
400550
if (!targetFragmenter) {
401551
continue;
402552
}
403553
targetFragmenter.addFragment(header, messageData);
554+
555+
const waiters = this.channelWaiters.get(receivedChannel);
556+
if (waiters && waiters.length > 0) {
557+
const completedMessage = targetFragmenter.get();
558+
if (completedMessage) {
559+
const waiter = waiters.shift();
560+
if (waiter) {
561+
if (waiter.signal && waiter.onAbort) {
562+
waiter.signal.removeEventListener('abort', waiter.onAbort);
563+
}
564+
waiter.resolve(completedMessage);
565+
}
566+
}
567+
}
404568
}
405569
}
406570

407571
/**
408572
* Read exact number of bytes from socket with buffering
409573
*/
410-
private async readExact(length: number, signal?: AbortSignal): Promise<Buffer> {
411-
if (!this.socket) {
574+
private async readExact(length: number): Promise<Buffer> {
575+
const socket = this.socket;
576+
if (!socket) {
412577
throw new Error(`${this.constructor.name} is not initialized. Call connect() before sending messages.`);
413578
}
414579

415-
// Keep reading until we have enough data
416-
while (this.readBuffer.length < length) {
417-
// Fast-fail on closed/aborted reads so callers can terminate blocked
418-
// iterators instead of hanging until another socket event arrives.
419-
if (this.socket.destroyed) {
580+
if (this.listeningSocket !== socket) {
581+
this.attachSocketListeners(socket);
582+
}
583+
584+
while (this.bufferedLength < length) {
585+
if (this.socketError) {
586+
throw this.socketError;
587+
}
588+
if (this.socket !== socket || socket.destroyed) {
420589
throw new Error('Socket is destroyed');
421590
}
422-
signal?.throwIfAborted();
423-
424-
const chunk = await new Promise<Buffer>((resolve, reject) => {
425-
const socket = this.socket;
426-
if (!socket) {
427-
reject(new Error('Socket is not available'));
428-
return;
429-
}
430-
const cleanup = () => {
431-
socket.off('data', onData);
432-
socket.off('error', onError);
433-
socket.off('close', onClose);
434-
signal?.removeEventListener('abort', onAbort);
435-
};
436-
437-
const onData = (data: Buffer) => {
438-
cleanup();
439-
resolve(data);
440-
};
441591

442-
const onError = (err: Error) => {
443-
cleanup();
444-
reject(err);
445-
};
446-
447-
const onClose = () => {
448-
cleanup();
449-
reject(new Error('Socket closed during read'));
450-
};
451-
452-
const onAbort = () => {
453-
cleanup();
454-
reject(signal?.reason ?? new DOMException('Read aborted', 'AbortError'));
455-
};
456-
457-
socket.once('data', onData);
458-
socket.once('error', onError);
459-
socket.once('close', onClose);
460-
signal?.addEventListener('abort', onAbort, {once: true});
592+
socket.resume();
593+
await new Promise<void>((resolve) => {
594+
this.dataWaiters.push(resolve);
461595
});
462-
463-
this.readBuffer = Buffer.concat([this.readBuffer, chunk]);
464596
}
465597

466-
// Extract exact amount requested
598+
this.consolidateReadBuffer();
467599
const result = this.readBuffer.subarray(0, length);
468600
this.readBuffer = this.readBuffer.subarray(length);
601+
this.bufferedLength = this.readBuffer.length;
469602

470603
return result;
471604
}

0 commit comments

Comments
 (0)