fix(desktop): restore backend lifecycle and update build contracts

This commit is contained in:
ethernet
2026-09-11 11:47:02 -04:00
parent 06ef8ce786
commit 3301c31ff8
5 changed files with 599 additions and 0 deletions

View File

@@ -1,7 +1,10 @@
import assert from 'node:assert/strict'
import { type ChildProcess, spawn } from 'node:child_process'
import { once } from 'node:events'
import { test } from 'vitest'
import { waitForBackendExit } from './backend-child'
import { createBackendConnectionState } from './backend-connection-state'
type FakeProcess = { id: string }
@@ -106,6 +109,116 @@ test('an invalidated attempt cannot attach a late-spawned process', () => {
assert.equal(state.getProcess(), null)
})
test('a failed primary stop retains its child and blocks a replacement until retry exits', async () => {
const state = createBackendConnectionState<ChildProcess, string>()
const child = spawn(process.execPath, ['-e', 'process.stdout.write("ready"); setInterval(() => {}, 1000)'], {
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true
})
try {
await once(child.stdout!, 'data')
const attempt = state.startAttempt()
state.setPromise(attempt, Promise.resolve('ready'))
const owner = state.attachProcess(attempt, child)
assert.ok(owner)
const failure = new Error('primary is still running')
const calls: ChildProcess[] = []
const fail = async (current: ChildProcess): Promise<void> => {
calls.push(current)
throw failure
}
const stopping = state.stopProcess(fail)
assert.equal(state.getProcess(), null)
assert.equal(state.getPromise(), null)
assert.throws(() => state.startAttempt(), /has not stopped/)
assert.equal(state.stopProcess(fail), stopping)
await assert.rejects(stopping, error => error === failure)
state.invalidate()
assert.throws(() => state.startAttempt(), /has not stopped/)
assert.equal(child.exitCode, null)
assert.equal(child.signalCode, null)
await state.stopProcess(async current => {
calls.push(current)
current.kill()
await waitForBackendExit(current, {
forceKillProcessTree: (): void => {
current.kill('SIGKILL')
},
killGroup: (): void => {
current.kill('SIGKILL')
}
})
})
assert.deepEqual(calls, [child, child])
assert.ok(child.exitCode !== null || child.signalCode !== null)
const replacement = state.startAttempt()
const connection = Promise.resolve('new')
assert.equal(state.setPromise(replacement, connection), true)
assert.equal(state.clearForCurrentProcess(owner), false)
assert.equal(state.getPromise(), connection)
} finally {
if (child.exitCode === null && child.signalCode === null) {
const closed = once(child, 'close')
child.kill()
await closed
}
}
}, 15_000)
test('shutdown sees a spawned child while its persistent claim is still pending', async () => {
const state = createBackendConnectionState<ChildProcess, string>()
const child = spawn(process.execPath, ['-e', 'process.stdout.write("ready"); setInterval(() => {}, 1000)'], {
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true
})
const claim = deferred<void>()
try {
await once(child.stdout!, 'data')
const attempt = state.startAttempt()
const claiming = state.claimProcess(attempt, child, async current => {
assert.equal(current, child)
assert.equal(state.getProcess(), child)
await claim.promise
})
assert.equal(state.getProcess(), child)
await state.stopProcess(async current => {
assert.equal(current, child)
current.kill()
await waitForBackendExit(current, {
forceKillProcessTree: (): void => {
current.kill('SIGKILL')
},
killGroup: (): void => {
current.kill('SIGKILL')
}
})
})
assert.ok(child.exitCode !== null || child.signalCode !== null)
claim.resolve()
assert.equal(await claiming, null, 'a completed claim cannot revive the stopped generation')
assert.equal(state.getProcess(), null)
assert.doesNotThrow(() => state.startAttempt())
} finally {
claim.resolve()
if (child.exitCode === null && child.signalCode === null) {
const closed = once(child, 'close')
child.kill()
await closed
}
}
}, 15_000)
test('distinguishes a pending connection attempt from a cached settled descriptor', async () => {
const state = createBackendConnectionState<FakeProcess, string>()
const connection = deferred<string>()

View File

@@ -4,6 +4,7 @@ import {
applyConnectionChange,
commitConnectionFailure,
resolveTerminalConnection,
sshQuitShouldBlock,
teardownSshState
} from './connection-apply'
@@ -91,6 +92,48 @@ describe('resolveTerminalConnection', () => {
})
})
describe('sshQuitShouldBlock', () => {
it('waits when connections exist and teardown has not finished', () => {
expect(sshQuitShouldBlock({ teardownDone: false, connectionCount: 1, bootstrapPending: 0, inFlight: null })).toBe(
true
)
})
it('waits when bootstrap is still running', () => {
expect(sshQuitShouldBlock({ teardownDone: false, connectionCount: 0, bootstrapPending: 1, inFlight: null })).toBe(
true
)
})
it('waits when the map is empty but a remote kill is already in flight', () => {
expect(
sshQuitShouldBlock({
teardownDone: false,
connectionCount: 0,
bootstrapPending: 0,
inFlight: Promise.resolve()
})
).toBe(true)
})
it('does not block a second quit after teardown finished', () => {
expect(
sshQuitShouldBlock({
teardownDone: true,
connectionCount: 1,
bootstrapPending: 1,
inFlight: Promise.resolve()
})
).toBe(false)
})
it('does not block quit when there is nothing to tear down', () => {
expect(sshQuitShouldBlock({ teardownDone: false, connectionCount: 0, bootstrapPending: 0, inFlight: null })).toBe(
false
)
})
})
describe('teardownSshState', () => {
it('terminates the owned remote backend before closing its tunnel and SSH transport', async () => {
const events: string[] = []

View File

@@ -61,6 +61,31 @@ async function resolveTerminalConnectionForSender(webContentsId, getTarget, ensu
)
}
/** A second before-quit must still wait for an in-flight remote kill.
*
* teardownSshConnection deletes the sshConnections entry first, then
* SSH-execs kill. backendShutdown's finally() calls app.quit() and
* re-enters before-quit with an empty map. Without `inFlight`, Electron
* exits while disconnect is running and the detached serve --isolated
* stays at pid 1 (post-#95085 leftover on #91668: window X on Windows). */
function sshQuitShouldBlock({
teardownDone,
connectionCount,
bootstrapPending,
inFlight
}: {
teardownDone: boolean
connectionCount: number
bootstrapPending: number
inFlight: Promise<unknown> | null
}): boolean {
if (teardownDone) {
return false
}
return connectionCount > 0 || bootstrapPending > 0 || Boolean(inFlight)
}
async function teardownSshState(state, { cleanupRemote }) {
// Remote process first, while the SSH channel can still exec kill.
// Then drop the local forward and close the transport. Each step is
@@ -91,5 +116,6 @@ export {
commitConnectionFailure,
resolveTerminalConnection,
resolveTerminalConnectionForSender,
sshQuitShouldBlock,
teardownSshState
}

View File

@@ -0,0 +1,302 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import {
compareApiUrl,
parseCompareBehindCount,
resolveBehindCount,
resolveCommitLogSelection,
shouldCountCommits
} from './update-count'
function createTempGitRepo() {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-update-count-'))
const git = (...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 }).trim()
try {
git('init', '--quiet')
git('config', 'commit.gpgSign', 'false')
git('config', 'core.hooksPath', '.git/no-hooks')
git('config', 'user.name', 'Hermes Test')
git('config', 'user.email', 'hermes@example.invalid')
return { cwd, git }
} catch (error) {
fs.rmSync(cwd, { recursive: true, force: true })
throw error
}
}
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
// rev-list count (e.g. 12104) — #51922. Later the branch returned the sentinel
// `1`, which the UI rendered as a literal "1 change included" even when the
// true count was far higher (e.g. 90, or the real-world 61 in #84591). An
// update IS available here, but its exact size is unknown — the only honest
// value is `null`.
test('shallow checkout with no merge-base reports null (unknown count), not a fake 1', () => {
assert.equal(
resolveBehindCount({
countStr: '12104',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true
}),
null
)
})
test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {
assert.equal(
resolveBehindCount({
countStr: '12104',
currentSha: 'abc',
targetSha: 'abc',
isShallow: true
}),
0
)
})
test('shallow local-ahead checkout reports up-to-date when origin is a known ancestor', () => {
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'local-child',
targetSha: 'origin-parent',
isShallow: true,
targetIsAncestorOfHead: true
}),
0
)
})
test('shallow Git graph proves the remote tip is an ancestor of a local commit', () => {
const { cwd, git } = createTempGitRepo()
try {
git('commit', '--allow-empty', '-m', 'origin tip')
const targetSha = git('rev-parse', 'HEAD')
git('update-ref', 'refs/remotes/origin/main', targetSha)
fs.writeFileSync(path.join(cwd, '.git', 'shallow'), `${targetSha}\n`)
git('commit', '--allow-empty', '-m', 'local child')
const currentSha = git('rev-parse', 'HEAD')
git('merge-base', '--is-ancestor', 'origin/main', 'HEAD')
assert.notEqual(currentSha, targetSha)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha,
targetSha,
isShallow: true,
targetIsAncestorOfHead: true
}),
0
)
} finally {
fs.rmSync(cwd, { recursive: true, force: true })
}
}, 30_000)
test('shallow checkout with a merge-base does not trust an inflated rev-list count', () => {
const { cwd, git } = createTempGitRepo()
try {
git('commit', '--allow-empty', '-m', 'root')
git('commit', '--allow-empty', '-m', 'ancestor')
const redundantParent = git('rev-parse', 'HEAD')
git('commit', '--allow-empty', '-m', 'installed head')
const currentSha = git('rev-parse', 'HEAD')
const tree = git('rev-parse', 'HEAD^{tree}')
const targetSha = execFileSync('git', ['commit-tree', tree, '-p', currentSha, '-p', redundantParent], {
cwd,
encoding: 'utf8',
input: 'remote merge\n',
timeout: 10_000
}).trim()
git('update-ref', 'refs/remotes/origin/main', targetSha)
const completeCount = git('rev-list', 'HEAD..origin/main', '--count')
assert.equal(completeCount, '1')
fs.writeFileSync(path.join(cwd, '.git', 'shallow'), `${currentSha}\n`)
assert.equal(git('rev-parse', '--is-shallow-repository'), 'true')
assert.equal(git('merge-base', 'HEAD', 'origin/main'), currentSha)
const shallowCount = git('rev-list', 'HEAD..origin/main', '--count')
assert.ok(Number.parseInt(shallowCount, 10) > Number.parseInt(completeCount, 10))
assert.equal(
resolveBehindCount({
countStr: shallowCount,
currentSha,
targetSha,
isShallow: true
}),
null
)
} finally {
fs.rmSync(cwd, { recursive: true, force: true })
}
}, 30_000)
test('shallow checkout with a merge-base still uses presence-only status', () => {
assert.equal(
resolveBehindCount({
countStr: '3',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true
}),
null
)
})
test('full (non-shallow) clone keeps the exact count path unchanged', () => {
assert.equal(
resolveBehindCount({
countStr: '7',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: false
}),
7
)
})
test('up-to-date full clone reports 0', () => {
assert.equal(
resolveBehindCount({
countStr: '0',
currentSha: 'x',
targetSha: 'x',
isShallow: false
}),
0
)
})
test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: false
}),
0
)
})
// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().
// Every shallow graph is incomplete, so a visible merge-base is not enough to
// prove that the count is exact.
test('shallow checkouts skip the rev-list count', () => {
assert.equal(shouldCountCommits({ isShallow: true }), false)
})
test('full (non-shallow) clones run the rev-list count', () => {
assert.equal(shouldCountCommits({ isShallow: false }), true)
})
test('shallow commit logs select only the fetched remote tip', () => {
assert.deepEqual(resolveCommitLogSelection({ branch: 'main', isShallow: true }), {
limit: 1,
revision: 'origin/main'
})
})
test('full-clone commit logs keep the complete behind range', () => {
assert.deepEqual(resolveCommitLogSelection({ branch: 'release', isShallow: false }), {
limit: 40,
revision: 'HEAD..origin/release'
})
})
// The skip path produces an empty countStr; resolveBehindCount must NOT trust
// it and must fall through to the SHA compare (mirrors the live call site).
test('skipped-count path resolves via SHA compare, never via empty countStr', () => {
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true
}),
null
)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'same',
targetSha: 'same',
isShallow: true
}),
0
)
})
// --- compare-API recovery: the accuracy half of the class fix (#84591) ---
const SHA_A = 'a'.repeat(40)
const SHA_B = 'b'.repeat(40)
test('compareApiUrl builds the GitHub compare URL for HTTPS origins', () => {
assert.equal(
compareApiUrl({
currentSha: SHA_A,
originUrl: 'https://github.com/NousResearch/hermes-agent.git',
targetSha: SHA_B
}),
`https://api.github.com/repos/NousResearch/hermes-agent/compare/${SHA_A}...${SHA_B}`
)
})
test('compareApiUrl handles SSH origin forms', () => {
for (const originUrl of [
'git@github.com:NousResearch/hermes-agent.git',
'ssh://git@github.com/NousResearch/hermes-agent.git',
'git@github.com:NousResearch/hermes-agent'
]) {
assert.equal(
compareApiUrl({ currentSha: SHA_A, originUrl, targetSha: SHA_B }),
`https://api.github.com/repos/NousResearch/hermes-agent/compare/${SHA_A}...${SHA_B}`
)
}
})
test('compareApiUrl refuses non-GitHub remotes and partial SHAs', () => {
assert.equal(compareApiUrl({ currentSha: SHA_A, originUrl: 'https://gitlab.com/x/y.git', targetSha: SHA_B }), null)
assert.equal(compareApiUrl({ currentSha: 'abc123', originUrl: 'https://github.com/x/y.git', targetSha: SHA_B }), null)
assert.equal(compareApiUrl({ currentSha: SHA_A, originUrl: '', targetSha: SHA_B }), null)
})
test('parseCompareBehindCount returns ahead_by (the behind count)', () => {
assert.equal(parseCompareBehindCount({ ahead_by: 61, status: 'ahead' }), 61)
assert.equal(parseCompareBehindCount({ ahead_by: 0, status: 'behind' }), 0)
})
test('parseCompareBehindCount rejects malformed payloads', () => {
assert.equal(parseCompareBehindCount(null), null)
assert.equal(parseCompareBehindCount({}), null)
assert.equal(parseCompareBehindCount({ ahead_by: -2 }), null)
assert.equal(parseCompareBehindCount({ ahead_by: '61' }), null)
assert.equal(parseCompareBehindCount({ ahead_by: 1.5 }), null)
assert.equal(parseCompareBehindCount([]), null)
})

