fix(desktop): bound bundle skew checks and reap timed-out git trees
Coalesce concurrent version polls, cache results briefly, and skip checks while an updater owns the local runtime. Use the existing execGit runner with a 5-second timeout and no lazy fetch; reap its process group or Windows process tree so Python-hosted git and promisor children cannot accumulate. Add concurrency/backoff coverage and a live descendant-process regression. Related to NousResearch/hermes-agent#125932. (cherry picked from commit 633d7de6cd68164d2293c47f8e37170debabeaed)
This commit is contained in:
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path'
|
||||
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { detectBundleSkew, isFallbackCommit, type RunGit, RUNTIME_PATHS } from './bundle-skew'
|
||||
import { createBundleSkewChecker, detectBundleSkew, isFallbackCommit, type RunGit, RUNTIME_PATHS } from './bundle-skew'
|
||||
|
||||
const REPO = '/repo'
|
||||
const STAMP = { commit: 'a'.repeat(40), source: 'ci' }
|
||||
@@ -44,6 +44,54 @@ function gitCounting(count: string): RunGit {
|
||||
return gitAnswering({ 'merge-base': { code: 0 }, 'rev-list': { stdout: count } }).git
|
||||
}
|
||||
|
||||
it('coalesces polls, backs off failures, and skips checks while an update owns the checkout', async () => {
|
||||
let updating = false
|
||||
let now = 0
|
||||
let finish: (value: { code: number; stderr: string; stdout: string }) => void
|
||||
const calls: string[][] = []
|
||||
|
||||
const git: RunGit = (args, options) => {
|
||||
calls.push(args)
|
||||
expect(options.timeoutMs).toBeGreaterThan(0)
|
||||
expect(options.timeoutMs).toBeLessThanOrEqual(5000)
|
||||
expect(options.env?.GIT_NO_LAZY_FETCH).toBe('1')
|
||||
|
||||
return new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
}
|
||||
|
||||
const check = createBundleSkewChecker(STAMP, git, { isUpdating: () => updating, now: () => now })
|
||||
const quiet = { desktopCommitsBehind: null, outOfSync: false }
|
||||
|
||||
const polls = Array.from({ length: 40 }, () => check(REPO))
|
||||
expect(calls).toHaveLength(1)
|
||||
finish!({ code: 128, stderr: 'missing tree', stdout: '' })
|
||||
expect(await Promise.all(polls)).toEqual(polls.map(() => quiet))
|
||||
await check(REPO)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
now += 60_000
|
||||
const retry = check(REPO)
|
||||
expect(calls).toHaveLength(2)
|
||||
finish!({ code: 128, stderr: '', stdout: '' })
|
||||
await retry
|
||||
|
||||
updating = true
|
||||
expect(await check(REPO)).toEqual(quiet)
|
||||
expect(calls).toHaveLength(2)
|
||||
updating = false
|
||||
const refreshed = check(REPO)
|
||||
expect(calls).toHaveLength(3) // update invalidates the cached failure
|
||||
finish!({ code: 128, stderr: '', stdout: '' })
|
||||
await refreshed
|
||||
|
||||
const other = check('/another-checkout')
|
||||
expect(calls).toHaveLength(4)
|
||||
finish!({ code: 128, stderr: '', stdout: '' })
|
||||
await other
|
||||
})
|
||||
|
||||
describe('isFallbackCommit', () => {
|
||||
it('matches the all-zero placeholder at any stamp length', () => {
|
||||
expect(isFallbackCommit('0'.repeat(40))).toBe(true)
|
||||
|
||||
@@ -56,8 +56,8 @@ export interface BundleSkewResult {
|
||||
|
||||
export type RunGit = (
|
||||
args: string[],
|
||||
options: { cwd: string }
|
||||
) => Promise<{ code: number; stderr: string; stdout: string }>
|
||||
options: { cwd: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }
|
||||
) => Promise<{ code: number | null; stderr: string; stdout: string }>
|
||||
|
||||
/**
|
||||
* The paths that actually reach the user: renderer sources, main-process
|
||||
@@ -82,6 +82,52 @@ export const RUNTIME_PATHS = [
|
||||
|
||||
const NOT_STALE: BundleSkewResult = { desktopCommitsBehind: null, outOfSync: false }
|
||||
|
||||
/** About/version polls share one check per checkout, including failed checks. */
|
||||
export function createBundleSkewChecker(
|
||||
stamp: BundleSkewStamp | null,
|
||||
runGit: RunGit,
|
||||
{ isUpdating, now = Date.now }: { isUpdating: () => boolean; now?: () => number }
|
||||
): (repoRoot: string) => Promise<BundleSkewResult> {
|
||||
const pending = new Map<string, Promise<BundleSkewResult>>()
|
||||
const cached = new Map<string, { result: BundleSkewResult; at: number }>()
|
||||
|
||||
return repoRoot => {
|
||||
if (isUpdating()) {
|
||||
cached.clear()
|
||||
|
||||
return Promise.resolve(NOT_STALE)
|
||||
}
|
||||
|
||||
const running = pending.get(repoRoot)
|
||||
|
||||
if (running) {
|
||||
return running
|
||||
}
|
||||
|
||||
const previous = cached.get(repoRoot)
|
||||
|
||||
if (previous && now() - previous.at < 30_000) {
|
||||
return Promise.resolve(previous.result)
|
||||
}
|
||||
|
||||
const check = detectBundleSkew(stamp, runGit, repoRoot)
|
||||
.then(result => {
|
||||
if (isUpdating()) {
|
||||
return NOT_STALE
|
||||
}
|
||||
|
||||
cached.set(repoRoot, { result, at: now() })
|
||||
|
||||
return result
|
||||
})
|
||||
.finally(() => pending.delete(repoRoot))
|
||||
|
||||
pending.set(repoRoot, check)
|
||||
|
||||
return check
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches write-build-stamp.mjs's all-zero placeholder for non-git builds. */
|
||||
export function isFallbackCommit(commit: string): boolean {
|
||||
return /^0{7,40}$/.test(commit)
|
||||
@@ -97,6 +143,15 @@ export async function detectBundleSkew(
|
||||
}
|
||||
|
||||
try {
|
||||
// A path-filtered walk of a tree:0 clone can otherwise fetch its entire
|
||||
// missing history. Older Git ignores NO_LAZY_FETCH, so also bound and reap
|
||||
// the process tree in execGit, not just the promise waiting for it.
|
||||
const options = {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, GIT_NO_LAZY_FETCH: '1', GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'Never' },
|
||||
timeoutMs: 5000
|
||||
}
|
||||
|
||||
// Exit 0 = ancestor, 1 = unrelated or diverged, anything else = git could
|
||||
// not answer (unknown object, shallow clone, not a repo). Only the first
|
||||
// makes the commit count below a statement about skew, and the other two
|
||||
@@ -108,17 +163,13 @@ export async function detectBundleSkew(
|
||||
// would be told "app build out of date" backwards. Ancestry is what makes
|
||||
// this a proof that the renderer PREDATES the tree, which is the claim the
|
||||
// warning actually makes.
|
||||
const ancestry = await runGit(['merge-base', '--is-ancestor', stamp.commit, 'HEAD'], {
|
||||
cwd: repoRoot
|
||||
})
|
||||
const ancestry = await runGit(['merge-base', '--is-ancestor', stamp.commit, 'HEAD'], options)
|
||||
|
||||
if (ancestry.code !== 0) {
|
||||
return NOT_STALE
|
||||
}
|
||||
|
||||
const result = await runGit(['rev-list', '--count', `${stamp.commit}..HEAD`, '--', ...RUNTIME_PATHS], {
|
||||
cwd: repoRoot
|
||||
})
|
||||
const result = await runGit(['rev-list', '--count', `${stamp.commit}..HEAD`, '--', ...RUNTIME_PATHS], options)
|
||||
|
||||
if (result.code !== 0) {
|
||||
return NOT_STALE
|
||||
|
||||
@@ -114,7 +114,7 @@ import {
|
||||
BROWSER_WINDOW_WIDTH,
|
||||
buildBrowserWindowUrl
|
||||
} from './browser-windows'
|
||||
import { detectBundleSkew } from './bundle-skew'
|
||||
import { createBundleSkewChecker } from './bundle-skew'
|
||||
import { detectBundleSwap, readBundleSwapStamp } from './bundle-swap'
|
||||
import { registerChatOnboardingWindow } from './chat-onboarding-window'
|
||||
import { provisionCliLinks } from './cli-provision'
|
||||
@@ -368,7 +368,7 @@ import {
|
||||
} from './native-oauth'
|
||||
import { runNativeLogin } from './native-oauth-login'
|
||||
import { loadNativeTokenSet, type NativeTokenStoreIo, persistNativeTokenSet } from './native-token-store'
|
||||
import { planNoConsoleGitSpawn, setNoConsoleGitRoots, windowsGitHost } from './no-console-git'
|
||||
import { execGit, planNoConsoleGitSpawn, setNoConsoleGitRoots, windowsGitHost } from './no-console-git'
|
||||
import { registerNativeNotifications } from './notification-ipc'
|
||||
import { isExpectedOauthNavigationAbort } from './oauth-navigation'
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
@@ -531,7 +531,7 @@ import {
|
||||
windowOpacityFor,
|
||||
windowOpacityOptions
|
||||
} from './translucency'
|
||||
import { waitForUpdateClearance } from './update-gate'
|
||||
import { updateGateReason, waitForUpdateClearance } from './update-gate'
|
||||
import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker'
|
||||
import {
|
||||
resolveUpdaterMechanism,
|
||||
@@ -18265,8 +18265,14 @@ function resolveHermesVersion(scope: { connectionId?: string; profile?: string }
|
||||
// apps/desktop/, and warn when the running renderer is provably behind.
|
||||
// Fail-quiet: dev runs (no stamp), non-git builds, and shallow-clone gaps all
|
||||
// report in-sync rather than risk a false "your install is torn" warning.
|
||||
const checkRendererSkew = createBundleSkewChecker(
|
||||
INSTALL_STAMP,
|
||||
(args, options) => execGit(resolveGitBinary(), args, options),
|
||||
{ isUpdating: () => updateGateReason(updateGateDeps()) !== null }
|
||||
)
|
||||
|
||||
async function detectRendererSkew() {
|
||||
return detectBundleSkew(INSTALL_STAMP, runGit, resolveUpdateRoot())
|
||||
return checkRendererSkew(resolveUpdateRoot())
|
||||
}
|
||||
|
||||
// Re-resolve the live Hermes version and push it into the native About panel
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import { connect } from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
CREATE_NO_WINDOW,
|
||||
execGit,
|
||||
NO_CONSOLE_GIT_SCRIPT,
|
||||
planNoConsoleGitSpawn,
|
||||
resolveNoConsolePython,
|
||||
@@ -17,6 +19,67 @@ import {
|
||||
const gitArgs = ['-c', 'windows.appendAtomically=false', 'merge-base', '--is-ancestor', 'origin/main', 'HEAD']
|
||||
const gitBin = 'C:\\Program Files\\Git\\cmd\\git.exe'
|
||||
|
||||
test('a timed-out git command reaps descendants, including the Windows Python host', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-timeout-'))
|
||||
const pids = path.join(dir, 'pids.json')
|
||||
const fixture = path.join(dir, 'hang.cjs')
|
||||
|
||||
const listening = (port: number): Promise<boolean> =>
|
||||
new Promise(resolve => {
|
||||
const socket = connect(port, '127.0.0.1')
|
||||
|
||||
const done = (alive: boolean) => {
|
||||
socket.destroy()
|
||||
resolve(alive)
|
||||
}
|
||||
|
||||
socket.once('connect', () => done(true))
|
||||
socket.once('error', () => done(false))
|
||||
socket.setTimeout(2000, () => done(true))
|
||||
})
|
||||
|
||||
fs.writeFileSync(
|
||||
fixture,
|
||||
`
|
||||
const { spawn } = require('node:child_process');
|
||||
const worker = String.raw\`const fs = require('node:fs');
|
||||
const server = require('node:net').createServer(socket => socket.end());
|
||||
server.listen(0, '127.0.0.1', () => fs.writeFileSync(process.argv[1],
|
||||
JSON.stringify({ pids: [Number(process.argv[2]), process.pid], port: server.address().port })));\`;
|
||||
spawn(process.execPath, ['-e', worker, process.argv[2], String(process.pid)], { stdio: 'inherit' });
|
||||
setInterval(() => {}, 1000);
|
||||
`
|
||||
)
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const python = execFileSync('python3', ['-c', 'import sys; print(sys.executable)'], { encoding: 'utf8' }).trim()
|
||||
vi.stubEnv('HERMES_DESKTOP_PYTHON', python)
|
||||
}
|
||||
|
||||
const success = await execGit(process.execPath, ['-e', 'process.stdout.write("ok")'], { timeoutMs: 5000 })
|
||||
expect(success).toMatchObject({ code: 0, stdout: 'ok' })
|
||||
|
||||
const error = await execGit(process.execPath, [fixture, pids], { timeoutMs: 5000 }).catch(error => error)
|
||||
const { port } = JSON.parse(fs.readFileSync(pids, 'utf8'))
|
||||
await vi.waitFor(async () => expect(await listening(port)).toBe(false), { timeout: 3000 })
|
||||
expect(error).toMatchObject({ code: 'ETIMEDOUT' })
|
||||
} finally {
|
||||
if (fs.existsSync(pids)) {
|
||||
for (const pid of JSON.parse(fs.readFileSync(pids, 'utf8')).pids) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {
|
||||
/* already reaped */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vi.unstubAllEnvs()
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
test('windows git spawn uses a CREATE_NO_WINDOW host and does not rewrite git argv', () => {
|
||||
const plan = planNoConsoleGitSpawn({
|
||||
gitBin,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// unchanged. simple-git cannot take a creation flag, so its binary tuple is
|
||||
// [python, this host script].
|
||||
|
||||
import { spawn, type SpawnOptions } from 'node:child_process'
|
||||
import { execFile, spawn, type SpawnOptions } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
@@ -252,6 +252,9 @@ export function execGit(
|
||||
const spec = hiddenGitSpawnSpec(gitBin, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
// Timed commands own a POSIX group so a promisor fetch cannot outlive
|
||||
// the git process. On Windows taskkill follows the Python host's tree.
|
||||
detached: Boolean(options.timeoutMs) && process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
@@ -260,6 +263,7 @@ export function execGit(
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
let timeoutError: (NodeJS.ErrnoException & { stderr?: string }) | undefined
|
||||
|
||||
const finish = (error?: Error, code: number | null = child.exitCode) => {
|
||||
if (settled) {
|
||||
@@ -268,6 +272,10 @@ export function execGit(
|
||||
|
||||
settled = true
|
||||
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
reject(error)
|
||||
|
||||
@@ -279,11 +287,40 @@ export function execGit(
|
||||
|
||||
const timer = options.timeoutMs
|
||||
? setTimeout(() => {
|
||||
child.kill()
|
||||
const error = new Error('git timed out') as NodeJS.ErrnoException & { stderr?: string }
|
||||
timeoutError = Object.assign(new Error('git timed out'), { code: 'ETIMEDOUT', stderr })
|
||||
|
||||
error.stderr = stderr
|
||||
finish(error)
|
||||
const done = () => {
|
||||
// Pipes held by descendants must not keep Electron alive after a
|
||||
// failed tree kill. The timeout remains a failure, never exit 0.
|
||||
child.stdout?.destroy()
|
||||
child.stderr?.destroy()
|
||||
finish(timeoutError)
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
execFile(
|
||||
'taskkill',
|
||||
['/PID', String(child.pid), '/T', '/F'],
|
||||
{ windowsHide: true, timeout: 5000 },
|
||||
error => {
|
||||
if (error) {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
try {
|
||||
if (child.pid) {
|
||||
process.kill(-child.pid, 'SIGKILL')
|
||||
}
|
||||
} catch {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
|
||||
done()
|
||||
}
|
||||
}, options.timeoutMs)
|
||||
: null
|
||||
|
||||
@@ -305,7 +342,10 @@ export function execGit(
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
finish(undefined, code)
|
||||
// On Windows wait for taskkill's completion, not just the root's exit.
|
||||
if (!timeoutError) {
|
||||
finish(undefined, code)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user