Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions apps/desktop/electron/handoff-result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'

import { test } from 'vitest'

import { handoffResultPath, readAndConsumeHandoffResult } from './handoff-result'

function tempHome(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'handoff-result-'))
}

function write(home: string, body: any) {
fs.writeFileSync(handoffResultPath(home), typeof body === 'string' ? body : JSON.stringify(body))
}

test('consumes and returns a fresh failure result', () => {
const home = tempHome()
write(home, { ok: false, exit_code: 6, message: 'rebuild failed', branch: 'main', finished_at: Math.floor(Date.now() / 1000) })

const result = readAndConsumeHandoffResult(home)

assert.ok(result)
assert.equal(result.ok, false)
assert.equal(result.exitCode, 6)
assert.equal(result.message, 'rebuild failed')
assert.equal(fs.existsSync(handoffResultPath(home)), false, 'result file must be consumed')
})

test('reports each result at most once', () => {
const home = tempHome()
write(home, { ok: true, exit_code: 0, message: 'done', branch: 'main', finished_at: Math.floor(Date.now() / 1000) })

assert.ok(readAndConsumeHandoffResult(home))
assert.equal(readAndConsumeHandoffResult(home), null)
})

test('discards stale results but still consumes the file', () => {
const home = tempHome()
write(home, { ok: false, exit_code: 5, message: 'old', branch: 'main', finished_at: Math.floor(Date.now() / 1000) - 3600 })

assert.equal(readAndConsumeHandoffResult(home), null)
assert.equal(fs.existsSync(handoffResultPath(home)), false)
})

test('malformed JSON is consumed silently', () => {
const home = tempHome()
write(home, '{nope')

assert.equal(readAndConsumeHandoffResult(home), null)
assert.equal(fs.existsSync(handoffResultPath(home)), false)
})

test('absent file returns null', () => {
assert.equal(readAndConsumeHandoffResult(tempHome()), null)
})
71 changes: 71 additions & 0 deletions apps/desktop/electron/handoff-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Consume the detached update hand-off's result file (#82328 follow-up).
*
* scripts/desktop-update.ps1 runs hidden/detached — the user never sees its
* console. It writes HERMES_HOME/.hermes-update-result.json on every exit
* path; the relaunched Desktop reads it exactly once on boot and surfaces
* failures (a silent failed update looks identical to "nothing happened",
* which is how the 2026-08-09 'closed the app then nothing' report was
* born). Read-and-delete so a result is reported at most once; results
* older than the freshness window are discarded unread (a stale file from a
* crashed relaunch chain must not resurface days later).
*/

import fs from 'fs'
import path from 'path'

export const HANDOFF_RESULT_MAX_AGE_MS = 30 * 60 * 1000

export interface HandoffResult {
ok: boolean
exitCode: number
message: string
branch: string
}

export function handoffResultPath(hermesHome: string): string {
return path.join(hermesHome, '.hermes-update-result.json')
}

export function readAndConsumeHandoffResult(
hermesHome: string,
{ now = Date.now, maxAgeMs = HANDOFF_RESULT_MAX_AGE_MS }: { now?: () => number; maxAgeMs?: number } = {}
): HandoffResult | null {
const file = handoffResultPath(hermesHome)
let raw: string

try {
raw = fs.readFileSync(file, 'utf8')
} catch {
return null
}

// Consume unconditionally — even a malformed/stale file must not be
// re-reported on every subsequent boot.
try {
fs.unlinkSync(file)
} catch {
// Best-effort; a locked file just gets consumed on the next boot.
}

let parsed: any

try {
parsed = JSON.parse(raw)
} catch {
return null
}

const finishedAt = Number(parsed?.finished_at)

if (!Number.isFinite(finishedAt) || now() - finishedAt * 1000 > maxAgeMs) {
return null
}

return {
ok: Boolean(parsed?.ok),
exitCode: Number.isFinite(Number(parsed?.exit_code)) ? Number(parsed.exit_code) : 1,
message: typeof parsed?.message === 'string' ? parsed.message : '',
branch: typeof parsed?.branch === 'string' ? parsed.branch : ''
}
}
52 changes: 42 additions & 10 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ import {
removeWorktree,
switchBranch
} from './git-worktree-ops'
import { readAndConsumeHandoffResult } from './handoff-result'
import {
ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
clampDataUrlReadMaxMb,
Expand Down Expand Up @@ -206,7 +207,8 @@ import {
resolveStagedUpdaterBinary,
resolveUpdateScriptHandoff,
spawnUpdaterProcess,
stagedUpdaterSupportsPrewrittenMarker
stagedUpdaterSupportsPrewrittenMarker,
wrapHandoffForDetachedConsole
} from './updater-process'
import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers } from './venv-blocker-scan'
import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace'
Expand Down Expand Up @@ -1802,6 +1804,28 @@ async function waitForUpdateToFinish() {
timeoutMs: UPDATE_WAIT_TIMEOUT_MS
})

// The detached hand-off script (scripts/desktop-update.ps1) runs hidden;
// its result file is the ONLY way the user learns a detached update
// failed. Consume it exactly once, here, right where boot passes the
// update gate — success gets a log line, failure gets a real dialog
// (previously a failed detached update was indistinguishable from
// "nothing happened").
try {
const result = readAndConsumeHandoffResult(HERMES_HOME)

if (result && result.ok) {
rememberLog(`[updates] detached update finished OK (branch ${result.branch})`)
} else if (result) {
rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`)
dialog.showErrorBox(
'Hermes update did not finish',
`${result.message}\n\nDetails: ${path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')}`
)
}
} catch (err) {
rememberLog(`[updates] could not read hand-off result: ${err.message}`)
}

