Skip to content
Open
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/hungry-pugs-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@0no-co/graphqlsp': patch
---

Fix intermittent `ENOENT: no such file or directory, unlink '<output>.d.ts.tmp'` failures when multiple plugin instances write the same `tadaOutputLocation`, e.g. several TS projects in a monorepo extending a shared tsconfig. The typings swap-file now has a unique name per process and write, so concurrent regenerations no longer clobber each other's swap-files, and swap-file cleanup no longer masks the original write error
44 changes: 23 additions & 21 deletions packages/graphqlsp/src/graphql/getSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,32 +38,34 @@ const touchFile = async (file: PathLike): Promise<void> => {
} catch (_error) {}
};

let swapFileCounter = 0;

/** Writes a file to a swapfile then moves it into place to prevent excess change events. */
export const swapWrite = async (
target: PathLike,
contents: string
): Promise<void> => {
if (!(await statFile(target, stat => stat.isFile()))) {
// If the file doesn't exist, we can write directly, and not
// try-catch so the error falls through
await fs.writeFile(target, contents);
} else {
// If the file exists, we write to a swap-file, then rename (i.e. move)
// the file into place. No try-catch around `writeFile` for proper
// directory/permission errors
const tempTarget = target + '.tmp';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still races on the first write: if the output doesn't exist yet, concurrent instances all take this direct writeFile path. Can we use the unique swap+rename path for missing targets too?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — done in 1358f0a. swapWrite now always writes to the unique swap-file and renames into place, for missing targets too; the direct-write branch is gone. Directory/permission errors still fall through since the swap-file lives in the same directory. Extended the unit test to run the 20-way concurrent write against both an existing and a missing target.

@macrozone macrozone Jul 9, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(above was written by fable, just to clarify)

for me it looks fine, but i am testing it currently in my large repo to see whether the issue is resolved

await fs.writeFile(tempTarget, contents);
try {
await fs.rename(tempTarget, target);
} catch (error) {
await fs.unlink(tempTarget);
throw error;
} finally {
// When we move the file into place, we also update its access and
// modification time manually, in case the rename doesn't trigger
// a change event
await touchFile(target);
}
// We write to a swap-file, then rename (i.e. move) the file into place.
// No try-catch around `writeFile` for proper directory/permission errors.
// The swap-file's name must be unique per process and call. Multiple
// plugin instances may target the same output file (e.g. several TS
// projects in a monorepo sharing one `tadaOutputLocation`), and a
// shared swap-file name would let one instance rename the file away
// while another still expects it, while direct writes to the target
// could interleave
const tempTarget = `${target}.${process.pid}.${swapFileCounter++}.tmp`;
await fs.writeFile(tempTarget, contents);
try {
await fs.rename(tempTarget, target);
} catch (error) {
// Clean-up is best-effort and must not mask the rename error
await fs.unlink(tempTarget).catch(() => {});
throw error;
} finally {
// When we move the file into place, we also update its access and
// modification time manually, in case the rename doesn't trigger
// a change event
await touchFile(target);
}
};

Expand Down
56 changes: 56 additions & 0 deletions test/unit/swapWrite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { swapWrite } from '../../packages/graphqlsp/src/graphql/getSchema';

let dir: string;

beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'graphqlsp-swap-write-'));
});

afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});

describe('swapWrite', () => {
it('writes a new file directly', async () => {
const target = path.join(dir, 'introspection.d.ts');
await swapWrite(target, 'contents');
expect(await fs.readFile(target, 'utf8')).toBe('contents');
});

it('replaces an existing file', async () => {
const target = path.join(dir, 'introspection.d.ts');
await fs.writeFile(target, 'old');
await swapWrite(target, 'new');
expect(await fs.readFile(target, 'utf8')).toBe('new');
});

// Multiple plugin instances may share one `tadaOutputLocation`, e.g.
// several TS projects in a monorepo extending the same tsconfig. See:
// https://github.com/0no-co/gql.tada/issues/571
it.each([
{ name: 'an existing target', createTarget: true },
{ name: 'a missing target', createTarget: false },
])('tolerates concurrent writes to $name', async ({ createTarget }) => {
const target = path.join(dir, 'introspection.d.ts');
if (createTarget) await fs.writeFile(target, 'initial');

const writers = Array.from({ length: 20 }, (_, index) =>
swapWrite(target, `contents-${index}`)
);
await expect(Promise.all(writers)).resolves.not.toThrow();

// The last rename wins, but the target must match one write in full
// and no swap-files may be left behind
const contents = await fs.readFile(target, 'utf8');
expect(contents).toMatch(/^contents-\d+$/);
const leftovers = (await fs.readdir(dir)).filter(file =>
file.endsWith('.tmp')
);
expect(leftovers).toEqual([]);
});
});