OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / 995ff8e

smoke: unbreak the boot gate — it had been dark, and it leaked a membrane on every run
The deploy smoke test could not pass on this machine, and the way it failed hid why. Four separate defects, found by actually running it: 1. STALE ASSERTION. It required getSession === 404, but server-bare.js:294-299 answers 401 ExpiredToken for an unauthenticated call (it routes by the token's sub, and no token means no route) — which is the correct atproto answer. Now asserts the property that matters: the endpoint is REFUSED (401 or 404), not that it returns one code. 2. BOOT FAILURES WERE UNREADABLE. stderr was inherited, so a crash reason never reached the thrown message — the failure read `boot output: ` with nothing after it. stderr is now captured and included, stdout and stderr labelled separately. 3. NO ENGINE DIAGNOSIS. A dep whose engines.bare floor exceeds the installed runtime makes require throw UNSUPPORTED_ENGINE, naming neither the wanted version nor the package that wants it. Boot then dies silently. scripts now scan node_modules for declared floors and, on a boot failure, append the specific hint. Deliberately ADVISORY, not a gate: bare-module declares >=1.29.4 yet the membrane boots fine on 1.28.0, because the runtime uses its own bundled copy — gating on the scan would fail a working install. 4. LEAKED A MEMBRANE EVERY RUN. `bare` on PATH is a node wrapper (bare-runtime/bin/bare → lib/spawn) that runs the real binary as its own child with suppressSignals: true, so it does not forward signals. child.kill() killed the wrapper and left the membrane running, reparented to init, still bound to the smoke port — which then broke the NEXT run, and held the inherited stdout pipe open so `npm run smoke | tail` hung forever waiting for an EOF that never came. Now spawns detached and kills the process GROUP, then waits for exit before removing the temp storage. Verified: exit 0, "smoke OK", port 3610 free afterwards, no stray process. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
995ff8ea8f19fc141eed9108c199277474354115
by plankatron <fleeky@gmail.com> · 2026-08-16T17:47:19.000Z

Changed files (1) +82 -7

scripts/smoke.mjs +82 -7
……@@ -9,9 +9,9 @@
99// (Fronting a live peer end-to-end is covered by test/membrane-only.test.js.)
1010// Used by CI and by hand: `node scripts/smoke.mjs`.
1111
12import { spawn } from 'node:child_process'
12import { spawn, execFileSync } from 'node:child_process'
1313import http from 'node:http'
14import { mkdtempSync, rmSync } from 'node:fs'
14import { mkdtempSync, rmSync, readFileSync, readdirSync } from 'node:fs'
1515import { tmpdir } from 'node:os'
1616import { join } from 'node:path'
1717
……@@ -19,6 +19,54 @@ const PORT = Number(process.env.SMOKE_PORT || 3610)
1919const storage = mkdtempSync(join(tmpdir(), 'membrane-smoke-'))
2020const sleep = (ms) => new Promise(r => setTimeout(r, ms))
2121
22// ── Engine preflight ────────────────────────────────────────────────────────
23// A dep whose `engines.bare` floor is above the installed runtime makes `require` throw
24// UNSUPPORTED_ENGINE — the module resolver reports the CODE and a stack, never the version it
25// wanted or the package that asked. Boot then dies before any output, so the failure below reads
26// "membrane did not report membrane listening (boot output: )" and says nothing about why.
27// (Lost time to exactly this: bare-performance@2.1.1 wants >=1.27.0, the box had 1.26.0.)
28const cmp = (a, b) => {
29 const pa = String(a).split('.').map(Number); const pb = String(b).split('.').map(Number)
30 for (let i = 0; i < 3; i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0) ? -1 : 1 }
31 return 0
32}
33function engineFloor () {
34 let worst = null
35 for (const dir of ['node_modules', 'pds/node_modules']) {
36 let names = []
37 try { names = readdirSync(new URL('../' + dir, import.meta.url).pathname) } catch { continue }
38 for (const name of names) {
39 if (name[0] === '.') continue
40 try {
41 const pkg = JSON.parse(readFileSync(new URL(`../${dir}/${name}/package.json`, import.meta.url).pathname, 'utf8'))
42 const want = pkg.engines && pkg.engines.bare
43 const m = want && /^>=\s*([0-9]+\.[0-9]+\.[0-9]+)/.exec(String(want))
44 if (m && (!worst || cmp(m[1], worst.version) > 0)) worst = { version: m[1], pkg: name }
45 } catch { /* unreadable/absent package.json is not our problem here */ }
46 }
47 }
48 return worst
49}
50// ADVISORY, never a gate. A declared floor is not proof the package is loaded: `bare-module` declares
51// >=1.29.4 yet the membrane boots on 1.28.0, because the runtime uses its own bundled copy and the one
52// in node_modules is never required. So gating on the scan would fail a working install. The scan's job
53// is to EXPLAIN a boot failure, which is where its value was in the first place.
54function preflight () {
55 let have = null
56 try { have = execFileSync('bare', ['-v'], { encoding: 'utf8' }).trim().replace(/^v/, '') } catch {
57 throw new Error('`bare` is not on PATH — install it with `npm i -g bare-runtime`')
58 }
59 return { have, need: engineFloor() }
60}
61
62// Appended to a boot failure: if a dep declares a floor above the running bare, say so explicitly.
63// UNSUPPORTED_ENGINE names neither the version it wanted nor who wanted it.
64function engineHint (have, need) {
65 if (!need || cmp(have, need.version) >= 0) return ''
66 return `\n hint: bare v${have} is below a declared dependency floor — ${need.pkg} requires >=${need.version}. ` +
67 `If stderr shows UNSUPPORTED_ENGINE, run \`npm i -g bare-runtime@${need.version}\`.`
68}
69
2270function req (path, { host, method = 'GET', body } = {}) {
2371 const data = body ? Buffer.from(JSON.stringify(body)) : null
2472 const headers = {}
……@@ -32,6 +80,9 @@ function req (path, { host, method = 'GET', body } = {}) {
3280 })
3381}
3482
83const { have, need } = preflight()
84console.log(`smoke: bare v${have}${need ? ` (dependency floor >=${need.version} from ${need.pkg})` : ''}`)
85
3586const child = spawn('bare', ['index.js'], {
3687 cwd: new URL('..', import.meta.url).pathname,
3788 env: {
……@@ -40,15 +91,28 @@ const child = spawn('bare', ['index.js'], {
4091 PDS_FRONTED_DIDS: 'did:plc:smoketestnotdiscoverable',
4192 PDS_SERVER_DID: 'did:web:pds.7rnx.net', PDS_USER_DOMAIN: '.7rnx.net'
4293 },
43 stdio: ['ignore', 'pipe', 'inherit']
94 // stderr is CAPTURED, not inherited: a boot crash writes its reason there, and inheriting it meant
95 // the thrown message below carried an empty `boot output` while the real cause scrolled past
96 // separately (or vanished entirely under a pipe).
97 stdio: ['ignore', 'pipe', 'pipe'],
98 // Own group, so cleanup can signal the WHOLE tree. `bare` on PATH is a node wrapper
99 // (bare-runtime/bin/bare → lib/spawn) that runs the real binary as ITS child with
100 // `suppressSignals: true` — it does not forward signals. Killing the wrapper therefore left the
101 // membrane running, reparented to init, still bound to the smoke port.
102 detached: true
44103})
45104let bootLine = ''
105let bootErr = ''
46106child.stdout.on('data', d => { bootLine += d.toString() })
107child.stderr.on('data', d => { bootErr += d.toString() })
47108
48109let code = 1
49110try {
50111 for (let i = 0; i < 150 && !bootLine.includes('membrane listening'); i++) await sleep(100)
51 if (!bootLine.includes('membrane listening')) throw new Error('membrane did not report "membrane listening" (boot output: ' + bootLine.slice(0, 500) + ')')
112 if (!bootLine.includes('membrane listening')) {
113 throw new Error('membrane did not report "membrane listening"\n stdout: ' + (bootLine.slice(0, 500) || '(none)') +
114 '\n stderr: ' + (bootErr.slice(0, 800) || '(none)') + engineHint(have, need))
115 }
52116
53117 const describe = JSON.parse((await req('/xrpc/com.atproto.server.describeServer')).body)
54118 const getSession = await req('/xrpc/com.atproto.server.getSession', { host: 'x' }) // authed
……@@ -57,9 +121,13 @@ try {
57121
58122 const ok =
59123 describe.did === 'did:web:pds.7rnx.net' && Array.isArray(describe.availableUserDomains) && // synthesized, no Pds
60 getSession.status === 404 && // authed endpoint refused on a keyless membrane
124 // REFUSED, not a specific code: an unauthenticated getSession is 401 ExpiredToken (server-bare.js
125 // routes by the token's sub, and no token means no route) — which is the correct atproto answer.
126 // This asserted `=== 404` and so failed the moment anyone looked, because the gate had been dark
127 // behind a boot failure. What the membrane must guarantee is that the endpoint is not SERVED.
128 (getSession.status === 401 || getSession.status === 404) &&
61129 createAcct.status === 404 && // no custodial account creation on a membrane
62 Array.isArray(listRepos.repos) // synthesized fronted-set (empty here — dummy DID undiscoverable)
130 Array.isArray(listRepos.repos) // synthesized fronted-set (the dummy DID is listed, never fronted)
63131
64132 if (!ok) throw new Error('membrane assertions failed: ' + JSON.stringify({ describe, getSession: getSession.status, createAcct: createAcct.status, listRepos }))
65133 console.log('smoke OK — bare membrane closure boots + serves the keyless surface')
……@@ -67,7 +135,14 @@ try {
67135} catch (e) {
68136 console.error('smoke FAILED:', e.message)
69137} finally {
70 child.kill('SIGKILL')
138 // WAIT for the child to actually die before exiting. `kill()` + immediate `process.exit()` raced:
139 // the membrane survived, kept the inherited stdout pipe open, and any consumer of our output
140 // (`npm run smoke | tail`, CI log capture) hung forever waiting for an EOF that never came —
141 // while a stray membrane sat bound to the smoke port and broke the NEXT run.
142 const exited = new Promise((resolve) => child.once('exit', resolve))
143 // Negative pid = the whole process group (wrapper + the real bare binary it shields from signals).
144 try { process.kill(-child.pid, 'SIGKILL') } catch { try { child.kill('SIGKILL') } catch {} }
145 await Promise.race([exited, sleep(2000)])
71146 rmSync(storage, { recursive: true, force: true })
72147 process.exit(code)
73148}