Skip to content

Commit fe842fb

Browse files
Merge branch 'main' into fix/issue-28141
2 parents 71d0959 + c8e8b58 commit fe842fb

736 files changed

Lines changed: 36470 additions & 5348 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/release.yml‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1482,7 +1482,18 @@ jobs:
14821482
14831483
git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml
14841484
git commit --no-verify -m "chore(release): prepare $RELEASE_TAG"
1485-
git push origin HEAD:main
1485+
1486+
# main moves while this job runs, so a rejected push rebases and retries.
1487+
for attempt in 1 2 3 4 5; do
1488+
if git push origin HEAD:main; then
1489+
exit 0
1490+
fi
1491+
echo "Push rejected (attempt $attempt). Rebasing onto latest main."
1492+
git pull --rebase origin main
1493+
done
1494+
1495+
echo "Could not push the version bump after 5 attempts." >&2
1496+
exit 1
14861497
14871498
announce_discord:
14881499
name: Announce release on Discord

‎apps/desktop/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@t3tools/desktop",
3-
"version": "0.1.8",
3+
"version": "0.1.9",
44
"private": true,
55
"type": "module",
66
"main": "dist-electron/boot.cjs",
@@ -37,6 +37,7 @@
3737
"acorn": "8.16.0",
3838
"cross-env": "^10.1.0",
3939
"electron-builder": "26.16.1",
40+
"jsdom": "^30.0.1",
4041
"tailwindcss": "^4.0.0",
4142
"vite-plus": "catalog:"
4243
},

‎apps/desktop/src/app/ClerkTokenStore.test.ts‎

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ import * as NodeFS from "node:fs";
33
import * as NodeOS from "node:os";
44
import * as NodePath from "node:path";
55
import { assert, describe, it } from "@effect/vitest";
6-
import { vi } from "vite-plus/test";
7-
8-
vi.mock("node:fs", { spy: true });
96

107
import { openClerkTokenStorage, type ClerkTokenStorage } from "./ClerkTokenStore.ts";
118

@@ -91,24 +88,6 @@ describePosix("ClerkTokenStore", () => {
9188
});
9289
});
9390

