Merge remote-tracking branch 'origin/main' into bb/skills-renovate

This commit is contained in:
Brooklyn Nicholson 2026-07-03 13:59:26 -05:00
commit 65415b1a12
119 changed files with 13920 additions and 336 deletions

View file

@ -230,6 +230,14 @@ async fn run_update(app: AppHandle) -> Result<()> {
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
// time `hermes update` runs there is no legitimate hermes.exe to protect,
// and the guard would only produce a false "Hermes is still running" stop.
//
// NOTE: --force does NOT bypass the venv-python holder guard (that needs
// an explicit `--force-venv`, which we deliberately do not pass). Our lock
// probe only checks the hermes.exe shim and app.asar, so an external venv
// python holding a native .pyd (a user terminal, an unmanaged gateway)
// could still be alive here — mutating the venv under it would strand the
// install half-updated. If that guard fires, it exits 2 and the match arm
// below surfaces the correct "close all Hermes windows" message.
update_args.push("--force".into());
update_args.push("--branch".into());
update_args.push(update_branch);

View file

@ -49,4 +49,23 @@ function guardLinkTitleSession(partitionSession) {
}
}
module.exports = { createLinkTitleWindow, guardLinkTitleSession, linkTitleWindowOptions }
// Read the page title from a title-fetch window. Callers schedule this from
// timers that can fire after finish() destroys the window, so every access must
// guard isDestroyed and swallow Electron's "Object has been destroyed" throws.
function readLinkTitleWindowTitle(window) {
try {
if (!window || window.isDestroyed()) return ''
const contents = window.webContents
if (!contents || contents.isDestroyed()) return ''
return contents.getTitle() || ''
} catch {
return ''
}
}
module.exports = {
createLinkTitleWindow,
guardLinkTitleSession,
linkTitleWindowOptions,
readLinkTitleWindowTitle
}

View file

@ -1,7 +1,12 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { createLinkTitleWindow, guardLinkTitleSession, linkTitleWindowOptions } = require('./link-title-window.cjs')
const {
createLinkTitleWindow,
guardLinkTitleSession,
linkTitleWindowOptions,
readLinkTitleWindowTitle
} = require('./link-title-window.cjs')
function makeFakeBrowserWindow() {
const calls = { audioMuted: [] }
@ -80,3 +85,44 @@ test('guardLinkTitleSession is a no-op when session.on throws', () => {
})
)
})
test('readLinkTitleWindowTitle returns empty for missing or destroyed windows', () => {
assert.equal(readLinkTitleWindowTitle(null), '')
assert.equal(readLinkTitleWindowTitle(undefined), '')
assert.equal(readLinkTitleWindowTitle({ isDestroyed: () => true }), '')
})
test('readLinkTitleWindowTitle returns empty when webContents is destroyed', () => {
const window = {
isDestroyed: () => false,
webContents: { isDestroyed: () => true, getTitle: () => 'Should Not Read' }
}
assert.equal(readLinkTitleWindowTitle(window), '')
})
test('readLinkTitleWindowTitle swallows getTitle throws after teardown', () => {
const window = {
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
getTitle: () => {
throw new Error('Object has been destroyed')
}
}
}
assert.equal(readLinkTitleWindowTitle(window), '')
})
test('readLinkTitleWindowTitle returns trimmed page title', () => {
const window = {
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
getTitle: () => 'Example Domain'
}
}
assert.equal(readLinkTitleWindowTitle(window), 'Example Domain')
})

View file

@ -36,7 +36,11 @@ const {
SESSION_WINDOW_MIN_WIDTH
} = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { createLinkTitleWindow, guardLinkTitleSession } = require('./link-title-window.cjs')
const {
createLinkTitleWindow,
guardLinkTitleSession,
readLinkTitleWindowTitle
} = require('./link-title-window.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs')
@ -2161,9 +2165,25 @@ async function releaseBackendLock(updateRoot, tag) {
rememberLog(`[${tag}] venv shim unlocked; safe to proceed`)
return { unlocked: true }
}
// A supervised backend can respawn between kill and check (grandchildren,
// pool entries registered mid-teardown). Re-collect and re-kill each pass
// instead of trusting the initial sweep.
const stragglers = []
if (hermesProcess && Number.isInteger(hermesProcess.pid)) stragglers.push(hermesProcess.pid)
for (const entry of backendPool.values()) {
if (entry.process && Number.isInteger(entry.process.pid)) stragglers.push(entry.process.pid)
}
for (const pid of stragglers) forceKillProcessTree(pid)
await new Promise(r => setTimeout(r, 300))
}
rememberLog(`[${tag}] venv shim still locked after 15s; proceeding anyway (force)`)
// Do NOT proceed past a held lock: handing off to the updater while another
// process (a second desktop window, a user terminal, an unkillable child)
// still maps the venv's files guarantees a half-updated venv — the updater's
// dependency sync dies on access-denied partway through uninstalls, leaving
// imports broken (the July 2026 brotlicffi/_sodium.pyd incidents). Failing
// the update loudly and keeping the app running is strictly better than a
// bricked install that needs manual venv surgery.
rememberLog(`[${tag}] venv shim still locked after 15s; aborting hand-off (something outside this app holds the venv)`)
return { unlocked: false }
}
@ -2243,7 +2263,20 @@ async function applyUpdates(opts = {}) {
// spawn the updater. Without this the updater races a still-locked
// hermes.exe (held by the backend child / its grandchildren) and the update
// bricks. See releaseBackendLockForUpdate for the full failure analysis.
await releaseBackendLockForUpdate(updateRoot)
const lock = await releaseBackendLockForUpdate(updateRoot)
if (!lock.unlocked) {
// Something OUTSIDE this app holds the venv (a second window, a user
// terminal running hermes, an unkillable child). Handing off anyway
// guarantees a half-updated venv — abort loudly instead and let the
// user close the holder and retry. Restart our own backend so the app
// keeps working after the failed attempt.
const message =
'Update aborted: another process is holding the Hermes install open ' +
'(a second Hermes window or a terminal running hermes?). Close it and retry.'
emitUpdateProgress({ stage: 'error', message, percent: null })
startHermes().catch(() => {})
return { ok: false, error: message }
}
// Detached so the updater outlives this process — it needs us GONE before
// `hermes update` will run (the venv shim is locked while we live).
@ -3552,13 +3585,13 @@ function runRenderTitleJob(rawUrl) {
return finish('')
}
const readTitle = () => window?.webContents?.getTitle?.() || ''
const finishWithTitle = () => finish(readLinkTitleWindowTitle(window))
const scheduleGrace = () => {
if (graceTimer) clearTimeout(graceTimer)
graceTimer = setTimeout(() => finish(readTitle()), RENDER_TITLE_GRACE_MS)
graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS)
}
hardTimer = setTimeout(() => finish(readTitle()), RENDER_TITLE_TIMEOUT_MS)
hardTimer = setTimeout(finishWithTitle, RENDER_TITLE_TIMEOUT_MS)
window.webContents.setUserAgent(TITLE_USER_AGENT)
window.webContents.on('page-title-updated', scheduleGrace)