View File

@@ -0,0 +1,115 @@
// Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful
// number worth computing. Installer checkouts are shallow (`--depth 1`), so
// their visible graph is incomplete even when `merge-base` happens to find a
// common commit. A merge can expose ancestry that the local shallow boundary
// hides from HEAD, inflating the count with old commits. Exact counts are only
// trustworthy in full clones; shallow checkouts use presence-only status plus
// any positively proven local-ahead ancestry.
function shouldCountCommits({ isShallow }: { isShallow: boolean }): boolean {
return !isShallow
}
// Resolve how many commits the local checkout is behind origin for the desktop
// update indicator. Shallow checkouts use SHA equality plus any positively
// proven local-ahead ancestry; exact counts remain exclusive to full clones.
function resolveBehindCount({
countStr,
currentSha,
targetSha,
isShallow,
targetIsAncestorOfHead = false
}: {
countStr: string
currentSha: string
targetSha: string
isShallow: boolean
targetIsAncestorOfHead?: boolean
}): number | null {
if (!shouldCountCommits({ isShallow })) {
if (currentSha && targetSha && (currentSha === targetSha || targetIsAncestorOfHead)) {
return 0
}
// An update IS available, but its size is unknowable without a merge-base.
// Return null — never a numeric sentinel: the UI used to render the old
// `1` as a literal "1 change included" even when the true distance was
// far larger. null lets every surface say "update available" honestly.
return null
}
return Number.parseInt(countStr, 10) || 0
}
// Shallow history can also contaminate the changelog range. Trust the fetched
// remote tip itself, but do not walk its ancestry. Full clones retain the
// detailed range used by the existing update overlay.
function resolveCommitLogSelection({ branch, isShallow }: { branch: string; isShallow: boolean }): {
limit: number
revision: string
} {
const remote = `origin/${branch}`
return isShallow ? { limit: 1, revision: remote } : { limit: 40, revision: `HEAD..${remote}` }
}
// When the local graph can't count (behind === null), the GitHub compare API
// still can: `GET /repos/<owner>/<repo>/compare/<current>...<target>` returns
// `ahead_by` — how many commits the remote tip is ahead of the local HEAD,
// i.e. exactly the behind count the shallow clone lost. Unauthenticated, no
// clone depth required. Pure URL builder + response parser here; the network
// call lives with the caller.
function compareApiUrl({
currentSha,
originUrl,
targetSha
}: {
currentSha: string
originUrl: string
targetSha: string
}): string | null {
const sha = /^[0-9a-f]{40}$/i
if (!sha.test(currentSha || '') || !sha.test(targetSha || '')) {
return null
}
// Only GitHub remotes have a compare API. Reuse the canonical form the
// official-remote check produces: `github.com/<owner>/<repo>`.
const canonical = canonicalRemoteForCompare(originUrl)
if (!canonical) {
return null
}
return `https://api.github.com/repos/${canonical}/compare/${currentSha}...${targetSha}`
}
function canonicalRemoteForCompare(originUrl: string): string | null {
const value = String(originUrl || '').trim()
const match =
/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(value) ||
/^(?:ssh:\/\/git@|https:\/\/|http:\/\/)github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(value)
return match ? match[1] : null
}
// `ahead_by` counts target commits not reachable from current — the behind
// count. `status` is "ahead" / "behind" / "diverged" / "identical" relative to
// current...target; any shape surprise returns null so the caller keeps the
// honest "update available" fallback instead of trusting a partial answer.
function parseCompareBehindCount(payload: unknown): number | null {
if (!payload || typeof payload !== 'object') {
return null
}
const ahead = (payload as { ahead_by?: unknown }).ahead_by
if (typeof ahead !== 'number' || !Number.isInteger(ahead) || ahead < 0) {
return null
}
return ahead
}
export { compareApiUrl, parseCompareBehindCount, resolveBehindCount, resolveCommitLogSelection, shouldCountCommits }