Updates
Release rolling / release (push) Successful in 9m23s

This commit is contained in:
Raven Scott
2026-07-14 20:27:41 -04:00
parent cb0b59f5c3
commit d822152c7e
4 changed files with 322 additions and 47 deletions
+194 -28
View File
@@ -3,6 +3,11 @@
*
* Package shape (JSON in peardock:pkg):
* { v:1, publicKeyHex, capability, role, alias?, expiresAt?, jti? }
*
* Note: [email protected] has two hang bugs in AutopassPairer that freeze the UI:
* 1) await pass.deleteInvite() on a non-writable (readOnly) invitee never completes
* 2) _whenWritable() returns without calling onresolve when already writable
* We patch both after import.
*/
import path from 'path'
import fs from 'fs'
@@ -13,6 +18,9 @@ import { classifyConnectionInput } from '../shared/crypto-auth.js'
const PKG_KEY = 'peardock:pkg'
/** @type {WeakSet<object>} */
const patchedAutopass = new WeakSet()
/**
* @returns {string}
*/
@@ -35,14 +43,107 @@ function storeDirForInvite(invite) {
return path.join(autopassRoot(), hash)
}
/**
* Yield so the Electron UI can paint status updates.
*/
function yieldUi() {
return new Promise((resolve) => setTimeout(resolve, 0))
}
/**
* Patch hang bugs in autopass pairer / Autopass.deleteInvite.
* @param {any} Autopass
*/
function patchAutopassHangs(Autopass) {
if (!Autopass || patchedAutopass.has(Autopass)) return
patchedAutopass.add(Autopass)
// Non-writers must not await append-based deleteInvite (never resolves)
const origDelete = Autopass.prototype.deleteInvite
if (typeof origDelete === 'function') {
Autopass.prototype.deleteInvite = async function deleteInviteSafe(...args) {
try {
if (this.opened === false) await this.ready()
} catch {
return
}
// Only writers can append; skip for read-only invitees
if (this.base && this.base.writable === false) return
try {
return await origDelete.apply(this, args)
} catch {
// Inviter already deleted the invite — ignore
}
}
}
}
/**
* Fix AutopassPairer instance hang: _whenWritable must resolve when already writable.
* @param {any} pairer
*/
function patchPairerInstance(pairer) {
if (!pairer || pairer.__peardockPatched) return
pairer.__peardockPatched = true
const origWritable = pairer._whenWritable?.bind(pairer)
pairer._whenWritable = function whenWritableFixed() {
if (this.pass?.base?.writable) {
if (typeof this.onresolve === 'function') this.onresolve(this.pass)
return
}
if (typeof origWritable === 'function') {
// Original may still return early without resolve when writable — re-check after
try {
origWritable()
} catch {
// fall through to manual wait
}
}
if (this.pass?.base?.writable && typeof this.onresolve === 'function') {
this.onresolve(this.pass)
return
}
if (!this.pass?.base || typeof this.onresolve !== 'function') return
const base = this.pass.base
const check = () => {
if (base.writable) {
base.off?.('update', check)
if (typeof this.onresolve === 'function') this.onresolve(this.pass)
}
}
base.on?.('update', check)
// Immediate check in case writable flipped during setup
check()
}
// If onadd already ran before finished() set onresolve, recover via pass field
const origFinished = pairer.finished?.bind(pairer)
pairer.finished = function finishedFixed() {
if (this.pass && this.pass.base) {
// Pairing already completed before finished() was called
if (this.pass.base.writable || this.pass.opened) {
return Promise.resolve(this.pass)
}
}
if (typeof origFinished === 'function') return origFinished()
return new Promise((resolve, reject) => {
this.onresolve = resolve
this.onreject = reject
})
}
}
/**
* Redeem an AutoPass invite and extract the PearDock connection package.
* @param {string} inviteZ32
* @param {{ name?: string, timeoutMs?: number }} [opts]
* @param {{ name?: string, timeoutMs?: number, onProgress?: (msg: string) => void }} [opts]
* @returns {Promise<{ publicKeyHex: string, capability: string, role: string|null, alias: string|null, jti?: string, expiresAt?: string }>}
*/
export async function redeemAutopassInvite(inviteZ32, opts = {}) {
const invite = String(inviteZ32 || '').trim()
const invite = String(inviteZ32 || '')
.replace(/\s+/g, '')
.trim()
if (!invite) {
const err = new Error('AutoPass invite required')
err.code = 'AUTOPASS_PAIR_FAILED'
@@ -54,6 +155,12 @@ export async function redeemAutopassInvite(inviteZ32, opts = {}) {
throw err
}
const progress = typeof opts.onProgress === 'function' ? opts.onProgress : () => {}
const timeoutMs = Math.max(5_000, Number(opts.timeoutMs) || 45_000)
progress('Loading AutoPass…')
await yieldUi()
let Autopass
try {
const mod = await import('autopass')
@@ -63,33 +170,82 @@ export async function redeemAutopassInvite(inviteZ32, opts = {}) {
e.code = 'AUTOPASS_UNAVAILABLE'
throw e
}
patchAutopassHangs(Autopass)
await yieldUi()
const dir = storeDirForInvite(invite)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
}
const store = new Corestore(path.join(dir, 'store'))
const pair = Autopass.pair(store, invite, { name: opts.name || 'peardock-client' })
progress('Opening local store')
await yieldUi()
const store = new Corestore(path.join(dir, 'store'))
let pair = null
let pass = null
let timedOut = false
let timer = null
const timeoutMs = opts.timeoutMs ?? 60_000
let pass
try {
pass = await Promise.race([
pair.finished(),
new Promise((_, reject) => {
setTimeout(() => reject(new Error(`AutoPass pairing timed out after ${timeoutMs}ms`)), timeoutMs)
}),
])
progress('Pairing with server (HyperDHT)…')
await yieldUi()
pair = Autopass.pair(store, invite, { name: opts.name || 'peardock-client' })
patchPairerInstance(pair)
// Ensure open has started; catch open failures
const readyPromise = pair.ready().catch((err) => {
const e = new Error(`AutoPass pair open failed: ${err.message || err}`)
e.code = 'AUTOPASS_PAIR_FAILED'
throw e
})
const finishedPromise = (async () => {
await readyPromise
// finished() may hang on unpatched autopass; we race a timeout below
return pair.finished()
})()
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(() => {
timedOut = true
reject(
Object.assign(
new Error(
`AutoPass pairing timed out after ${Math.round(timeoutMs / 1000)}s. ` +
'Ensure the peardock server is online and still has this invite active, then try again.'
),
{ code: 'AUTOPASS_PAIR_FAILED' }
)
)
}, timeoutMs)
})
pass = await Promise.race([finishedPromise, timeoutPromise])
if (timer) clearTimeout(timer)
timer = null
if (!pass) {
const err = new Error('AutoPass pairing returned no instance')
err.code = 'AUTOPASS_PAIR_FAILED'
throw err
}
progress('Syncing connection package…')
await yieldUi()
await pass.ready()
// Wait briefly for package sync if not yet present
// Wait for peardock:pkg (pairing may resolve before view is fully replicated)
let record = await pass.get(PKG_KEY)
if (!record) {
record = await waitForPackage(pass, timeoutMs / 2)
if (!record?.value) {
progress('Waiting for package from server…')
record = await waitForPackage(pass, Math.min(timeoutMs / 2, 30_000))
}
if (!record?.value) {
const err = new Error('AutoPass paired but peardock:pkg package not found')
const err = new Error(
'AutoPass paired but peardock:pkg was not found. Ask the admin to create a new invite and keep the server online.'
)
err.code = 'AUTOPASS_PAIR_FAILED'
throw err
}
@@ -116,6 +272,7 @@ export async function redeemAutopassInvite(inviteZ32, opts = {}) {
throw err
}
progress('Package received')
return {
publicKeyHex,
capability,
@@ -128,8 +285,10 @@ export async function redeemAutopassInvite(inviteZ32, opts = {}) {
if (!err.code) err.code = 'AUTOPASS_PAIR_FAILED'
throw err
} finally {
if (timer) clearTimeout(timer)
// Always tear down pairer/store so rocksdb/swarm do not pin the process
try {
await pair.close?.()
if (pair) await pair.close?.()
} catch {
// ignore
}
@@ -143,6 +302,9 @@ export async function redeemAutopassInvite(inviteZ32, opts = {}) {
} catch {
// ignore
}
if (timedOut) {
// best-effort: leave no dangling hyperswarm
}
}
}
@@ -152,32 +314,36 @@ export async function redeemAutopassInvite(inviteZ32, opts = {}) {
*/
function waitForPackage(pass, timeoutMs) {
return new Promise((resolve) => {
let settled = false
const start = Date.now()
const cleanup = () => {
if (settled) return
settled = true
clearInterval(timer)
pass.off?.('update', onUpdate)
}
const finish = (val) => {
cleanup()
resolve(val)
}
const check = async () => {
if (settled) return
try {
const rec = await pass.get(PKG_KEY)
if (rec?.value) {
cleanup()
resolve(rec)
finish(rec)
return
}
} catch {
// retry
}
if (Date.now() - start >= timeoutMs) {
cleanup()
resolve(null)
}
if (Date.now() - start >= timeoutMs) finish(null)
}
const onUpdate = () => {
check()
}
const cleanup = () => {
clearInterval(timer)
pass.off?.('update', onUpdate)
}
pass.on?.('update', onUpdate)
const timer = setInterval(check, 500)
const timer = setInterval(check, 400)
check()
})
}