Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/server/src/atomicWrite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import { writeFileStringAtomically } from "./atomicWrite.ts";

it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => {
it.effect("keeps a symlinked file linked and rewrites its destination", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
const destination = path.join(root, "dotfiles", "settings.json");
const link = path.join(root, "home", "settings.json");
yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
yield* fs.makeDirectory(path.dirname(link), { recursive: true });
yield* fs.writeFileString(destination, "before");
yield* fs.symlink(destination, link);

yield* writeFileStringAtomically({ filePath: link, contents: "after" });

assert.strictEqual(yield* fs.readLink(link), destination);
assert.strictEqual(yield* fs.readFileString(destination), "after");
}),
);

it.effect("keeps a dangling symlink linked and creates its destination", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
const destination = path.join(root, "dotfiles", "settings.json");
const link = path.join(root, "home", "settings.json");
yield* fs.makeDirectory(path.dirname(link), { recursive: true });
yield* fs.symlink(destination, link);

yield* writeFileStringAtomically({ filePath: link, contents: "fresh" });

assert.strictEqual(yield* fs.readLink(link), destination);
assert.strictEqual(yield* fs.readFileString(destination), "fresh");
}),
);

it.effect("creates a missing file and its directory", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" });
const filePath = path.join(root, "nested", "settings.json");

yield* writeFileStringAtomically({ filePath, contents: "fresh" });

assert.strictEqual(yield* fs.readFileString(filePath), "fresh");
}),
);
});
33 changes: 30 additions & 3 deletions apps/server/src/atomicWrite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

const MAX_SYMLINK_HOPS = 40;

/**
* Follows a chain of symlinks to the file it finally names, which may not exist
* yet. Any path that is not a symlink resolves to itself.
*/
export const resolveSymlinkTarget = (filePath: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
let current = path.resolve(filePath);
for (let hop = 0; hop < MAX_SYMLINK_HOPS; hop++) {
const link = yield* fs.readLink(current).pipe(Effect.option);
if (link._tag === "None") {
return current;
}
current = path.resolve(path.dirname(current), link.value);
}
return current;
});

/**
* Replaces a file's contents via a sibling temp file and rename. A symlinked
* target is resolved first so the link survives and its destination is
* rewritten, since renaming over the link itself would swap it for a regular file.
*/
export const writeFileStringAtomically = (input: {
readonly filePath: string;
readonly contents: string;
Expand All @@ -10,16 +36,17 @@ export const writeFileStringAtomically = (input: {
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const targetDirectory = path.dirname(input.filePath);
const targetPath = yield* resolveSymlinkTarget(input.filePath);
const targetDirectory = path.dirname(targetPath);

yield* fs.makeDirectory(targetDirectory, { recursive: true });
const tempDirectory = yield* fs.makeTempDirectoryScoped({
directory: targetDirectory,
prefix: `${path.basename(input.filePath)}.`,
prefix: `${path.basename(targetPath)}.`,
});
const tempPath = path.join(tempDirectory, "contents.tmp");

yield* fs.writeFileString(tempPath, input.contents);
yield* fs.rename(tempPath, input.filePath);
yield* fs.rename(tempPath, targetPath);
Comment thread
yordis marked this conversation as resolved.
Resolved
}),
);
75 changes: 75 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import * as ServerConfig from "./config.ts";
import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
import { writeFileStringAtomically } from "./atomicWrite.ts";
import * as ServerSettingsModule from "./serverSettings.ts";
import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts";

Expand Down Expand Up @@ -131,6 +133,79 @@ it.layer(NodeServices.layer)("server settings", (it) => {
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("saves through a symlinked settings file without replacing the link", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const service = yield* ServerSettingsModule.ServerSettingsService;
const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" });
const linkedSettingsPath = path.join(dotfiles, "settings.json");
yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`);
yield* fs.remove(config.settingsPath, { force: true });
yield* fs.symlink(linkedSettingsPath, config.settingsPath);

yield* service.updateSettings({ responseStreamingMode: "paragraph" });

assert.equal(yield* fs.readLink(config.settingsPath), linkedSettingsPath);
const persisted = yield* decodeServerSettingsJson(
yield* fs.readFileString(linkedSettingsPath),
);
assert.equal(persisted.responseStreamingMode, "paragraph");
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("reloads when the destination of a symlinked settings file changes", () =>
Effect.scoped(
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const service = yield* ServerSettingsModule.ServerSettingsService;
const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" });
const linkedSettingsPath = path.join(dotfiles, "settings.json");
yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`);
yield* fs.remove(config.settingsPath, { force: true });
yield* fs.symlink(linkedSettingsPath, config.settingsPath);
yield* service.start;
const changes = yield* service.subscribeChanges;

yield* writeFileStringAtomically({
filePath: linkedSettingsPath,
contents: `{ "responseStreamingMode": "paragraph" }`,
});

const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds"));
assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph");
}),
).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())),
);

