From decf6419e7cc5f2324bba9ab25aa2033c711e9f9 Mon Sep 17 00:00:00 2001 From: "codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie)" Date: Tue, 11 Aug 2026 00:02:19 +0000 Subject: [PATCH] diagnose transient native transport crashes Co-Authored-By: codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie) --- packages/daemon/README.md | 27 ++++++++ packages/daemon/src/node-shims.d.ts | 2 + packages/daemon/src/private-run.ts | 8 ++- packages/daemon/src/private-transport.ts | 51 ++++++++++---- .../daemon/test/private-transport.test.mjs | 26 ++++++- packages/transport-iroh/package.json | 1 + packages/transport-iroh/src/index.ts | 2 + .../transport-iroh/test/startup-probe.mjs | 69 +++++++++++++++++++ 8 files changed, 171 insertions(+), 15 deletions(-) create mode 100644 packages/transport-iroh/test/startup-probe.mjs diff --git a/packages/daemon/README.md b/packages/daemon/README.md index 854d1ce..6027cca 100644 --- a/packages/daemon/README.md +++ b/packages/daemon/README.md @@ -235,3 +235,30 @@ enough to publish its address and catch up, stop it completely, run the CLI comm `packages/daemon/test/private-transport.test.mjs` drives all of it — two daemons, shared public repos, and a loopback transport carrying real encoded frames — through the same startup path production uses. + +### Troubleshooting a native startup crash + +pnpm's `ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL` message only reports that `radiald` died. If it names +`SIGSEGV`, collect the last `private transport:` startup phase and a native backtrace: on Linux use +`coredumpctl info` and `coredumpctl gdb` (or the platform equivalent). Core dumps may contain +credentials and private records; keep the dump secret and share only a redacted, symbolized +backtrace. Record the Radial commit, Node and pnpm versions, OS, architecture, libc, container use, +whether `run.privateSpaces` is non-empty, and the relay mode. Redact DIDs, paths, keys, and tokens. + +The isolated probe exercises import, bind, online/address discovery, and close in fresh child +processes. A crash in one iteration is reported by signal without losing the remaining results: + +```sh +pnpm --filter @radial/transport-iroh probe -- --iterations 100 --relays disabled +pnpm --filter @radial/transport-iroh probe -- --iterations 100 --relays default +pnpm --filter @radial/transport-iroh probe -- --iterations 100 --relays disabled \ + --key-file /path/to/copied/endpoint.key --timeout-ms 10000 +``` + +Run both a fresh identity and a **copy** of the affected installation's +`/private/endpoint.key`. Never delete, rotate, or probe with the live key or data directory: +that changes the published identity and confounds the comparison. Use a copied temporary config and +data directory for destructive experiments. Compare enough fresh starts to exceed the observed +failure rate, first with relays disabled and then with default relays. An external supervisor may +restart a crashed daemon as a temporary mitigation, but JavaScript cannot catch or safely retry a +native segmentation fault. diff --git a/packages/daemon/src/node-shims.d.ts b/packages/daemon/src/node-shims.d.ts index 3cf10fc..f8fbc65 100644 --- a/packages/daemon/src/node-shims.d.ts +++ b/packages/daemon/src/node-shims.d.ts @@ -54,7 +54,9 @@ declare module 'node:path' { } declare const process: { argv: string[] + arch: string platform: string + version: string cwd(): string env: Record exitCode?: number diff --git a/packages/daemon/src/private-run.ts b/packages/daemon/src/private-run.ts index 7f15495..904bd04 100644 --- a/packages/daemon/src/private-run.ts +++ b/packages/daemon/src/private-run.ts @@ -66,7 +66,7 @@ import { import { EndpointKeyStore, PrivateEndpoint, - irohPeerTransport, + createIrohPeerTransport, publishEndpointAddresses, type AddressedDevice, type PeerTransportFactory, @@ -213,7 +213,7 @@ export async function openPrivateSpaces( const endpoint = await PrivateEndpoint.bind({ // Named here rather than defaulted in `core`, which takes no dependency on any binding: the // daemon's production transport is the native one, and a test substitutes a loopback for it. - transport: options.peerTransport ?? irohPeerTransport, + transport: options.peerTransport ?? createIrohPeerTransport({ log }), ...(held ? { secretKey: held } : {}), ...(options.relays ? { relays: options.relays } : {}), log, @@ -232,6 +232,10 @@ export async function openPrivateSpaces( 'publishing the hint anyway — peers may not be able to dial this daemon until it does', ) } + log( + `private transport: address discovery complete ` + + `(${endpoint.relays.length} relay${endpoint.relays.length === 1 ? '' : 's'})`, + ) log( `private endpoint ${endpoint.endpointId}` + `${endpoint.relays.length > 0 ? ` via ${endpoint.relays.join(', ')}` : ' (no relay)'}`, diff --git a/packages/daemon/src/private-transport.ts b/packages/daemon/src/private-transport.ts index e971af5..83a920d 100644 --- a/packages/daemon/src/private-transport.ts +++ b/packages/daemon/src/private-transport.ts @@ -46,21 +46,48 @@ export { * fail to start when no prebuilt binary exists for their platform. The failure is reported where it * can be acted on: at the space that asked for it. */ -export const irohPeerTransport: PeerTransportFactory = async (options) => { - const { IrohTransport } = await import('@radial/transport-iroh') - // The native binding takes the two-valued relay policy it has always taken. An explicit relay list - // is a browser affordance (`RelayPolicy`); a native endpoint that was handed one would silently - // ignore it, which is worse than saying so. - if (Array.isArray(options.relays)) { - throw new Error('the native iroh transport takes relays: "default" or "disabled", not a relay list') +type IrohModule = typeof import('@radial/transport-iroh') + +export interface IrohPeerTransportOptions { + log?: (message: string) => void + /** Test seam: production always uses the dynamic import below. */ + load?: () => Promise +} + +/** Build the native transport factory with startup phase logging. */ +export function createIrohPeerTransport( + factoryOptions: IrohPeerTransportOptions = {}, +): PeerTransportFactory { + const log = factoryOptions.log ?? (() => {}) + const load = factoryOptions.load ?? (() => import('@radial/transport-iroh')) + return async (options) => { + log( + `private transport: loading native iroh ` + + `(Node ${process.version}, ${process.platform}/${process.arch})`, + ) + const { IrohTransport, IROH_BINDING_VERSION } = await load() + log(`private transport: loaded native iroh ${IROH_BINDING_VERSION}`) + // The native binding takes the two-valued relay policy it has always taken. An explicit relay + // list is a browser affordance (`RelayPolicy`); a native endpoint that was handed one would + // silently ignore it, which is worse than saying so. + if (Array.isArray(options.relays)) { + throw new Error( + 'the native iroh transport takes relays: "default" or "disabled", not a relay list', + ) + } + const { relays, ...rest } = options + log(`private transport: binding native iroh endpoint (relays: ${relays ?? 'default'})`) + const transport = await IrohTransport.bind({ + ...rest, + ...(relays === 'default' || relays === 'disabled' ? { relays } : {}), + }) + log(`private transport: bound native iroh endpoint ${transport.endpointId}`) + return transport } - const { relays, ...rest } = options - return IrohTransport.bind({ - ...rest, - ...(relays === 'default' || relays === 'disabled' ? { relays } : {}), - }) } +export const irohPeerTransport: PeerTransportFactory = createIrohPeerTransport() + /** * This machine's endpoint identity on disk: one secret, for the whole daemon. * diff --git a/packages/daemon/test/private-transport.test.mjs b/packages/daemon/test/private-transport.test.mjs index f932e2d..9548977 100644 --- a/packages/daemon/test/private-transport.test.mjs +++ b/packages/daemon/test/private-transport.test.mjs @@ -28,7 +28,31 @@ import { } from '../../core/dist/index.js' import { readSpaceMeta, spaceDirectory, writeSpaceMeta } from '../../core/dist/node.js' import { openPrivateSpaces } from '../dist/private-run.js' -import { PrivateEndpoint } from '../dist/private-transport.js' +import { PrivateEndpoint, createIrohPeerTransport } from '../dist/private-transport.js' + +it('reports native transport load and bind phases in order', async () => { + const phases = [] + const transport = { + endpointId: 'native-endpoint', relays: [], secretKey: new Uint8Array(32), + async dial() { throw new Error('not used') }, + async close() {}, + } + const factory = createIrohPeerTransport({ + log: (message) => phases.push(message), + load: async () => ({ + IROH_BINDING_VERSION: 'test-version', + IrohTransport: { bind: async () => transport }, + }), + }) + + assert.equal(await factory({ endpoint: { async accept() {} }, relays: 'disabled' }), transport) + assert.match(phases[0], /^private transport: loading native iroh \(Node /) + assert.deepEqual(phases.slice(1), [ + 'private transport: loaded native iroh test-version', + 'private transport: binding native iroh endpoint (relays: disabled)', + 'private transport: bound native iroh endpoint native-endpoint', + ]) +}) /** * The public half: every member's PDS repo, in one object. diff --git a/packages/transport-iroh/package.json b/packages/transport-iroh/package.json index 525c581..e9d6cd1 100644 --- a/packages/transport-iroh/package.json +++ b/packages/transport-iroh/package.json @@ -9,6 +9,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "lint": "pnpm typecheck", + "probe": "pnpm build && node test/startup-probe.mjs", "test": "pnpm build && node --test test/*.test.mjs", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/packages/transport-iroh/src/index.ts b/packages/transport-iroh/src/index.ts index 9a04d3b..908ba2d 100644 --- a/packages/transport-iroh/src/index.ts +++ b/packages/transport-iroh/src/index.ts @@ -18,6 +18,8 @@ import { } from '@radial/core' const { Endpoint, EndpointAddr, EndpointId, RelayMode } = iroh +/** Exact native binding version, exposed so callers can report it without reading node_modules. */ +export const IROH_BINDING_VERSION = '1.1.0' type EndpointInstance = InstanceType type Connection = Awaited> diff --git a/packages/transport-iroh/test/startup-probe.mjs b/packages/transport-iroh/test/startup-probe.mjs new file mode 100644 index 0000000..6eba8a9 --- /dev/null +++ b/packages/transport-iroh/test/startup-probe.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +const args = process.argv.slice(2) +const value = (name, fallback) => { + const index = args.indexOf(name) + return index < 0 ? fallback : args[index + 1] +} +const integer = (name, fallback) => { + const parsed = Number.parseInt(value(name, String(fallback)), 10) + if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${name} must be a positive integer`) + return parsed +} + +async function child() { + const { IrohTransport, IROH_BINDING_VERSION } = await import('../dist/index.js') + const keyFile = value('--key-file', undefined) + const secretKey = keyFile + ? Buffer.from((await readFile(keyFile, 'utf8')).trim(), 'base64url') + : undefined + const relays = value('--relays', 'disabled') + if (relays !== 'default' && relays !== 'disabled') throw new Error('--relays must be default or disabled') + const timeoutMs = integer('--timeout-ms', 10_000) + console.log(`probe: importing iroh ${IROH_BINDING_VERSION} on Node ${process.version} ${process.platform}/${process.arch}`) + const transport = await IrohTransport.bind({ + endpoint: { async accept() {} }, + relays, + ...(secretKey ? { secretKey } : {}), + }) + try { + const online = await Promise.race([ + transport.online().then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]) + console.log(JSON.stringify({ + endpointId: transport.endpointId, + relays: transport.relays, + directAddresses: transport.directAddresses, + online, + })) + } finally { + await transport.close() + } +} + +async function parent() { + const iterations = integer('--iterations', 1) + const forwarded = args.filter((argument, index) => + argument !== '--iterations' && args[index - 1] !== '--iterations' && argument !== '--child') + let failures = 0 + for (let iteration = 1; iteration <= iterations; iteration += 1) { + const result = await new Promise((resolve) => { + const childProcess = spawn(process.execPath, [fileURLToPath(import.meta.url), '--child', ...forwarded], { + stdio: 'inherit', + }) + childProcess.on('error', (error) => resolve({ error })) + childProcess.on('exit', (code, signal) => resolve({ code, signal })) + }) + if (result.error || result.code !== 0) failures += 1 + console.log(`probe: iteration ${iteration}/${iterations}: ${result.error ? result.error.message : result.signal ? `signal ${result.signal}` : `exit ${result.code}`}`) + } + console.log(`probe: ${iterations - failures} succeeded, ${failures} failed`) + if (failures > 0) process.exitCode = 1 +} + +if (args.includes('--child')) await child() +else await parent() -- 2.51.2