authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 21:37:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 21:37:12-07:00
logeef36a67c37fa74905fc7feaa836e5a81daa8685
treee1834215f8fe70289bea56e22786d620c4636e26
parent3236d8ce73d06df91e30532d0a5fd03cb537de8e
signature Signed by PGP key B5690EEEBB952194

fix(server): caches and ids are written atomically (#16242)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

8 files changed, 126 insertions(+), 13 deletions(-)

apps/server/src/atomicWrite.test.ts+46
......@@ -137,3 +137,49 @@ it.effect("surfaces an unreadable link instead of writing over it", () =>
137137 ),
138138 ),
139139);
140
141it.effect("succeeds when the write lands but its temp directory cannot be removed", () =>
142 Effect.gen(function* () {
143 const renamed: Array<string> = [];
144 const removed: Array<string> = [];
145 const fileSystem = FileSystem.layerNoop({
146 // The target does not exist yet, so it is written in place.
147 readLink: (path) =>
148 Effect.fail(
149 PlatformError.systemError({
150 _tag: "NotFound",
151 module: "FileSystem",
152 method: "readLink",
153 pathOrDescriptor: path,
154 }),
155 ),
156 makeDirectory: () => Effect.void,
157 makeTempDirectory: () => Effect.succeed("/home/settings.json.abc123"),
158 writeFileString: () => Effect.void,
159 rename: (_from, to) => Effect.sync(() => void renamed.push(to)),
160 remove: (path) =>
161 Effect.sync(() => void removed.push(path)).pipe(
162 Effect.andThen(
163 Effect.fail(
164 PlatformError.systemError({
165 _tag: "PermissionDenied",
166 module: "FileSystem",
167 method: "remove",
168 pathOrDescriptor: path,
169 }),
170 ),
171 ),
172 ),
173 });
174
175 const result = yield* Effect.exit(
176 writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }).pipe(
177 Effect.provide(Layer.mergeAll(Path.layer, fileSystem)),
178 ),
179 );
180
181 assert.deepStrictEqual(result, Exit.void);
182 assert.deepStrictEqual(renamed, ["/home/settings.json"]);
183 assert.deepStrictEqual(removed, ["/home/settings.json.abc123"]);
184 }),
185);
apps/server/src/atomicWrite.ts+10-4
......@@ -20,10 +20,16 @@ export const writeFileStringAtomically = (input: {
2020 const targetDirectory = path.dirname(targetPath);
2121
2222 yield* fs.makeDirectory(targetDirectory, { recursive: true });
23 const tempDirectory = yield* fs.makeTempDirectoryScoped({
24 directory: targetDirectory,
25 prefix: `${path.basename(targetPath)}.`,
26 });
23 // The temp directory is cleanup, not part of the write: failing to remove
24 // it (a virus scanner holding it on Windows) must not fail a write that
25 // already landed.
26 const tempDirectory = yield* Effect.acquireRelease(
27 fs.makeTempDirectory({
28 directory: targetDirectory,
29 prefix: `${path.basename(targetPath)}.`,
30 }),
31 (directory) => fs.remove(directory, { recursive: true }).pipe(Effect.ignore({ log: true })),
32 );
2733 const tempPath = path.join(tempDirectory, "contents.tmp");
2834
2935 yield* fs.writeFileString(tempPath, input.contents);
apps/server/src/device/LocalDeviceHost.ts+6-1
......@@ -47,6 +47,7 @@ import { HttpClient } from "effect/http";
4747import * as ChildProcess from "effect/process/ChildProcess";
4848import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
4949
50import { writeFileStringAtomically } from "../atomicWrite.ts";
5051import * as ServerConfig from "../config.ts";
5152import * as ProcessRunner from "../processRunner.ts";
5253import * as DeviceHost from "./DeviceHost.ts";
......@@ -354,7 +355,11 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
354355 port: Number(new URL(hub.origin).port),
355356 entryPath: hubTool.entryPath,
356357 }).pipe(
357 Effect.flatMap((json) => fs.writeFileString(hubStatePath(), json)),
358 Effect.flatMap((contents) =>
359 writeFileStringAtomically({ filePath: hubStatePath(), contents }),
360 ),
361 Effect.provideService(FileSystem.FileSystem, fs),
362 Effect.provideService(Path.Path, path),
358363 Effect.ignore,
359364 );
360365
apps/server/src/provider/ModelManifest.ts+4-1
......@@ -30,6 +30,7 @@ import * as Schema from "effect/Schema";
3030import * as Semaphore from "effect/Semaphore";
3131import { HttpClient, HttpClientResponse } from "effect/http";
3232
33import { writeFileStringAtomically } from "../atomicWrite.ts";
3334import { ServerConfig } from "../config.ts";
3435import * as ServerSettings from "../serverSettings.ts";
3536import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
......@@ -411,7 +412,9 @@ export const make = Effect.gen(function* () {
411412 manifest = fetched;
412413 fetchedAtMs = now;
413414 yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe(
414 Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)),
415 Effect.flatMap((contents) => writeFileStringAtomically({ filePath: cachePath, contents })),
416 Effect.provideService(FileSystem.FileSystem, fileSystem),
417 Effect.provideService(Path.Path, path),
415418 Effect.ignoreCause,
416419 );
417420 return manifest;
apps/server/src/provider/OpenCodeServerLedger.ts+8-2
......@@ -8,6 +8,7 @@ import * as Path from "effect/Path";
88import * as Schema from "effect/Schema";
99import { ChildProcess, ChildProcessSpawner } from "effect/process";
1010
11import { writeFileStringAtomically } from "../atomicWrite.ts";
1112import * as ServerConfig from "../config.ts";
1213import { signalProcessGroup } from "../process/processGroup.ts";
1314
......@@ -245,8 +246,13 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: {
245246 stateDir: input.stateDir,
246247 owner,
247248 };
248 yield* fs.makeDirectory(directory, { recursive: true });
249 yield* fs.writeFileString(entryPath, yield* encodeEntry(entry));
249 yield* writeFileStringAtomically({
250 filePath: entryPath,
251 contents: yield* encodeEntry(entry),
252 }).pipe(
253 Effect.provideService(FileSystem.FileSystem, fs),
254 Effect.provideService(Path.Path, path),
255 );
250256 return fs
251257 .remove(entryPath, { force: true })
252258 .pipe(
apps/server/src/telemetry/Identify.test.ts+40
......@@ -6,6 +6,7 @@ import * as FileSystem from "effect/FileSystem";
66import * as Layer from "effect/Layer";
77import * as Logger from "effect/Logger";
88import * as Path from "effect/Path";
9import * as PlatformError from "effect/PlatformError";
910import * as References from "effect/References";
1011
1112import * as ServerConfig from "../config.ts";
......@@ -57,6 +58,45 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => {
5758 ),
5859 );
5960
61 it.effect("leaves no torn anonymous id behind when its write fails midway", () =>
62 Effect.gen(function* () {
63 const config = yield* ServerConfig.ServerConfig;
64 const fileSystem = yield* FileSystem.FileSystem;
65 const path = yield* Path.Path;
66 const tornWrites = FileSystem.FileSystem.of({
67 ...fileSystem,
68 writeFileString: (filePath, data, options) =>
69 fileSystem
70 .writeFileString(filePath, data.slice(0, Math.floor(data.length / 2)), options)
71 .pipe(
72 Effect.andThen(
73 Effect.fail(
74 PlatformError.systemError({
75 _tag: "WriteZero",
76 module: "FileSystem",
77 method: "writeFileString",
78 pathOrDescriptor: filePath,
79 }),
80 ),
81 ),
82 ),
83 });
84
85 const identifier = yield* Identify.getTelemetryIdentifierForHome(
86 path.join(config.baseDir, "home"),
87 ).pipe(Effect.provideService(FileSystem.FileSystem, tornWrites));
88
89 assert.isNull(identifier);
90 assert.isFalse(yield* fileSystem.exists(config.anonymousIdPath));
91 }).pipe(
92 Effect.provide(
93 ServerConfig.layerTest(process.cwd(), {
94 prefix: "t3-telemetry-identify-torn-",
95 }),
96 ),
97 ),
98 );
99
60100 it.effect.each([
61101 {
62102 login: "an API key",
apps/server/src/telemetry/Identify.ts+2-1
......@@ -8,6 +8,7 @@ import * as Path from "effect/Path";
88import * as PlatformError from "effect/PlatformError";
99import * as Schema from "effect/Schema";
1010
11import { writeFileStringAtomically } from "../atomicWrite.ts";
1112import * as ServerConfig from "../config.ts";
1213
1314/**
......@@ -240,7 +241,7 @@ const upsertAnonymousId = Effect.gen(function* () {
240241 }),
241242 ),
242243 );
243 yield* fileSystem.writeFileString(anonymousIdPath, anonymousId).pipe(
244 yield* writeFileStringAtomically({ filePath: anonymousIdPath, contents: anonymousId }).pipe(
244245 Effect.mapError(
245246 (cause) =>
246247 new TelemetryAnonymousIdPersistenceError({
apps/server/src/usage/UsageService.ts+10-4
......@@ -44,6 +44,7 @@ import * as Schema from "effect/Schema";
4444import * as Semaphore from "effect/Semaphore";
4545import { HttpClient, HttpClientResponse } from "effect/http";
4646
47import { writeFileStringAtomically } from "../atomicWrite.ts";
4748import * as ServerConfig from "../config.ts";
4849import { expandHomePath } from "../pathExpansion.ts";
4950import * as ServerSettings from "../serverSettings.ts";
......@@ -204,6 +205,11 @@ export const make = Effect.gen(function* () {
204205 const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");
205206 const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME);
206207 const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME);
208 const writeCacheFile = (filePath: string, contents: string) =>
209 writeFileStringAtomically({ filePath, contents }).pipe(
210 Effect.provideService(FileSystem.FileSystem, fileSystem),
211 Effect.provideService(Path.Path, path),
212 );
207213 let rates: RateTable = new Map();
208214 let ratesFetchedAtMs: number | null = null;
209215 let ratesStatus: UsagePricing["status"] = "unavailable";
......@@ -267,7 +273,7 @@ export const make = Effect.gen(function* () {
267273 ratesStatus = "fresh";
268274
269275 yield* encodeRatesCache({ fetchedAtMs: now, document: fetched }).pipe(
270 Effect.flatMap((serialized) => fileSystem.writeFileString(ratesCachePath, serialized)),
276 Effect.flatMap((contents) => writeCacheFile(ratesCachePath, contents)),
271277 Effect.ignoreCause,
272278 );
273279 });
......@@ -419,8 +425,8 @@ export const make = Effect.gen(function* () {
419425 );
420426
421427 const writeScanCache = makeScanCacheWriter();
422 // Scans with different windows can finish together; two writes interleaved
423 // in one file would corrupt it.
428 // Scans with different windows can finish together; serializing the writes
429 // keeps an older snapshot from landing after a newer one.
424430 const persistLock = yield* Semaphore.make(1);
425431
426432 const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () {
......@@ -432,7 +438,7 @@ export const make = Effect.gen(function* () {
432438 yield* Effect.sync(() =>
433439 writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }),
434440 ).pipe(
435 Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)),
441 Effect.flatMap((contents) => writeCacheFile(scanCachePath, contents)),
436442 // A cache we cannot write is a slower next start, not a failed read.
437443 Effect.catchCause(() =>
438444 Effect.sync(() => {