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 {...@@ -46,6 +46,7 @@ import {
46} from "effect";46} from "effect";
47import * as Semaphore from "effect/Semaphore";47import * as Semaphore from "effect/Semaphore";
48import { ServerConfig } from "./config.ts";48import { ServerConfig } from "./config.ts";
49import { writeFileStringAtomically } from "./atomicWrite.ts";
49import { fromLenientJson } from "@t3tools/shared/schemaJson";50import { fromLenientJson } from "@t3tools/shared/schemaJson";
5051
51type WhenToken =52type WhenToken =
...@@ -670,14 +671,17 @@ const makeKeybindings = Effect.gen(function* () {...@@ -670,14 +671,17 @@ const makeKeybindings = Effect.gen(function* () {
670 });671 });
671672
672 const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {673 const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {
673 const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;
674
675 return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe(674 return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe(
676 Effect.map((encoded) => `${encoded}\n`),675 Effect.map((encoded) => `${encoded}\n`),
677 Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })),676 Effect.flatMap((encoded) =>
678 Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),677 writeFileStringAtomically({
679 Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)),678 filePath: keybindingsConfigPath,
680 Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))),679 contents: encoded,
680 }).pipe(
681 Effect.provideService(FileSystem.FileSystem, fs),
682 Effect.provideService(Path.Path, path),
683 ),
684 ),
681 Effect.mapError(685 Effect.mapError(
682 (cause) =>686 (cause) =>
683 new KeybindingsConfigError({687 new KeybindingsConfigError({
apps/server/src/provider/providerStatusCache.ts+8-20
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import * as nodePath from "node:path";1import * as nodePath from "node:path";
2import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts";2import { 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
5export const PROVIDER_CACHE_IDS = [7export const PROVIDER_CACHE_IDS = [
6 "codex",8 "codex",
...@@ -96,22 +98,8 @@ export const readProviderStatusCache = (filePath: string) =>...@@ -96,22 +98,8 @@ export const readProviderStatusCache = (filePath: string) =>
96export const writeProviderStatusCache = (input: {98export const writeProviderStatusCache = (input: {
97 readonly filePath: string;99 readonly filePath: string;
98 readonly provider: ServerProvider;100 readonly provider: ServerProvider;
99}) => {101}) =>
100 const tempPath = `${input.filePath}.${process.pid}.${Date.now()}.tmp`;102 writeFileStringAtomically({
101 return Effect.gen(function* () {103 filePath: input.filePath,
102 const fs = yield* FileSystem.FileSystem;104 contents: `${JSON.stringify(input.provider, null, 2)}\n`,
103 const path = yield* Path.Path;105 });
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};
apps/server/src/serverRuntimeState.ts+5-10
...@@ -1,5 +1,6 @@...@@ -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";
3import { type ServerConfigShape } from "./config.ts";4import { type ServerConfigShape } from "./config.ts";
4import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts";5import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts";
56
...@@ -42,15 +43,9 @@ export const persistServerRuntimeState = (input: {...@@ -42,15 +43,9 @@ export const persistServerRuntimeState = (input: {
42 readonly path: string;43 readonly path: string;
43 readonly state: PersistedServerRuntimeState;44 readonly state: PersistedServerRuntimeState;
44}) =>45}) =>
45 Effect.gen(function* () {46 writeFileStringAtomically({
46 const fs = yield* FileSystem.FileSystem;47 filePath: input.path,
47 const pathService = yield* Path.Path;48 contents: `${JSON.stringify(input.state)}\n`,
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 );
54 });49 });
5550
56export const clearPersistedServerRuntimeState = (path: string) =>51export const clearPersistedServerRuntimeState = (path: string) =>
apps/server/src/serverSettings.ts+7-6
...@@ -39,6 +39,7 @@ import {...@@ -39,6 +39,7 @@ import {
39 Cause,39 Cause,
40} from "effect";40} from "effect";
41import * as Semaphore from "effect/Semaphore";41import * as Semaphore from "effect/Semaphore";
42import { writeFileStringAtomically } from "./atomicWrite.ts";
42import { ServerConfig } from "./config.ts";43import { ServerConfig } from "./config.ts";
43import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";44import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
44import { fromLenientJson } from "@t3tools/shared/schemaJson";45import { fromLenientJson } from "@t3tools/shared/schemaJson";
...@@ -233,14 +234,14 @@ const makeServerSettings = Effect.gen(function* () {...@@ -233,14 +234,14 @@ const makeServerSettings = Effect.gen(function* () {
233 const getSettingsFromCache = Cache.get(settingsCache, cacheKey);234 const getSettingsFromCache = Cache.get(settingsCache, cacheKey);
234235
235 const writeSettingsAtomically = (settings: ServerSettings) => {236 const writeSettingsAtomically = (settings: ServerSettings) => {
236 const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`;
237 const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {};237 const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {};
238238
239 return Effect.succeed(`${JSON.stringify(sparseSettings, null, 2)}\n`).pipe(239 return writeFileStringAtomically({
240 Effect.tap(() => fs.makeDirectory(pathService.dirname(settingsPath), { recursive: true })),240 filePath: settingsPath,
241 Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),241 contents: `${JSON.stringify(sparseSettings, null, 2)}\n`,
242 Effect.flatMap(() => fs.rename(tempPath, settingsPath)),242 }).pipe(
243 Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))),243 Effect.provideService(FileSystem.FileSystem, fs),
244 Effect.provideService(Path.Path, pathService),
244 Effect.mapError(245 Effect.mapError(
245 (cause) =>246 (cause) =>
246 new ServerSettingsError({247 new ServerSettingsError({