diff --git a/CONTEXT.md b/CONTEXT.md index 315b7b88..2b685da3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -111,6 +111,7 @@ Compilation flow: - `compileWebCSS` runs Lightning CSS with `UniwindCSSVisitor` and returns CSS. - `compileNativeCSS` runs `ProcessorBuilder`, serializes variables, scoped variables, and native stylesheet metadata into JS source. - `UniwindBundlerConfig.generateArtifacts` writes CSS artifacts and generated theme typings. +- Generated artifacts are rewritten in place and Metro regenerates them from a worker pool, so `buildCSS` and `buildDtsFile` write through `writeFileAtomicSync`: a unique temporary file next to the target, renamed over it. Readers racing the write see the whole old file or the whole new one, the rename breaks the package manager's hardlink into its content-addressable store instead of mutating the shared copy, and a rename a lock refuses is retried before it fails the build. - Internal package aliases such as `@/*` are only safe inside `packages/uniwind/src/bundler`. Bundler files are built and transformed to JS, but runtime/component/hook/HOC files are published directly as `.ts`/`.tsx` React Native entrypoints, so aliases in those files are not rewritten. Metro integration: diff --git a/packages/uniwind/src/bundler/artifacts/css/index.ts b/packages/uniwind/src/bundler/artifacts/css/index.ts index 420c421c..ac43b5e2 100644 --- a/packages/uniwind/src/bundler/artifacts/css/index.ts +++ b/packages/uniwind/src/bundler/artifacts/css/index.ts @@ -1,4 +1,5 @@ import fs from 'fs' +import { writeFileAtomicSync } from '../writeFileAtomic' import { EXTRA_UTILITIES_CSS } from './extraUtilities' import { INSETS_CSS } from './insets' import { OVERWRITE_CSS } from './overwrite' @@ -23,8 +24,5 @@ export const buildCSS = async (themes: Array, input: string, cssFilePath return } - fs.writeFileSync( - cssFilePath, - newCssFile, - ) + writeFileAtomicSync(cssFilePath, newCssFile) } diff --git a/packages/uniwind/src/bundler/artifacts/dts.ts b/packages/uniwind/src/bundler/artifacts/dts.ts index 2df073f4..2a054d4e 100644 --- a/packages/uniwind/src/bundler/artifacts/dts.ts +++ b/packages/uniwind/src/bundler/artifacts/dts.ts @@ -1,4 +1,5 @@ import fs from 'fs' +import { writeFileAtomicSync } from './writeFileAtomic' export const buildDtsFile = (dtsPath: string, stringifiedThemes: string) => { const oldDtsContent = fs.existsSync(dtsPath) @@ -22,5 +23,5 @@ export const buildDtsFile = (dtsPath: string, stringifiedThemes: string) => { return } - fs.writeFileSync(dtsPath, dtsContent) + writeFileAtomicSync(dtsPath, dtsContent) } diff --git a/packages/uniwind/src/bundler/artifacts/writeFileAtomic.ts b/packages/uniwind/src/bundler/artifacts/writeFileAtomic.ts new file mode 100644 index 00000000..021ec645 --- /dev/null +++ b/packages/uniwind/src/bundler/artifacts/writeFileAtomic.ts @@ -0,0 +1,52 @@ +import crypto from 'crypto' +import fs from 'fs' +import path from 'path' + +// A rename over an open file is refused while another process holds a lock on it, which +// on Windows covers an antivirus scanner or the search indexer touching the artifact we +// just wrote. Those locks clear in milliseconds. +const TRANSIENT_ERRORS = new Set(['EPERM', 'EACCES', 'EBUSY']) +const RETRIES = 5 +const RETRY_DELAY = 20 + +const sleepSync = (ms: number) => void Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) + +const renameSyncWithRetries = (tmpPath: string, filePath: string) => { + for (let attempt = 0;; attempt++) { + try { + return fs.renameSync(tmpPath, filePath) + } catch (error) { + const isTransient = TRANSIENT_ERRORS.has((error as NodeJS.ErrnoException).code ?? '') + + if (!isTransient || attempt === RETRIES) { + throw error + } + + sleepSync(RETRY_DELAY * 2 ** attempt) + } + } +} + +/** + * Writes `content` to a unique temporary file next to `filePath`, then renames it into + * place. Metro regenerates artifacts from a worker pool, so writing in place would let + * another worker read a half-written file. The rename also replaces the directory entry + * instead of writing through the hardlink package managers create from their store. + */ +export const writeFileAtomicSync = (filePath: string, content: string) => { + // A shared temporary name would only move the race between workers, which can be + // processes or threads, hence the random suffix on top of the pid. + const tmpPath = path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`, + ) + + try { + fs.writeFileSync(tmpPath, content) + renameSyncWithRetries(tmpPath, filePath) + } catch (error) { + fs.rmSync(tmpPath, { force: true }) + + throw error + } +} diff --git a/packages/uniwind/tests/web/bundler/write-file-atomic.test.ts b/packages/uniwind/tests/web/bundler/write-file-atomic.test.ts new file mode 100644 index 00000000..d5cd5fba --- /dev/null +++ b/packages/uniwind/tests/web/bundler/write-file-atomic.test.ts @@ -0,0 +1,264 @@ +// @vitest-environment node +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' +import { transformWithOxc } from 'vite' +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest' +import { buildCSS } from '../../../src/bundler/artifacts/css' +import { EXTRA_UTILITIES_CSS } from '../../../src/bundler/artifacts/css/extraUtilities' +import { INSETS_CSS } from '../../../src/bundler/artifacts/css/insets' +import { OVERWRITE_CSS } from '../../../src/bundler/artifacts/css/overwrite' +import { generateCSSForThemes } from '../../../src/bundler/artifacts/css/themes' +import { VARIANTS_CSS } from '../../../src/bundler/artifacts/css/variants' +import { buildDtsFile } from '../../../src/bundler/artifacts/dts' +import { writeFileAtomicSync } from '../../../src/bundler/artifacts/writeFileAtomic' + +const execFileAsync = promisify(execFile) + +const HELPER_PATH = path.resolve('./src/bundler/artifacts/writeFileAtomic.ts') +const CSS_ENTRY_FILE = './tests/test.css' +const THEMES = ['light', 'dark'] +const WORKERS = 4 +const WRITES_PER_WORKER = 60 +const READS_PER_WRITE = 10 + +// Shaped like the real artifact: big enough that a truncated write is observable, and +// ending with the `@theme` block that a partial read used to cut in half. +const buildContent = (id: number) => + [ + `/* uniwind artifact ${id} */`, + ...Array.from({ length: 2000 }, (_, index) => `.uniwind-${id}-${index} { color: red; }`), + '@theme {', + ` --uniwind-marker: ${id};`, + '}', + '', + ].join('\n') + +const CONTENTS = [0, 1, 2].map(buildContent) + +// Each worker rewrites the same path and keeps reading it, so every read races the other +// workers' writes. A read that is not byte-identical to one of the known contents is a +// torn read. +const WORKER_SCRIPT = ` +import fs from 'node:fs' +import { writeFileAtomicSync } from './writeFileAtomic.mjs' + +const [target, contentsPath, seed, writes, reads] = process.argv.slice(2) +const contents = JSON.parse(fs.readFileSync(contentsPath, 'utf-8')) +const expected = new Set(contents) +const fail = message => { + process.stdout.write(JSON.stringify({ error: message })) + process.exit(1) +} + +let observed = 0 + +for (let write = 0; write < Number(writes); write++) { + writeFileAtomicSync(target, contents[(Number(seed) + write) % contents.length]) + + for (let read = 0; read < Number(reads); read++) { + let content + + try { + content = fs.readFileSync(target, 'utf-8') + } catch (error) { + fail(\`read failed with \${error.code ?? error.message}\`) + } + + observed++ + + if (!expected.has(content)) { + fail(\`read \${content.length} bytes that match none of the written contents\`) + } + } +} + +process.stdout.write(JSON.stringify({ observed })) +` + +describe('writeFileAtomicSync', () => { + let workingDir = '' + + beforeAll(async () => { + workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniwind-atomic-')) + + // The workers run in their own processes, so they need the implementation as + // plain JS. Transpiling the real source keeps them on the shipped code. + const { code } = await transformWithOxc(fs.readFileSync(HELPER_PATH, 'utf-8'), HELPER_PATH) + + fs.writeFileSync(path.join(workingDir, 'writeFileAtomic.mjs'), code) + fs.writeFileSync(path.join(workingDir, 'worker.mjs'), WORKER_SCRIPT) + fs.writeFileSync(path.join(workingDir, 'contents.json'), JSON.stringify(CONTENTS)) + }) + + afterAll(() => { + fs.rmSync(workingDir, { recursive: true, force: true }) + }) + + test('concurrent writers and readers never observe a partial file', async () => { + const target = path.join(workingDir, 'uniwind.css') + const results = await Promise.all( + Array.from({ length: WORKERS }, (_, seed) => + execFileAsync(process.execPath, [ + path.join(workingDir, 'worker.mjs'), + target, + path.join(workingDir, 'contents.json'), + String(seed), + String(WRITES_PER_WORKER), + String(READS_PER_WRITE), + ]) + // A worker that saw a torn read exits with a non-zero code, which + // rejects here - its report is still on stdout. + .catch(error => error as { stdout?: string; stderr?: string }) + .then(({ stdout, stderr }) => + JSON.parse(stdout || JSON.stringify({ error: `worker crashed: ${stderr}` })) as { + observed?: number + error?: string + } + )), + ) + + expect(results.map(result => result.error).filter(Boolean)).toEqual([]) + expect(results.reduce((total, result) => total + (result.observed ?? 0), 0)).toBe( + WORKERS * WRITES_PER_WORKER * READS_PER_WRITE, + ) + expect(CONTENTS).toContain(fs.readFileSync(target, 'utf-8')) + // Every temporary file was renamed away, none were left behind. + expect(fs.readdirSync(workingDir).filter(entry => entry.endsWith('.tmp'))).toEqual([]) + }, 60_000) + + test('replaces the file instead of writing through a package manager hardlink', () => { + const storePath = path.join(workingDir, 'store.css') + const installedPath = path.join(workingDir, 'installed.css') + + fs.writeFileSync(storePath, 'store content') + fs.linkSync(storePath, installedPath) + + expect(fs.statSync(installedPath).nlink).toBe(2) + + writeFileAtomicSync(installedPath, 'rebuilt content') + + expect(fs.readFileSync(installedPath, 'utf-8')).toBe('rebuilt content') + expect(fs.readFileSync(storePath, 'utf-8')).toBe('store content') + expect(fs.statSync(installedPath).nlink).toBe(1) + }) + + describe('when a lock refuses the rename', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + const lockError = () => Object.assign(new Error('EPERM: operation not permitted, rename'), { code: 'EPERM' }) + + test('retries until the lock clears', () => { + const target = path.join(workingDir, 'locked-then-free.css') + const renameSync = fs.renameSync + const rename = vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => { + throw lockError() + }).mockImplementationOnce(() => { + throw lockError() + }).mockImplementation(renameSync) + + writeFileAtomicSync(target, 'written under a lock') + + expect(rename).toHaveBeenCalledTimes(3) + expect(fs.readFileSync(target, 'utf-8')).toBe('written under a lock') + expect(fs.readdirSync(workingDir).filter(entry => entry.endsWith('.tmp'))).toEqual([]) + }) + + test('gives up and cleans up when the lock never clears', () => { + const target = path.join(workingDir, 'locked-forever.css') + const rename = vi.spyOn(fs, 'renameSync').mockImplementation(() => { + throw lockError() + }) + + expect(() => writeFileAtomicSync(target, 'never written')).toThrow('EPERM') + expect(rename).toHaveBeenCalledTimes(6) + expect(fs.existsSync(target)).toBe(false) + expect(fs.readdirSync(workingDir).filter(entry => entry.endsWith('.tmp'))).toEqual([]) + }) + + test('does not retry an error that is not a lock', () => { + const target = path.join(workingDir, 'broken.css') + const rename = vi.spyOn(fs, 'renameSync').mockImplementation(() => { + throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' }) + }) + + expect(() => writeFileAtomicSync(target, 'never written')).toThrow('ENOSPC') + expect(rename).toHaveBeenCalledTimes(1) + expect(fs.readdirSync(workingDir).filter(entry => entry.endsWith('.tmp'))).toEqual([]) + }) + }) +}) + +describe('buildCSS', () => { + let workingDir = '' + + beforeAll(() => { + workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniwind-build-css-')) + }) + + afterAll(() => { + fs.rmSync(workingDir, { recursive: true, force: true }) + }) + + test('writes the artifact once and leaves it alone while it is up to date', async () => { + const cssFilePath = path.join(workingDir, 'uniwind.css') + + await buildCSS(THEMES, CSS_ENTRY_FILE, cssFilePath) + + const expected = [ + VARIANTS_CSS, + INSETS_CSS, + OVERWRITE_CSS, + EXTRA_UTILITIES_CSS, + await generateCSSForThemes(THEMES, CSS_ENTRY_FILE), + ].join('\n') + + expect(fs.readFileSync(cssFilePath, 'utf-8')).toBe(expected) + + // A rename swaps the inode, so an unchanged inode proves the warm build wrote + // nothing at all. + const inode = fs.statSync(cssFilePath).ino + + await buildCSS(THEMES, CSS_ENTRY_FILE, cssFilePath) + + expect(fs.statSync(cssFilePath).ino).toBe(inode) + expect(fs.readFileSync(cssFilePath, 'utf-8')).toBe(expected) + }, 60_000) +}) + +describe('buildDtsFile', () => { + let workingDir = '' + + beforeAll(() => { + workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniwind-dts-')) + }) + + afterAll(() => { + fs.rmSync(workingDir, { recursive: true, force: true }) + }) + + test('writes the declaration file once and leaves it alone while it is up to date', () => { + const dtsPath = path.join(workingDir, 'uniwind-types.d.ts') + + buildDtsFile(dtsPath, '[\'light\', \'dark\']') + + const content = fs.readFileSync(dtsPath, 'utf-8') + + expect(content).toContain('themes: readonly [\'light\', \'dark\']') + + const inode = fs.statSync(dtsPath).ino + + buildDtsFile(dtsPath, '[\'light\', \'dark\']') + + expect(fs.statSync(dtsPath).ino).toBe(inode) + + buildDtsFile(dtsPath, '[\'light\', \'dark\', \'sepia\']') + + expect(fs.statSync(dtsPath).ino).not.toBe(inode) + expect(fs.readFileSync(dtsPath, 'utf-8')).toContain('themes: readonly [\'light\', \'dark\', \'sepia\']') + }) +})