if (outcome === 'clear') {
return false
}
Expand Down Expand Up @@ -2997,8 +3021,14 @@ async function applyUpdates(opts = {}) {
let child

if (scriptHandoff) {
const scriptArgs = [
...scriptHandoff.args,
// A bare detached+hidden powershell spawn silently dies before -File
// processing (console-subsystem init failure — see
// wrapHandoffForDetachedConsole). Route through `cmd start` so the
// script gets its own minimized console and survives our exit. The
// wrapper cmd.exe exits immediately, so child.pid is NOT the script's
// pid — the script claims the update marker itself with its own $PID
// as its first action, and a relaunched Desktop parks on that.
const wrapped = wrapHandoffForDetachedConsole(scriptHandoff, [
'-InstallRoot',
updateRoot,
'-Branch',
Expand All @@ -3007,9 +3037,9 @@ async function applyUpdates(opts = {}) {
String(process.pid),
'-RelaunchExe',
process.execPath
]
])

child = spawnUpdaterProcess(scriptHandoff.command, scriptArgs, {
child = spawnUpdaterProcess(wrapped.command, wrapped.args, {
cwd: HERMES_HOME,
env: {
...process.env,
Expand All @@ -3020,11 +3050,13 @@ async function applyUpdates(opts = {}) {
stdio: 'ignore'
})

// The script's own pid owns the marker. Unlike the stale-binary path
// there is NO adoption hazard: hermes_cli/update_lock.py accepts a live
// marker held by a process ANCESTOR (the script is the `hermes update`
// child's parent), so the pre-write is always safe here — no
// stagedUpdaterSupportsPrewrittenMarker() mtime heuristics needed.
// Bridge marker: child.pid is the short-lived cmd.exe WRAPPER, not the
// script (see wrapHandoffForDetachedConsole). Write it anyway to cover
// the first moments of the hand-off — the script's step 0 overwrites it
// with its own live $PID, and if the script never starts the wrapper's
// dead pid makes the marker read as stale and self-delete (no wedge).
// The `hermes update` child adopts the SCRIPT's claim via
// update_lock.py's process-ancestry rule; no mtime heuristics needed.
if (Number.isInteger(child.pid)) {
writeUpdateMarker(HERMES_HOME, child.pid)
}
Expand Down
22 changes: 21 additions & 1 deletion apps/desktop/electron/updater-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
resolveStagedUpdaterBinary,
resolveUpdateScriptHandoff,
spawnUpdaterProcess,
stagedUpdaterSupportsPrewrittenMarker
stagedUpdaterSupportsPrewrittenMarker,
wrapHandoffForDetachedConsole
} from './updater-process'

const DAY_MS = 24 * 60 * 60 * 1000
Expand Down Expand Up @@ -199,3 +200,22 @@

assert.equal(handoff, null)
})

test('wrapHandoffForDetachedConsole routes through cmd start with own console', () => {
const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent`
const expected = path.join(root, 'scripts', 'desktop-update.ps1')
const handoff = resolveUpdateScriptHandoff(root, {

Check warning on line 207 in apps/desktop/electron/updater-process.test.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
isWindows: true,
fileExists: candidate => candidate === expected
})

assert.ok(handoff)
const wrapped = wrapHandoffForDetachedConsole(handoff, ['-InstallRoot', root, '-Branch', 'main'])

assert.equal(wrapped.command, 'cmd.exe')
assert.deepEqual(wrapped.args, [
'/d', '/s', '/c', 'start', '', '/min',
'powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', expected,
'-InstallRoot', root, '-Branch', 'main'
])
})
30 changes: 30 additions & 0 deletions apps/desktop/electron/updater-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,36 @@ export function resolveUpdateScriptHandoff(
}
}

/**
* Wrap a PowerShell hand-off invocation so it survives a detached, hidden
* spawn from Electron.
*
* Verified empirically (2026-08-09, Windows 11): `spawn('powershell', [...,
* '-File', script], { detached: true, stdio: 'ignore', windowsHide: true })`
* exits 0 WITHOUT executing a single line of the script. powershell.exe is a
* console-subsystem binary; detached+windowsHide gives it no console to
* attach to, and Windows PowerShell 5.1 dies during console init before
* -File processing (the same class of failure as #54220's conhost work, on
* the launch side). The same spawn with a visible console, or non-detached,
* runs fine — so unit tests and foreground use hide the bug.
*
* `cmd /c start "" /min powershell ...` was the variant that survived the
* full detached+hidden production shape in testing: `start` allocates the
* child its own (minimized) console and fully detaches it from cmd.exe,
* which exits immediately. The spawned pid is therefore the WRAPPER's —
* callers must not use it as a marker owner (the script claims the marker
* itself with its own $PID).
*/
export function wrapHandoffForDetachedConsole(handoff: UpdateScriptHandoff, extraArgs: string[]): {
command: string
args: string[]
} {
return {
command: 'cmd.exe',
args: ['/d', '/s', '/c', 'start', '', '/min', handoff.command, ...handoff.args, ...extraArgs]
}
}

export interface ResolveStagedUpdaterBinaryDeps {
isWindows?: boolean
fileExists?: (candidate: string) => boolean
Expand Down
Loading
Loading