94-
it("removes the legacy token file when the private copy already exists", async () => {
95-
await withStateDir((stateDir) => {
96-
const directory = NodePath.join(stateDir, "clerk");
97-
const legacyPath = NodePath.join(stateDir, "clerk-tokens.json");
98-
const tokenPath = NodePath.join(directory, "clerk-tokens.json");
99-
NodeFS.mkdirSync(directory, { mode: 0o755 });
100-
writeLoose(legacyPath, "stale-credential");
101-
writeLoose(tokenPath, "kept-credential");
102-
103-
openClerkTokenStorage(stateDir, () => idleStorage());
104-
105-
assert.equal(NodeFS.existsSync(legacyPath), false);
106-
assert.equal(modeOf(directory), 0o700);
107-
assert.equal(modeOf(tokenPath), 0o600);
108-
assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "kept-credential");
109-
});
110-
});
111-
11291
it("resolves a relative state directory before opening storage", async () => {
11392
await withStateDir(async (stateDir) => {
11493
const relativeStateDir = NodePath.relative(process.cwd(), stateDir);
@@ -150,23 +129,6 @@ describePosix("ClerkTokenStore", () => {
150129
});
151130
});
152131

153-
it("migrates tokens when the legacy directory is on another filesystem", async () => {
154-
await withStateDir((stateDir) => {
155-
const legacyPath = NodePath.join(stateDir, "clerk-tokens.json");
156-
writeLoose(legacyPath, "existing-session");
157-
using _rename = vi.spyOn(NodeFS, "renameSync").mockImplementation(() => {
158-
throw Object.assign(new Error("cross-device link"), { code: "EXDEV" });
159-
});
160-
161-
openClerkTokenStorage(stateDir, idleStorage);
162-
163-
const tokenPath = NodePath.join(stateDir, "clerk", "clerk-tokens.json");
164-
assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "existing-session");
165-
assert.equal(modeOf(tokenPath), 0o600);
166-
assert.equal(NodeFS.existsSync(legacyPath), false);
167-
});
168-
});
169-
170132
it("does not follow a symlinked clerk directory or token file", async () => {
171133
await withStateDir(async (stateDir) => {
172134
const outsideDirectory = NodePath.join(stateDir, "outside-dir");

‎apps/desktop/src/app/DesktopApp.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as Layer from "effect/Layer";
77
import * as FileSystem from "effect/FileSystem";
88
import * as Path from "effect/Path";
99
import * as HttpClient from "effect/http/HttpClient";
10+
import { ChildProcessSpawner } from "effect/process";
1011
import * as NetService from "@t3tools/shared/Net";
1112
import * as Fiber from "effect/Fiber";
1213
import * as Option from "effect/Option";
@@ -213,6 +214,7 @@ it.effect.each([false, true])(
213214
Effect.provide(
214215
Layer.mergeAll(
215216
Layer.mock(BrowserImport.BrowserImport, {}),
217+
Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}),
216218
Layer.mock(DesktopBackendConfiguration.DesktopBackendConfiguration, {}),
217219
Layer.mock(DesktopCliCommand.DesktopCliCommand, {}),
218220
Layer.mock(DesktopClientSettings.DesktopClientSettings, {}),

‎apps/desktop/src/app/DesktopClerk.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ export const make = Effect.gen(function* () {
116116
yield* electronApp.quit;
117117
return yield* Effect.interrupt;
118118
}
119+
// Chromium opens these caches at the first event-loop turn, before ready.
120+
yield* DesktopUserData.clearStaleGpuCaches({
121+
userDataPath,
122+
electronVersion: process.versions.electron,
123+
});
119124

120125
const pendingSecondInstances: Array<readonly string[]> = [];
121126
let handleSecondInstance = (argv: readonly string[]) => {

‎apps/desktop/src/app/DesktopPreReadyFileSystem.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ export const make = FileSystem.makeNoop({
4949
syncFs("makeDirectory", path, () => {
5050
NodeFS.mkdirSync(path, { recursive: options?.recursive ?? false });
5151
}),
52+
remove: (path, options) =>
53+
syncFs("remove", path, () =>
54+
NodeFS.rmSync(path, {
55+
recursive: options?.recursive ?? false,
56+
force: options?.force ?? false,
57+
}),
58+
),
5259
writeFileString: (path, data, options) =>
5360
syncFs("writeFileString", path, () =>
5461
NodeFS.writeFileSync(path, data, { flag: options?.flag ?? "w" }),

‎apps/desktop/src/app/DesktopUserData.test.ts‎

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { assert, it } from "@effect/vitest";
33
import * as Effect from "effect/Effect";
44
import * as FileSystem from "effect/FileSystem";
55
import * as Path from "effect/Path";
6+
import * as PlatformError from "effect/PlatformError";
67

7-
import { resolveUserDataPath } from "./DesktopUserData.ts";
8+
import { clearStaleGpuCaches, resolveUserDataPath } from "./DesktopUserData.ts";
89

910
it.effect.each(["t3code", "T3 Code (Alpha)"])(
1011
"preserves Windows credential keys from %s without copying browser databases",
@@ -40,3 +41,100 @@ it.effect.each(["t3code", "T3 Code (Alpha)"])(
4041
);
4142
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
4243
);
44+
45+
const gpuCacheDirectories = [
46+
"GPUCache",
47+
"GrShaderCache",
48+
"ShaderCache",
49+
"DawnGraphiteCache",
50+
"DawnWebGPUCache",
51+
"GPUPersistentCache",
52+
];
53+
54+
it.effect.each([
55+
{ profile: "written by Electron 44.10.0", marker: "44.10.0" },
56+
{ profile: "with no recorded Electron version", marker: undefined },
57+
])("clears GPU caches $profile once when Electron 44.10.1 starts", ({ marker }) =>
58+
Effect.gen(function* () {
59+
const fs = yield* FileSystem.FileSystem;
60+
const path = yield* Path.Path;
61+
const userDataPath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-gpu-cache-" });
62+
for (const name of gpuCacheDirectories) {
63+
yield* fs.makeDirectory(path.join(userDataPath, name), { recursive: true });
64+
yield* fs.writeFileString(path.join(userDataPath, name, "data_0"), "44.10.0 shaders");
65+
}
66+
yield* fs.makeDirectory(path.join(userDataPath, "Local Storage"), { recursive: true });
67+
yield* fs.writeFileString(path.join(userDataPath, "Local Storage", "LOCK"), "profile");
68+
yield* fs.writeFileString(path.join(userDataPath, "Local State"), "profile");
69+
if (marker !== undefined) {
70+
yield* fs.writeFileString(path.join(userDataPath, "gpu-cache-electron-version"), marker);
71+
}
72+
73+
yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" });
74+
75+
const survivingCaches: Array<string> = [];
76+
for (const name of gpuCacheDirectories) {
77+
if (yield* fs.exists(path.join(userDataPath, name))) survivingCaches.push(name);
78+
}
79+
assert.deepStrictEqual(survivingCaches, []);
80+
assert.equal(
81+
yield* fs.readFileString(path.join(userDataPath, "Local Storage", "LOCK")),
82+
"profile",
83+
);
84+
assert.equal(yield* fs.readFileString(path.join(userDataPath, "Local State")), "profile");
85+
86+
yield* fs.makeDirectory(path.join(userDataPath, "GPUCache"));
87+
yield* fs.writeFileString(path.join(userDataPath, "GPUCache", "data_0"), "44.10.1 shaders");
88+
yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" });
89+
assert.equal(
90+
yield* fs.readFileString(path.join(userDataPath, "GPUCache", "data_0")),
91+
"44.10.1 shaders",
92+
);
93+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
94+
);
95+
96+
it.effect("keeps GPU caches built on the first launch of a fresh install", () =>
97+
Effect.gen(function* () {
98+
const fs = yield* FileSystem.FileSystem;
99+
const path = yield* Path.Path;
100+
const appData = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-fresh-" });
101+
const userDataPath = path.join(appData, "t3code-v2");
102+
103+
yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" });
104+
yield* fs.makeDirectory(path.join(userDataPath, "GPUCache"));
105+
yield* fs.writeFileString(path.join(userDataPath, "GPUCache", "data_0"), "44.10.1 shaders");
106+
yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" });
107+
108+
assert.equal(
109+
yield* fs.readFileString(path.join(userDataPath, "GPUCache", "data_0")),
110+
"44.10.1 shaders",
111+
);
112+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
113+
);
114+
115+
it.effect("retries on the next launch when a GPU cache cannot be removed", () =>
116+
Effect.gen(function* () {
117+
const writes: Array<string> = [];
118+
const fileSystem = FileSystem.makeNoop({
119+
remove: (path) =>
120+
Effect.fail(
121+
PlatformError.systemError({
122+
_tag: "PermissionDenied",
123+
module: "FileSystem",
124+
method: "remove",
125+
pathOrDescriptor: path,
126+
}),
127+
),
128+
writeFileString: (path) =>
129+
Effect.sync(() => {
130+
writes.push(path);
131+
}),
132+
});
133+
134+
yield* clearStaleGpuCaches({ userDataPath: "/profile", electronVersion: "44.10.1" }).pipe(
135+
Effect.provideService(FileSystem.FileSystem, fileSystem),
136+
);
137+
138+
assert.deepStrictEqual(writes, []);
139+
}).pipe(Effect.provide(NodeServices.layer)),
140+
);

‎apps/desktop/src/app/DesktopUserData.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,43 @@ export const resolveUserDataPath = Effect.fn("desktop.userData.resolveUserDataPa
9595
return destinationPath;
9696
},
9797
);
98+
99+
const GPU_CACHE_DIRECTORIES = [
100+
"GPUCache",
101+
"GrShaderCache",
102+
"ShaderCache",
103+
"DawnGraphiteCache",
104+
"DawnWebGPUCache",
105+
"GPUPersistentCache",
106+
];
107+
108+
// Keyed on Electron, not Chromium: Electron patches ship Chromium backports under the same Chrome version.
109+
export const clearStaleGpuCaches = Effect.fn("desktop.userData.clearStaleGpuCaches")(
110+
function* (input: { readonly userDataPath: string; readonly electronVersion: string }) {
111+
const fs = yield* FileSystem.FileSystem;
112+
const path = yield* Path.Path;
113+
const markerPath = path.join(input.userDataPath, "gpu-cache-electron-version");
114+
const cachedVersion = yield* fs.readFileString(markerPath).pipe(
115+
Effect.catchIf(
116+
(error) => error.reason._tag === "NotFound",
117+
() => Effect.undefined,
118+
),
119+
);
120+
if (cachedVersion?.trim() === input.electronVersion) return;
121+
for (const name of GPU_CACHE_DIRECTORIES) {
122+
yield* fs.remove(path.join(input.userDataPath, name), { recursive: true, force: true });
123+
}
124+
yield* fs.makeDirectory(input.userDataPath, { recursive: true });
125+
yield* fs.writeFileString(markerPath, input.electronVersion);
126+
},
127+
(effect, input) =>
128+
effect.pipe(
129+
Effect.catch((error) =>
130+
Effect.logWarning("could not clear stale GPU caches", {
131+
userDataPath: input.userDataPath,
132+
electronVersion: input.electronVersion,
133+
error: error.message,
134+
}),
135+
),
136+
),
137+
);

‎apps/desktop/src/backend/DesktopBackendManager.test.ts‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ describe("DesktopBackendManager", () => {
421421
),
422422
);
423423

424-
it.effect("starts the configured backend and closes the scoped process on stop", () =>
424+
it.effect("stops the scoped process with output still pending", () =>
425425
Effect.scoped(
426426
Effect.gen(function* () {
427427
let startCount = 0;
@@ -458,6 +458,7 @@ describe("DesktopBackendManager", () => {
458458
yield* Scope.addFinalizer(scope, close);
459459

460460
return makeProcess({
461+
stdout: Stream.never,
461462
exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
462463
kill: () => close,
463464
});
@@ -721,14 +722,12 @@ describe("DesktopBackendManager", () => {
721722
),
722723
);
723724

724-
it.effect("the quit stop deadline waits for a backend that only exits at the force kill", () =>
725+
it.effect("the quit stop keeps waiting for a backend that outlasts its deadline", () =>
725726
Effect.scoped(
726727
Effect.gen(function* () {
727728
const spawned = yield* Deferred.make<void>();
728729
const teardownStarted = yield* Deferred.make<void>();
729730
const teardownFinished = yield* Deferred.make<void>();
730-
// The real spawner sends SIGTERM, waits the grace, then SIGKILLs. A backend that
731-
// ignores SIGTERM therefore finishes tearing down just after the grace.
732731
const instance = yield* makeTestInstance({
733732
spawnerLayer: Layer.succeed(
734733
ChildProcessSpawner.ChildProcessSpawner,
@@ -739,7 +738,7 @@ describe("DesktopBackendManager", () => {
739738
yield* Scope.addFinalizer(
740739
scope,
741740
Deferred.succeed(teardownStarted, undefined).pipe(
742-
Effect.andThen(Effect.sleep(Duration.seconds(6))),
741+
Effect.andThen(Effect.sleep(Duration.seconds(10))),
743742
Effect.andThen(Deferred.succeed(teardownFinished, undefined)),
744743
Effect.asVoid,
745744
),
@@ -764,11 +763,11 @@ describe("DesktopBackendManager", () => {
764763
.pipe(Effect.andThen(Deferred.succeed(quitDone, undefined)), Effect.forkChild);
765764

766765
yield* Deferred.await(teardownStarted);
767-
// Past the SIGTERM grace but before the force kill lands: quit must still be waiting.
768-
yield* TestClock.adjust(Duration.millis(5_500));
766+
// Past the quit deadline, quit must still wait for the backend to exit.
767+
yield* TestClock.adjust(Duration.seconds(9));
769768
assert.isFalse(yield* Deferred.isDone(quitDone));
770769

771-
yield* TestClock.adjust(Duration.millis(500));
770+
yield* TestClock.adjust(Duration.seconds(1));
772771
yield* Fiber.join(quitFiber);
773772
assert.isTrue(yield* Deferred.isDone(teardownFinished));
774773
}).pipe(Effect.provide(TestClock.layer())),

0 commit comments

Comments
 (0)