// The membrane as a module: started and stopped inside one process, with no exit.
//
// ⛔ WHY. The favela gateway (hyper-social #80) runs the membrane beside ap-bridge in ONE process.
// pds-serve.js did everything at module top level — bound the port, installed SIGINT/SIGTERM handlers
// and called process.exit — so the only way to stop it was to end the process. A role that cannot be
// stopped cannot share a process: an OTA of the gateway, or a second role failing, would have no way
// to release this one's port and store locks.
//
// So: startMembrane(cfg) returns the running membrane plus its stop steps, and running those steps
// leaves the process alive with the port and the front-store free for the next start.
import '../../lib/bare-globals.js' // FIRST: cborg touches TextEncoder at module load
import test from 'brittle'
import fs from 'bare-fs'
import os from 'bare-os'
import path from 'bare-path'
import http from 'bare-http1'
import { startMembrane } from '../../lib/membrane.js'
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'membrane-'))
// bootstrap [] keeps the swarm off the public DHT: start/stop is what this tests, not discovery.
function cfgFor (storage, port = 0) {
return {
serverDid: 'did:web:pds.test',
userDomain: '.test',
frontedDids: [],
feedRegistries: [],
crawlRelay: null,
crawlHostname: null,
port,
bind: '127.0.0.1',
storage
}
}
function get (port, p) {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port, path: p, method: 'GET' }, (res) => {
let body = ''
res.on('data', (c) => { body += c })
res.on('end', () => resolve({ status: res.statusCode, body }))
})
req.on('error', reject)
req.end()
})
}
async function stop (m) {
for (const [, fn] of m.steps) await fn()
}
test('★ start → serve → stop → start again on the same port and storage, in one process', async (t) => {
const storage = tmp()
const one = await startMembrane(cfgFor(storage), { bootstrap: [], log: () => {} })
t.ok(one.port > 0, 'bound a port')
t.is((await get(one.port, '/xrpc/com.atproto.server.describeServer')).status, 200, 'serving')
const key = one.swarmKey
await stop(one)
// Same port and same storage: the port is released and the front-store's lock is let go.
const two = await startMembrane(cfgFor(storage, one.port), { bootstrap: [], log: () => {} })
t.teardown(() => stop(two))
t.is(two.port, one.port, 'the same port binds again')
t.is((await get(two.port, '/xrpc/com.atproto.server.describeServer')).status, 200, 'and serves')
t.is(two.swarmKey, key, 'the persistent swarm identity is the same one')
})
test('the stop steps are named, so a shutdown can time and report each', async (t) => {
const m = await startMembrane(cfgFor(tmp()), { bootstrap: [], log: () => {} })
t.teardown(() => stop(m))
const names = m.steps.map(([n]) => n)
t.alike(names.slice(-2), ['server', 'frontStore'], 'the store fd-lock goes last, after the server')
t.ok(names.includes('swarm') && names.includes('peers') && names.includes('relay'))
})
test('the control verbs are exposed as plain handlers, for whichever socket serves them', async (t) => {
const m = await startMembrane(cfgFor(tmp()), { bootstrap: [], log: () => {} })
t.teardown(() => stop(m))
for (const verb of ['status', 'list-repos', 'list-fronted', 'diagnose', 'set-seq-base', 'emit-sync', 'probe-front', 'drop-front', 'allow-front', 'forget-did', 'resolve-handle']) {
t.is(typeof m.handlers[verb], 'function', verb)
}
t.alike((await m.handlers['list-fronted']({})).fronted, [], 'answers')
})
test('★ metrics: per-DID peer state, the relay seq and the guard, with no network call', async (t) => {
const m = await startMembrane(cfgFor(tmp()), { bootstrap: [], log: () => {} })
t.teardown(() => stop(m))
const x = m.metrics()
t.alike(x.dids, [], 'nothing fronted, nothing listed')
t.alike(x.relay, { emitted: 0, seqBase: 0 }, 'the relay seq bsky\'s cursor is compared against')
t.is(x.guard.configured, false, 'no crawlRelay configured: the guard is off, and says so')
t.is(x.guard.heals, 0)
})