authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-22 21:33:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-23 00:33:16-04:00
loge25db3a572a6718a1f3c5a64529395cc88cd5249
treef40153716283936e95729073be4d9c006ba8cd43
parentb8305afa29309e52045987caab91db9b7e481ac0
signature Signed by PGP key B5690EEEBB952194

Fix provider cache atomic write temp path collisions (#2291)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>

5 files changed, 55 insertions(+), 42 deletions(-)

apps/server/src/atomicWrite.ts created+25
......@@ -0,0 +1,25 @@
1import { Effect, FileSystem, Path } from "effect";
2import * as Random from "effect/Random";
3
4export const writeFileStringAtomically = (input: {
5 readonly filePath: string;
6 readonly contents: string;
7}) =>
8 Effect.scoped(
9 Effect.gen(function* () {
10 const fs = yield* FileSystem.FileSystem;
11 const path = yield* Path.Path;
12 const tempFileId = yield* Random.nextUUIDv4;
13 const targetDirectory = path.dirname(input.filePath);
14
15 yield* fs.makeDirectory(targetDirectory, { recursive: true });
16 const tempDirectory = yield* fs.makeTempDirectoryScoped({
17 directory: targetDirectory,
18 prefix: `${path.basename(input.filePath)}.`,
19 });
20 const tempPath = path.join(tempDirectory, `${tempFileId}.tmp`);
21
22 yield* fs.writeFileString(tempPath, input.contents);
23 yield* fs.rename(tempPath, input.filePath);
24 }),
25 );
apps/server/src/keybindings.ts+10-6
......@@ -46,6 +46,7 @@ import {
4646} from "effect";
4747import * as Semaphore from "effect/Semaphore";
4848import { ServerConfig } from "./config.ts";
49import { writeFileStringAtomically } from "./atomicWrite.ts";
4950import { fromLenientJson } from "@t3tools/shared/schemaJson";
5051
5152type WhenToken =
......@@ -670,14 +671,17 @@ const makeKeybindings = Effect.gen(function* () {
670671 });
671672
672673 const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {
673 const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;
674
675674 return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe(
676675 Effect.map((encoded) => `${encoded}\n`),
677 Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })),
678 Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),
679 Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)),
680 Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))),
676 Effect.flatMap((encoded) =>
677 writeFileStringAtomically({
678 filePath: keybindingsConfigPath,
679 contents: encoded,
680 }).pipe(
681 Effect.provideService(FileSystem.FileSystem, fs),
682 Effect.provideService(Path.Path, path),
683 ),
684 ),
681685 Effect.mapError(
682686 (cause) =>
683687 new KeybindingsConfigError({
apps/server/src/provider/providerStatusCache.ts+8-20
......@@ -1,6 +1,8 @@
11import * as nodePath from "node:path";
22import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts";
3import { Cause, Effect, FileSystem, Path, Schema } from "effect";
3import { Cause, Effect, FileSystem, Schema } from "effect";
4
5import { writeFileStringAtomically } from "../atomicWrite.ts";
46
57export const PROVIDER_CACHE_IDS = [
68 "codex",
......@@ -96,22 +98,8 @@ export const readProviderStatusCache = (filePath: string) =>
9698export const writeProviderStatusCache = (input: {
9799 readonly filePath: string;
98100 readonly provider: ServerProvider;
99}) => {
100 const tempPath = `${input.filePath}.${process.pid}.${Date.now()}.tmp`;
101 return Effect.gen(function* () {
102 const fs = yield* FileSystem.FileSystem;
103 const path = yield* Path.Path;
104 const encoded = `${JSON.stringify(input.provider, null, 2)}\n`;
105
106 yield* fs.makeDirectory(path.dirname(input.filePath), { recursive: true });
107 yield* fs.writeFileString(tempPath, encoded);
108 yield* fs.rename(tempPath, input.filePath);
109 }).pipe(
110 Effect.ensuring(
111 Effect.gen(function* () {
112 const fs = yield* FileSystem.FileSystem;
113 yield* fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }));
114 }),
115 ),
116 );
117};
101}) =>
102 writeFileStringAtomically({
103 filePath: input.filePath,
104 contents: `${JSON.stringify(input.provider, null, 2)}\n`,
105 });
apps/server/src/serverRuntimeState.ts+5-10
......@@ -1,5 +1,6 @@
1import { Effect, FileSystem, Option, Path, Schema } from "effect";
1import { Effect, FileSystem, Option, Schema } from "effect";
22
3import { writeFileStringAtomically } from "./atomicWrite.ts";
34import { type ServerConfigShape } from "./config.ts";
45import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts";
56
......@@ -42,15 +43,9 @@ export const persistServerRuntimeState = (input: {
4243 readonly path: string;
4344 readonly state: PersistedServerRuntimeState;
4445}) =>
45 Effect.gen(function* () {
46 const fs = yield* FileSystem.FileSystem;
47 const pathService = yield* Path.Path;
48 const tempPath = `${input.path}.${process.pid}.${Date.now()}.tmp`;
49 return yield* fs.makeDirectory(pathService.dirname(input.path), { recursive: true }).pipe(
50 Effect.flatMap(() => fs.writeFileString(tempPath, `${JSON.stringify(input.state)}\n`)),
51 Effect.flatMap(() => fs.rename(tempPath, input.path)),
52 Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))),
53 );
46 writeFileStringAtomically({
47 filePath: input.path,
48 contents: `${JSON.stringify(input.state)}\n`,
5449 });
5550
5651export const clearPersistedServerRuntimeState = (path: string) =>
apps/server/src/serverSettings.ts+7-6
......@@ -39,6 +39,7 @@ import {
3939 Cause,
4040} from "effect";
4141import * as Semaphore from "effect/Semaphore";
42import { writeFileStringAtomically } from "./atomicWrite.ts";
4243import { ServerConfig } from "./config.ts";
4344import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
4445import { fromLenientJson } from "@t3tools/shared/schemaJson";
......@@ -233,14 +234,14 @@ const makeServerSettings = Effect.gen(function* () {
233234 const getSettingsFromCache = Cache.get(settingsCache, cacheKey);
234235
235236 const writeSettingsAtomically = (settings: ServerSettings) => {
236 const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`;
237237 const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {};
238238
239 return Effect.succeed(`${JSON.stringify(sparseSettings, null, 2)}\n`).pipe(
240 Effect.tap(() => fs.makeDirectory(pathService.dirname(settingsPath), { recursive: true })),
241 Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),
242 Effect.flatMap(() => fs.rename(tempPath, settingsPath)),
243 Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))),
239 return writeFileStringAtomically({
240 filePath: settingsPath,
241 contents: `${JSON.stringify(sparseSettings, null, 2)}\n`,
242 }).pipe(
243 Effect.provideService(FileSystem.FileSystem, fs),
244 Effect.provideService(Path.Path, pathService),
244245 Effect.mapError(
245246 (cause) =>
246247 new ServerSettingsError({