| … | … | @@ -20,11 +20,11 @@ |
| 20 | 20 | * await client.close() |
| 21 | 21 | */ |
| 22 | 22 | |
| 23 | import net from '#node:net' | |
| 24 | 23 | import http from '#node:http' |
| 25 | 24 | import { URL } from '#node:url' |
| 26 | 25 | import b4a from 'b4a' |
| 27 | 26 | import { stat } from '#node:fs/promises' |
| 27 | import { udsBackend } from './uds-backend.js' | |
| 28 | 28 | import Hyperswarm from 'hyperswarm' |
| 29 | 29 | import Protomux from 'protomux' |
| 30 | 30 | import crypto from 'hypercore-crypto' |
| … | … | @@ -69,7 +69,8 @@ export class Client { |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | 71 | static async connectUds (path) { |
| 72 | const sock = net.createConnection(path) | |
| 72 | const { backend, name } = await udsBackend(path) | |
| 73 | const sock = backend.createConnection(name) | |
| 73 | 74 | await new Promise((resolve, reject) => { |
| 74 | 75 | sock.once('connect', () => { |
| 75 | 76 | sock.removeListener('error', reject) |
| … | … | @@ -267,16 +268,20 @@ export class Client { |
| 267 | 268 | } |
| 268 | 269 | |
| 269 | 270 | async function udsReachable (path) { |
| 270 | // Check the socket file exists and is actually a socket | |
| 271 | try { | |
| 272 | const s = await stat(path) | |
| 273 | if (!s.isSocket()) return false | |
| 274 | } catch { | |
| 275 | return false | |
| 271 | const { backend, name, abstract } = await udsBackend(path) | |
| 272 | // Pathname sockets: verify the file exists and is actually a socket first. | |
| 273 | // Abstract sockets have no filesystem entry — skip straight to the connect probe. | |
| 274 | if (!abstract) { | |
| 275 | try { | |
| 276 | const s = await stat(path) | |
| 277 | if (!s.isSocket()) return false | |
| 278 | } catch { | |
| 279 | return false | |
| 280 | } | |
| 276 | 281 | } |
| 277 | 282 | // Try a real connect — covers stale sockets where the daemon crashed |
| 278 | 283 | return new Promise((resolve) => { |
| 279 | const sock = net.createConnection(path) | |
| 284 | const sock = backend.createConnection(name) | |
| 280 | 285 | sock.once('connect', () => { |
| 281 | 286 | sock.destroy() |
| 282 | 287 | resolve(true) |
| … | … | @@ -14,9 +14,9 @@ |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | import process from '#node:process' |
| 17 | import net from '#node:net' | |
| 18 | 17 | import { unlink, stat } from '#node:fs/promises' |
| 19 | 18 | import { encodeFrame, FrameDecoder } from '../frame.js' |
| 19 | import { udsBackend, isAbstract } from '../uds-backend.js' | |
| 20 | 20 | |
| 21 | 21 | export class UdsServer { |
| 22 | 22 | /** |
| … | … | @@ -53,21 +53,30 @@ export class UdsServer { |
| 53 | 53 | async listen () { |
| 54 | 54 | if (this._listening) return |
| 55 | 55 | |
| 56 | // Best-effort cleanup of a stale socket file (server crashed without unlinking). | |
| 57 | // ONLY remove if it's actually a socket — never touch regular files / dirs. | |
| 58 | try { | |
| 59 | const s = await stat(this._path) | |
| 60 | if (s.isSocket()) { | |
| 61 | // Try to connect first — if a daemon is already running, refuse to clobber. | |
| 62 | const inUse = await this._probe() | |
| 63 | if (inUse) throw new Error(`socket ${this._path} is already in use`) | |
| 64 | await unlink(this._path) | |
| 65 | } | |
| 66 | } catch (err) { | |
| 67 | if (err.code !== 'ENOENT' && err.message.indexOf('already in use') === -1) { | |
| 68 | // stat failed for some reason other than missing — ignore, let listen() fail naturally | |
| 56 | // Select the transport: #node:net for pathname sockets (unchanged), or | |
| 57 | // bare-abstract-socket for an '@'-prefixed abstract name. `name` is the | |
| 58 | // bind name with the '@' stripped. | |
| 59 | const { backend, name, abstract } = await udsBackend(this._path) | |
| 60 | ||
| 61 | // Best-effort cleanup of a stale socket FILE (server crashed without | |
| 62 | // unlinking). Abstract sockets have no filesystem entry (the kernel frees | |
| 63 | // the name when the socket closes), so this applies to pathname sockets only. | |
| 64 | if (!abstract) { | |
| 65 | // ONLY remove if it's actually a socket — never touch regular files / dirs. | |
| 66 | try { | |
| 67 | const s = await stat(this._path) | |
| 68 | if (s.isSocket()) { | |
| 69 | // Try to connect first — if a daemon is already running, refuse to clobber. | |
| 70 | const inUse = await this._probe() | |
| 71 | if (inUse) throw new Error(`socket ${this._path} is already in use`) | |
| 72 | await unlink(this._path) | |
| 73 | } | |
| 74 | } catch (err) { | |
| 75 | if (err.code !== 'ENOENT' && err.message.indexOf('already in use') === -1) { | |
| 76 | // stat failed for some reason other than missing — ignore, let listen() fail naturally | |
| 77 | } | |
| 78 | if (err.message && err.message.indexOf('already in use') !== -1) throw err | |
| 69 | 79 | } |
| 70 | if (err.message && err.message.indexOf('already in use') !== -1) throw err | |
| 71 | 80 | } |
| 72 | 81 | |
| 73 | 82 | // allowHalfOpen:false is CRITICAL. bare-net defaults it to true (unlike |
| … | … | @@ -76,11 +85,12 @@ export class UdsServer { |
| 76 | 85 | // With false, peer disconnect surfaces as 'end', which _onConnection |
| 77 | 86 | // turns into a destroy() so the socket fully closes. See the fd-leak that |
| 78 | 87 | // grew pgh-sidecar to ~205k open sockets / 14GB RSS (2026-06-09). |
| 79 | this._server = net.createServer({ allowHalfOpen: false }, (sock) => this._onConnection(sock)) | |
| 88 | // (bare-abstract-socket honours the same allowHalfOpen:false on accept.) | |
| 89 | this._server = backend.createServer({ allowHalfOpen: false }, (sock) => this._onConnection(sock)) | |
| 80 | 90 | |
| 81 | 91 | await new Promise((resolve, reject) => { |
| 82 | 92 | this._server.once('error', reject) |
| 83 | this._server.listen(this._path, () => { | |
| 93 | this._server.listen(name, () => { | |
| 84 | 94 | this._server.removeListener('error', reject) |
| 85 | 95 | resolve() |
| 86 | 96 | }) |
| … | … | @@ -108,9 +118,10 @@ export class UdsServer { |
| 108 | 118 | } |
| 109 | 119 | } |
| 110 | 120 | |
| 111 | _probe () { | |
| 121 | async _probe () { | |
| 122 | const { backend, name } = await udsBackend(this._path) | |
| 112 | 123 | return new Promise((resolve) => { |
| 113 | const sock = net.createConnection(this._path) | |
| 124 | const sock = backend.createConnection(name) | |
| 114 | 125 | sock.once('connect', () => { |
| 115 | 126 | sock.destroy() |
| 116 | 127 | resolve(true) |
| … | … | @@ -201,7 +212,9 @@ export class UdsServer { |
| 201 | 212 | this._server = null |
| 202 | 213 | this._listening = false |
| 203 | 214 | |
| 204 | // Clean up the socket file (best-effort) | |
| 205 | try { await unlink(this._path) } catch {} | |
| 215 | // Clean up the socket file (best-effort). Abstract sockets have no fs entry. | |
| 216 | if (!isAbstract(this._path)) { | |
| 217 | try { await unlink(this._path) } catch {} | |
| 218 | } | |
| 206 | 219 | } |
| 207 | 220 | } |
| … | … | @@ -0,0 +1,42 @@ |
| 1 | /** | |
| 2 | * UDS backend selection. | |
| 3 | * | |
| 4 | * A socket "path" beginning with '@' names a Linux ABSTRACT-namespace socket | |
| 5 | * (no filesystem entry) instead of a pathname socket. Abstract sockets are the | |
| 6 | * only UNIX-socket option when SELinux denies the shell domain from creating | |
| 7 | * pathname sockets — e.g. an unrooted Android phone (celltron) running the | |
| 8 | * daemon under proot, where bind(AF_UNIX, "/path.sock") returns EACCES. | |
| 9 | * | |
| 10 | * For every non-'@' path we use #node:net exactly as before — byte-for-byte | |
| 11 | * unchanged behavior. bare-abstract-socket is imported LAZILY (dynamic import, | |
| 12 | * only at the async call sites below) so pathname deployments never load the | |
| 13 | * native addon. | |
| 14 | */ | |
| 15 | ||
| 16 | import net from '#node:net' | |
| 17 | ||
| 18 | let _abstract = null | |
| 19 | ||
| 20 | async function loadAbstract () { | |
| 21 | if (_abstract === null) { | |
| 22 | const m = await import('bare-abstract-socket') | |
| 23 | _abstract = m.default || m | |
| 24 | } | |
| 25 | return _abstract | |
| 26 | } | |
| 27 | ||
| 28 | // True if `path` names an abstract-namespace socket (leading '@'). | |
| 29 | export function isAbstract (path) { | |
| 30 | return typeof path === 'string' && path.length > 1 && path[0] === '@' | |
| 31 | } | |
| 32 | ||
| 33 | // Resolve the transport backend + the name to bind/connect. The returned | |
| 34 | // `backend` exposes net-compatible createServer(opts, onConnection) and | |
| 35 | // createConnection(name). For abstract paths the leading '@' is stripped | |
| 36 | // (bare-abstract-socket adds the actual leading NUL). | |
| 37 | export async function udsBackend (path) { | |
| 38 | if (isAbstract(path)) { | |
| 39 | return { backend: await loadAbstract(), name: path.slice(1), abstract: true } | |
| 40 | } | |
| 41 | return { backend: net, name: path, abstract: false } | |
| 42 | } |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-svc", |
| 3 | "version": "0.1.5", | |
| 4 | "description": "Reusable per-app daemon library — UDS + HTTP for local RPC, Hyperswarm for peer mirroring", | |
| 3 | "version": "0.1.6", | |
| 4 | "description": "Reusable per-app daemon library \u2014 UDS + HTTP for local RPC, Hyperswarm for peer mirroring", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| 7 | 7 | "scripts": { |
| … | … | @@ -69,7 +69,8 @@ |
| 69 | 69 | "hypercore-crypto": "^3.0.0", |
| 70 | 70 | "hyperswarm": "^4.0.0", |
| 71 | 71 | "protomux": "^3.0.0", |
| 72 | "ready-resource": "^1.0.0" | |
| 72 | "ready-resource": "^1.0.0", | |
| 73 | "bare-abstract-socket": "^1.0.0" | |
| 73 | 74 | }, |
| 74 | 75 | "devDependencies": { |
| 75 | 76 | "@hyperswarm/testnet": "^3.0.0", |
| … | … | @@ -0,0 +1,31 @@ |
| 1 | // Abstract-namespace UNIX socket transport (the '@'-prefixed path). Exercises | |
| 2 | // the full UdsServer + Client RPC round-trip over an abstract socket, which is | |
| 3 | // how the daemon runs where SELinux denies pathname sockets (unrooted Android / | |
| 4 | // celltron). Stream I/O is done by stock bare-pipe; bare-abstract-socket only | |
| 5 | // creates the socket. Linux-only (abstract sockets are a Linux extension). | |
| 6 | import test from 'brittle' | |
| 7 | import { UdsServer } from '../lib/transports/uds.js' | |
| 8 | import { Client } from '../lib/client.js' | |
| 9 | ||
| 10 | test('RPC over an abstract-namespace UNIX socket (@-prefix)', async (t) => { | |
| 11 | const NAME = '@hyper-svc-abstract-test' | |
| 12 | ||
| 13 | const server = new UdsServer({ | |
| 14 | path: NAME, | |
| 15 | idleTimeout: 0, | |
| 16 | dispatch: async (method, params) => { | |
| 17 | if (method === 'echo') return { echoed: params } | |
| 18 | throw new Error('unknown method: ' + method) | |
| 19 | } | |
| 20 | }) | |
| 21 | ||
| 22 | await server.listen() | |
| 23 | t.teardown(() => server.close()) | |
| 24 | t.ok(server.listening, 'server is listening on the abstract name') | |
| 25 | ||
| 26 | const client = await Client.connectUds(NAME) | |
| 27 | t.teardown(() => client.close()) | |
| 28 | ||
| 29 | const res = await client.call('echo', { hi: 'celltron' }) | |
| 30 | t.alike(res, { echoed: { hi: 'celltron' } }, 'RPC round-trips over the abstract socket') | |
| 31 | }) |