OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge / scripts / smoke.mjs

177 lines · javascript ref:
// Deploy smoke test — boot the flat-closure MEMBRANE under `bare` and drive its bsky edge.
//
// The bridge is a keyless membrane (Model B): no Pds, no key, no account. This proves the
// packaged app actually boots on the target runtime (not just that unit tests pass under
// node) — the exact failure class that took wj62 down (a membrane closure that never booted
// on bare due to a missing dep). It launches `bare index.js` in membrane mode with a dummy
// fronted DID (not discoverable — front() fails gracefully), then asserts the synthesized
// surface: describeServer answers, and custodial/authed endpoints 404. Exit 0 = deployable.
// (Fronting a live peer end-to-end is covered by test/membrane-only.test.js.)
// Used by CI and by hand: `node scripts/smoke.mjs`.

import { spawn, execFileSync } from 'node:child_process'
import http from 'node:http'
import { mkdtempSync, rmSync, readFileSync, readdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

const PORT = Number(process.env.SMOKE_PORT || 3610)
const storage = mkdtempSync(join(tmpdir(), 'membrane-smoke-'))
const sleep = (ms) => new Promise(r => setTimeout(r, ms))

// ── Engine preflight ────────────────────────────────────────────────────────
// A dep whose `engines.bare` floor is above the installed runtime makes `require` throw
// UNSUPPORTED_ENGINE — the module resolver reports the CODE and a stack, never the version it
// wanted or the package that asked. Boot then dies before any output, so the failure below reads
// "membrane did not report membrane listening (boot output: )" and says nothing about why.
// (Lost time to exactly this: bare-performance@2.1.1 wants >=1.27.0, the box had 1.26.0.)
const cmp = (a, b) => {
  const pa = String(a).split('.').map(Number); const pb = String(b).split('.').map(Number)
  for (let i = 0; i < 3; i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0) ? -1 : 1 }
  return 0
}
function engineFloor () {
  let worst = null
  for (const dir of ['node_modules', 'pds/node_modules']) {
    let names = []
    try { names = readdirSync(new URL('../' + dir, import.meta.url).pathname) } catch { continue }
    for (const name of names) {
      if (name[0] === '.') continue
      try {
        const pkg = JSON.parse(readFileSync(new URL(`../${dir}/${name}/package.json`, import.meta.url).pathname, 'utf8'))
        const want = pkg.engines && pkg.engines.bare
        const m = want && /^>=\s*([0-9]+\.[0-9]+\.[0-9]+)/.exec(String(want))
        if (m && (!worst || cmp(m[1], worst.version) > 0)) worst = { version: m[1], pkg: name }
      } catch { /* unreadable/absent package.json is not our problem here */ }
    }
  }
  return worst
}
// ADVISORY, never a gate. A declared floor is not proof the package is loaded: `bare-module` declares
// >=1.29.4 yet the membrane boots on 1.28.0, because the runtime uses its own bundled copy and the one
// in node_modules is never required. So gating on the scan would fail a working install. The scan's job
// is to EXPLAIN a boot failure, which is where its value was in the first place.
// WHICH bare. The gate used to shell out to `bare` from PATH — but nothing in production runs that
// binary. pgh launchers exec a runtime out of the tree store, and the supervisor picks its own; on
// 2026-08-17 PATH was v1.28.0 while the live membrane ran v1.28.7 out of pgh/runtime/bare. So "smoke
// OK" was evidence about a build that never executes the closure, which is the same shape of blind
// spot as the gate being dark in the first place (#46).
//
// Resolution order, most-production-like first. SMOKE_BARE overrides everything so a specific build
// can be pinned in CI or bisected by hand.
const RUNTIME_CANDIDATES = [
  process.env.SMOKE_BARE,
  process.env.HOME && `${process.env.HOME}/.local/share/store/apps/pgh-sidecar/runtime/bare`,
  process.env.HOME && `${process.env.HOME}/.local/share/store/apps/pgh/runtime/bare`
].filter(Boolean)

function resolveBare () {
  for (const bin of RUNTIME_CANDIDATES) {
    try {
      const v = execFileSync(bin, ['-v'], { encoding: 'utf8' }).trim().replace(/^v/, '')
      return { bin, have: v, source: bin === process.env.SMOKE_BARE ? 'SMOKE_BARE' : 'pgh runtime (as production runs)' }
    } catch { /* not installed on this box — try the next */ }
  }
  try {
    const v = execFileSync('bare', ['-v'], { encoding: 'utf8' }).trim().replace(/^v/, '')
    // Deliberately loud. Passing here does NOT establish that the closure boots in production.
    return { bin: 'bare', have: v, source: 'PATH — ⚠ NOT the runtime production uses' }
  } catch {
    throw new Error('no bare runtime found — install one (`npm i -g bare-runtime`) or set SMOKE_BARE=<path>')
  }
}

function preflight () {
  const { bin, have, source } = resolveBare()
  return { bin, have, source, need: engineFloor() }
}

