* 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.
60 lines
2.9 KiB
JavaScript
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)
|
|
})
|