Files
hermes-agent/tests-js/source-update-observer.test.mjs
ethernet 4f9b741a3a fix(install-e2e): tolerate the update-result consume race and held lock files (#135261)
* fix(install-e2e): tolerate the update-result consume race and held lock files

Two HEAD -> NEXT legs of the stable-release matrix went red for harness reasons,
not product ones.

macOS (app-driven update): source-update-observer listed
.hermes-update-result.json and then read it. The relaunched app consumes
(unlinks) that file on boot, so the read could land after the unlink and throw
ENOENT, which launch-from-spec reported as a failed update although the update
receipt said success. A vanished file is now "not there", the same as before it
was listed.

Windows (user-state verify): verify-user-state hashed every judged file. The
gateway's cron ticker holds an msvcrt byte-range lock on cron/.tick.lock, so
open() raised PermissionError and the upgrade was reported as changing user
state. Proven on a real Windows host: base raises Errno 13, the fix records the
lock as a sentinel. A lock file is held by a live process and is never user
content, so it is recorded without being read. Any other unreadable entry now
surfaces as a ScanError that names the path instead of a bare errno.

* test(install-e2e): an unreadable user file still fails the scan with its path

Skipping lock files must not let a user file the verifier cannot read pass
silently. The test makes a real mode-000 file under memories/ and asserts
snapshot_home raises ScanError naming the path, with the PermissionError kept
as the cause. It goes red when the OSError -> ScanError wrapper is removed.
2026-10-08 17:37:28 -04:00

60 lines
2.9 KiB
JavaScript

import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, test, vi } from 'vitest'
import { observeSourceUpdate } from '../tests/install/e2e-assets/source-update-observer.mjs'
// Lets a test play the relaunched app: it unlinks the result file after the
// observer listed it and before the observer reads it.
const hooks = vi.hoisted(() => ({ beforeRead: null }))
vi.mock('node:fs', async importOriginal => {
const actual = await importOriginal()
return { ...actual, readFileSync: (...args) => { hooks.beforeRead?.(args[0]); return actual.readFileSync(...args) } }
})
const roots = []
afterEach(() => { hooks.beforeRead = null; roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true })) })
function fixture() {
const home = mkdtempSync(join(tmpdir(), 'source-update-observer-'))
roots.push(home)
const receipts = join(home, 'logs/update_receipts')
mkdirSync(receipts, { recursive: true })
const resultPath = join(home, '.hermes-update-result.json')
const write = (name, data) => writeFileSync(join(receipts, name), JSON.stringify(data))
return { home, resultPath, write }
}
test('checkout movement and old receipts are not update completion', () => {
const f = fixture()
f.write('update_old.json', { outcome: 'success', finished_at: 'old', post_update: { sha: 'target' } })
const observe = observeSourceUpdate({ ...f, expectSha: 'target' })
expect(observe('target')).toBe(false)
f.write('pm_sync.json', { outcome: 'success', finished_at: 'now', post_update: { sha: 'target' } })
expect(observe('target')).toBe(false)
f.write('update_new.json', { outcome: 'success', finished_at: 'now', post_update: { sha: 'target' } })
expect(observe('old')).toBe(false)
expect(observe('target')).toBe(true)
})
test('failed handoff fails even at target SHA; success waits for marker removal', () => {
const f = fixture()
const observe = observeSourceUpdate({ ...f, expectSha: 'target' })
writeFileSync(f.resultPath, JSON.stringify({ ok: false, exit_code: 1 }))
expect(() => observe('target')).toThrow(/failed/)
writeFileSync(f.resultPath, JSON.stringify({ ok: true, exit_code: 0 }))
const marker = join(f.home, '.hermes-update-in-progress')
writeFileSync(marker, 'updater still finishing')
expect(observe('target')).toBe(false)
rmSync(marker)
expect(observe('target')).toBe(true)
})
test('a result file consumed by the relaunched app mid-poll does not abort the update', () => {
const f = fixture()
const observe = observeSourceUpdate({ ...f, expectSha: 'target' })
f.write('update_new.json', { outcome: 'success', finished_at: 'now', post_update: { sha: 'target' } })
writeFileSync(f.resultPath, JSON.stringify({ ok: true, exit_code: 0 }))
hooks.beforeRead = file => { if (file === f.resultPath) rmSync(file) }
expect(observe('target')).toBe(true)
})