// Appended to a boot failure: if a dep declares a floor above the running bare, say so explicitly.
// UNSUPPORTED_ENGINE names neither the version it wanted nor who wanted it.
function engineHint (have, need) {
  if (!need || cmp(have, need.version) >= 0) return ''
  return `\n  hint: bare v${have} is below a declared dependency floor — ${need.pkg} requires >=${need.version}. ` +
    `If stderr shows UNSUPPORTED_ENGINE, run \`npm i -g bare-runtime@${need.version}\`.`
}

function req (path, { host, method = 'GET', body } = {}) {
  const data = body ? Buffer.from(JSON.stringify(body)) : null
  const headers = {}
  if (host) headers.host = host
  if (data) { headers['content-type'] = 'application/json'; headers['content-length'] = data.length }
  return new Promise((resolve, reject) => {
    const r = http.request({ host: '127.0.0.1', port: PORT, path, method, headers }, (res) => {
      const c = []; res.on('data', d => c.push(d)); res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(c).toString('utf8') }))
    })
    r.on('error', reject); if (data) r.write(data); r.end()
  })
}

const { bin, have, source, need } = preflight()
console.log(`smoke: bare v${have} via ${source}`)
console.log(`smoke: runtime ${bin}${need ? `  (advisory floor >=${need.version} from ${need.pkg} — see engineFloor)` : ''}`)

const child = spawn(bin, ['index.js'], {
  cwd: new URL('..', import.meta.url).pathname,
  env: {
    ...process.env,
    PDS_PORT: String(PORT), PDS_BIND: '127.0.0.1', PDS_STORAGE: storage,
    PDS_FRONTED_DIDS: 'did:plc:smoketestnotdiscoverable',
    PDS_SERVER_DID: 'did:web:pds.7rnx.net', PDS_USER_DOMAIN: '.7rnx.net'
  },
  // stderr is CAPTURED, not inherited: a boot crash writes its reason there, and inheriting it meant
  // the thrown message below carried an empty `boot output` while the real cause scrolled past
  // separately (or vanished entirely under a pipe).
  stdio: ['ignore', 'pipe', 'pipe'],
  // Own group, so cleanup can signal the WHOLE tree. `bare` on PATH is a node wrapper
  // (bare-runtime/bin/bare → lib/spawn) that runs the real binary as ITS child with
  // `suppressSignals: true` — it does not forward signals. Killing the wrapper therefore left the
  // membrane running, reparented to init, still bound to the smoke port.
  detached: true
})
let bootLine = ''
let bootErr = ''
child.stdout.on('data', d => { bootLine += d.toString() })
child.stderr.on('data', d => { bootErr += d.toString() })

let code = 1
try {
  for (let i = 0; i < 150 && !bootLine.includes('membrane listening'); i++) await sleep(100)
  if (!bootLine.includes('membrane listening')) {
    throw new Error('membrane did not report "membrane listening"\n  stdout: ' + (bootLine.slice(0, 500) || '(none)') +
      '\n  stderr: ' + (bootErr.slice(0, 800) || '(none)') + engineHint(have, need))
  }

  const describe = JSON.parse((await req('/xrpc/com.atproto.server.describeServer')).body)
  const getSession = await req('/xrpc/com.atproto.server.getSession', { host: 'x' }) // authed
  const createAcct = await req('/xrpc/com.atproto.server.createAccount', { method: 'POST', body: { handle: 'x.7rnx.net' } })
  const listRepos = JSON.parse((await req('/xrpc/com.atproto.sync.listRepos')).body)

  const ok =
    describe.did === 'did:web:pds.7rnx.net' && Array.isArray(describe.availableUserDomains) && // synthesized, no Pds
    // REFUSED, not a specific code: an unauthenticated getSession is 401 ExpiredToken (server-bare.js
    // routes by the token's sub, and no token means no route) — which is the correct atproto answer.
    // This asserted `=== 404` and so failed the moment anyone looked, because the gate had been dark
    // behind a boot failure. What the membrane must guarantee is that the endpoint is not SERVED.
    (getSession.status === 401 || getSession.status === 404) &&
    createAcct.status === 404 && // no custodial account creation on a membrane
    Array.isArray(listRepos.repos) // synthesized fronted-set (the dummy DID is listed, never fronted)

  if (!ok) throw new Error('membrane assertions failed: ' + JSON.stringify({ describe, getSession: getSession.status, createAcct: createAcct.status, listRepos }))
  console.log('smoke OK — bare membrane closure boots + serves the keyless surface')
  code = 0
} catch (e) {
  console.error('smoke FAILED:', e.message)
} finally {
  // WAIT for the child to actually die before exiting. `kill()` + immediate `process.exit()` raced:
  // the membrane survived, kept the inherited stdout pipe open, and any consumer of our output
  // (`npm run smoke | tail`, CI log capture) hung forever waiting for an EOF that never came —
  // while a stray membrane sat bound to the smoke port and broke the NEXT run.
  const exited = new Promise((resolve) => child.once('exit', resolve))
  // Negative pid = the whole process group (wrapper + the real bare binary it shields from signals).
  try { process.kill(-child.pid, 'SIGKILL') } catch { try { child.kill('SIGKILL') } catch {} }
  await Promise.race([exited, sleep(2000)])
  rmSync(storage, { recursive: true, force: true })
  process.exit(code)
}