Skip to content

providers.json writes fail with EPERM on Windows when anything else has the file open #98

Description

@jonvanausdeln

In plain English: say you're on Windows with Positron open in two windows. You connect an AI provider for the first time -- paste your API key, click Connect. Instead of connecting, you get a raw error like:

EPERM: operation not permitted, rename
'C:\Users\you\.posit\ai\providers.json.tmp.9028'
-> 'C:\Users\you\.posit\ai\providers.json'

Nothing is wrong with your key. What happened is that the other window was reading the shared provider-settings file at the moment this window tried to replace it. On Windows, replacing a file that another process has open fails outright rather than waiting; on macOS and Linux the same operation just succeeds, which is why this is Windows-only. Clicking Connect again usually works, because the other reader has finished by then.

It's timing-dependent, so an individual user won't hit it often. But our Windows e2e lane -- which runs three Positron instances against one settings file -- hits it about 10% of the time, which is what surfaced this.


Where it is

packages/ai-config/src/node/mutate-config.ts, atomicWrite (~L185). It writes a temp file and then renames it over the target with a single, unretried fs.rename:

const tempPath = `${configPath}.tmp.${process.pid}`;
await fs.mkdir(dir, { recursive: true });
try {
    await fs.writeFile(tempPath, text, { encoding: "utf-8", mode: 0o644 });
    await fs.rename(tempPath, configPath);   // <-- EPERM here on Windows
} finally {
    try { await fs.unlink(tempPath); } catch { /* already renamed */ }
}

mutateProvidersConfig does hold a proper-lockfile lock, so it serializes writers. It does nothing about readers, and readers are what break the rename.

The competing reads are largely self-inflicted, so this reproduces even in a single process:

  • Positron's extensions/authentication/src/providerCatalog.ts calls refreshProviderCatalog() immediately after every mutation, which re-reads the file.
  • ai-config's own watcher (watch-catalog.ts L175) watches the parent directory and fires on providers.json, scheduling a 300ms-debounced re-read after every successful write.

So each write schedules reads that can land on top of the next write's rename.

Same pattern in ai-credentials

packages/ai-credentials/src/store/SingleFileStore.ts, writeStore() (~L318) has an identical unretried temp-file + fs.rename. That file holds the user's actual credentials, so the same contention can silently fail to save an API key. Worth fixing in the same pass, ideally via one shared helper.

Mechanism confirmed empirically

Windows 11, Node v24, against a scratch file:

Case Result
baseline, no open handles rename succeeded
open read handle on the destination rename failed, EPERM
open read handle on the source temp file rename succeeded
after the handle was released rename succeeded

Conclusions: an ordinary reader holding providers.json open is sufficient; "antivirus scanning the freshly written temp file" is ruled out as the mechanism; and a retry is a validated fix.

Scope of the failure

The connect aborts cleanly -- it does not leave half-saved state. In Positron's handleApiKeySave (extensions/authentication/src/configDialog.ts), the config write (onSave) runs before provider.storeKey(...), so an EPERM there aborts the whole operation and no credential is stored. The user-visible cost is a failed connect showing a raw Node error, recoverable by retrying.

Note the write only happens when the mutation actually changes bytes (see gotcha 1 below), so in practice this bites first-time provider setup rather than reconnecting an already-configured provider.

Reproducing it

Hold a read handle open on ~/.posit/ai/providers.json while a connect runs:

const fs = require('fs');
const fd = fs.openSync(process.env.HOME + '/.posit/ai/providers.json', 'r');
// ... run the connect ... then:
fs.closeSync(fd);

Two gotchas that make a naive repro silently useless:

  1. mutate-config.ts short-circuits before atomicWrite: if (output === raw) { return; }. If providers.json already contains the provider block the connect would write, there is no write, no rename, and no EPERM -- the attempt just succeeds and looks like it disproves the bug. Delete the provider's block first.
  2. The temp file is providers.json.tmp.${process.pid}, so every retry inside one process reuses the same filename. Watching the directory for new temp filenames yields exactly one sighting no matter how many attempts occur -- don't build trigger logic on distinct temp names.

Proposed fix

Retry the rename with bounded backoff on the Windows-transient errnos (EPERM, EACCES, EBUSY; consider ENOTEMPTY) -- roughly 5-10 attempts over a few hundred ms.

  • Keep the total budget well under mutate-config.ts's 10s stale lock timeout, so a retrying writer can never outlive its own lock.
  • Only retry those errnos; a genuine permanent permission error must still surface.
  • Prefer one shared helper used by both ai-config and ai-credentials over two copies.
  • Leave POSIX behaviour unchanged -- the first rename succeeds there, so the retry path is never entered. An errno check is enough; no need to gate on process.platform (and an errno check also covers network/container filesystems).
  • On final failure, throw an error naming the file and stating the write did not land, so callers can surface something better than a raw EPERM string.
  • Keep the finally { unlink(tempPath) } cleanup correct across the retry loop.

Consider additionally performing the post-write reads under the same lock, which would remove the self-inflicted collision rather than just tolerating it.

Tests

ai-config uses Vitest (packages/ai-config/src/__tests__/); extend the existing mutate-config.test.ts. Suggested cases:

  • rename fails EPERM twice then succeeds -> write lands, no error, temp file gone
  • rename fails for the whole budget -> throws, error names the path, temp file cleaned up
  • a non-transient error (e.g. EROFS) -> throws immediately, no retries
  • success on first attempt -> exactly one rename call (guards the POSIX no-op claim)

Inject the failure rather than stubbing globals: take an optional rename-like dependency defaulting to the real implementation, consistent with how the surrounding tests already fake the filesystem. Add equivalent coverage for SingleFileStore.writeStore().

Onset / context

Positron's Windows e2e test Posit Assistant Sign-in > anthropic-api - Sign in, send hello, sign out started failing 2026-08-25, win/electron only (~10%; every other platform 100% green). Example run: https://github.com/posit-dev/positron/actions/runs/33418599892

atomicWrite has not changed since 2026-08-10 (48988a4), so the onset correlates with Positron's ai-lib bump rather than a change to this function -- correlation, not a proven cause. Still present in a05f21e9, the submodule Positron main currently points at.

A temporary retry stopgap lives in Positron's e2e page object (posit-dev/positron#15830) so CI isn't blocked; it should be removed when this is fixed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions