@gjsify/net 0.3.21 → 0.4.3

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/src/socket.ts DELETED
@@ -1,458 +0,0 @@
1
- // Reference: Node.js lib/net.js, refs/deno/ext/node/polyfills/net.ts
2
- // Reimplemented for GJS using Gio.SocketClient / Gio.SocketConnection
3
-
4
- import Gio from '@girs/gio-2.0';
5
- import GLib from '@girs/glib-2.0';
6
- import { Duplex } from 'node:stream';
7
- import { Buffer } from 'node:buffer';
8
- import { createNodeError, gbytesToUint8Array } from '@gjsify/utils';
9
- import type { DuplexOptions } from 'node:stream';
10
-
11
- export interface SocketConnectOptions {
12
- port: number;
13
- host?: string;
14
- localAddress?: string;
15
- localPort?: number;
16
- family?: 4 | 6 | 0;
17
- keepAlive?: boolean;
18
- keepAliveInitialDelay?: number;
19
- noDelay?: boolean;
20
- timeout?: number;
21
- }
22
-
23
- export interface SocketOptions extends DuplexOptions {
24
- allowHalfOpen?: boolean;
25
- }
26
-
27
- export class Socket extends Duplex {
28
- // Public properties matching Node.js net.Socket
29
- remoteAddress?: string;
30
- remotePort?: number;
31
- remoteFamily?: string;
32
- localAddress?: string;
33
- localPort?: number;
34
- bytesRead = 0;
35
- bytesWritten = 0;
36
- connecting = false;
37
- destroyed = false;
38
- pending = true;
39
- readyState: 'opening' | 'open' | 'readOnly' | 'writeOnly' | 'closed' = 'closed';
40
- declare allowHalfOpen: boolean;
41
-
42
- private _connection: Gio.SocketConnection | null = null;
43
- private _ioStream: Gio.IOStream | null = null;
44
- private _inputStream: Gio.InputStream | null = null;
45
- private _outputStream: Gio.OutputStream | null = null;
46
- private _cancellable: Gio.Cancellable = new Gio.Cancellable();
47
- private _reading = false;
48
- private _timeout = 0;
49
- private _timeoutId: ReturnType<typeof setTimeout> | null = null;
50
-
51
- constructor(options?: SocketOptions) {
52
- super(options);
53
- this.allowHalfOpen = options?.allowHalfOpen ?? false;
54
- }
55
-
56
- /** @internal Set the connection from an accepted server socket. */
57
- _setConnection(connection: Gio.SocketConnection): void {
58
- this._connection = connection;
59
- }
60
-
61
- /**
62
- * @internal Set up this socket from a raw Gio.IOStream (e.g., stolen from Soup.Server
63
- * during an HTTP upgrade). Extracts I/O streams, attempts to read address info
64
- * if the underlying stream is a SocketConnection, and starts reading.
65
- */
66
- _setupFromIOStream(ioStream: Gio.IOStream): void {
67
- this._ioStream = ioStream;
68
-
69
- // If the IOStream is actually a SocketConnection, use it for full features
70
- try {
71
- const sockConn = ioStream as unknown as Gio.SocketConnection;
72
- if (typeof sockConn.get_socket === 'function') {
73
- this._connection = sockConn;
74
- const remoteAddr = sockConn.get_remote_address() as Gio.InetSocketAddress;
75
- this.remoteAddress = remoteAddr.get_address().to_string();
76
- this.remotePort = remoteAddr.get_port();
77
- this.remoteFamily = remoteAddr.get_address().get_family() === Gio.SocketFamily.IPV6 ? 'IPv6' : 'IPv4';
78
- const localAddr = sockConn.get_local_address() as Gio.InetSocketAddress;
79
- this.localAddress = localAddr.get_address().to_string();
80
- this.localPort = localAddr.get_port();
81
- }
82
- } catch { /* not a SocketConnection — use IOStream only */ }
83
-
84
- this._inputStream = ioStream.get_input_stream();
85
- this._outputStream = ioStream.get_output_stream();
86
-
87
- this.connecting = false;
88
- this.pending = false;
89
- this.readyState = 'open';
90
-
91
- this.emit('connect');
92
- this.emit('ready');
93
-
94
- this._startReading();
95
- }
96
-
97
- /**
98
- * @internal For HTTP upgrade handoff: configures write capability and address
99
- * info from the IOStream but does NOT start the async read loop. Used by
100
- * http.Server._handleRequest so handleUpgrade() can write the 101 response and
101
- * then hand the IOStream to Soup.WebsocketConnection without a read-loop race.
102
- */
103
- _attachOutputOnly(ioStream: Gio.IOStream): void {
104
- this._ioStream = ioStream;
105
- try {
106
- const sockConn = ioStream as unknown as Gio.SocketConnection;
107
- if (typeof sockConn.get_socket === 'function') {
108
- this._connection = sockConn;
109
- const remoteAddr = sockConn.get_remote_address() as Gio.InetSocketAddress;
110
- this.remoteAddress = remoteAddr.get_address().to_string();
111
- this.remotePort = remoteAddr.get_port();
112
- this.remoteFamily = remoteAddr.get_address().get_family() === Gio.SocketFamily.IPV6 ? 'IPv6' : 'IPv4';
113
- const localAddr = sockConn.get_local_address() as Gio.InetSocketAddress;
114
- this.localAddress = localAddr.get_address().to_string();
115
- this.localPort = localAddr.get_port();
116
- }
117
- } catch { /* not a SocketConnection — use IOStream only */ }
118
- this._inputStream = ioStream.get_input_stream();
119
- this._outputStream = ioStream.get_output_stream();
120
- this.connecting = false;
121
- this.pending = false;
122
- this.readyState = 'open';
123
- this.emit('connect');
124
- this.emit('ready');
125
- // Intentionally NO _startReading() — caller will hand off to Soup.WebsocketConnection
126
- }
127
-
128
- /** Release IOStream ownership to the caller (Soup.WebsocketConnection handoff).
129
- * Nullifies the socket's stream references so they are not closed when the
130
- * socket itself is later destroyed. */
131
- _releaseIOStream(): Gio.IOStream | null {
132
- const s = this._ioStream;
133
- this._ioStream = null;
134
- this._inputStream = null;
135
- this._outputStream = null;
136
- this._connection = null;
137
- return s;
138
- }
139
-
140
- /**
141
- * Initiate a TCP connection.
142
- */
143
- connect(options: SocketConnectOptions | number, host?: string | (() => void), connectionListener?: () => void): this;
144
- connect(options: SocketConnectOptions | number, host?: string | (() => void), connectionListener?: () => void): this {
145
- // Normalize arguments
146
- let opts: SocketConnectOptions;
147
- if (typeof options === 'number') {
148
- opts = { port: options, host: typeof host === 'string' ? host : 'localhost' };
149
- if (typeof host === 'function') connectionListener = host;
150
- } else {
151
- opts = options;
152
- if (typeof host === 'function') connectionListener = host;
153
- }
154
-
155
- if (connectionListener) {
156
- this.once('connect', connectionListener);
157
- }
158
-
159
- this.connecting = true;
160
- this.readyState = 'opening';
161
- this.pending = true;
162
-
163
- const targetHost = opts.host || 'localhost';
164
- const targetPort = opts.port;
165
-
166
- const client = new Gio.SocketClient();
167
-
168
- if (opts.timeout) {
169
- client.set_timeout(Math.ceil(opts.timeout / 1000));
170
- }
171
-
172
- // Async connect
173
- client.connect_to_host_async(
174
- targetHost,
175
- targetPort,
176
- this._cancellable,
177
- (_source: Gio.SocketClient | null, asyncResult: Gio.AsyncResult) => {
178
- try {
179
- this._connection = client.connect_to_host_finish(asyncResult);
180
- this._setupConnection(opts);
181
- } catch (err: unknown) {
182
- this.connecting = false;
183
- this.readyState = 'closed';
184
- const nodeErr = createNodeError(err, 'connect', {
185
- address: targetHost,
186
- port: targetPort,
187
- });
188
- this.destroy(nodeErr);
189
- }
190
- },
191
- );
192
-
193
- return this;
194
- }
195
-
196
- /** @internal Set up streams and emit connect after connection is established. */
197
- _setupConnection(opts: SocketConnectOptions | Record<string, never>): void {
198
- if (!this._connection) return;
199
-
200
- const sock = this._connection.get_socket();
201
- this._inputStream = this._connection.get_input_stream();
202
- this._outputStream = this._connection.get_output_stream();
203
-
204
- // Set socket options
205
- if ('keepAlive' in opts && opts.keepAlive) {
206
- sock.set_keepalive(true);
207
- }
208
- if (!('noDelay' in opts) || opts.noDelay !== false) {
209
- // TCP_NODELAY: level=6 (IPPROTO_TCP), optname=1 (TCP_NODELAY)
210
- try { sock.set_option(6, 1, 1); } catch { /* may not be supported */ }
211
- }
212
-
213
- // Extract address info
214
- try {
215
- const remoteAddr = this._connection.get_remote_address() as Gio.InetSocketAddress;
216
- this.remoteAddress = remoteAddr.get_address().to_string();
217
- this.remotePort = remoteAddr.get_port();
218
- this.remoteFamily = remoteAddr.get_address().get_family() === Gio.SocketFamily.IPV6 ? 'IPv6' : 'IPv4';
219
- } catch { /* ignore */ }
220
-
221
- try {
222
- const localAddr = this._connection.get_local_address() as Gio.InetSocketAddress;
223
- this.localAddress = localAddr.get_address().to_string();
224
- this.localPort = localAddr.get_port();
225
- } catch { /* ignore */ }
226
-
227
- this.connecting = false;
228
- this.pending = false;
229
- this.readyState = 'open';
230
-
231
- this.emit('connect');
232
- this.emit('ready');
233
-
234
- // Start reading
235
- this._startReading();
236
- }
237
-
238
- private _startReading(): void {
239
- if (this._reading || !this._inputStream) return;
240
- this._reading = true;
241
- this._readLoop();
242
- }
243
-
244
- private async _readLoop(): Promise<void> {
245
- const inputStream = this._inputStream;
246
- if (!inputStream) return;
247
-
248
- const CHUNK_SIZE = 16384;
249
-
250
- try {
251
- while (this._reading && inputStream) {
252
- const bytes = await new Promise<GLib.Bytes | null>((resolve, reject) => {
253
- inputStream.read_bytes_async(
254
- CHUNK_SIZE,
255
- GLib.PRIORITY_DEFAULT,
256
- this._cancellable,
257
- (_source: Gio.InputStream | null, asyncResult: Gio.AsyncResult) => {
258
- try {
259
- resolve(inputStream.read_bytes_finish(asyncResult));
260
- } catch (err) {
261
- reject(err);
262
- }
263
- },
264
- );
265
- });
266
- if (!bytes || bytes.get_size() === 0) {
267
- // EOF — remote peer closed their write side
268
- this._reading = false;
269
- if (!this.allowHalfOpen) {
270
- // Default: close our write side after 'end' listeners have run,
271
- // so they can still write before the socket shuts down.
272
- this.once('end', () => {
273
- this.end();
274
- this.readyState = 'closed';
275
- });
276
- } else {
277
- this.readyState = this.writable ? 'writeOnly' : 'closed';
278
- }
279
- this.push(null);
280
- break;
281
- }
282
-
283
- const data = gbytesToUint8Array(bytes);
284
- this.bytesRead += data.length;
285
- this._resetTimeout();
286
-
287
- if (!this.push(Buffer.from(data))) {
288
- // Backpressure — pause reading until _read is called
289
- this._reading = false;
290
- break;
291
- }
292
- }
293
- } catch (err: unknown) {
294
- if (!this._cancellable.is_cancelled()) {
295
- this.destroy(createNodeError(err, 'read', { address: this.remoteAddress }));
296
- }
297
- }
298
- }
299
-
300
- // Duplex stream interface
301
- _read(_size: number): void {
302
- if (!this._reading && this._inputStream) {
303
- this._startReading();
304
- }
305
- }
306
-
307
- _write(chunk: any, encoding: string, callback: (error?: Error | null) => void): void {
308
- if (!this._outputStream) {
309
- callback(new Error('Socket is not connected'));
310
- return;
311
- }
312
-
313
- const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding as BufferEncoding);
314
-
315
- this._outputStream.write_bytes_async(
316
- new GLib.Bytes(data),
317
- GLib.PRIORITY_DEFAULT,
318
- this._cancellable,
319
- (_source: Gio.OutputStream | null, asyncResult: Gio.AsyncResult) => {
320
- try {
321
- const written = this._outputStream!.write_bytes_finish(asyncResult);
322
- this.bytesWritten += written;
323
- this._resetTimeout();
324
- callback(null);
325
- } catch (err: unknown) {
326
- callback(createNodeError(err, 'write', { address: this.remoteAddress }));
327
- }
328
- },
329
- );
330
- }
331
-
332
- _final(callback: (error?: Error | null) => void): void {
333
- // Half-close: shutdown write side
334
- if (this._connection) {
335
- try {
336
- this._connection.get_socket().shutdown(false, true);
337
- this.readyState = this.readable ? 'readOnly' : 'closed';
338
- } catch { /* ignore */ }
339
- } else if (this._ioStream) {
340
- // Fallback for IOStream-based sockets (e.g., stolen from Soup.Server).
341
- // SoupIOStream doesn't support half-close via output stream close,
342
- // so close the entire IOStream to send TCP FIN to the peer.
343
- try {
344
- this._ioStream.close(null);
345
- this.readyState = 'closed';
346
- } catch { /* ignore */ }
347
- this._ioStream = null;
348
- this._inputStream = null;
349
- this._outputStream = null;
350
- }
351
- callback();
352
- }
353
-
354
- _destroy(err: Error | null, callback: (error?: Error | null) => void): void {
355
- this._reading = false;
356
- this._clearTimeout();
357
- this._cancellable.cancel();
358
-
359
- if (this._connection) {
360
- try {
361
- this._connection.close(null);
362
- } catch { /* ignore */ }
363
- this._connection = null;
364
- } else if (this._ioStream) {
365
- try {
366
- this._ioStream.close(null);
367
- } catch { /* ignore */ }
368
- }
369
-
370
- this._ioStream = null;
371
- this._inputStream = null;
372
- this._outputStream = null;
373
- this.readyState = 'closed';
374
- this.destroyed = true;
375
-
376
- callback(err);
377
- }
378
-
379
- /** Set the socket timeout in milliseconds. 0 disables. */
380
- setTimeout(timeout: number, callback?: () => void): this {
381
- this._timeout = timeout;
382
- this._clearTimeout();
383
-
384
- if (callback) {
385
- this.once('timeout', callback);
386
- }
387
-
388
- if (timeout > 0) {
389
- this._resetTimeout();
390
- }
391
-
392
- return this;
393
- }
394
-
395
- private _resetTimeout(): void {
396
- this._clearTimeout();
397
- if (this._timeout > 0) {
398
- this._timeoutId = setTimeout(() => {
399
- this.emit('timeout');
400
- }, this._timeout);
401
- }
402
- }
403
-
404
- private _clearTimeout(): void {
405
- if (this._timeoutId !== null) {
406
- clearTimeout(this._timeoutId);
407
- this._timeoutId = null;
408
- }
409
- }
410
-
411
- /** Enable/disable TCP keep-alive. */
412
- setKeepAlive(enable?: boolean, _initialDelay?: number): this {
413
- if (this._connection) {
414
- try {
415
- this._connection.get_socket().set_keepalive(enable ?? false);
416
- } catch { /* ignore */ }
417
- }
418
- return this;
419
- }
420
-
421
- /** Enable/disable TCP_NODELAY (disable Nagle algorithm). */
422
- setNoDelay(noDelay?: boolean): this {
423
- if (this._connection) {
424
- try {
425
- this._connection.get_socket().set_option(6, 1, noDelay !== false ? 1 : 0);
426
- } catch { /* ignore */ }
427
- }
428
- return this;
429
- }
430
-
431
- /** Half-close then destroy: end() if writable, destroy() once finished. */
432
- destroySoon(): void {
433
- if (this.writable)
434
- this.end();
435
-
436
- if (this.writableFinished)
437
- this.destroy();
438
- else
439
- this.once('finish', this.destroy.bind(this));
440
- }
441
-
442
- /** Get the underlying socket address info. */
443
- address(): { port: number; family: string; address: string } | {} {
444
- if (this.localAddress && this.localPort) {
445
- return {
446
- port: this.localPort,
447
- family: this.remoteFamily || 'IPv4',
448
- address: this.localAddress,
449
- };
450
- }
451
- return {};
452
- }
453
-
454
- /** Reference this socket (keep event loop alive). No-op in GJS. */
455
- ref(): this { return this; }
456
- /** Unreference this socket. No-op in GJS. */
457
- unref(): this { return this; }
458
- }
package/src/test.mts DELETED
@@ -1,11 +0,0 @@
1
-
2
- import { run } from '@gjsify/unit';
3
-
4
- import testSuiteNet from './index.spec.js';
5
- import extendedTestSuite from './extended.spec.js';
6
- import serverTestSuite from './server.spec.js';
7
- import timeoutTestSuite from './timeout.spec.js';
8
- import errorTestSuite from './error.spec.js';
9
- import throughputTestSuite from './throughput.spec.js';
10
-
11
- run({ testSuiteNet, extendedTestSuite, serverTestSuite, timeoutTestSuite, errorTestSuite, throughputTestSuite });
@@ -1,76 +0,0 @@
1
- // Regression test for socket read starvation under sustained load.
2
- //
3
- // Root cause: when process.nextTick() was routed through GLib.idle_add(priority=100)
4
- // instead of microtasks, it fired AFTER TCP I/O callbacks (priority=0).
5
- // Code that schedules writes or parsing via process.nextTick (e.g. bittorrent-protocol)
6
- // would get starved: I/O kept firing at priority 0 while nextTick callbacks at priority 100
7
- // never got scheduled, causing buffers to overflow and throughput to drop to 0B/s.
8
- //
9
- // This test transfers 2MB over a loopback TCP connection using process.nextTick-driven
10
- // write scheduling (the exact pattern bittorrent-protocol uses). It must complete within
11
- // 10 seconds; with the bug it would hang indefinitely or time out.
12
-
13
- import { describe, it, expect } from '@gjsify/unit';
14
- import { createServer, connect } from 'node:net';
15
- import { Buffer } from 'node:buffer';
16
- import process from 'node:process';
17
-
18
- const CHUNK_SIZE = 16_384; // 16 KB — matches socket read buffer and typical BitTorrent block size
19
- const NUM_CHUNKS = 128; // 2 MB total
20
- const TIMEOUT_MS = 10_000;
21
-
22
- export default async () => {
23
- await describe('net Socket throughput', async () => {
24
- await it('transfers 2MB over loopback using process.nextTick-driven writes without stalling', async () => {
25
- const { bytesReceived, timedOut } = await new Promise<{ bytesReceived: number; timedOut: boolean }>((resolve) => {
26
- const deadline = setTimeout(() => resolve({ bytesReceived: -1, timedOut: true }), TIMEOUT_MS);
27
-
28
- const server = createServer((conn) => {
29
- let sent = 0;
30
- const chunk = Buffer.alloc(CHUNK_SIZE, 0x42);
31
-
32
- // Mirrors bittorrent-protocol: schedule each successive write via nextTick
33
- // so that the write loop yields between chunks rather than blocking.
34
- function sendNext() {
35
- if (sent >= NUM_CHUNKS) { conn.end(); return; }
36
- const ok = conn.write(chunk);
37
- sent++;
38
- if (ok) process.nextTick(sendNext);
39
- else conn.once('drain', sendNext);
40
- }
41
- sendNext();
42
-
43
- conn.on('error', () => {});
44
- });
45
-
46
- server.listen(0, '127.0.0.1', () => {
47
- const { port } = (server.address() as { port: number });
48
- let received = 0;
49
-
50
- const client = connect(port, '127.0.0.1');
51
-
52
- client.on('data', (chunk) => {
53
- received += chunk.length;
54
- // Simulate protocol-layer processing scheduled via nextTick (bittorrent-protocol pattern).
55
- process.nextTick(() => { /* parse */ });
56
- });
57
-
58
- client.on('end', () => {
59
- clearTimeout(deadline);
60
- server.close(() => resolve({ bytesReceived: received, timedOut: false }));
61
- });
62
-
63
- client.on('error', (err) => {
64
- clearTimeout(deadline);
65
- server.close(() => resolve({ bytesReceived: received, timedOut: true }));
66
- });
67
- });
68
-
69
- server.on('error', () => resolve({ bytesReceived: -1, timedOut: true }));
70
- });
71
-
72
- expect(timedOut).toBeFalsy();
73
- expect(bytesReceived).toBe(CHUNK_SIZE * NUM_CHUNKS);
74
- });
75
- });
76
- };