OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc commits / 9dee4ed

uds: support abstract-namespace sockets via bare-abstract-socket (@-prefix)
A socket path beginning with '@' now binds/connects a Linux abstract-namespace UNIX socket instead of a pathname socket — the only UNIX-socket option where SELinux denies the shell domain from creating pathname sockets (unrooted Android / celltron under proot: bind(AF_UNIX,"/x.sock") -> EACCES). - lib/uds-backend.js: picks #node:net (pathname, unchanged) vs bare-abstract-socket (abstract) by the '@' prefix. bare-abstract-socket is imported LAZILY (dynamic import at the async call sites) so pathname deployments never load the addon. - uds.js: UdsServer selects the backend; skips the stale-file stat/unlink for abstract (no fs entry); honours allowHalfOpen:false on accept (the fd-leak fix). - client.js: connectUds + udsReachable route through the backend selector. - test/uds-abstract.test.js: full RPC round-trip over an abstract socket. Opt-in, zero-change for pathname paths. Existing UDS tests (backpressure, leak) still pass. Not yet deployed; dependency bundling for the pgh-sidecar deploy is a follow-up (bare-abstract-socket is forge-hosted, vendored for tests). Bump 0.1.6. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9dee4eda1c3b32ff24d6c0487ef6c0353e601e43
by plankatron <fleeky@gmail.com> · 2026-07-13T23:40:53.000Z

Changed files (5) +125 -33

lib/client.js +14 -9
……@@ -20,11 +20,11 @@
2020 * await client.close()
2121 */
2222
23import net from '#node:net'
2423import http from '#node:http'
2524import { URL } from '#node:url'
2625import b4a from 'b4a'
2726import { stat } from '#node:fs/promises'
27import { udsBackend } from './uds-backend.js'
2828import Hyperswarm from 'hyperswarm'
2929import Protomux from 'protomux'
3030import crypto from 'hypercore-crypto'
……@@ -69,7 +69,8 @@ export class Client {
6969 }
7070
7171 static async connectUds (path) {
72 const sock = net.createConnection(path)
72 const { backend, name } = await udsBackend(path)
73 const sock = backend.createConnection(name)
7374 await new Promise((resolve, reject) => {
7475 sock.once('connect', () => {
7576 sock.removeListener('error', reject)
……@@ -267,16 +268,20 @@ export class Client {
267268}
268269
269270async 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 }
276281 }
277282 // Try a real connect — covers stale sockets where the daemon crashed
278283 return new Promise((resolve) => {
279 const sock = net.createConnection(path)
284 const sock = backend.createConnection(name)
280285 sock.once('connect', () => {
281286 sock.destroy()
282287 resolve(true)
lib/transports/uds.js +34 -21
……@@ -14,9 +14,9 @@
1414 */
1515
1616import process from '#node:process'
17import net from '#node:net'
1817import { unlink, stat } from '#node:fs/promises'
1918import { encodeFrame, FrameDecoder } from '../frame.js'
19import { udsBackend, isAbstract } from '../uds-backend.js'
2020
2121export class UdsServer {
2222 /**
……@@ -53,21 +53,30 @@ export class UdsServer {
5353 async listen () {
5454 if (this._listening) return
5555
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
6979 }
70 if (err.message && err.message.indexOf('already in use') !== -1) throw err
7180 }
7281
7382 // allowHalfOpen:false is CRITICAL. bare-net defaults it to true (unlike
……@@ -76,11 +85,12 @@ export class UdsServer {
7685 // With false, peer disconnect surfaces as 'end', which _onConnection
7786 // turns into a destroy() so the socket fully closes. See the fd-leak that
7887 // 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))
8090
8191 await new Promise((resolve, reject) => {
8292 this._server.once('error', reject)
83 this._server.listen(this._path, () => {
93 this._server.listen(name, () => {
8494 this._server.removeListener('error', reject)
8595 resolve()
8696 })
……@@ -108,9 +118,10 @@ export class UdsServer {
108118 }
109119 }
110120
111 _probe () {
121 async _probe () {
122 const { backend, name } = await udsBackend(this._path)
112123 return new Promise((resolve) => {
113 const sock = net.createConnection(this._path)
124 const sock = backend.createConnection(name)
114125 sock.once('connect', () => {
115126 sock.destroy()
116127 resolve(true)
……@@ -201,7 +212,9 @@ export class UdsServer {
201212 this._server = null
202213 this._listening = false
203214
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 }
206219 }
207220}
lib/uds-backend.js +42 -0
……@@ -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
16import net from '#node:net'
17
18let _abstract = null
19
20async 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 '@').
29export 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).
37export 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}
package.json +4 -3
……@@ -1,7 +1,7 @@
11{
22 "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",
55 "type": "module",
66 "main": "index.js",
77 "scripts": {
……@@ -69,7 +69,8 @@
6969 "hypercore-crypto": "^3.0.0",
7070 "hyperswarm": "^4.0.0",
7171 "protomux": "^3.0.0",
72 "ready-resource": "^1.0.0"
72 "ready-resource": "^1.0.0",
73 "bare-abstract-socket": "^1.0.0"
7374 },
7475 "devDependencies": {
7576 "@hyperswarm/testnet": "^3.0.0",
test/uds-abstract.test.js +31 -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).
6import test from 'brittle'
7import { UdsServer } from '../lib/transports/uds.js'
8import { Client } from '../lib/client.js'
9
10test('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})