OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / da4d016

the socket adapter takes the interface instead of assuming it
`createServer` called `dispatch` directly, so favela-surface's own description of the split — "dispatch is the interface and the socket is one ADAPTER over it" — was only half true. A composition that holds this node alongside something else can answer more verbs, and had no way to offer the same set over the socket. `call` is now supplied and defaults to dispatch, so nothing that exists changes. This teaches favela-peer nothing about what the extra verbs are, or that any exist; the daemon still knows nothing about projections and boundary.test.js still holds. Why it is needed: on Android the surface and daemon are one process with no socket at all (SELinux refuses an unprivileged app a sock_file), so the worklet composes with an in-process client. A desktop host wants that same composition WITH a socket, for favela-bridge and the CLI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
da4d01668dfec146e02bb7c5328a54c34e552028
by plankatron <fleeky@gmail.com> · 2026-09-11T15:16:44.000Z

Changed files (2) +87 -2

lib/rpc.js +16 -2
……@@ -19,7 +19,21 @@ import { recordName } from './name.js'
1919import { makePointer, AUTHOR, selectLevel, planLevels, derivedBy } from 'favela-markdown/lib/levels.js'
2020import { available as ffmpegAvailable, probe, encodeLevel } from './transcode.js'
2121
22export function createServer (node, sockPath, { onError = () => {} } = {}) {
22/**
23 * Serve `dispatch` over a unix socket.
24 *
25 * ⛔ `call` IS THE INTERFACE, and it is supplied rather than assumed. `dispatch(node, cmd, a)` is
26 * what this daemon can do; a COMPOSITION that holds this node alongside something else can do more,
27 * and it must be able to offer the same set here as it offers over any other adapter. The Android
28 * worklet already composes exactly that way and has no socket to serve it on (SELinux refuses an
29 * unprivileged app a `sock_file`); a desktop host wants the same composition WITH a socket, for
30 * `favela-bridge` and the CLI.
31 *
32 * ⚠ This teaches favela-peer nothing about what those extra verbs are, or that any exist. The
33 * default is unchanged, so every existing caller behaves exactly as before.
34 */
35export function createServer (node, sockPath, { onError = () => {}, call = null } = {}) {
36 const handle = call || ((cmd, args) => dispatch(node, cmd, args))
2337 // A stale socket from a crashed daemon would make bind fail with EADDRINUSE and look like
2438 // "already running". Removing it is safe ONLY because the caller has already established that no
2539 // daemon holds it — see index.js, which connects first and refuses to start if one answers.
……@@ -39,7 +53,7 @@ export function createServer (node, sockPath, { onError = () => {} } = {}) {
3953 try { req = JSON.parse(line) } catch { continue }
4054 let res
4155 try {
42 res = { id: req.id, ok: true, value: await dispatch(node, req.cmd, req.args || {}) }
56 res = { id: req.id, ok: true, value: await handle(req.cmd, req.args || {}) }
4357 } catch (err) {
4458 res = { id: req.id, ok: false, error: (err && err.message) || String(err) }
4559 }
test/rpc-adapter.test.js +71 -0
……@@ -0,0 +1,71 @@
1// The socket is an ADAPTER, so it must take the interface rather than assume it.
2//
3// `favela-surface/index.js` already states the split this rests on: "favela-peer's `lib/rpc.js`
4// exports `dispatch(node, cmd, a)` alongside `createServer(node, sock)`: `dispatch` is the interface
5// and the socket is one ADAPTER over it." But `createServer` called `dispatch` directly, so the
6// sentence was only half true — a composition that adds verbs could serve them over HTTP, as the
7// Android worklet does, and had no way to serve the same set over the socket.
8//
9// ⛔ THIS TEACHES favela-peer NOTHING. It does not learn what the extra verbs are, or that any
10// exist; it learns only that the thing it dispatches to is supplied rather than assumed. The daemon
11// still knows nothing about projections, which is what test/boundary.test.js is there to hold.
12//
13// ⚠ Why it matters: on Android the surface and the daemon are one process and there is no socket at
14// all (SELinux refuses an unprivileged app a `sock_file`), so the worklet composes with an in-process
15// client. A desktop host wants the SAME composition plus a socket, for `favela-bridge` and the CLI.
16// Without this, the desktop could only have the composition by re-implementing the server.
17
18import test from 'brittle'
19import fs from '#node:fs'
20import os from '#node:os'
21import net from '#node:net'
22import { join } from '#node:path'
23import { createServer } from '../lib/rpc.js'
24
25const sockPath = () => join(fs.mkdtempSync(join(os.tmpdir(), 'favela-rpc-')), 'favela.sock')
26
27function ask (path, cmd, args = {}) {
28 return new Promise((resolve, reject) => {
29 const socket = net.connect(path)
30 let buf = ''
31 socket.on('error', reject)
32 socket.on('connect', () => socket.write(JSON.stringify({ id: 1, cmd, args }) + '\n'))
33 socket.on('data', (chunk) => {
34 buf += chunk.toString()
35 const i = buf.indexOf('\n')
36 if (i === -1) return
37 socket.destroy()
38 resolve(JSON.parse(buf.slice(0, i)))
39 })
40 })
41}
42
43test('a supplied call handles the request instead of dispatch', async (t) => {
44 const sock = sockPath()
45 const seen = []
46 const server = createServer(null, sock, {
47 call: async (cmd, args) => { seen.push(cmd); return { echoed: cmd, got: args.n } }
48 })
49 t.teardown(() => { try { server.close() } catch {} })
50
51 const res = await ask(sock, 'a-verb-dispatch-does-not-have', { n: 7 })
52
53 t.ok(res.ok, 'the request succeeded, where dispatch would have thrown "unknown command"')
54 t.is(res.value.echoed, 'a-verb-dispatch-does-not-have', 'the supplied call saw the verb')
55 t.is(res.value.got, 7, 'and its arguments')
56 t.alike(seen, ['a-verb-dispatch-does-not-have'], 'exactly once')
57})
58
59test('with no call supplied it still dispatches, so nothing that exists changes', async (t) => {
60 const sock = sockPath()
61 // ⚠ A stub with a `peer`, because `dispatch` reads `node.peer` before the switch — so a null node
62 // throws a TypeError on the way IN and would not prove which arm answered. An unknown verb is not
63 // in MUTATIONS, so nothing else about the node is touched before the default arm refuses it.
64 const server = createServer({ peer: {} }, sock, {})
65 t.teardown(() => { try { server.close() } catch {} })
66
67 const res = await ask(sock, 'definitely-not-a-verb')
68
69 t.absent(res.ok, 'it failed, as before')
70 t.ok(/unknown command/.test(res.error), 'and failed through dispatch: ' + res.error)
71})