| author | |
| committer | |
| log | 00eb8f6183ba4b36a429db08767031ad8ca9d81d |
| tree | e89800d856e5ae5d0b9157048b896aea22c055c6 |
| parent | 8d846660cecfc69ef4e192322fced29fa07f374c |
| signature | Signed by PGP key B5690EEEBB952194 |
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", () => { |
| 357 | 357 | ), |
| 358 | 358 | ); |
| 359 | 359 | |
| 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 | ||
| 360 | 388 | it.effect("migrates legacy implicit update channels to the runtime default", () => |
| 361 | 389 | withSettings( |
| 362 | 390 | Effect.gen(function* () { |
apps/desktop/src/settings/DesktopAppSettings.ts+17-3| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | type DesktopUpdateChannel, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 8 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; | |
| 8 | 9 | import * as Context from "effect/Context"; |
| 9 | 10 | import * as Crypto from "effect/Crypto"; |
| 10 | 11 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -130,6 +131,7 @@ const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSet |
| 130 | 131 | |
| 131 | 132 | const DesktopSettingsWriteOperation = Schema.Literals([ |
| 132 | 133 | "create-temporary-file-name", |
| 134 | "resolve-symlink", | |
| 133 | 135 | "encode-document", |
| 134 | 136 | "create-directory", |
| 135 | 137 | "write-temporary-file", |
| ... | ... | @@ -421,8 +423,20 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp |
| 421 | 423 | readonly defaultSettings: DesktopSettings; |
| 422 | 424 | readonly suffix: string; |
| 423 | 425 | }): 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`; | |
| 426 | 440 | const encoded = yield* encodeDesktopSettingsJson( |
| 427 | 441 | toDesktopSettingsDocument(input.settings, input.defaultSettings), |
| 428 | 442 | ).pipe( |
| ... | ... | @@ -455,7 +469,7 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp |
| 455 | 469 | }), |
| 456 | 470 | ), |
| 457 | 471 | ); |
| 458 | yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( | |
| 472 | yield* input.fileSystem.rename(tempPath, targetPath).pipe( | |
| 459 | 473 | Effect.mapError( |
| 460 | 474 | (cause) => |
| 461 | 475 | new DesktopSettingsWriteError({ |
apps/desktop/src/settings/DesktopClientSettings.test.ts+28| ... | ... | @@ -149,6 +149,34 @@ describe("DesktopClientSettings", () => { |
| 149 | 149 | ), |
| 150 | 150 | ); |
| 151 | 151 | |
| 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 | ||
| 152 | 180 | it.effect.each([ |
| 153 | 181 | { label: "permission", reason: "PermissionDenied" }, |
| 154 | 182 | { label: "I/O", reason: "Unknown" }, |
apps/desktop/src/settings/DesktopClientSettings.ts+17-3| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; |
| 2 | 2 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 3 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; | |
| 3 | 4 | import * as Context from "effect/Context"; |
| 4 | 5 | import * as Crypto from "effect/Crypto"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -41,6 +42,7 @@ export class DesktopClientSettingsReadError extends Schema.TaggedError<DesktopCl |
| 41 | 42 | |
| 42 | 43 | const DesktopClientSettingsWriteOperation = Schema.Literals([ |
| 43 | 44 | "create-temporary-file-name", |
| 45 | "resolve-symlink", | |
| 44 | 46 | "encode-document", |
| 45 | 47 | "create-directory", |
| 46 | 48 | "write-temporary-file", |
| ... | ... | @@ -126,8 +128,20 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { |
| 126 | 128 | readonly settings: ClientSettings; |
| 127 | 129 | readonly suffix: string; |
| 128 | 130 | }): 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`; | |
| 131 | 145 | const encoded = yield* encodeClientSettingsJson(input.settings).pipe( |
| 132 | 146 | Effect.mapError( |
| 133 | 147 | (cause) => |
| ... | ... | @@ -158,7 +172,7 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { |
| 158 | 172 | }), |
| 159 | 173 | ), |
| 160 | 174 | ); |
| 161 | yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( | |
| 175 | yield* input.fileSystem.rename(tempPath, targetPath).pipe( | |
| 162 | 176 | Effect.mapError( |
| 163 | 177 | (cause) => |
| 164 | 178 | new DesktopClientSettingsWriteError({ |
apps/server/src/atomicWrite.test.ts created+139| ... | ... | @@ -0,0 +1,139 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Path from "effect/Path"; | |
| 8 | import * as PlatformError from "effect/PlatformError"; | |
| 9 | ||
| 10 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | |
| 11 | ||
| 12 | it.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 | ||
| 106 | it.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 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | 3 | import * as Path from "effect/Path"; |
| 4 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; | |
| 4 | 5 | |
| 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 | */ | |
| 5 | 11 | export const writeFileStringAtomically = (input: { |
| 6 | 12 | readonly filePath: string; |
| 7 | 13 | readonly contents: string; |
| ... | ... | @@ -10,16 +16,17 @@ export const writeFileStringAtomically = (input: { |
| 10 | 16 | Effect.gen(function* () { |
| 11 | 17 | const fs = yield* FileSystem.FileSystem; |
| 12 | 18 | 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); | |
| 14 | 21 | |
| 15 | 22 | yield* fs.makeDirectory(targetDirectory, { recursive: true }); |
| 16 | 23 | const tempDirectory = yield* fs.makeTempDirectoryScoped({ |
| 17 | 24 | directory: targetDirectory, |
| 18 | prefix: `${path.basename(input.filePath)}.`, | |
| 25 | prefix: `${path.basename(targetPath)}.`, | |
| 19 | 26 | }); |
| 20 | 27 | const tempPath = path.join(tempDirectory, "contents.tmp"); |
| 21 | 28 | |
| 22 | 29 | yield* fs.writeFileString(tempPath, input.contents); |
| 23 | yield* fs.rename(tempPath, input.filePath); | |
| 30 | yield* fs.rename(tempPath, targetPath); | |
| 24 | 31 | }), |
| 25 | 32 | ); |
apps/server/src/serverSettings.test.ts+110| ... | ... | @@ -23,10 +23,12 @@ import * as Path from "effect/Path"; |
| 23 | 23 | import * as PlatformError from "effect/PlatformError"; |
| 24 | 24 | import * as Schema from "effect/Schema"; |
| 25 | 25 | import * as Stream from "effect/Stream"; |
| 26 | import * as TestClock from "effect/testing/TestClock"; | |
| 26 | 27 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 27 | 28 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 28 | 29 | import * as ServerConfig from "./config.ts"; |
| 29 | 30 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; |
| 31 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | |
| 30 | 32 | import * as ServerSettingsModule from "./serverSettings.ts"; |
| 31 | 33 | import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; |
| 32 | 34 | |
| ... | ... | @@ -131,6 +133,114 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 131 | 133 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 132 | 134 | ); |
| 133 | 135 | |
| 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 | ||
| 134 | 244 | it.effect("preserves context when reading a provider environment secret fails", () => { |
| 135 | 245 | const platformCause = PlatformError.systemError({ |
| 136 | 246 | _tag: "PermissionDenied", |
apps/server/src/serverSettings.ts+51-12| ... | ... | @@ -52,6 +52,7 @@ import * as Scope from "effect/Scope"; |
| 52 | 52 | import * as Stream from "effect/Stream"; |
| 53 | 53 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 54 | 54 | import { writeFileStringAtomically } from "./atomicWrite.ts"; |
| 55 | import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; | |
| 55 | 56 | import * as ServerConfig from "./config.ts"; |
| 56 | 57 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; |
| 57 | 58 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| ... | ... | @@ -1181,10 +1182,24 @@ const make = Effect.gen(function* () { |
| 1181 | 1182 | }), |
| 1182 | 1183 | ); |
| 1183 | 1184 | |
| 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 | ||
| 1184 | 1201 | const startWatcher = Effect.gen(function* () { |
| 1185 | 1202 | const settingsDir = pathService.dirname(settingsPath); |
| 1186 | const settingsFile = pathService.basename(settingsPath); | |
| 1187 | const settingsPathResolved = pathService.resolve(settingsPath); | |
| 1188 | 1203 | |
| 1189 | 1204 | yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( |
| 1190 | 1205 | Effect.mapError( |
| ... | ... | @@ -1199,19 +1214,43 @@ const make = Effect.gen(function* () { |
| 1199 | 1214 | |
| 1200 | 1215 | const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); |
| 1201 | 1216 | |
| 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 | ||
| 1202 | 1247 | // Debounce watch events so the file is fully written before we read it. |
| 1203 | 1248 | // Editors emit multiple events per save (truncate, write, rename) and |
| 1204 | 1249 | // `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))); | |
| 1215 | 1254 | |
| 1216 | 1255 | yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe( |
| 1217 | 1256 | Effect.ignoreCause({ log: true }), |
packages/shared/package.json+4| ... | ... | @@ -195,6 +195,10 @@ |
| 195 | 195 | "types": "./src/connectAuth.ts", |
| 196 | 196 | "import": "./src/connectAuth.ts" |
| 197 | 197 | }, |
| 198 | "./symlink": { | |
| 199 | "types": "./src/symlink.ts", | |
| 200 | "import": "./src/symlink.ts" | |
| 201 | }, | |
| 198 | 202 | "./path": { |
| 199 | 203 | "types": "./src/path.ts", |
| 200 | 204 | "import": "./src/path.ts" |
packages/shared/src/symlink.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as FileSystem from "effect/FileSystem"; | |
| 3 | import * as Option from "effect/Option"; | |
| 4 | import * as Path from "effect/Path"; | |
| 5 | import * as PlatformError from "effect/PlatformError"; | |
| 6 | ||
| 7 | const MAX_SYMLINK_HOPS = 40; | |
| 8 | ||
| 9 | const isNotFound = (error: PlatformError.PlatformError) => error.reason._tag === "NotFound"; | |
| 10 | ||
| 11 | const 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 | */ | |
| 22 | export 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 | }); |