authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-10-03 18:16:23-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 17:16:23-05:00
log00eb8f6183ba4b36a429db08767031ad8ca9d81d
treee89800d856e5ae5d0b9157048b896aea22c055c6
parent8d846660cecfc69ef4e192322fced29fa07f374c
signature Signed by PGP key B5690EEEBB952194

fix(settings): symlinked settings files stay linked when saved (#15009)

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>

10 files changed, 453 insertions(+), 21 deletions(-)

apps/desktop/src/settings/DesktopAppSettings.test.ts+28
......@@ -357,6 +357,34 @@ describe("DesktopSettings", () => {
357357 ),
358358 );
359359
360 it.effect("saves through a symlinked settings file without replacing the link", () =>
361 withSettings(
362 Effect.gen(function* () {
363 const environment = yield* DesktopEnvironment.DesktopEnvironment;
364 const fileSystem = yield* FileSystem.FileSystem;
365 const settings = yield* DesktopAppSettings.DesktopAppSettings;
366 const dotfiles = yield* fileSystem.makeTempDirectoryScoped({
367 prefix: "t3-desktop-settings-dotfiles-",
368 });
369 const linkedSettingsPath = `${dotfiles}/desktop-settings.json`;
370 yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n");
371 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
372 yield* fileSystem.symlink(linkedSettingsPath, environment.desktopSettingsPath);
373
374 yield* settings.setServerExposureMode("network-accessible");
375
376 assert.equal(
377 yield* fileSystem.readLink(environment.desktopSettingsPath),
378 linkedSettingsPath,
379 );
380 const persisted = yield* decodeDesktopSettingsPatch(
381 yield* fileSystem.readFileString(linkedSettingsPath),
382 );
383 assert.equal(persisted.serverExposureMode, "network-accessible");
384 }),
385 ),
386 );
387
360388 it.effect("migrates legacy implicit update channels to the runtime default", () =>
361389 withSettings(
362390 Effect.gen(function* () {
apps/desktop/src/settings/DesktopAppSettings.ts+17-3
......@@ -5,6 +5,7 @@ import {
55 type DesktopUpdateChannel,
66} from "@t3tools/contracts";
77import { fromLenientJson } from "@t3tools/shared/schemaJson";
8import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
89import * as Context from "effect/Context";
910import * as Crypto from "effect/Crypto";
1011import * as Effect from "effect/Effect";
......@@ -130,6 +131,7 @@ const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSet
130131
131132const DesktopSettingsWriteOperation = Schema.Literals([
132133 "create-temporary-file-name",
134 "resolve-symlink",
133135 "encode-document",
134136 "create-directory",
135137 "write-temporary-file",
......@@ -421,8 +423,20 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp
421423 readonly defaultSettings: DesktopSettings;
422424 readonly suffix: string;
423425}): Effect.fn.Return<void, DesktopSettingsWriteError> {
424 const directory = input.path.dirname(input.settingsPath);
425 const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
426 const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe(
427 Effect.provideService(FileSystem.FileSystem, input.fileSystem),
428 Effect.provideService(Path.Path, input.path),
429 Effect.mapError(
430 (cause) =>
431 new DesktopSettingsWriteError({
432 operation: "resolve-symlink",
433 path: input.settingsPath,
434 cause,
435 }),
436 ),
437 );
438 const directory = input.path.dirname(targetPath);
439 const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`;
426440 const encoded = yield* encodeDesktopSettingsJson(
427441 toDesktopSettingsDocument(input.settings, input.defaultSettings),
428442 ).pipe(
......@@ -455,7 +469,7 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp
455469 }),
456470 ),
457471 );
458 yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe(
472 yield* input.fileSystem.rename(tempPath, targetPath).pipe(
459473 Effect.mapError(
460474 (cause) =>
461475 new DesktopSettingsWriteError({
apps/desktop/src/settings/DesktopClientSettings.test.ts+28
......@@ -149,6 +149,34 @@ describe("DesktopClientSettings", () => {
149149 ),
150150 );
151151
152 it.effect("saves through a symlinked client settings file without replacing the link", () =>
153 withClientSettings(
154 Effect.gen(function* () {
155 const environment = yield* DesktopEnvironment.DesktopEnvironment;
156 const fileSystem = yield* FileSystem.FileSystem;
157 const settings = yield* DesktopClientSettings.DesktopClientSettings;
158 const dotfiles = yield* fileSystem.makeTempDirectoryScoped({
159 prefix: "t3-desktop-client-settings-dotfiles-",
160 });
161 const linkedSettingsPath = `${dotfiles}/client-settings.json`;
162 yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n");
163 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
164 yield* fileSystem.symlink(linkedSettingsPath, environment.clientSettingsPath);
165
166 yield* settings.set(clientSettings);
167
168 assert.equal(
169 yield* fileSystem.readLink(environment.clientSettingsPath),
170 linkedSettingsPath,
171 );
172 assert.deepEqual(
173 yield* decodeClientSettingsJson(yield* fileSystem.readFileString(linkedSettingsPath)),
174 clientSettings,
175 );
176 }),
177 ),
178 );
179
152180 it.effect.each([
153181 { label: "permission", reason: "PermissionDenied" },
154182 { label: "I/O", reason: "Unknown" },
apps/desktop/src/settings/DesktopClientSettings.ts+17-3
......@@ -1,5 +1,6 @@
11import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
22import { fromLenientJson } from "@t3tools/shared/schemaJson";
3import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
34import * as Context from "effect/Context";
45import * as Crypto from "effect/Crypto";
56import * as Effect from "effect/Effect";
......@@ -41,6 +42,7 @@ export class DesktopClientSettingsReadError extends Schema.TaggedError<DesktopCl
4142
4243const DesktopClientSettingsWriteOperation = Schema.Literals([
4344 "create-temporary-file-name",
45 "resolve-symlink",
4446 "encode-document",
4547 "create-directory",
4648 "write-temporary-file",
......@@ -126,8 +128,20 @@ const writeClientSettings = Effect.fnUntraced(function* (input: {
126128 readonly settings: ClientSettings;
127129 readonly suffix: string;
128130}): Effect.fn.Return<void, DesktopClientSettingsWriteError> {
129 const directory = input.path.dirname(input.settingsPath);
130 const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
131 const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe(
132 Effect.provideService(FileSystem.FileSystem, input.fileSystem),
133 Effect.provideService(Path.Path, input.path),
134 Effect.mapError(
135 (cause) =>
136 new DesktopClientSettingsWriteError({
137 operation: "resolve-symlink",
138 path: input.settingsPath,
139 cause,
140 }),
141 ),
142 );
143 const directory = input.path.dirname(targetPath);
144 const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`;
131145 const encoded = yield* encodeClientSettingsJson(input.settings).pipe(
132146 Effect.mapError(
133147 (cause) =>
......@@ -158,7 +172,7 @@ const writeClientSettings = Effect.fnUntraced(function* (input: {
158172 }),
159173 ),
160174 );
161 yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe(
175 yield* input.fileSystem.rename(tempPath, targetPath).pipe(
162176 Effect.mapError(
163177 (cause) =>
164178 new DesktopClientSettingsWriteError({
apps/server/src/atomicWrite.test.ts created+139
......@@ -0,0 +1,139 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
9
10import { writeFileStringAtomically } from "./atomicWrite.ts";
11
12it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => {
13 it.effect("keeps a symlinked file linked and rewrites its destination", () =>
14 Effect.gen(function* () {
15 const fs = yield* FileSystem.FileSystem;
16 const path = yield* Path.Path;
17 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
18 const destination = path.join(root, "dotfiles", "settings.json");
19 const link = path.join(root, "home", "settings.json");
20 yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
21 yield* fs.makeDirectory(path.dirname(link), { recursive: true });
22 yield* fs.writeFileString(destination, "before");
23 yield* fs.symlink(destination, link);
24
25 yield* writeFileStringAtomically({ filePath: link, contents: "after" });
26
27 assert.strictEqual(yield* fs.readLink(link), destination);
28 assert.strictEqual(yield* fs.readFileString(destination), "after");
29 }),
30 );
31
32 it.effect("keeps a dangling symlink linked and creates its destination", () =>
33 Effect.gen(function* () {
34 const fs = yield* FileSystem.FileSystem;
35 const path = yield* Path.Path;
36 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
37 const destination = path.join(root, "dotfiles", "settings.json");
38 const link = path.join(root, "home", "settings.json");
39 yield* fs.makeDirectory(path.dirname(link), { recursive: true });
40 yield* fs.symlink(destination, link);
41
42 yield* writeFileStringAtomically({ filePath: link, contents: "fresh" });
43
44 assert.strictEqual(yield* fs.readLink(link), destination);
45 assert.strictEqual(yield* fs.readFileString(destination), "fresh");
46 }),
47 );
48
49 it.effect("fails on a symlink cycle without replacing either link", () =>
50 Effect.gen(function* () {
51 const fs = yield* FileSystem.FileSystem;
52 const path = yield* Path.Path;
53 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
54 const first = path.join(root, "first.json");
55 const second = path.join(root, "second.json");
56 yield* fs.symlink(second, first);
57 yield* fs.symlink(first, second);
58
59 const result = yield* Effect.exit(
60 writeFileStringAtomically({ filePath: first, contents: "after" }),
61 );
62
63 assert.isTrue(Exit.isFailure(result));
64 assert.strictEqual(yield* fs.readLink(first), second);
65 assert.strictEqual(yield* fs.readLink(second), first);
66 }),
67 );
68
69 it.effect("resolves a relative link through a symlinked parent directory", () =>
70 Effect.gen(function* () {
71 const fs = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
73 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
74 const destination = path.join(root, "dotfiles", "config", "settings.json");
75 const linkedState = path.join(root, "dotfiles", "state");
76 const home = path.join(root, "home");
77 const link = path.join(home, "state", "settings.json");
78 yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
79 yield* fs.makeDirectory(linkedState, { recursive: true });
80 yield* fs.makeDirectory(home, { recursive: true });
81 yield* fs.symlink(linkedState, path.join(home, "state"));
82 yield* fs.writeFileString(destination, "before");
83 yield* fs.symlink("../config/settings.json", link);
84
85 yield* writeFileStringAtomically({ filePath: link, contents: "after" });
86
87 assert.strictEqual(yield* fs.readLink(link), "../config/settings.json");
88 assert.strictEqual(yield* fs.readFileString(destination), "after");
89 }),
90 );
91
92 it.effect("creates a missing file and its directory", () =>
93 Effect.gen(function* () {
94 const fs = yield* FileSystem.FileSystem;
95 const path = yield* Path.Path;
96 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
97 const filePath = path.join(root, "nested", "settings.json");
98
99 yield* writeFileStringAtomically({ filePath, contents: "fresh" });
100
101 assert.strictEqual(yield* fs.readFileString(filePath), "fresh");
102 }),
103 );
104});
105
106it.effect("surfaces an unreadable link instead of writing over it", () =>
107 Effect.gen(function* () {
108 const readLinkFailure = PlatformError.systemError({
109 _tag: "Unknown",
110 module: "FileSystem",
111 method: "readLink",
112 pathOrDescriptor: "/home/settings.json",
113 });
114
115 const result = yield* Effect.exit(
116 writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }),
117 );
118
119 assert.deepStrictEqual(result, Exit.fail(readLinkFailure));
120 }).pipe(
121 Effect.provide(
122 Layer.mergeAll(
123 Path.layer,
124 FileSystem.layerNoop({
125 readLink: () =>
126 Effect.fail(
127 PlatformError.systemError({
128 _tag: "Unknown",
129 module: "FileSystem",
130 method: "readLink",
131 pathOrDescriptor: "/home/settings.json",
132 }),
133 ),
134 rename: () => Effect.die("an unreadable link must not be replaced"),
135 }),
136 ),
137 ),
138 ),
139);
apps/server/src/atomicWrite.ts+10-3
......@@ -1,7 +1,13 @@
11import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
33import * as Path from "effect/Path";
4import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
45
6/**
7 * Replaces a file's contents via a sibling temp file and rename. A symlinked
8 * target is resolved first so the link survives and its destination is
9 * rewritten, since renaming over the link itself would swap it for a regular file.
10 */
511export const writeFileStringAtomically = (input: {
612 readonly filePath: string;
713 readonly contents: string;
......@@ -10,16 +16,17 @@ export const writeFileStringAtomically = (input: {
1016 Effect.gen(function* () {
1117 const fs = yield* FileSystem.FileSystem;
1218 const path = yield* Path.Path;
13 const targetDirectory = path.dirname(input.filePath);
19 const targetPath = yield* resolveSymlinkTarget(input.filePath);
20 const targetDirectory = path.dirname(targetPath);
1421
1522 yield* fs.makeDirectory(targetDirectory, { recursive: true });
1623 const tempDirectory = yield* fs.makeTempDirectoryScoped({
1724 directory: targetDirectory,
18 prefix: `${path.basename(input.filePath)}.`,
25 prefix: `${path.basename(targetPath)}.`,
1926 });
2027 const tempPath = path.join(tempDirectory, "contents.tmp");
2128
2229 yield* fs.writeFileString(tempPath, input.contents);
23 yield* fs.rename(tempPath, input.filePath);
30 yield* fs.rename(tempPath, targetPath);
2431 }),
2532 );
apps/server/src/serverSettings.test.ts+110
......@@ -23,10 +23,12 @@ import * as Path from "effect/Path";
2323import * as PlatformError from "effect/PlatformError";
2424import * as Schema from "effect/Schema";
2525import * as Stream from "effect/Stream";
26import * as TestClock from "effect/testing/TestClock";
2627import * as SqlClient from "effect/unstable/sql/SqlClient";
2728import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
2829import * as ServerConfig from "./config.ts";
2930import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
31import { writeFileStringAtomically } from "./atomicWrite.ts";
3032import * as ServerSettingsModule from "./serverSettings.ts";
3133import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts";
3234
......@@ -131,6 +133,114 @@ it.layer(NodeServices.layer)("server settings", (it) => {
131133 }).pipe(Effect.provide(makeServerSettingsLayer())),
132134 );
133135
136 it.effect("saves through a symlinked settings file without replacing the link", () =>
137 Effect.gen(function* () {
138 const config = yield* ServerConfig.ServerConfig;
139 const fs = yield* FileSystem.FileSystem;
140 const path = yield* Path.Path;
141 const service = yield* ServerSettingsModule.ServerSettingsService;
142 const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" });
143 const linkedSettingsPath = path.join(dotfiles, "settings.json");
144 yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`);
145 yield* fs.remove(config.settingsPath, { force: true });
146 yield* fs.symlink(linkedSettingsPath, config.settingsPath);
147
148 yield* service.updateSettings({ responseStreamingMode: "paragraph" });
149
150 assert.equal(yield* fs.readLink(config.settingsPath), linkedSettingsPath);
151 const persisted = yield* decodeServerSettingsJson(
152 yield* fs.readFileString(linkedSettingsPath),
153 );
154 assert.equal(persisted.responseStreamingMode, "paragraph");
155 }).pipe(Effect.provide(makeServerSettingsLayer())),
156 );
157
158 it.effect("reloads when the destination of a symlinked settings file changes", () =>
159 Effect.scoped(
160 Effect.gen(function* () {
161 const config = yield* ServerConfig.ServerConfig;
162 const fs = yield* FileSystem.FileSystem;
163 const path = yield* Path.Path;
164 const service = yield* ServerSettingsModule.ServerSettingsService;
165 const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" });
166 const linkedSettingsPath = path.join(dotfiles, "settings.json");
167 yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`);
168 yield* fs.remove(config.settingsPath, { force: true });
169 yield* fs.symlink(linkedSettingsPath, config.settingsPath);
170 yield* service.start;
171 const changes = yield* service.subscribeChanges;
172
173 yield* writeFileStringAtomically({
174 filePath: linkedSettingsPath,
175 contents: `{ "responseStreamingMode": "paragraph" }`,
176 });
177
178 const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds"));
179 assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph");
180 }),
181 ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())),
182 );
183
184 it.effect("follows a settings link that is repointed to another directory", () =>
185 Effect.scoped(
186 Effect.gen(function* () {
187 const config = yield* ServerConfig.ServerConfig;
188 const fs = yield* FileSystem.FileSystem;
189 const path = yield* Path.Path;
190 const service = yield* ServerSettingsModule.ServerSettingsService;
191 const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" });
192 const firstSettingsPath = path.join(dotfiles, "first", "settings.json");
193 const secondSettingsPath = path.join(dotfiles, "second", "settings.json");
194 yield* fs.makeDirectory(path.dirname(firstSettingsPath), { recursive: true });
195 yield* fs.makeDirectory(path.dirname(secondSettingsPath), { recursive: true });
196 yield* fs.writeFileString(firstSettingsPath, `{ "responseStreamingMode": "turn" }`);
197 yield* fs.writeFileString(secondSettingsPath, `{ "responseStreamingMode": "paragraph" }`);
198 yield* fs.remove(config.settingsPath, { force: true });
199 yield* fs.symlink(firstSettingsPath, config.settingsPath);
200 yield* service.start;
201
202 const repointChanges = yield* service.subscribeChanges;
203 yield* fs.remove(config.settingsPath);
204 yield* fs.symlink(secondSettingsPath, config.settingsPath);
205 const repointed = yield* repointChanges.pipe(Stream.runHead, Effect.timeout("2 seconds"));
206 assert.equal(Option.getOrUndefined(repointed)?.responseStreamingMode, "paragraph");
207
208 const editChanges = yield* service.subscribeChanges;
209 yield* writeFileStringAtomically({
210 filePath: secondSettingsPath,
211 contents: `{ "responseStreamingMode": "turn" }`,
212 });
213 const edited = yield* editChanges.pipe(Stream.runHead, Effect.timeout("2 seconds"));
214 assert.equal(Option.getOrUndefined(edited)?.responseStreamingMode, "turn");
215 }),
216 ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())),
217 );
218
219 it.effect("reloads when a dangling settings link gets its destination", () =>
220 Effect.scoped(
221 Effect.gen(function* () {
222 const config = yield* ServerConfig.ServerConfig;
223 const fs = yield* FileSystem.FileSystem;
224 const path = yield* Path.Path;
225 const service = yield* ServerSettingsModule.ServerSettingsService;
226 const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" });
227 const linkedSettingsPath = path.join(dotfiles, "not-yet", "settings.json");
228 yield* fs.remove(config.settingsPath, { force: true });
229 yield* fs.symlink(linkedSettingsPath, config.settingsPath);
230 yield* service.start;
231 const changes = yield* service.subscribeChanges;
232
233 yield* writeFileStringAtomically({
234 filePath: linkedSettingsPath,
235 contents: `{ "responseStreamingMode": "paragraph" }`,
236 });
237
238 const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds"));
239 assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph");
240 }),
241 ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())),
242 );
243
134244 it.effect("preserves context when reading a provider environment secret fails", () => {
135245 const platformCause = PlatformError.systemError({
136246 _tag: "PermissionDenied",
apps/server/src/serverSettings.ts+51-12
......@@ -52,6 +52,7 @@ import * as Scope from "effect/Scope";
5252import * as Stream from "effect/Stream";
5353import * as SqlClient from "effect/unstable/sql/SqlClient";
5454import { writeFileStringAtomically } from "./atomicWrite.ts";
55import { resolveSymlinkTarget } from "@t3tools/shared/symlink";
5556import * as ServerConfig from "./config.ts";
5657import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
5758import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
......@@ -1181,10 +1182,24 @@ const make = Effect.gen(function* () {
11811182 }),
11821183 );
11831184
1185 const watchFileChanges = (filePath: string) => {
1186 const directory = pathService.dirname(filePath);
1187 const fileName = pathService.basename(filePath);
1188 const resolvedFilePath = pathService.resolve(filePath);
1189 return fs
1190 .watch(directory)
1191 .pipe(
1192 Stream.filter(
1193 (event) =>
1194 event.path === fileName ||
1195 event.path === filePath ||
1196 pathService.resolve(directory, event.path) === resolvedFilePath,
1197 ),
1198 );
1199 };
1200
11841201 const startWatcher = Effect.gen(function* () {
11851202 const settingsDir = pathService.dirname(settingsPath);
1186 const settingsFile = pathService.basename(settingsPath);
1187 const settingsPathResolved = pathService.resolve(settingsPath);
11881203
11891204 yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe(
11901205 Effect.mapError(
......@@ -1199,19 +1214,43 @@ const make = Effect.gen(function* () {
11991214
12001215 const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true }));
12011216
1217 // A symlinked settings file is rewritten in its destination's directory,
1218 // which a watch on the link's directory never sees. The link is resolved
1219 // again whenever it changes, so repointing it moves the watch along.
1220 const watchLinkTarget = Effect.gen(function* () {
1221 const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe(
1222 Effect.provideService(FileSystem.FileSystem, fs),
1223 Effect.provideService(Path.Path, pathService),
1224 );
1225 if (linkTargetPath === pathService.resolve(settingsPath)) {
1226 return Option.none<string>();
1227 }
1228 yield* fs
1229 .makeDirectory(pathService.dirname(linkTargetPath), { recursive: true })
1230 .pipe(Effect.ignore({ log: true }));
1231 return Option.some(linkTargetPath);
1232 }).pipe(Effect.orElseSucceed(() => Option.none<string>()));
1233
1234 const initialLinkTarget = yield* watchLinkTarget;
1235 const linkTargetEvents = Stream.make(initialLinkTarget).pipe(
1236 Stream.concat(watchFileChanges(settingsPath).pipe(Stream.mapEffect(() => watchLinkTarget))),
1237 Stream.changes,
1238 Stream.switchMap(
1239 Option.match({
1240 onNone: () => Stream.empty,
1241 onSome: (linkTargetPath) =>
1242 watchFileChanges(linkTargetPath).pipe(Stream.ignore({ log: true })),
1243 }),
1244 ),
1245 );
1246
12021247 // Debounce watch events so the file is fully written before we read it.
12031248 // Editors emit multiple events per save (truncate, write, rename) and
12041249 // `fs.watch` can fire before the content has been flushed to disk.
1205 const debouncedSettingsEvents = fs.watch(settingsDir).pipe(
1206 Stream.filter((event) => {
1207 return (
1208 event.path === settingsFile ||
1209 event.path === settingsPath ||
1210 pathService.resolve(settingsDir, event.path) === settingsPathResolved
1211 );
1212 }),
1213 Stream.debounce(Duration.millis(100)),
1214 );
1250 const debouncedSettingsEvents = Stream.merge(
1251 watchFileChanges(settingsPath),
1252 linkTargetEvents,
1253 ).pipe(Stream.debounce(Duration.millis(100)));
12151254
12161255 yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe(
12171256 Effect.ignoreCause({ log: true }),
packages/shared/package.json+4
......@@ -195,6 +195,10 @@
195195 "types": "./src/connectAuth.ts",
196196 "import": "./src/connectAuth.ts"
197197 },
198 "./symlink": {
199 "types": "./src/symlink.ts",
200 "import": "./src/symlink.ts"
201 },
198202 "./path": {
199203 "types": "./src/path.ts",
200204 "import": "./src/path.ts"
packages/shared/src/symlink.ts created+49
......@@ -0,0 +1,49 @@
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Option from "effect/Option";
4import * as Path from "effect/Path";
5import * as PlatformError from "effect/PlatformError";
6
7const MAX_SYMLINK_HOPS = 40;
8
9const isNotFound = (error: PlatformError.PlatformError) => error.reason._tag === "NotFound";
10
11const isNotASymlink = (error: PlatformError.PlatformError) =>
12 isNotFound(error) ||
13 (error.cause instanceof Error && "code" in error.cause && error.cause.code === "EINVAL");
14
15/**
16 * Follows a chain of symlinks to the file it finally names, which may not exist
17 * yet. Any path that is not a symlink resolves to itself. Atomic writers rename
18 * onto this path so a linked file keeps its link. Fails on a cycle, an overly
19 * long chain, or an unreadable link rather than handing back a link that a
20 * rename would replace.
21 */
22export const resolveSymlinkTarget = (filePath: string) =>
23 Effect.gen(function* () {
24 const fs = yield* FileSystem.FileSystem;
25 const path = yield* Path.Path;
26 let current = path.resolve(filePath);
27 for (let hop = 0; hop < MAX_SYMLINK_HOPS; hop++) {
28 const link = yield* fs.readLink(current).pipe(
29 Effect.map(Option.some),
30 Effect.catchIf(isNotASymlink, () => Effect.succeedNone),
31 );
32 if (Option.isNone(link)) {
33 return current;
34 }
35 // A relative target is relative to where the link really lives, which
36 // differs from its lexical parent when that parent is itself a symlink.
37 const linkDirectory = yield* fs
38 .realPath(path.dirname(current))
39 .pipe(Effect.catchIf(isNotFound, () => Effect.succeed(path.dirname(current))));
40 current = path.resolve(linkDirectory, link.value);
41 }
42 return yield* PlatformError.systemError({
43 _tag: "Unknown",
44 module: "FileSystem",
45 method: "readLink",
46 description: "Too many levels of symbolic links",
47 pathOrDescriptor: filePath,
48 });
49 });