-
Notifications
You must be signed in to change notification settings - Fork 1
Make generation safe under concurrent invocation against a shared outDir #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
KayleeWilliams
merged 3 commits into
main
from
KayleeWilliams/generation-is-racy-when-invoked-concurrently-aga
Jul 2, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| --- | ||
| "leadtype": patch | ||
| --- | ||
|
|
||
| Make generation safe to invoke concurrently against a shared `outDir`. | ||
|
|
||
| Parallel task graphs (lint, typecheck, and build each depending on "docs are | ||
| generated") used to race on the shared output directory, causing intermittent | ||
| partial reads, ENOENT on files another run had just replaced, and half-written | ||
| artifacts. | ||
|
|
||
| - Every generated artifact (converted `docs/*.md`, `llms.txt`, `llms-full.txt`, | ||
| search index, sitemaps, robots, feeds, MCP card, NLWeb, skills, sync | ||
| manifests) is now written to a temp sibling and atomically renamed into | ||
| place, so concurrent readers see the old content or the new content — never | ||
| a truncated file. | ||
| - Delete-then-recreate windows are gone: the agent-skills surface and mounted | ||
| markdown mirrors now write the new files first and prune stale ones after, | ||
| instead of `rm -rf`-ing a live directory before rebuilding it. | ||
| - `leadtype generate` runs are single-flight per output directory via a | ||
| cross-process lock stored under the system temp dir (keyed by the resolved | ||
| `--out` path). Concurrent invocations wait for the in-flight run. Abandoned | ||
| locks recover fast: interrupted runs (SIGINT/SIGTERM) release on the way | ||
| out, hard-killed runs are reclaimed as soon as their recorded pid is gone, | ||
| and unidentifiable locks are reclaimed after 10 minutes. Waiting runs fail | ||
| loudly after 15 minutes instead of hanging CI (`LEADTYPE_LOCK_TIMEOUT_MS` | ||
| overrides). Set `LEADTYPE_NO_LOCK=1` to opt out. Temp files leaked by a | ||
| hard-killed run are swept at the start of the next locked run. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import { | ||
| mkdir, | ||
| mkdtemp, | ||
| readdir, | ||
| readFile, | ||
| rm, | ||
| writeFile, | ||
| } from "node:fs/promises"; | ||
| import { tmpdir } from "node:os"; | ||
| import path from "node:path"; | ||
| import { afterEach, describe, expect, it } from "vitest"; | ||
| import { convertAllMdx } from "./convert"; | ||
|
|
||
| const tempDirs: string[] = []; | ||
|
|
||
| async function createTempProject(): Promise<string> { | ||
| const dir = await mkdtemp(path.join(tmpdir(), "leadtype-convert-race-")); | ||
| tempDirs.push(dir); | ||
| return dir; | ||
| } | ||
|
|
||
| afterEach(async () => { | ||
| await Promise.all( | ||
| tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })) | ||
| ); | ||
| }); | ||
|
|
||
| describe("convertAllMdx concurrency", () => { | ||
| it("keeps every output complete while concurrent runs share an outDir", async () => { | ||
| const dir = await createTempProject(); | ||
| const srcDir = path.join(dir, "docs"); | ||
| const outDir = path.join(dir, "public", "docs"); | ||
| const fileCount = 24; | ||
| const concurrentRuns = 3; | ||
| // Large enough that a truncating write would be observable mid-flight. | ||
| const filler = | ||
| "Some paragraph text that pads the document body.\n\n".repeat(200); | ||
|
|
||
| await mkdir(srcDir, { recursive: true }); | ||
| await Promise.all( | ||
| Array.from({ length: fileCount }, (_, index) => | ||
| writeFile( | ||
| path.join(srcDir, `doc-${index}.mdx`), | ||
| `---\ntitle: "Doc ${index}"\n---\n\n# Doc ${index}\n\n${filler}\nEND-OF-DOC-${index}\n` | ||
| ) | ||
| ) | ||
| ); | ||
|
|
||
| let runsSettled = false; | ||
| const runs = Promise.all( | ||
| Array.from({ length: concurrentRuns }, () => | ||
| convertAllMdx({ srcDir, outDir }) | ||
| ) | ||
| ).finally(() => { | ||
| runsSettled = true; | ||
| }); | ||
|
|
||
| // Concurrent reader modeling a sibling build step (tsc, next build) | ||
| // reading the shared output directory while generation is in flight: | ||
| // every successfully read file must be complete, and a file must never | ||
| // disappear once it has been observed. | ||
| const seen = new Set<number>(); | ||
| const reader = (async () => { | ||
| while (!runsSettled) { | ||
| for (let index = 0; index < fileCount; index++) { | ||
| const outputPath = path.join(outDir, `doc-${index}.md`); | ||
| try { | ||
| const content = await readFile(outputPath, "utf8"); | ||
| seen.add(index); | ||
| expect(content.startsWith("---")).toBe(true); | ||
| expect(content).toContain(`END-OF-DOC-${index}`); | ||
| } catch (error) { | ||
| expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); | ||
| expect(seen.has(index)).toBe(false); | ||
| } | ||
| } | ||
| } | ||
| })(); | ||
|
|
||
| await Promise.all([runs, reader]); | ||
|
|
||
| // Final state: every output present and complete, no temp files leaked. | ||
| const entries = await readdir(outDir); | ||
| expect(entries.sort()).toEqual( | ||
| Array.from({ length: fileCount }, (_, index) => `doc-${index}.md`).sort() | ||
| ); | ||
| for (let index = 0; index < fileCount; index++) { | ||
| const content = await readFile( | ||
| path.join(outDir, `doc-${index}.md`), | ||
| "utf8" | ||
| ); | ||
| expect(content).toContain(`END-OF-DOC-${index}`); | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a mount's
urlPrefixresolves inside its own source subtree, such aspathPrefix: "guides"withurlPrefix: "/docs/guides/public", leaving the mirror in place means the earlier glob oversourceDiralso sees files from the previous mirror. This keep-set then treats those mirror files as current and the copy step writes them underpublic/public/...on every run; the oldrm(targetDir)avoided that, so excludetargetDirfrom the source glob or reject nested targets before pruning.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 6b4dcc3. When the mirror target resolves inside its own source subtree, the target is now excluded from the source glob (
ignore: ["<target>/**"]), so a previous run's mirror output is never picked up as source content and re-mirrored intopublic/public/....