镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
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
5 changes: 5 additions & 0 deletions .changeset/generator-in-place-save-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-generator': patch
---

Avoid overwriting a route file with the scaffold template when an in-place save finishes while the generator is reading the file.
31 changes: 25 additions & 6 deletions packages/router-generator/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,7 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
strategy: {
type: 'mtime',
expectedMtimeMs: updatedCacheEntry.mtimeMs,
expectedContent: existingRouteFile.fileContent,
},
})
updatedCacheEntry.mtimeMs = stats.mtimeMs
Expand Down Expand Up @@ -1206,6 +1207,9 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
| {
type: 'mtime'
expectedMtimeMs: bigint
// An in-place save can truncate and rewrite a file within one mtime
// tick, so user-authored files also compare the content.
expectedContent?: string
}
| {
type: 'new-file'
Expand All @@ -1215,13 +1219,8 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
await this.fs.writeFile(tmpPath, opts.newContent)

if (opts.strategy.type === 'mtime') {
const { expectedMtimeMs, expectedContent } = opts.strategy
const beforeStat = await this.fs.stat(opts.filePath)
if (beforeStat.mtimeMs !== opts.strategy.expectedMtimeMs) {
throw rerun({
msg: `File ${opts.filePath} was modified by another process during processing.`,
event: { type: 'update', path: opts.filePath },
})
}
const newFileState = await this.fs.stat(tmpPath)
if (newFileState.mode !== beforeStat.mode) {
await this.fs.chmod(tmpPath, beforeStat.mode)
Expand All @@ -1247,6 +1246,25 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
}
}
}
// Check right before the rename so that changes made while the
// temporary file was prepared are not overwritten.
let changed: boolean
if (expectedContent === undefined) {
const current = await this.fs.stat(opts.filePath)
changed = current.mtimeMs !== expectedMtimeMs
} else {
const current = await this.fs.readFile(opts.filePath)
changed =
current === 'file-not-existing' ||
current.stat.mtimeMs !== expectedMtimeMs ||
current.fileContent !== expectedContent
}
if (changed) {
throw rerun({
msg: `File ${opts.filePath} was modified by another process during processing.`,
event: { type: 'update', path: opts.filePath },
})
}
} else {
if (await checkFileExists(opts.filePath)) {
throw rerun({
Expand Down Expand Up @@ -1370,6 +1388,7 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
strategy: {
type: 'mtime',
expectedMtimeMs: rootNodeFile.stat.mtimeMs,
expectedContent: rootNodeFile.fileContent,
},
})
updatedCacheEntry.fileContent = rootRouteContent
Expand Down
148 changes: 148 additions & 0 deletions packages/router-generator/tests/concurrent-write.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'

import { Generator, getConfig } from '../src'

const rootRouteSource = `import { createRootRoute } from '@tanstack/react-router'

export const Route = createRootRoute()
`

const postsRouteSource = `import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts')({
component: () => 'posts',
})
`

const savedPostsRouteSource = postsRouteSource.replace("'posts'", "'saved'")

let tempDir: string | undefined

afterEach(async () => {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true })
tempDir = undefined
}
})

async function readFileWithStat(filePath: string) {
try {
const fileHandle = await fs.open(filePath, 'r')
const stat = await fileHandle.stat({ bigint: true })
const fileContent = (await fileHandle.readFile()).toString()
await fileHandle.close()
return { stat, fileContent }
} catch (e: any) {
if (e?.code === 'ENOENT') {
return 'file-not-existing' as const
}
throw e
}
}

describe('generator with concurrent in-place saves', () => {
it.each(['route file read', 'temporary file stat'] as const)(
'does not scaffold over a route file whose in-place save completes during the %s',
async (completeSaveDuring) => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tsr-generator-race-'))
const routesDirectory = path.join(tempDir, 'routes')
const tmpDir = path.join(tempDir, 'tmp')
const postsRoutePath = path.join(routesDirectory, 'posts.tsx')
await fs.mkdir(routesDirectory)
await fs.writeFile(
path.join(routesDirectory, '__root.tsx'),
rootRouteSource,
)
await fs.writeFile(postsRoutePath, postsRouteSource)

let saveRacesNextRead = false
let completeSave: (() => Promise<void>) | undefined

const generator = new Generator({
config: getConfig(
{
disableLogging: true,
routesDirectory,
generatedRouteTree: path.join(tempDir, 'routeTree.gen.ts'),
tmpDir,
},
tempDir,
),
root: tempDir,
fs: {
rename: (oldPath, newPath) => fs.rename(oldPath, newPath),
writeFile: (filePath, content) => fs.writeFile(filePath, content),
readFile: async (filePath) => {
// The generator passes route paths with forward slashes, also on
// Windows.
if (
!saveRacesNextRead ||
path.resolve(filePath) !== postsRoutePath
) {
return readFileWithStat(filePath)
}
saveRacesNextRead = false

// An editor saving in place truncates the file before writing the
// new content, so the generator can observe an empty route file.
await fs.truncate(postsRoutePath)
const result = await readFileWithStat(postsRoutePath)
if (result === 'file-not-existing') {
throw new Error('expected the route file to exist')
}

// The save then completes within the same timestamp tick, which is
// common for filesystems with coarse mtime granularity.
completeSave = async () => {
completeSave = undefined
await fs.writeFile(postsRoutePath, savedPostsRouteSource)
const truncatedAt = new Date(Number(result.stat.mtimeMs))
await fs.utimes(postsRoutePath, truncatedAt, truncatedAt)
}
if (completeSaveDuring === 'route file read') {
await completeSave()
}

return result
},
stat: async (filePath) => {
if (completeSave && path.resolve(filePath).startsWith(tmpDir)) {
await completeSave()
}
const stat = await fs.stat(filePath, { bigint: true })
return {
mtimeMs: stat.mtimeMs,
mode: Number(stat.mode),
uid: Number(stat.uid),
gid: Number(stat.gid),
}
},
chmod: (filePath, mode) => fs.chmod(filePath, mode),
chown: (filePath, uid, gid) => fs.chown(filePath, uid, gid),
},
})

await generator.run()

// A first save makes the generator process the route file again, and the
// next save races with that read.
await fs.writeFile(
postsRoutePath,
postsRouteSource.replace("'posts'", "'edited'"),
)
const editedAt = new Date(Date.now() + 10_000)
await fs.utimes(postsRoutePath, editedAt, editedAt)
saveRacesNextRead = true
await generator.run({ type: 'update', path: postsRoutePath })

expect(saveRacesNextRead).toBe(false)
expect(completeSave).toBeUndefined()
await expect(fs.readFile(postsRoutePath, 'utf8')).resolves.toBe(
savedPostsRouteSource,
)
},
)
})
Loading