it.effect("reloads when a dangling settings link gets its destination", () =>
Effect.scoped(
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const service = yield* ServerSettingsModule.ServerSettingsService;
const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" });
const linkedSettingsPath = path.join(dotfiles, "not-yet", "settings.json");
yield* fs.remove(config.settingsPath, { force: true });
yield* fs.symlink(linkedSettingsPath, config.settingsPath);
yield* service.start;
const changes = yield* service.subscribeChanges;

yield* writeFileStringAtomically({
filePath: linkedSettingsPath,
contents: `{ "responseStreamingMode": "paragraph" }`,
});

const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds"));
assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph");
}),
).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())),
);

it.effect("preserves context when reading a provider environment secret fails", () => {
const platformCause = PlatformError.systemError({
_tag: "PermissionDenied",
Expand Down
50 changes: 37 additions & 13 deletions apps/server/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import * as Semaphore from "effect/Semaphore";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import { writeFileStringAtomically } from "./atomicWrite.ts";
import { resolveSymlinkTarget, writeFileStringAtomically } from "./atomicWrite.ts";
import * as ServerConfig from "./config.ts";
import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
Expand Down Expand Up @@ -1181,10 +1181,24 @@ const make = Effect.gen(function* () {
}),
);

const watchFileChanges = (filePath: string) => {
const directory = pathService.dirname(filePath);
const fileName = pathService.basename(filePath);
const resolvedFilePath = pathService.resolve(filePath);
return fs
.watch(directory)
.pipe(
Stream.filter(
(event) =>
event.path === fileName ||
event.path === filePath ||
pathService.resolve(directory, event.path) === resolvedFilePath,
),
);
};

const startWatcher = Effect.gen(function* () {
const settingsDir = pathService.dirname(settingsPath);
const settingsFile = pathService.basename(settingsPath);
const settingsPathResolved = pathService.resolve(settingsPath);

yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe(
Effect.mapError(
Expand All @@ -1199,19 +1213,29 @@ const make = Effect.gen(function* () {

const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true }));

// A symlinked settings file is rewritten in its destination's directory,
// which a watch on the link's directory never sees.
const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, pathService),
);
const isLinked = linkTargetPath !== pathService.resolve(settingsPath);
if (isLinked) {
yield* fs
.makeDirectory(pathService.dirname(linkTargetPath), { recursive: true })
.pipe(Effect.ignore({ log: true }));
}
const linkTargetEvents = isLinked
? watchFileChanges(linkTargetPath).pipe(Stream.ignore({ log: true }))
: Stream.empty;

// Debounce watch events so the file is fully written before we read it.
// Editors emit multiple events per save (truncate, write, rename) and
// `fs.watch` can fire before the content has been flushed to disk.
const debouncedSettingsEvents = fs.watch(settingsDir).pipe(
Stream.filter((event) => {
return (
event.path === settingsFile ||
event.path === settingsPath ||
pathService.resolve(settingsDir, event.path) === settingsPathResolved
);
}),
Stream.debounce(Duration.millis(100)),
);
const debouncedSettingsEvents = Stream.merge(
watchFileChanges(settingsPath),
linkTargetEvents,
).pipe(Stream.debounce(Duration.millis(100)));

yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe(
Effect.ignoreCause({ log: true }),
Expand Down
31 changes: 31 additions & 0 deletions docs/fork/0027-symlinked-settings-stay-linked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# 0027: Symlinked settings stay linked

- PR: [TrogonStack/t3code#73](https://github.com/TrogonStack/t3code/pull/73)
- Status: active

## What you can do now

- Keep T3 Code's settings in a dotfiles repository and link them into the T3
home. Changing a setting in the app writes through the link, so the
repository copy is always the one in use.
- Edit settings from the app as often as you like without the link quietly
turning into a standalone copy that the repository no longer sees.

## Why

Saving settings used to replace a linked file with a regular one. Nothing
failed and the app kept working, so the first sign was a later discovery that
the repository and the machine had drifted apart, with no record of which
changes happened where. Configuration that was supposed to be versioned had
stopped being versioned on the first save.

That silence is the reason to fix it rather than document it. A setup that
breaks loudly gets fixed on the spot; one that detaches without a trace costs
a reconciliation session weeks later, after the two copies have each picked up
changes the other lacks.

## Upstream considerations

A clean upstream submission. Writes still land atomically, nothing changes for
anyone who does not use links, and the behavior is what a user who links a
config file expects. Once an equivalent lands upstream, this entry goes.
2 changes: 2 additions & 0 deletions docs/fork/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ Each entry uses these sections:
active, [#38](https://github.com/TrogonStack/t3code/pull/38)
- **0025** [A test run leaves no processes behind](./0025-a-test-run-leaves-no-processes-behind.md)
active, [#57](https://github.com/TrogonStack/t3code/pull/57)
- **0027** [Symlinked settings stay linked](./0027-symlinked-settings-stay-linked.md)
active, [#73](https://github.com/TrogonStack/t3code/pull/73)
Loading