From fc43d5f4b792ef965d239988c0928bb2b2ed97f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Apr 2026 13:51:53 +0000 Subject: [PATCH 1/4] Fix atomic provider cache writes Co-authored-by: Julius Marminge --- apps/server/src/atomicWrite.ts | 28 ++++++++++++ apps/server/src/keybindings.ts | 16 ++++--- .../src/provider/providerStatusCache.test.ts | 45 +++++++++++++++++++ .../src/provider/providerStatusCache.ts | 28 ++++-------- apps/server/src/serverRuntimeState.ts | 15 +++---- apps/server/src/serverSettings.ts | 11 +++-- 6 files changed, 101 insertions(+), 42 deletions(-) create mode 100644 apps/server/src/atomicWrite.ts diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts new file mode 100644 index 0000000000..928fb48d79 --- /dev/null +++ b/apps/server/src/atomicWrite.ts @@ -0,0 +1,28 @@ +import * as Crypto from "node:crypto"; + +import { Effect, FileSystem, Path } from "effect"; + +export const makeAtomicWriteTempPath = (filePath: string): string => + `${filePath}.${process.pid}.${Crypto.randomUUID()}.tmp`; + +export const writeFileStringAtomically = (input: { + readonly filePath: string; + readonly contents: string; +}) => { + const tempPath = makeAtomicWriteTempPath(input.filePath); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* fs.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fs.writeFileString(tempPath, input.contents); + yield* fs.rename(tempPath, input.filePath); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true })); + }), + ), + ); +}; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 9689254c17..165b2edeb0 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -46,6 +46,7 @@ import { } from "effect"; import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config.ts"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; type WhenToken = @@ -670,14 +671,17 @@ const makeKeybindings = Effect.gen(function* () { }); const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { - const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; - return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe( Effect.map((encoded) => `${encoded}\n`), - Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), - Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), - Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), - Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), + Effect.flatMap((encoded) => + writeFileStringAtomically({ + filePath: keybindingsConfigPath, + contents: encoded, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + ), Effect.mapError( (cause) => new KeybindingsConfigError({ diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index b0cb5bc663..2aab3a5c55 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import type { ServerProvider } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Effect, FileSystem } from "effect"; +import { afterEach, vi } from "vitest"; import { hydrateCachedProvider, @@ -27,6 +28,10 @@ const makeProvider = ( ...overrides, }); +afterEach(() => { + vi.restoreAllMocks(); +}); + it.layer(NodeServices.layer)("providerStatusCache", (it) => { it.effect("writes and reads provider status snapshots", () => Effect.gen(function* () { @@ -73,6 +78,46 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { }), ); + it.effect("supports overlapping writes for the same provider cache file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-overlap-" }); + const filePath = resolveProviderStatusCachePath({ + cacheDir: tempDir, + provider: "opencode", + }); + const stableNow = 1_776_841_093_506; + vi.spyOn(Date, "now").mockReturnValue(stableNow); + + const initialProvider = makeProvider("opencode", { + auth: { status: "unknown", type: "opencode" }, + }); + const updatedProvider = makeProvider("opencode", { + version: "1.0.1", + auth: { status: "unknown", type: "opencode" }, + }); + + const results = yield* Effect.all( + [ + Effect.exit(writeProviderStatusCache({ filePath, provider: initialProvider })), + Effect.exit(writeProviderStatusCache({ filePath, provider: updatedProvider })), + ], + { concurrency: "unbounded" }, + ); + + assert.strictEqual(results[0]._tag, "Success"); + assert.strictEqual(results[1]._tag, "Success"); + + const persistedProvider = yield* readProviderStatusCache(filePath); + assert.deepStrictEqual( + [initialProvider, updatedProvider].some((provider) => + JSON.stringify(provider) === JSON.stringify(persistedProvider), + ), + true, + ); + }), + ); + it("hydrates cached provider status while preserving current settings-derived models", () => { const cachedCodex = makeProvider("codex", { checkedAt: "2026-04-10T12:00:00.000Z", diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 369fca6218..fdb31eecbe 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -1,6 +1,8 @@ import * as nodePath from "node:path"; import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts"; -import { Cause, Effect, FileSystem, Path, Schema } from "effect"; +import { Cause, Effect, FileSystem, Schema } from "effect"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; export const PROVIDER_CACHE_IDS = [ "codex", @@ -96,22 +98,8 @@ export const readProviderStatusCache = (filePath: string) => export const writeProviderStatusCache = (input: { readonly filePath: string; readonly provider: ServerProvider; -}) => { - const tempPath = `${input.filePath}.${process.pid}.${Date.now()}.tmp`; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const encoded = `${JSON.stringify(input.provider, null, 2)}\n`; - - yield* fs.makeDirectory(path.dirname(input.filePath), { recursive: true }); - yield* fs.writeFileString(tempPath, encoded); - yield* fs.rename(tempPath, input.filePath); - }).pipe( - Effect.ensuring( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true })); - }), - ), - ); -}; +}) => + writeFileStringAtomically({ + filePath: input.filePath, + contents: `${JSON.stringify(input.provider, null, 2)}\n`, + }); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 569e4ac117..4b300f29c2 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -1,5 +1,6 @@ -import { Effect, FileSystem, Option, Path, Schema } from "effect"; +import { Effect, FileSystem, Option, Schema } from "effect"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; import { type ServerConfigShape } from "./config.ts"; import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; @@ -42,15 +43,9 @@ export const persistServerRuntimeState = (input: { readonly path: string; readonly state: PersistedServerRuntimeState; }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const tempPath = `${input.path}.${process.pid}.${Date.now()}.tmp`; - return yield* fs.makeDirectory(pathService.dirname(input.path), { recursive: true }).pipe( - Effect.flatMap(() => fs.writeFileString(tempPath, `${JSON.stringify(input.state)}\n`)), - Effect.flatMap(() => fs.rename(tempPath, input.path)), - Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), - ); + writeFileStringAtomically({ + filePath: input.path, + contents: `${JSON.stringify(input.state)}\n`, }); export const clearPersistedServerRuntimeState = (path: string) => diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index c47c442a86..f9db536887 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -39,6 +39,7 @@ import { Cause, } from "effect"; import * as Semaphore from "effect/Semaphore"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; import { ServerConfig } from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; @@ -233,14 +234,12 @@ const makeServerSettings = Effect.gen(function* () { const getSettingsFromCache = Cache.get(settingsCache, cacheKey); const writeSettingsAtomically = (settings: ServerSettings) => { - const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; - return Effect.succeed(`${JSON.stringify(sparseSettings, null, 2)}\n`).pipe( - Effect.tap(() => fs.makeDirectory(pathService.dirname(settingsPath), { recursive: true })), - Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), - Effect.flatMap(() => fs.rename(tempPath, settingsPath)), - Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), + return writeFileStringAtomically({ + filePath: settingsPath, + contents: `${JSON.stringify(sparseSettings, null, 2)}\n`, + }).pipe( Effect.mapError( (cause) => new ServerSettingsError({ From a950047294e4dc281c10d67b660e5a0eea20cc49 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Apr 2026 13:56:00 +0000 Subject: [PATCH 2/4] Fix server settings atomic write context Co-authored-by: Julius Marminge --- apps/server/src/provider/providerStatusCache.test.ts | 4 ++-- apps/server/src/serverSettings.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 2aab3a5c55..2ed3a0c470 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -110,8 +110,8 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { const persistedProvider = yield* readProviderStatusCache(filePath); assert.deepStrictEqual( - [initialProvider, updatedProvider].some((provider) => - JSON.stringify(provider) === JSON.stringify(persistedProvider), + [initialProvider, updatedProvider].some( + (provider) => JSON.stringify(provider) === JSON.stringify(persistedProvider), ), true, ); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index f9db536887..c2147a0f45 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -240,6 +240,8 @@ const makeServerSettings = Effect.gen(function* () { filePath: settingsPath, contents: `${JSON.stringify(sparseSettings, null, 2)}\n`, }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathService), Effect.mapError( (cause) => new ServerSettingsError({ From 7ff4da2f979857618884a9d70e6c7a1895e2ffc6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Apr 2026 14:45:31 +0000 Subject: [PATCH 3/4] Use Effect temp dirs for atomic writes Co-authored-by: Julius Marminge --- apps/server/src/atomicWrite.ts | 40 +++++++++++++++------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts index 928fb48d79..57c337c3c8 100644 --- a/apps/server/src/atomicWrite.ts +++ b/apps/server/src/atomicWrite.ts @@ -1,28 +1,24 @@ -import * as Crypto from "node:crypto"; - -import { Effect, FileSystem, Path } from "effect"; - -export const makeAtomicWriteTempPath = (filePath: string): string => - `${filePath}.${process.pid}.${Crypto.randomUUID()}.tmp`; +import { Effect, FileSystem, Path, Random } from "effect"; export const writeFileStringAtomically = (input: { readonly filePath: string; readonly contents: string; -}) => { - const tempPath = makeAtomicWriteTempPath(input.filePath); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; +}) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempFileId = yield* Random.nextUUIDv4; + const targetDirectory = path.dirname(input.filePath); + + yield* fs.makeDirectory(targetDirectory, { recursive: true }); + const tempDirectory = yield* fs.makeTempDirectoryScoped({ + directory: targetDirectory, + prefix: `${path.basename(input.filePath)}.`, + }); + const tempPath = path.join(tempDirectory, `${tempFileId}.tmp`); - yield* fs.makeDirectory(path.dirname(input.filePath), { recursive: true }); - yield* fs.writeFileString(tempPath, input.contents); - yield* fs.rename(tempPath, input.filePath); - }).pipe( - Effect.ensuring( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true })); - }), - ), + yield* fs.writeFileString(tempPath, input.contents); + yield* fs.rename(tempPath, input.filePath); + }), ); -}; From fddbf7672f5f7624eee1309349299e31ad6c81a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Apr 2026 14:47:45 +0000 Subject: [PATCH 4/4] Use Effect Random for atomic temp files Co-authored-by: Julius Marminge --- apps/server/src/atomicWrite.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts index 57c337c3c8..431b2f4a01 100644 --- a/apps/server/src/atomicWrite.ts +++ b/apps/server/src/atomicWrite.ts @@ -1,4 +1,5 @@ -import { Effect, FileSystem, Path, Random } from "effect"; +import { Effect, FileSystem, Path } from "effect"; +import * as Random from "effect/Random"; export const writeFileStringAtomically = (input: { readonly filePath: string;