Files
hermes-agent/scripts/build/frontend-common.mjs
Brooklyn Nicholson f0b9649032 fix(build): wait out Windows scanner holds when publishing products
publishDirectory moved a finished product into place with one renameSync.
On Windows an antivirus scanner opens every freshly written file, so the
rename onto build/native-deps (right after hud-modifier-monitor.exe is
compiled) or hermes_cli/web_dist failed EPERM and threw away a build that
took minutes.

retryHeld retries EPERM/EACCES/EBUSY for about 3s and raises every other
code at once. All three publication renames use it, as does the HUD
helper's staging rename. The previous product's backup now sits beside
the output instead of inside withProduct's scratch tree, so a rollback
that also fails no longer has its only copy deleted by scratch cleanup;
the error names where it is.

Co-authored-by: dskwe <6286305+dskwe@users.noreply.github.com>
Co-authored-by: juliorobertoalvarez <39749815+juliorobertoalvarez@users.noreply.github.com>
Co-authored-by: bighamx <10262931+bighamx@users.noreply.github.com>
Co-authored-by: lzmuhioin <1814826+lzmuhioin@users.noreply.github.com>
2026-10-05 17:08:32 -05:00

167 lines
7.2 KiB
JavaScript

import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { setTimeout } from 'node:timers/promises'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { parseArgs } from 'node:util'
export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
// Look only in the prepared workspace, not a parent checkout's dependency tree.
// Package symlinks themselves are valid inputs (including Nix store references).
export function workspaceTool(source, workspace, name) {
for (const root of [path.join(source, workspace), source]) {
const manifest = path.join(root, 'node_modules', name, 'package.json')
if (existsSync(manifest)) {
return createRequire(path.join(root, 'package.json')).resolve(name)
}
}
throw new Error(`Missing prepared ${name} dependency in ${workspace} or ${source}/node_modules`)
}
function canonical(file) {
if (existsSync(file)) return realpathSync(file)
return path.join(canonical(path.dirname(file)), path.basename(file))
}
function contains(parent, child) {
const relative = path.relative(parent, child)
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
}
const productMarker = '.hermes-product'
const productOwner = 'hermes-frontend-product-v1\n'
const developerOutputs = ['ui-tui/dist', 'hermes_cli/web_dist', 'apps/desktop/dist', 'apps/desktop/build/native-deps']
function developerOutput(source, out) {
return source && developerOutputs.some(name => path.resolve(out) === path.join(path.resolve(source), name))
}
// A destination name alone does not confer ownership of its existing contents.
function requireOwnedOutput(out, source) {
const stat = lstatSync(out, { throwIfNoEntry: false })
if (!stat) return
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Output must be a directory, not a file or symlink: ${out}`)
// These exact npm destinations belong to their compiler. Other paths require
// explicit ownership before an existing directory can be replaced.
if (developerOutput(source, out)) return
const marker = path.join(out, productMarker)
if (!lstatSync(marker, { throwIfNoEntry: false })?.isFile() || readFileSync(marker, 'utf8') !== productOwner) {
throw new Error(`Output already exists and is not builder-owned; choose a fresh output directory: ${out}`)
}
}
// Classify in-tree destinations here, not by enumerating existing workspace
// children: generated parents also exist after the first build. inputs names
// explicit prepared trees/files; their protection wins even in generated homes.
export function productOutput(source, out, inputs) {
if (!source || !out) throw new Error('source and output paths are required')
const src = realpathSync(path.resolve(source))
const dest = canonical(path.resolve(out))
if (contains(dest, src) || inputs.some(input => {
const protectedPath = canonical(path.join(src, input))
return contains(dest, protectedPath) || contains(protectedPath, dest)
})) throw new Error(`Output must not overlap build inputs: ${out}`)
// In-tree products have explicit homes; everything else under source is an
// input, even when this particular compiler does not read it.
const generated = developerOutput(src, dest)
|| ['.build', 'apps/desktop/build/products'].some(name => {
const root = path.join(src, name)
return dest !== root && contains(root, dest)
})
if (contains(src, dest) && !generated) throw new Error(`Output must be outside source or in a supported generated destination: ${out}`)
requireOwnedOutput(out, src)
return { source: src, out: dest }
}
// Windows refuses to move a tree while anything holds a handle inside it, and
// an antivirus scanner opens every freshly written exe. Node reports that as
// EPERM/EACCES/EBUSY; it clears in moments, so ride it out (~3s) rather than
// throw away a build that took minutes. Other codes never clear by waiting.
const heldCodes = new Set(['EPERM', 'EACCES', 'EBUSY'])
const heldRetryDelays = [100, 200, 400, 800, 1600]
export function retryHeld(operation) {
for (const delay of [...heldRetryDelays, undefined]) {
try {
return operation()
} catch (error) {
if (delay === undefined || !heldCodes.has(error?.code)) throw error
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
}
}
}
// Never delete the last successful product before a compiler succeeds. The
// backup lives beside out, outside withProduct's scratch tree, so a failed
// rollback leaves the previous product somewhere a person can recover it.
export function publishDirectory(staged, out, { source } = {}) {
// The destination may have been occupied while the compiler was running.
requireOwnedOutput(out, source)
writeFileSync(path.join(staged, productMarker), productOwner)
if (!existsSync(out)) return retryHeld(() => renameSync(staged, out))
const backupRoot = mkdtempSync(path.join(path.dirname(out), `.${path.basename(out)}-previous-`))
const backup = path.join(backupRoot, 'product')
try {
retryHeld(() => renameSync(out, backup))
} catch (error) {
rmSync(backupRoot, { recursive: true, force: true })
throw error
}
try {
retryHeld(() => renameSync(staged, out))
} catch (error) {
try {
retryHeld(() => renameSync(backup, out))
} catch (rollbackError) {
error.message += `; rollback failed (${rollbackError.message}); previous product kept at ${backup}`
throw error
}
rmSync(backupRoot, { recursive: true, force: true })
throw error
}
rmSync(backupRoot, { recursive: true, force: true })
}
export async function withProduct(out, compile, { source } = {}) {
requireOwnedOutput(out, source)
mkdirSync(path.dirname(out), { recursive: true })
const scratch = mkdtempSync(path.join(path.dirname(out), `.${path.basename(out)}-build-`))
const product = path.join(scratch, 'product')
mkdirSync(product)
try {
await compile(product, scratch)
publishDirectory(product, out, { source })
} finally {
await rmTree(scratch)
}
}
// macOS's Finder/Spotlight can drop a fresh .DS_Store (or xattr) into a
// directory while we're removing it, making rmSync throw ENOTEMPTY on the
// first pass. That file is cosmetic garbage — retry the removal so the race
// can't abort an otherwise-clean product build.
export async function rmTree(dir) {
const maxAttempts = 5
for (let attempt = 1; ; attempt++) {
try {
rmSync(dir, { recursive: true, force: true })
return
} catch (error) {
if (error?.code !== 'ENOTEMPTY' || attempt >= maxAttempts) throw error
// Give Finder a beat to finish writing/clear the directory.
await setTimeout(50)
}
}
}
export function isMain(url) {
return process.argv[1] && url === pathToFileURL(path.resolve(process.argv[1])).href
}
export function frontendArgs(args, extra = {}) {
const { values } = parseArgs({ args, options: { source: { type: 'string' }, out: { type: 'string' }, ...extra } })
if (!values.source || !values.out) throw new Error('--source and --out are required for product builds')
return values
}