appium-ios-tuntap 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.5.0](https://github.com/appium/appium-ios-tuntap/compare/v0.4.4...v0.5.0) (2026-06-13)
2
+
3
+ ### Features
4
+
5
+ * Switch to a different tunnelling architecture ([#51](https://github.com/appium/appium-ios-tuntap/issues/51)) ([7267755](https://github.com/appium/appium-ios-tuntap/commit/726775526d50d1976192bf3ebfbae6cb81883631))
6
+
1
7
  ## [0.4.4](https://github.com/appium/appium-ios-tuntap/compare/v0.4.3...v0.4.4) (2026-06-12)
2
8
 
3
9
  ### Bug Fixes
@@ -1,5 +1,6 @@
1
1
  import { TunTapError } from '../errors.js';
2
2
  import { log } from '../logger.js';
3
+ import { tunDebug } from '../tunnel/debug-log.js';
3
4
  import { assertAdminOnWindows } from './require-admin.js';
4
5
  import { execFileAsync } from './exec.js';
5
6
  /** Tightly-restricted character set for adapter names passed into PowerShell. */
@@ -19,7 +20,7 @@ export class WindowsTunTapPlatform {
19
20
  async configure(interfaceName, address, mtu) {
20
21
  await assertAdminOnWindows();
21
22
  assertSafeAdapterName(interfaceName);
22
- log.debug(`[win] configure: interface=${interfaceName} address=${address} mtu=${mtu}`);
23
+ tunDebug(`[win] configure: interface=${interfaceName} address=${address} mtu=${mtu}`);
23
24
  await addIpv6Address(interfaceName, address);
24
25
  await setIpv6Mtu(interfaceName, mtu);
25
26
  }
@@ -27,7 +28,7 @@ export class WindowsTunTapPlatform {
27
28
  async addRoute(interfaceName, destination) {
28
29
  await assertAdminOnWindows();
29
30
  assertSafeAdapterName(interfaceName);
30
- log.debug(`[win] addRoute: interface=${interfaceName} destination=${destination}`);
31
+ tunDebug(`[win] addRoute: interface=${interfaceName} destination=${destination}`);
31
32
  await addIpv6Route(interfaceName, destination);
32
33
  // WinTun presents as an Ethernet adapter, so Windows requires Neighbor
33
34
  // Discovery (NDP) before it will send packets through the interface.
@@ -102,7 +103,7 @@ async function addIpv6Address(interfaceName, address) {
102
103
  `address=${address}/64`,
103
104
  'store=active',
104
105
  ]);
105
- log.debug(`[win] add address ok: ${r.stdout.trim() || '(no output)'}`);
106
+ tunDebug(`[win] add address ok: ${r.stdout.trim() || '(no output)'}`);
106
107
  }
107
108
  catch (err) {
108
109
  const message = err.message ?? '';
@@ -124,7 +125,7 @@ async function setIpv6Mtu(interfaceName, mtu) {
124
125
  `mtu=${mtu}`,
125
126
  'store=active',
126
127
  ]);
127
- log.debug(`[win] set mtu ok: ${r.stdout.trim() || '(no output)'}`);
128
+ tunDebug(`[win] set mtu ok: ${r.stdout.trim() || '(no output)'}`);
128
129
  }
129
130
  catch (err) {
130
131
  log.warn(`[win] set mtu err: ${err.message ?? err}`);
@@ -142,13 +143,13 @@ async function addIpv6Route(interfaceName, destination) {
142
143
  interfaceName,
143
144
  'store=active',
144
145
  ]);
145
- log.debug(`[win] add route ok: ${r.stdout.trim() || '(no output)'}`);
146
+ tunDebug(`[win] add route ok: ${r.stdout.trim() || '(no output)'}`);
146
147
  }
147
148
  catch (err) {
148
149
  const message = err.message ?? '';
149
150
  log.warn(`[win] add route err: ${message}`);
150
151
  if (/already exists|object already/i.test(message)) {
151
- log.debug(`Route to ${destination} already exists`);
152
+ tunDebug(`Route to ${destination} already exists`);
152
153
  return;
153
154
  }
154
155
  throw err;
@@ -174,7 +175,7 @@ async function deleteIpv6Route(interfaceName, destination) {
174
175
  }
175
176
  }
176
177
  async function addStaticNeighbor(interfaceName, address) {
177
- log.debug(`[win] addStaticNeighbor: interface=${interfaceName} address=${address}`);
178
+ tunDebug(`[win] addStaticNeighbor: interface=${interfaceName} address=${address}`);
178
179
  try {
179
180
  const r = await execFileAsync('netsh', [
180
181
  'interface',
@@ -186,7 +187,7 @@ async function addStaticNeighbor(interfaceName, address) {
186
187
  '00-00-00-00-00-01',
187
188
  'store=active',
188
189
  ]);
189
- log.debug(`[win] add neighbor ok: ${r.stdout.trim() || '(no output)'}`);
190
+ tunDebug(`[win] add neighbor ok: ${r.stdout.trim() || '(no output)'}`);
190
191
  }
191
192
  catch (err) {
192
193
  const msg = err.message ?? String(err);
@@ -6,15 +6,23 @@ export declare const MAX_TUN_POLL_BUFFER = 65535;
6
6
  export declare const LARGE_TUN_POLL_BUFFER: number;
7
7
  /** Default ThreadSafeFunction queue depth for TUN polling. */
8
8
  export declare const DEFAULT_TUN_POLL_QUEUE_DEPTH = 8;
9
- /** Deeper TSFN queue when packet tap is off (bulk transfer). */
10
- export declare const FAST_TUN_POLL_QUEUE_DEPTH = 16;
9
+ /** One in-flight TUN read for sequential pump forwarding. */
10
+ export declare const SEQUENTIAL_TUN_POLL_QUEUE_DEPTH = 1;
11
+ /** Max utun packets buffered on the JS side before TLS write. */
12
+ export declare const MAX_TUN_INGRESS_QUEUE = 256;
13
+ /** Max ThreadSafeFunction queue depth (native addon limit). */
14
+ export declare const MAX_TUN_POLL_TSFN_QUEUE_DEPTH = 64;
15
+ /** ThreadSafeFunction queue depth passed to {@link TunTap.startPolling}. */
16
+ export declare const TUN_POLL_TSFN_QUEUE_DEPTH = 64;
17
+ /** Yield device→TUN loop periodically so utun poll callbacks can run. */
18
+ export declare const DEVICE_PUMP_YIELD_EVERY_FRAMES = 64;
11
19
  export declare const CD_TUNNEL_MAGIC = "CDTunnel";
12
20
  export declare const CD_TUNNEL_MAGIC_SIZE = 8;
13
21
  export declare const CD_TUNNEL_HEADER_SIZE: number;
14
22
  export declare const CD_TUNNEL_HANDSHAKE_TIMEOUT_MS = 30000;
23
+ /** Pause device ingress when the reassembly buffer exceeds this size. */
24
+ export declare const MAX_DEVICE_INGRESS_BUFFER: number;
15
25
  export declare const IPV6_HEADER_SIZE = 40;
16
26
  export declare const IPV6_VERSION = 6;
17
27
  export declare const IPPROTO_TCP = 6;
18
28
  export declare const IPPROTO_UDP = 17;
19
- /** Pause device ingress when the reassembly buffer exceeds this size. */
20
- export declare const MAX_DEVICE_INGRESS_BUFFER: number;
@@ -6,15 +6,23 @@ export const MAX_TUN_POLL_BUFFER = 65_535;
6
6
  export const LARGE_TUN_POLL_BUFFER = 64 * 1024;
7
7
  /** Default ThreadSafeFunction queue depth for TUN polling. */
8
8
  export const DEFAULT_TUN_POLL_QUEUE_DEPTH = 8;
9
- /** Deeper TSFN queue when packet tap is off (bulk transfer). */
10
- export const FAST_TUN_POLL_QUEUE_DEPTH = 16;
9
+ /** One in-flight TUN read for sequential pump forwarding. */
10
+ export const SEQUENTIAL_TUN_POLL_QUEUE_DEPTH = 1;
11
+ /** Max utun packets buffered on the JS side before TLS write. */
12
+ export const MAX_TUN_INGRESS_QUEUE = 256;
13
+ /** Max ThreadSafeFunction queue depth (native addon limit). */
14
+ export const MAX_TUN_POLL_TSFN_QUEUE_DEPTH = 64;
15
+ /** ThreadSafeFunction queue depth passed to {@link TunTap.startPolling}. */
16
+ export const TUN_POLL_TSFN_QUEUE_DEPTH = MAX_TUN_POLL_TSFN_QUEUE_DEPTH;
17
+ /** Yield device→TUN loop periodically so utun poll callbacks can run. */
18
+ export const DEVICE_PUMP_YIELD_EVERY_FRAMES = 64;
11
19
  export const CD_TUNNEL_MAGIC = 'CDTunnel';
12
20
  export const CD_TUNNEL_MAGIC_SIZE = 8;
13
21
  export const CD_TUNNEL_HEADER_SIZE = CD_TUNNEL_MAGIC_SIZE + 2;
14
22
  export const CD_TUNNEL_HANDSHAKE_TIMEOUT_MS = 30_000;
23
+ /** Pause device ingress when the reassembly buffer exceeds this size. */
24
+ export const MAX_DEVICE_INGRESS_BUFFER = 8 * 1024 * 1024;
15
25
  export const IPV6_HEADER_SIZE = 40;
16
26
  export const IPV6_VERSION = 6;
17
27
  export const IPPROTO_TCP = 6;
18
28
  export const IPPROTO_UDP = 17;
19
- /** Pause device ingress when the reassembly buffer exceeds this size. */
20
- export const MAX_DEVICE_INGRESS_BUFFER = 8 * 1024 * 1024;
@@ -0,0 +1,12 @@
1
+ import { log } from '../logger.js';
2
+ /** Tunnel debug logging — set APPIUM_TUNTAP_DEBUG=1 on the tunnel process. */
3
+ export declare const APPIUM_TUNTAP_DEBUG: boolean;
4
+ /** {@link log.debug} when {@link APPIUM_TUNTAP_DEBUG} is enabled. */
5
+ export declare function tunDebug(...args: Parameters<typeof log.debug>): void;
6
+ /** Log a numbered tunnel forward diagnostic when {@link APPIUM_TUNTAP_DEBUG} is enabled. */
7
+ export declare function fwdDebug(event: string, detail?: Record<string, string | number | boolean>): void;
8
+ /** Summarize reassembly buffer state for debug logs. */
9
+ export declare function fwdBufferState(buffer: Buffer): {
10
+ buf: number;
11
+ tailKind: string;
12
+ };
@@ -0,0 +1,44 @@
1
+ import { log } from '../logger.js';
2
+ /** Tunnel debug logging — set APPIUM_TUNTAP_DEBUG=1 on the tunnel process. */
3
+ export const APPIUM_TUNTAP_DEBUG = process.env.APPIUM_TUNTAP_DEBUG === '1' || process.env.APPIUM_TUNTAP_DEBUG === 'true';
4
+ let seq = 0;
5
+ /** {@link log.debug} when {@link APPIUM_TUNTAP_DEBUG} is enabled. */
6
+ export function tunDebug(...args) {
7
+ if (!APPIUM_TUNTAP_DEBUG) {
8
+ return;
9
+ }
10
+ log.debug(...args);
11
+ }
12
+ /** Log a numbered tunnel forward diagnostic when {@link APPIUM_TUNTAP_DEBUG} is enabled. */
13
+ export function fwdDebug(event, detail) {
14
+ if (!APPIUM_TUNTAP_DEBUG) {
15
+ return;
16
+ }
17
+ seq += 1;
18
+ const parts = [`#${seq}`, event];
19
+ if (detail) {
20
+ for (const [key, value] of Object.entries(detail)) {
21
+ parts.push(`${key}=${value}`);
22
+ }
23
+ }
24
+ log.info(`[fwd] ${parts.join(' ')}`);
25
+ }
26
+ /** Summarize reassembly buffer state for debug logs. */
27
+ export function fwdBufferState(buffer) {
28
+ if (buffer.length === 0) {
29
+ return { buf: 0, tailKind: 'empty' };
30
+ }
31
+ if (buffer.length < 40) {
32
+ return { buf: buffer.length, tailKind: 'short' };
33
+ }
34
+ const version = (buffer[0] >> 4) & 0x0f;
35
+ if (version !== 6) {
36
+ return { buf: buffer.length, tailKind: 'resync-needed' };
37
+ }
38
+ const payloadLength = buffer.readUInt16BE(4);
39
+ const frameLength = 40 + payloadLength;
40
+ if (buffer.length >= frameLength) {
41
+ return { buf: buffer.length, tailKind: 'has-complete-frame' };
42
+ }
43
+ return { buf: buffer.length, tailKind: 'incomplete-tail' };
44
+ }
@@ -0,0 +1,36 @@
1
+ import type { Socket } from 'node:net';
2
+ import type { TunTap } from '../TunTap.js';
3
+ export type DeviceToTunProgressHook = () => void;
4
+ /**
5
+ * Device→TUN forwarding: read one exact IPv6 frame from the socket, write to TUN,
6
+ * repeat. Yields only when TUN write blocks (via notifyTunWritable from the TUN→device pump).
7
+ */
8
+ export declare class DeviceToTunPump {
9
+ private readonly onFrameWritten?;
10
+ private cancelled;
11
+ private running;
12
+ private buffer;
13
+ private frameWaiter;
14
+ private frameReject;
15
+ private tunWritableWaiter;
16
+ private loopPromise;
17
+ private fwdFrames;
18
+ private deviceIngressPaused;
19
+ private deviceConn;
20
+ private pendingDeviceChunks;
21
+ private deviceDrainScheduled;
22
+ constructor(onFrameWritten?: DeviceToTunProgressHook | undefined);
23
+ start(deviceConn: Socket, tun: TunTap): void;
24
+ notifyTunWritable(): void;
25
+ stop(): Promise<void>;
26
+ private enqueueDeviceData;
27
+ private onDeviceData;
28
+ private maybeResumeDeviceIngress;
29
+ private tryDeliverFrame;
30
+ private readOneFrame;
31
+ private pauseDeviceIngress;
32
+ private resumeDeviceIngress;
33
+ private waitTunWritable;
34
+ private writeFrameToTun;
35
+ private runLoop;
36
+ }
@@ -0,0 +1,229 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { appendBuffer } from './buffer-utils.js';
3
+ import { fwdDebug } from './debug-log.js';
4
+ import { IPV6_HEADER_SIZE, IPV6_VERSION, MAX_DEVICE_INGRESS_BUFFER, DEVICE_PUMP_YIELD_EVERY_FRAMES, } from './constants.js';
5
+ /**
6
+ * Device→TUN forwarding: read one exact IPv6 frame from the socket, write to TUN,
7
+ * repeat. Yields only when TUN write blocks (via notifyTunWritable from the TUN→device pump).
8
+ */
9
+ export class DeviceToTunPump {
10
+ onFrameWritten;
11
+ cancelled = false;
12
+ running = false;
13
+ buffer = Buffer.alloc(0);
14
+ frameWaiter = null;
15
+ frameReject = null;
16
+ tunWritableWaiter = null;
17
+ loopPromise = null;
18
+ fwdFrames = 0;
19
+ deviceIngressPaused = false;
20
+ deviceConn = null;
21
+ pendingDeviceChunks = [];
22
+ deviceDrainScheduled = false;
23
+ constructor(onFrameWritten) {
24
+ this.onFrameWritten = onFrameWritten;
25
+ }
26
+ start(deviceConn, tun) {
27
+ if (this.running) {
28
+ return;
29
+ }
30
+ this.running = true;
31
+ this.cancelled = false;
32
+ this.deviceConn = deviceConn;
33
+ deviceConn.on('data', (chunk) => this.enqueueDeviceData(chunk));
34
+ fwdDebug('device-pump-start', {});
35
+ this.loopPromise = this.runLoop(deviceConn, tun);
36
+ }
37
+ notifyTunWritable() {
38
+ const waiter = this.tunWritableWaiter;
39
+ if (waiter) {
40
+ this.tunWritableWaiter = null;
41
+ waiter();
42
+ }
43
+ }
44
+ async stop() {
45
+ this.cancelled = true;
46
+ if (this.frameReject) {
47
+ this.frameReject(new Error(DEVICE_PUMP_CANCELLED));
48
+ }
49
+ if (this.tunWritableWaiter) {
50
+ this.tunWritableWaiter();
51
+ }
52
+ this.frameWaiter = null;
53
+ this.frameReject = null;
54
+ this.tunWritableWaiter = null;
55
+ if (this.loopPromise) {
56
+ await this.loopPromise.catch(() => undefined);
57
+ this.loopPromise = null;
58
+ }
59
+ this.buffer = Buffer.alloc(0);
60
+ this.deviceConn = null;
61
+ this.running = false;
62
+ }
63
+ enqueueDeviceData(chunk) {
64
+ if (this.cancelled || chunk.length === 0) {
65
+ return;
66
+ }
67
+ this.pendingDeviceChunks.push(chunk);
68
+ if (this.deviceDrainScheduled) {
69
+ return;
70
+ }
71
+ this.deviceDrainScheduled = true;
72
+ setImmediate(() => {
73
+ this.deviceDrainScheduled = false;
74
+ const chunks = this.pendingDeviceChunks;
75
+ this.pendingDeviceChunks = [];
76
+ for (const pending of chunks) {
77
+ this.onDeviceData(pending);
78
+ }
79
+ });
80
+ }
81
+ onDeviceData(chunk) {
82
+ if (this.cancelled || chunk.length === 0) {
83
+ return;
84
+ }
85
+ this.buffer = appendBuffer(this.buffer, chunk);
86
+ if (this.deviceConn &&
87
+ this.buffer.length > MAX_DEVICE_INGRESS_BUFFER &&
88
+ !this.deviceIngressPaused) {
89
+ this.pauseDeviceIngress(this.deviceConn, 'max-buffer');
90
+ }
91
+ this.tryDeliverFrame();
92
+ }
93
+ maybeResumeDeviceIngress() {
94
+ if (!this.deviceConn ||
95
+ !this.deviceIngressPaused ||
96
+ this.buffer.length > MAX_DEVICE_INGRESS_BUFFER) {
97
+ return;
98
+ }
99
+ this.resumeDeviceIngress(this.deviceConn);
100
+ }
101
+ tryDeliverFrame() {
102
+ const waiter = this.frameWaiter;
103
+ if (!waiter) {
104
+ return;
105
+ }
106
+ const taken = takeOneIpv6Frame(this.buffer);
107
+ if (taken.kind === 'incomplete' || taken.kind === 'resync') {
108
+ if (taken.kind === 'resync') {
109
+ this.buffer = this.buffer.subarray(1);
110
+ this.tryDeliverFrame();
111
+ }
112
+ return;
113
+ }
114
+ this.buffer = shrinkBuffer(this.buffer, taken.length);
115
+ this.frameWaiter = null;
116
+ this.frameReject = null;
117
+ waiter(taken.packet);
118
+ this.maybeResumeDeviceIngress();
119
+ }
120
+ readOneFrame() {
121
+ if (this.cancelled) {
122
+ return Promise.reject(new Error(DEVICE_PUMP_CANCELLED));
123
+ }
124
+ const taken = takeOneIpv6Frame(this.buffer);
125
+ if (taken.kind === 'frame') {
126
+ this.buffer = shrinkBuffer(this.buffer, taken.length);
127
+ return Promise.resolve(taken.packet);
128
+ }
129
+ if (taken.kind === 'resync') {
130
+ this.buffer = this.buffer.subarray(1);
131
+ return this.readOneFrame();
132
+ }
133
+ return new Promise((resolve, reject) => {
134
+ this.frameWaiter = resolve;
135
+ this.frameReject = reject;
136
+ });
137
+ }
138
+ pauseDeviceIngress(deviceConn, reason) {
139
+ if (this.deviceIngressPaused || deviceConn.destroyed) {
140
+ return;
141
+ }
142
+ this.deviceIngressPaused = true;
143
+ deviceConn.pause();
144
+ fwdDebug('ingress-pause', { reason, buf: this.buffer.length });
145
+ }
146
+ resumeDeviceIngress(deviceConn) {
147
+ if (!this.deviceIngressPaused || deviceConn.destroyed) {
148
+ return;
149
+ }
150
+ this.deviceIngressPaused = false;
151
+ deviceConn.resume();
152
+ fwdDebug('ingress-resume', { buf: this.buffer.length });
153
+ }
154
+ waitTunWritable() {
155
+ if (this.cancelled) {
156
+ return Promise.reject(new Error(DEVICE_PUMP_CANCELLED));
157
+ }
158
+ return new Promise((resolve) => {
159
+ this.tunWritableWaiter = resolve;
160
+ });
161
+ }
162
+ async writeFrameToTun(deviceConn, tun, packet) {
163
+ while (!this.cancelled) {
164
+ const bytesWritten = tun.write(packet);
165
+ if (bytesWritten > 0) {
166
+ this.maybeResumeDeviceIngress();
167
+ return;
168
+ }
169
+ // Block on tun.write() without pausing the TLS stream — keep reading into the
170
+ // reassembly buffer while waiting for TUN→device progress to free utun capacity.
171
+ fwdDebug('tun-write-blocked', { frameLen: packet.length, buf: this.buffer.length });
172
+ await this.waitTunWritable();
173
+ }
174
+ }
175
+ async runLoop(deviceConn, tun) {
176
+ try {
177
+ while (!this.cancelled && !deviceConn.destroyed) {
178
+ const packet = await this.readOneFrame();
179
+ if (this.cancelled) {
180
+ break;
181
+ }
182
+ await this.writeFrameToTun(deviceConn, tun, packet);
183
+ this.fwdFrames += 1;
184
+ if (this.fwdFrames === 1 || this.fwdFrames % 200 === 0) {
185
+ fwdDebug('device-pump-write', { len: packet.length, frames: this.fwdFrames });
186
+ }
187
+ this.onFrameWritten?.();
188
+ if (this.fwdFrames % DEVICE_PUMP_YIELD_EVERY_FRAMES === 0) {
189
+ await new Promise((resolve) => setImmediate(resolve));
190
+ }
191
+ }
192
+ }
193
+ catch (err) {
194
+ if (!isPumpCancelled(err)) {
195
+ throw err;
196
+ }
197
+ }
198
+ fwdDebug('device-pump-stop', { frames: this.fwdFrames });
199
+ }
200
+ }
201
+ const DEVICE_PUMP_CANCELLED = 'device pump cancelled';
202
+ function isPumpCancelled(err) {
203
+ return err instanceof Error && err.message === DEVICE_PUMP_CANCELLED;
204
+ }
205
+ function takeOneIpv6Frame(buffer) {
206
+ if (buffer.length < IPV6_HEADER_SIZE) {
207
+ return { kind: 'incomplete' };
208
+ }
209
+ const version = (buffer[0] >> 4) & 0x0f;
210
+ if (version !== IPV6_VERSION) {
211
+ return { kind: 'resync' };
212
+ }
213
+ const payloadLength = buffer.readUInt16BE(4);
214
+ const length = IPV6_HEADER_SIZE + payloadLength;
215
+ if (buffer.length < length) {
216
+ return { kind: 'incomplete' };
217
+ }
218
+ return {
219
+ kind: 'frame',
220
+ packet: buffer.subarray(0, length),
221
+ length,
222
+ };
223
+ }
224
+ function shrinkBuffer(buffer, consumed) {
225
+ if (consumed >= buffer.length) {
226
+ return Buffer.alloc(0);
227
+ }
228
+ return buffer.subarray(consumed);
229
+ }
@@ -14,8 +14,10 @@ export declare class TunnelManager extends EventEmitter<TunnelManagerEvents> {
14
14
  private readonly packetConsumers;
15
15
  private deviceConn;
16
16
  private cleanupPromise;
17
- private tunReadPausedForBackpressure;
18
- private deviceIngressPausedForTun;
17
+ private tunToDevicePump;
18
+ private deviceToTunPump;
19
+ private deviceIngressPaused;
20
+ private fwdDeviceDataChunks;
19
21
  /**
20
22
  * Register a listener for parsed tunnel packets (in addition to the `data` event).
21
23
  *
@@ -58,15 +60,14 @@ export declare class TunnelManager extends EventEmitter<TunnelManagerEvents> {
58
60
  */
59
61
  stop(): Promise<void>;
60
62
  private hasPacketTap;
61
- private processBuffer;
62
- private writeDeviceFrameToTun;
63
- private shouldResumeDeviceIngress;
64
63
  private pauseDeviceIngress;
65
64
  private resumeDeviceIngress;
65
+ private processBuffer;
66
+ private writeDeviceFrameToTun;
66
67
  private tapL4Packet;
67
68
  private dispatchPacketData;
68
- private startTunReadLoop;
69
- private writeTunPacketToDevice;
69
+ private startDeviceToTunPump;
70
+ private startTunToDevicePump;
70
71
  private _performStop;
71
72
  }
72
73
  /**
@@ -2,8 +2,11 @@ import { log } from '../logger.js';
2
2
  import { TunTap } from '../TunTap.js';
3
3
  import { EventEmitter } from 'node:events';
4
4
  import { Buffer } from 'node:buffer';
5
- import { CD_TUNNEL_HANDSHAKE_TIMEOUT_MS, CD_TUNNEL_HEADER_SIZE, CD_TUNNEL_MAGIC, CD_TUNNEL_MAGIC_SIZE, CD_TUNNEL_MTU, DEFAULT_TUN_POLL_QUEUE_DEPTH, FAST_TUN_POLL_QUEUE_DEPTH, IPV6_HEADER_SIZE, LARGE_TUN_POLL_BUFFER, MAX_DEVICE_INGRESS_BUFFER, MAX_TUN_POLL_BUFFER, IPV6_VERSION, IPPROTO_TCP, IPPROTO_UDP, } from './constants.js';
5
+ import { CD_TUNNEL_HANDSHAKE_TIMEOUT_MS, CD_TUNNEL_HEADER_SIZE, CD_TUNNEL_MAGIC, CD_TUNNEL_MAGIC_SIZE, CD_TUNNEL_MTU, IPV6_HEADER_SIZE, MAX_DEVICE_INGRESS_BUFFER, IPV6_VERSION, IPPROTO_TCP, IPPROTO_UDP, } from './constants.js';
6
6
  import { appendBuffer } from './buffer-utils.js';
7
+ import { fwdBufferState, fwdDebug, tunDebug } from './debug-log.js';
8
+ import { TunToDevicePump } from './tun-to-device-pump.js';
9
+ import { DeviceToTunPump } from './device-to-tun-pump.js';
7
10
  /**
8
11
  * Bridges a CoreDevice tunnel `Socket` and a {@link TunTap} interface: IPv6 framing, TUN I/O, and packet fan-out.
9
12
  * Emits {@link TunnelManagerEvents} (currently `data` with {@link PacketData}) for TCP/UDP packets, same as registered consumers.
@@ -16,8 +19,10 @@ export class TunnelManager extends EventEmitter {
16
19
  packetConsumers = new Set();
17
20
  deviceConn = null;
18
21
  cleanupPromise = null;
19
- tunReadPausedForBackpressure = false;
20
- deviceIngressPausedForTun = false;
22
+ tunToDevicePump = null;
23
+ deviceToTunPump = null;
24
+ deviceIngressPaused = false;
25
+ fwdDeviceDataChunks = 0;
21
26
  /**
22
27
  * Register a listener for parsed tunnel packets (in addition to the `data` event).
23
28
  *
@@ -82,20 +87,20 @@ export class TunnelManager extends EventEmitter {
82
87
  * @returns interface name, MTU, and the live {@link TunTap} instance
83
88
  */
84
89
  async setupInterface(tunnelInfo) {
85
- log.debug(`Setting up tunnel with parameters:`, tunnelInfo);
90
+ tunDebug(`Setting up tunnel with parameters:`, tunnelInfo);
86
91
  try {
87
92
  this.tun = new TunTap();
88
93
  // Open the TUN device
89
94
  if (!this.tun.open()) {
90
95
  throw new Error('Failed to open TUN device');
91
96
  }
92
- log.debug(`Opened TUN device: ${this.tun.name}`);
97
+ tunDebug(`Opened TUN device: ${this.tun.name}`);
93
98
  this.mtu = tunnelInfo.clientParameters.mtu;
94
99
  // Configure the TUN device with IPv6 address and MTU
95
100
  await this.tun.configure(tunnelInfo.clientParameters.address, tunnelInfo.clientParameters.mtu);
96
101
  // Add route for the server address
97
102
  await this.tun.addRoute(`${tunnelInfo.serverAddress}/128`);
98
- log.debug(`Configured TUN interface ${this.tun.name} with address ${tunnelInfo.clientParameters.address} and MTU ${tunnelInfo.clientParameters.mtu}`);
103
+ tunDebug(`Configured TUN interface ${this.tun.name} with address ${tunnelInfo.clientParameters.address} and MTU ${tunnelInfo.clientParameters.mtu}`);
99
104
  return {
100
105
  name: this.tun.name,
101
106
  mtu: tunnelInfo.clientParameters.mtu,
@@ -127,32 +132,47 @@ export class TunnelManager extends EventEmitter {
127
132
  return;
128
133
  }
129
134
  this.deviceConn = deviceConn;
130
- log.debug(`Starting bidirectional data forwarding for ${this.tun.name}`);
135
+ tunDebug(`Starting bidirectional data forwarding for ${this.tun.name}`);
131
136
  deviceConn.setNoDelay(true);
132
137
  deviceConn.setKeepAlive(true, 1000);
133
- // Handle data from the device connection
134
- deviceConn.on('data', (data) => {
135
- if (this.cancelled) {
136
- return;
137
- }
138
- try {
139
- this.buffer = appendBuffer(this.buffer, data);
140
- if (this.buffer.length > MAX_DEVICE_INGRESS_BUFFER) {
141
- this.pauseDeviceIngress();
138
+ if (this.hasPacketTap()) {
139
+ deviceConn.on('data', (data) => {
140
+ if (this.cancelled) {
141
+ return;
142
142
  }
143
- this.processBuffer();
144
- }
145
- catch (err) {
146
- if (!this.cancelled) {
147
- log.error('Error processing device data:', err.message);
143
+ try {
144
+ this.fwdDeviceDataChunks += 1;
145
+ this.buffer = appendBuffer(this.buffer, data);
146
+ if (this.buffer.length > MAX_DEVICE_INGRESS_BUFFER) {
147
+ this.pauseDeviceIngress('max-buffer');
148
+ }
149
+ if (this.fwdDeviceDataChunks === 1 || this.fwdDeviceDataChunks % 200 === 0) {
150
+ fwdDebug('device-data', {
151
+ chunk: data.length,
152
+ ...fwdBufferState(this.buffer),
153
+ ingressPaused: this.deviceIngressPaused,
154
+ chunks: this.fwdDeviceDataChunks,
155
+ });
156
+ }
157
+ this.processBuffer('data');
148
158
  }
149
- }
159
+ catch (err) {
160
+ if (!this.cancelled) {
161
+ log.error('Error processing device data:', err.message);
162
+ }
163
+ }
164
+ });
165
+ }
166
+ else {
167
+ this.startDeviceToTunPump(deviceConn);
168
+ }
169
+ deviceConn.on('drain', () => {
170
+ this.deviceToTunPump?.notifyTunWritable();
150
171
  });
151
- // Set up TUN read loop
152
- this.startTunReadLoop(deviceConn);
172
+ this.startTunToDevicePump(deviceConn);
153
173
  // Listen for device connection close
154
174
  deviceConn.on('close', async () => {
155
- log.debug('Device connection closed, stopping tunnel');
175
+ tunDebug('Device connection closed, stopping tunnel');
156
176
  try {
157
177
  await this.stop();
158
178
  }
@@ -180,8 +200,32 @@ export class TunnelManager extends EventEmitter {
180
200
  hasPacketTap() {
181
201
  return this.packetConsumers.size > 0 || this.listenerCount('data') > 0;
182
202
  }
183
- processBuffer() {
203
+ pauseDeviceIngress(reason) {
204
+ if (this.deviceIngressPaused || !this.deviceConn || this.deviceConn.destroyed) {
205
+ return;
206
+ }
207
+ this.deviceIngressPaused = true;
208
+ this.deviceConn.pause();
209
+ fwdDebug('ingress-pause', {
210
+ reason,
211
+ ...fwdBufferState(this.buffer),
212
+ });
213
+ }
214
+ resumeDeviceIngress() {
215
+ if (!this.deviceIngressPaused || !this.deviceConn || this.deviceConn.destroyed) {
216
+ return;
217
+ }
218
+ this.deviceIngressPaused = false;
219
+ this.deviceConn.resume();
220
+ fwdDebug('ingress-resume', {
221
+ ...fwdBufferState(this.buffer),
222
+ });
223
+ }
224
+ processBuffer(trigger = 'unknown') {
184
225
  let offset = 0;
226
+ let tunWriteBlocked = false;
227
+ let framesWritten = 0;
228
+ const bufBefore = this.buffer.length;
185
229
  while (offset + IPV6_HEADER_SIZE <= this.buffer.length) {
186
230
  const frame = nextIpv6Frame(this.buffer, offset);
187
231
  if (frame.kind === 'incomplete') {
@@ -196,11 +240,22 @@ export class TunnelManager extends EventEmitter {
196
240
  break;
197
241
  }
198
242
  try {
199
- this.writeDeviceFrameToTun(this.tun, frame.packet, frame.nextHeader);
243
+ const bytesWritten = this.writeDeviceFrameToTun(this.tun, frame.packet, frame.nextHeader);
244
+ if (bytesWritten === 'blocked') {
245
+ tunWriteBlocked = true;
246
+ fwdDebug('tun-write-blocked', {
247
+ trigger,
248
+ frameLen: frame.length,
249
+ ...fwdBufferState(this.buffer),
250
+ ingressPaused: this.deviceIngressPaused,
251
+ });
252
+ break;
253
+ }
200
254
  }
201
255
  catch (err) {
202
256
  log.error(`Error writing to TUN: ${err.message}`);
203
257
  }
258
+ framesWritten += 1;
204
259
  offset += frame.length;
205
260
  }
206
261
  if (offset > 0) {
@@ -211,62 +266,63 @@ export class TunnelManager extends EventEmitter {
211
266
  this.buffer = this.buffer.subarray(offset);
212
267
  }
213
268
  }
214
- if (this.deviceIngressPausedForTun && this.shouldResumeDeviceIngress()) {
215
- this.resumeDeviceIngress();
269
+ if (this.deviceIngressPaused) {
270
+ const mayResume = !tunWriteBlocked &&
271
+ (this.buffer.length === 0 || this.buffer.length <= MAX_DEVICE_INGRESS_BUFFER / 2);
272
+ if (mayResume) {
273
+ this.resumeDeviceIngress();
274
+ }
275
+ else if (tunWriteBlocked || framesWritten > 0 || bufBefore > 0) {
276
+ fwdDebug('process-buffer', {
277
+ trigger,
278
+ bufBefore,
279
+ bufAfter: this.buffer.length,
280
+ offset,
281
+ framesWritten,
282
+ tunWriteBlocked,
283
+ mayResume,
284
+ ingressPaused: this.deviceIngressPaused,
285
+ ...fwdBufferState(this.buffer),
286
+ });
287
+ }
216
288
  }
217
289
  }
218
290
  writeDeviceFrameToTun(tun, packet, nextHeader) {
219
- tun.write(packet);
291
+ const bytesWritten = tun.write(packet);
292
+ if (bytesWritten <= 0) {
293
+ this.pauseDeviceIngress('tun-write-blocked');
294
+ return 'blocked';
295
+ }
220
296
  if (!this.hasPacketTap()) {
221
- return;
297
+ return bytesWritten;
222
298
  }
223
299
  const { src, dst } = ipv6Endpoints(packet);
224
- log.debug(`Device → TUN: ${packet.length} bytes, IPv6 src=${src}, dst=${dst}`);
300
+ tunDebug(`Device → TUN: ${bytesWritten} bytes, IPv6 src=${src}, dst=${dst}`);
225
301
  this.tapL4Packet(packet, nextHeader, src, dst);
226
- }
227
- shouldResumeDeviceIngress() {
228
- if (this.buffer.length === 0) {
229
- return true;
230
- }
231
- // All complete frames were written; waiting on more socket bytes to finish the tail frame.
232
- return nextIpv6Frame(this.buffer, 0).kind === 'incomplete';
233
- }
234
- pauseDeviceIngress() {
235
- if (this.deviceIngressPausedForTun || !this.deviceConn || this.deviceConn.destroyed) {
236
- return;
237
- }
238
- this.deviceIngressPausedForTun = true;
239
- this.deviceConn.pause();
240
- }
241
- resumeDeviceIngress() {
242
- if (!this.deviceIngressPausedForTun || !this.deviceConn || this.deviceConn.destroyed) {
243
- return;
244
- }
245
- this.deviceIngressPausedForTun = false;
246
- this.deviceConn.resume();
302
+ return bytesWritten;
247
303
  }
248
304
  tapL4Packet(packet, nextHeader, src, dst) {
249
305
  let packetData = null;
250
306
  if (nextHeader === IPPROTO_UDP) {
251
307
  packetData = parseUdpPacketData(packet, src, dst);
252
308
  if (!packetData) {
253
- log.debug('UDP payload too short, not emitting event.');
309
+ tunDebug('UDP payload too short, not emitting event.');
254
310
  }
255
311
  else {
256
- log.debug(`UDP packet detected: payload length=${packetData.payload.length}`);
312
+ tunDebug(`UDP packet detected: payload length=${packetData.payload.length}`);
257
313
  }
258
314
  }
259
315
  else if (nextHeader === IPPROTO_TCP) {
260
316
  packetData = parseTcpPacketData(packet, src, dst);
261
317
  if (!packetData) {
262
- log.debug('TCP packet too short or malformed, skipping.');
318
+ tunDebug('TCP packet too short or malformed, skipping.');
263
319
  }
264
320
  else {
265
- log.debug(`TCP packet detected: payload length=${packetData.payload.length}`);
321
+ tunDebug(`TCP packet detected: payload length=${packetData.payload.length}`);
266
322
  }
267
323
  }
268
324
  else {
269
- log.debug('Packet is not UDP or TCP (nextHeader !== 17 and !== 6)');
325
+ tunDebug('Packet is not UDP or TCP (nextHeader !== 17 and !== 6)');
270
326
  }
271
327
  if (packetData) {
272
328
  this.dispatchPacketData(packetData);
@@ -282,54 +338,54 @@ export class TunnelManager extends EventEmitter {
282
338
  log.error('Error in packet consumer:', err);
283
339
  }
284
340
  }
285
- log.debug(`Emitted data event for ${packetData.protocol} packet`);
341
+ tunDebug(`Emitted data event for ${packetData.protocol} packet`);
286
342
  }
287
- startTunReadLoop(deviceConn) {
343
+ startDeviceToTunPump(deviceConn) {
288
344
  if (!this.tun) {
289
345
  return;
290
346
  }
291
- const tapOn = this.hasPacketTap();
292
- const pollBuffer = tapOn
293
- ? this.mtu
294
- : Math.min(MAX_TUN_POLL_BUFFER, Math.max(this.mtu, LARGE_TUN_POLL_BUFFER));
295
- const queueDepth = tapOn ? DEFAULT_TUN_POLL_QUEUE_DEPTH : FAST_TUN_POLL_QUEUE_DEPTH;
296
- this.tun.startPolling((data) => {
297
- if (this.cancelled || !data.length || deviceConn.destroyed) {
298
- return;
299
- }
300
- if (tapOn && data.length >= IPV6_HEADER_SIZE) {
301
- log.debug(`TUN → Device: ${data.length} bytes, IPv6 src=${formatIPv6Address(data.subarray(8, 24))}, dst=${formatIPv6Address(data.subarray(24, 40))}`);
302
- }
303
- else if (tapOn) {
304
- log.debug(`TUN → Device: ${data.length} bytes (too small for IPv6 header)`);
305
- }
306
- this.writeTunPacketToDevice(deviceConn, data);
307
- }, pollBuffer, queueDepth);
347
+ this.deviceToTunPump = new DeviceToTunPump();
348
+ this.deviceToTunPump.start(deviceConn, this.tun);
308
349
  }
309
- writeTunPacketToDevice(deviceConn, data) {
310
- if (deviceConn.destroyed) {
311
- return;
312
- }
313
- const canWriteMore = deviceConn.write(data);
314
- if (canWriteMore || this.tunReadPausedForBackpressure || !this.tun) {
350
+ startTunToDevicePump(deviceConn) {
351
+ if (!this.tun) {
315
352
  return;
316
353
  }
317
- this.tunReadPausedForBackpressure = true;
318
- this.tun.pausePolling();
319
- const onDrain = () => {
320
- if (this.cancelled || deviceConn.destroyed) {
321
- return;
354
+ const tapOn = this.hasPacketTap();
355
+ this.tunToDevicePump = new TunToDevicePump(this.tun, this.mtu, tapOn
356
+ ? (data) => {
357
+ if (data.length >= IPV6_HEADER_SIZE) {
358
+ tunDebug(`TUN → Device: ${data.length} bytes, IPv6 src=${formatIPv6Address(data.subarray(8, 24))}, dst=${formatIPv6Address(data.subarray(24, 40))}`);
359
+ }
360
+ else {
361
+ tunDebug(`TUN → Device: ${data.length} bytes (too small for IPv6 header)`);
362
+ }
322
363
  }
323
- this.tunReadPausedForBackpressure = false;
324
- this.tun?.resumePolling();
325
- };
326
- deviceConn.once('drain', onDrain);
364
+ : undefined, () => {
365
+ this.deviceToTunPump?.notifyTunWritable();
366
+ if (this.deviceIngressPaused) {
367
+ fwdDebug('forward-progress', {
368
+ ingressPaused: this.deviceIngressPaused,
369
+ ...fwdBufferState(this.buffer),
370
+ });
371
+ this.processBuffer('forward-progress');
372
+ }
373
+ });
374
+ this.tunToDevicePump.start(deviceConn);
327
375
  }
328
376
  async _performStop() {
329
377
  const tunName = this.tun ? this.tun.name : 'unknown';
330
- log.debug(`Stopping tunnel manager for ${tunName}`);
378
+ tunDebug(`Stopping tunnel manager for ${tunName}`);
331
379
  // Signal cancellation
332
380
  this.cancelled = true;
381
+ if (this.tunToDevicePump) {
382
+ await this.tunToDevicePump.stop();
383
+ this.tunToDevicePump = null;
384
+ }
385
+ if (this.deviceToTunPump) {
386
+ await this.deviceToTunPump.stop();
387
+ this.deviceToTunPump = null;
388
+ }
333
389
  // Close device connection if exists
334
390
  if (this.deviceConn && !this.deviceConn.destroyed) {
335
391
  this.deviceConn.destroy();
@@ -351,7 +407,7 @@ export class TunnelManager extends EventEmitter {
351
407
  }
352
408
  this.tun = null;
353
409
  }
354
- log.debug(`Tunnel for ${tunName} closed successfully`);
410
+ tunDebug(`Tunnel for ${tunName} closed successfully`);
355
411
  }
356
412
  }
357
413
  /**
@@ -366,7 +422,7 @@ export async function exchangeCoreTunnelParameters(socket) {
366
422
  mtu: CD_TUNNEL_MTU,
367
423
  });
368
424
  const message = encodeCdTunnelMessage(requestJson);
369
- log.debug(`Sending CDTunnel packet: magic=${CD_TUNNEL_MAGIC}, length=${message.length - CD_TUNNEL_HEADER_SIZE}, body=${requestJson}`);
425
+ tunDebug(`Sending CDTunnel packet: magic=${CD_TUNNEL_MAGIC}, length=${message.length - CD_TUNNEL_HEADER_SIZE}, body=${requestJson}`);
370
426
  socket.write(message);
371
427
  return readCdTunnelResponse(socket, CD_TUNNEL_HANDSHAKE_TIMEOUT_MS);
372
428
  }
@@ -381,15 +437,15 @@ export async function connectToTunnelLockdown(secureServiceSocket) {
381
437
  try {
382
438
  // Exchange tunnel parameters with the device
383
439
  const tunnelInfo = await exchangeCoreTunnelParameters(secureServiceSocket);
384
- log.debug('Tunnel parameters exchanged:', tunnelInfo);
440
+ tunDebug('Tunnel parameters exchanged:', tunnelInfo);
385
441
  // Setup tunnel interface
386
442
  const tunInterfaceInfo = await tunnelManager.setupInterface(tunnelInfo);
387
- log.debug('Tunnel interface set up:', tunInterfaceInfo.name);
443
+ tunDebug('Tunnel interface set up:', tunInterfaceInfo.name);
388
444
  // Start bidirectional forwarding
389
445
  tunnelManager.startForwarding(secureServiceSocket);
390
446
  // Create close function
391
447
  const closeFunc = async () => {
392
- log.debug('Closing tunnel connection');
448
+ tunDebug('Closing tunnel connection');
393
449
  await tunnelManager.stop();
394
450
  if (!secureServiceSocket.destroyed) {
395
451
  secureServiceSocket.end();
@@ -458,11 +514,11 @@ function readCdTunnelResponse(socket, timeoutMs) {
458
514
  action();
459
515
  };
460
516
  const onData = (chunk) => {
461
- log.debug('Received data chunk:', chunk.length, 'bytes');
517
+ tunDebug('Received data chunk:', chunk.length, 'bytes');
462
518
  buffer = appendBuffer(buffer, chunk);
463
519
  if (buffer.length >= CD_TUNNEL_HEADER_SIZE) {
464
520
  const payloadLength = buffer.readUInt16BE(CD_TUNNEL_MAGIC_SIZE);
465
- log.debug('Expected total packet length:', CD_TUNNEL_HEADER_SIZE + payloadLength, 'current buffer:', buffer.length);
521
+ tunDebug('Expected total packet length:', CD_TUNNEL_HEADER_SIZE + payloadLength, 'current buffer:', buffer.length);
466
522
  }
467
523
  const result = tryParseCdTunnelResponse(buffer);
468
524
  if (result.kind === 'incomplete') {
@@ -472,7 +528,7 @@ function readCdTunnelResponse(socket, timeoutMs) {
472
528
  finish(() => reject(result.error));
473
529
  return;
474
530
  }
475
- log.debug('Parsed CDTunnel response:', result.value);
531
+ tunDebug('Parsed CDTunnel response:', result.value);
476
532
  finish(() => resolve(result.value));
477
533
  };
478
534
  const onError = (err) => {
@@ -480,9 +536,9 @@ function readCdTunnelResponse(socket, timeoutMs) {
480
536
  finish(() => reject(err));
481
537
  };
482
538
  const onEnd = () => {
483
- log.debug('Connection ended');
539
+ tunDebug('Connection ended');
484
540
  if (buffer.length > 0) {
485
- log.debug('Buffer at end:', buffer.toString('hex'));
541
+ tunDebug('Buffer at end:', buffer.toString('hex'));
486
542
  }
487
543
  finish(() => reject(new Error('Connection closed before receiving complete response')));
488
544
  };
@@ -0,0 +1,30 @@
1
+ import type { Socket } from 'node:net';
2
+ import type { TunTap } from '../TunTap.js';
3
+ export type TunToDeviceProgressHook = () => void;
4
+ /**
5
+ * TUN→device forwarding: utun poll fills an ingress queue; a writer loop drains
6
+ * the queue to TLS with backpressure (await drain).
7
+ */
8
+ export declare class TunToDevicePump {
9
+ private readonly tun;
10
+ private readonly mtu;
11
+ private readonly onPacketRead?;
12
+ private readonly onForwardProgress?;
13
+ private cancelled;
14
+ private running;
15
+ private tunIngressQueue;
16
+ private ingressWaiter;
17
+ private ingressReject;
18
+ private drainAbort;
19
+ private loopPromise;
20
+ private fwdPumpPackets;
21
+ constructor(tun: TunTap, mtu: number, onPacketRead?: ((data: Buffer) => void) | undefined, onForwardProgress?: TunToDeviceProgressHook | undefined);
22
+ start(deviceConn: Socket): void;
23
+ stop(): Promise<void>;
24
+ private onTunPollData;
25
+ private signalIngress;
26
+ private maybeResumeTunPolling;
27
+ private waitForIngress;
28
+ private writeAndDrain;
29
+ private runLoop;
30
+ }
@@ -0,0 +1,166 @@
1
+ import { once } from 'node:events';
2
+ import { MAX_TUN_INGRESS_QUEUE, TUN_POLL_TSFN_QUEUE_DEPTH } from './constants.js';
3
+ import { fwdDebug } from './debug-log.js';
4
+ /**
5
+ * TUN→device forwarding: utun poll fills an ingress queue; a writer loop drains
6
+ * the queue to TLS with backpressure (await drain).
7
+ */
8
+ export class TunToDevicePump {
9
+ tun;
10
+ mtu;
11
+ onPacketRead;
12
+ onForwardProgress;
13
+ cancelled = false;
14
+ running = false;
15
+ tunIngressQueue = [];
16
+ ingressWaiter = null;
17
+ ingressReject = null;
18
+ drainAbort = null;
19
+ loopPromise = null;
20
+ fwdPumpPackets = 0;
21
+ constructor(tun, mtu, onPacketRead, onForwardProgress) {
22
+ this.tun = tun;
23
+ this.mtu = mtu;
24
+ this.onPacketRead = onPacketRead;
25
+ this.onForwardProgress = onForwardProgress;
26
+ }
27
+ start(deviceConn) {
28
+ if (this.running) {
29
+ return;
30
+ }
31
+ this.running = true;
32
+ this.cancelled = false;
33
+ this.tun.startPolling((data) => this.onTunPollData(data), this.mtu, TUN_POLL_TSFN_QUEUE_DEPTH);
34
+ this.tun.resumePolling();
35
+ fwdDebug('pump-start', { mtu: this.mtu, queueDepth: TUN_POLL_TSFN_QUEUE_DEPTH });
36
+ this.loopPromise = this.runLoop(deviceConn);
37
+ }
38
+ async stop() {
39
+ this.cancelled = true;
40
+ this.drainAbort?.abort();
41
+ this.drainAbort = null;
42
+ this.tun.pausePolling();
43
+ if (this.ingressReject) {
44
+ this.ingressReject(new Error(TUN_PUMP_CANCELLED));
45
+ }
46
+ else if (this.ingressWaiter) {
47
+ this.ingressWaiter();
48
+ }
49
+ this.ingressWaiter = null;
50
+ this.ingressReject = null;
51
+ this.tunIngressQueue = [];
52
+ if (this.loopPromise) {
53
+ await this.loopPromise.catch(() => undefined);
54
+ this.loopPromise = null;
55
+ }
56
+ this.running = false;
57
+ }
58
+ onTunPollData(data) {
59
+ if (this.cancelled || !data.length) {
60
+ return;
61
+ }
62
+ this.onPacketRead?.(data);
63
+ this.tunIngressQueue.push(data);
64
+ if (this.tunIngressQueue.length >= MAX_TUN_INGRESS_QUEUE) {
65
+ this.tun.pausePolling();
66
+ fwdDebug('tun-ingress-pause', { queued: this.tunIngressQueue.length });
67
+ }
68
+ this.signalIngress();
69
+ }
70
+ signalIngress() {
71
+ const waiter = this.ingressWaiter;
72
+ if (!waiter) {
73
+ return;
74
+ }
75
+ this.ingressWaiter = null;
76
+ this.ingressReject = null;
77
+ waiter();
78
+ }
79
+ maybeResumeTunPolling() {
80
+ if (this.tunIngressQueue.length < MAX_TUN_INGRESS_QUEUE) {
81
+ this.tun.resumePolling();
82
+ }
83
+ }
84
+ waitForIngress() {
85
+ if (this.cancelled) {
86
+ return Promise.reject(new Error(TUN_PUMP_CANCELLED));
87
+ }
88
+ if (this.tunIngressQueue.length > 0) {
89
+ return Promise.resolve();
90
+ }
91
+ return new Promise((resolve, reject) => {
92
+ this.ingressWaiter = resolve;
93
+ this.ingressReject = reject;
94
+ });
95
+ }
96
+ async writeAndDrain(deviceConn, data) {
97
+ if (deviceConn.destroyed) {
98
+ return;
99
+ }
100
+ const canWriteMore = deviceConn.write(data);
101
+ if (this.fwdPumpPackets === 1 || this.fwdPumpPackets % 200 === 0 || !canWriteMore) {
102
+ fwdDebug('pump-write', {
103
+ len: data.length,
104
+ canWriteMore,
105
+ writableLength: deviceConn.writableLength,
106
+ packets: this.fwdPumpPackets,
107
+ });
108
+ }
109
+ if (!canWriteMore || deviceConn.writableNeedDrain) {
110
+ this.onForwardProgress?.();
111
+ fwdDebug('pump-drain-wait', {
112
+ writableLength: deviceConn.writableLength,
113
+ len: data.length,
114
+ tunQueued: this.tunIngressQueue.length,
115
+ });
116
+ this.drainAbort = new AbortController();
117
+ try {
118
+ await once(deviceConn, 'drain', { signal: this.drainAbort.signal });
119
+ }
120
+ catch {
121
+ return;
122
+ }
123
+ finally {
124
+ this.drainAbort = null;
125
+ }
126
+ fwdDebug('pump-drain-done', {
127
+ writableLength: deviceConn.writableLength,
128
+ tunQueued: this.tunIngressQueue.length,
129
+ });
130
+ }
131
+ if (this.cancelled) {
132
+ return;
133
+ }
134
+ this.onForwardProgress?.();
135
+ }
136
+ async runLoop(deviceConn) {
137
+ try {
138
+ while (!this.cancelled && !deviceConn.destroyed) {
139
+ await this.waitForIngress();
140
+ while (this.tunIngressQueue.length > 0 && !this.cancelled && !deviceConn.destroyed) {
141
+ const packet = this.tunIngressQueue.shift();
142
+ if (!packet) {
143
+ break;
144
+ }
145
+ this.maybeResumeTunPolling();
146
+ this.fwdPumpPackets += 1;
147
+ if (this.fwdPumpPackets === 1 || this.fwdPumpPackets % 200 === 0) {
148
+ fwdDebug('pump-read', { len: packet.length, packets: this.fwdPumpPackets });
149
+ }
150
+ this.onForwardProgress?.();
151
+ await this.writeAndDrain(deviceConn, packet);
152
+ }
153
+ }
154
+ }
155
+ catch (err) {
156
+ if (!isPumpCancelled(err)) {
157
+ throw err;
158
+ }
159
+ }
160
+ fwdDebug('pump-stop', { packets: this.fwdPumpPackets });
161
+ }
162
+ }
163
+ const TUN_PUMP_CANCELLED = 'pump cancelled';
164
+ function isPumpCancelled(err) {
165
+ return err instanceof Error && err.message === TUN_PUMP_CANCELLED;
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-ios-tuntap",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Native TUN/TAP interface module for Node.js",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -18,8 +18,8 @@
18
18
  "refresh:wintun": "node scripts/fetch-wintun.mjs",
19
19
  "prepare": "npm run build",
20
20
  "test": "npm run test:integration && npm run test:unit",
21
- "test:unit": "node --test --test-force-exit --test-timeout=120000 \"test/unit/**/*.spec.mjs\"",
22
- "test:integration": "node --test --test-force-exit --test-timeout=120000 \"test/integration/**/*.spec.mjs\""
21
+ "test:unit": "node --test --test-force-exit --test-timeout=5000 \"test/unit/**/*.spec.mjs\"",
22
+ "test:integration": "node --test --test-force-exit --test-timeout=15000 \"test/integration/**/*.spec.mjs\""
23
23
  },
24
24
  "files": [
25
25
  "src/tuntap.cc",
@@ -121,8 +121,8 @@ void PosixUvPollLoop::OnPoll(uv_poll_t* handle, int status, int events) {
121
121
 
122
122
  switch (rs) {
123
123
  case ReadPacketStatus::Data:
124
- if (state->on_packet) {
125
- state->on_packet(std::move(packet));
124
+ if (state->on_packet && !state->on_packet(std::move(packet))) {
125
+ return;
126
126
  }
127
127
  break;
128
128
  case ReadPacketStatus::NoData:
@@ -42,7 +42,7 @@ public:
42
42
  // background thread (libuv loop thread on POSIX, worker thread on Windows);
43
43
  // the caller in `tuntap.cc` is responsible for marshalling onto the JS
44
44
  // thread via `Napi::ThreadSafeFunction`.
45
- using PacketCallback = std::function<void(std::vector<uint8_t>)>;
45
+ using PacketCallback = std::function<bool(std::vector<uint8_t>)>;
46
46
 
47
47
  // Invoked at most once when the receive loop encounters a fatal error and
48
48
  // stops. The receive loop must not deliver any further packets afterwards.
@@ -319,8 +319,8 @@ private:
319
319
  : static_cast<size_t>(packet_size);
320
320
  std::vector<uint8_t> data(packet, packet + copy_len);
321
321
  api.ReleaseReceivePacket(session_, packet);
322
- if (on_packet) {
323
- on_packet(std::move(data));
322
+ if (on_packet && !on_packet(std::move(data))) {
323
+ break;
324
324
  }
325
325
  continue;
326
326
  }
package/src/tuntap.cc CHANGED
@@ -6,10 +6,74 @@
6
6
  #include <mutex>
7
7
  #include <string>
8
8
  #include <utility>
9
- #include <vector>
9
+ #include <deque>
10
10
 
11
11
  #include "native/tun_backend.h"
12
12
 
13
+ struct TunPollDispatch {
14
+ Napi::ThreadSafeFunction tsfn;
15
+ std::mutex mutex;
16
+ std::deque<std::vector<uint8_t>> pending;
17
+ static constexpr size_t MAX_PENDING = 512;
18
+
19
+ struct PacketJob {
20
+ TunPollDispatch* dispatch;
21
+ std::vector<uint8_t>* packet;
22
+ };
23
+
24
+ static void CallJs(Napi::Env env,
25
+ Napi::Function jsCallback,
26
+ TunPollDispatch* self,
27
+ std::vector<uint8_t>* packet) {
28
+ if (env == nullptr || jsCallback.IsEmpty() || packet == nullptr) {
29
+ delete packet;
30
+ return;
31
+ }
32
+ auto* backing = packet;
33
+ Napi::Buffer<uint8_t> buf = Napi::Buffer<uint8_t>::New(
34
+ env,
35
+ backing->data(),
36
+ backing->size(),
37
+ [](Napi::Env, uint8_t*, std::vector<uint8_t>* vec) { delete vec; },
38
+ backing);
39
+ jsCallback.Call({buf});
40
+ if (self != nullptr) {
41
+ self->FlushPending();
42
+ }
43
+ }
44
+
45
+ void FlushPending() {
46
+ std::lock_guard<std::mutex> lock(mutex);
47
+ while (!pending.empty()) {
48
+ auto* packet = new std::vector<uint8_t>(std::move(pending.front()));
49
+ auto* job = new PacketJob{this, packet};
50
+ napi_status status = tsfn.NonBlockingCall(
51
+ job,
52
+ [](Napi::Env env, Napi::Function jsCallback, PacketJob* job) {
53
+ CallJs(env, jsCallback, job->dispatch, job->packet);
54
+ delete job;
55
+ });
56
+ if (status != napi_ok) {
57
+ delete packet;
58
+ delete job;
59
+ break;
60
+ }
61
+ pending.pop_front();
62
+ }
63
+ }
64
+
65
+ bool PostPacket(std::vector<uint8_t> packet) {
66
+ {
67
+ std::lock_guard<std::mutex> lock(mutex);
68
+ pending.push_back(std::move(packet));
69
+ }
70
+ FlushPending();
71
+ std::lock_guard<std::mutex> lock(mutex);
72
+ // Stop reading more from utun until JS drains the backlog.
73
+ return pending.size() < MAX_PENDING;
74
+ }
75
+ };
76
+
13
77
  class TunDevice : public Napi::ObjectWrap<TunDevice> {
14
78
  public:
15
79
  static Napi::Object Init(Napi::Env env, Napi::Object exports);
@@ -38,6 +102,7 @@ private:
38
102
  std::mutex device_mutex_;
39
103
 
40
104
  Napi::ThreadSafeFunction tsfn_;
105
+ TunPollDispatch* poll_dispatch_ = nullptr;
41
106
  std::atomic<bool> polling_;
42
107
  static constexpr size_t MAX_POLL_BUFFER = 65535;
43
108
 
@@ -240,21 +305,11 @@ Napi::Value TunDevice::StartPolling(const Napi::CallbackInfo& info) {
240
305
  }
241
306
 
242
307
  Napi::ThreadSafeFunction tsfn = tsfn_;
243
- auto packet_cb = [tsfn](std::vector<uint8_t> packet) mutable {
244
- tsfn.BlockingCall(
245
- [packet = std::move(packet)](Napi::Env env, Napi::Function jsCallback) mutable {
246
- if (env == nullptr || jsCallback.IsEmpty()) {
247
- return;
248
- }
249
- auto* backing = new std::vector<uint8_t>(std::move(packet));
250
- Napi::Buffer<uint8_t> buf = Napi::Buffer<uint8_t>::New(
251
- env,
252
- backing->data(),
253
- backing->size(),
254
- [](Napi::Env, uint8_t*, std::vector<uint8_t>* vec) { delete vec; },
255
- backing);
256
- jsCallback.Call({buf});
257
- });
308
+ auto* dispatch = new TunPollDispatch();
309
+ dispatch->tsfn = tsfn;
310
+ poll_dispatch_ = dispatch;
311
+ auto packet_cb = [dispatch](std::vector<uint8_t> packet) mutable -> bool {
312
+ return dispatch->PostPacket(std::move(packet));
258
313
  };
259
314
  // Terminal errors from the receive loop (poll error, device closed, read
260
315
  // error) call back here so the JS-side polling_ flag and TSFN are released
@@ -326,8 +381,14 @@ void TunDevice::StopPollingLocked() {
326
381
  }
327
382
 
328
383
  void TunDevice::ReleaseTsfnLocked() {
384
+ // Release TSFN first — it blocks until queued callbacks finish. Those callbacks
385
+ // may still dereference poll_dispatch_, so it must outlive the TSFN drain.
329
386
  if (tsfn_) {
330
387
  tsfn_.Release();
331
388
  tsfn_ = nullptr;
332
389
  }
390
+ if (poll_dispatch_ != nullptr) {
391
+ delete poll_dispatch_;
392
+ poll_dispatch_ = nullptr;
393
+ }
333
394
  }