Skip to content
Closed
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
20 changes: 16 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,16 @@ jobs:
- name: Start installing browser secret helper build libraries
if: needs.changes.outputs.build != 'false'
run: |
sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
# Fetch only the indexes these main and universe packages need. The full set is ~42 MB,
# which a slow mirror can stretch past the wait below.
sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \
/etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
(
set +e
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config xvfb
sudo apt-get update -o Acquire::Languages=none \
-o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \
-o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false &&
sudo apt-get install -y libsecret-1-dev pkg-config xvfb
echo "$?" > "$RUNNER_TEMP/apt-status"
) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null &

Expand Down Expand Up @@ -287,10 +293,16 @@ jobs:
# the background while Vite+ installs; the step before the tests waits for it.
- name: Start installing browser secret helper build libraries
run: |
sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
# Fetch only the indexes these main and universe packages need. The full set is ~42 MB,
# which a slow mirror can stretch past the wait below.
sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \
/etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
(
set +e
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
sudo apt-get update -o Acquire::Languages=none \
-o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \
-o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false &&
sudo apt-get install -y libsecret-1-dev pkg-config
echo "$?" > "$RUNNER_TEMP/apt-status"
) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null &

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/mobile-showcase-screenshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ jobs:
axe --version

- name: Install foldable simulator controls
run: npm install --global agent-device@0.21.25
run: npm install --global agent-device@0.21.36

- name: Capture iOS showcase
run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ apps/mobile/.android-cache/
artifacts/app-store/screenshots/
artifacts/mobile-e2e/
artifacts/mobile-behavior/
scripts/.e2e/
.github/pr-assets/
native/**/target/
node_modules/
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real
- [Testing](docs/internals/testing.md) carries these same test rules, plus review guidance, for review bots. Change both together.
- **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite.
- Backend behavior changes ship with focused tests for that behavior.
- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript.
- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. For a bug a user can reproduce in the web client, `vp run scenarios:prove` shows it failing on base and passing on the fix in the real app; see [Scenarios](docs/operations/scenarios.md).
- The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely:
- `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures.
- No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker.
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"electron-updater": "^6.8.9",
"electron-webauthn": "catalog:",
"ffi-rs": "1.3.2",
"playwright-core": "1.60.0",
"playwright-core": "1.64.1",
"react-grab": "^0.1.32",
"tldts": "catalog:"
},
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/app/DesktopClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ export const make = Effect.gen(function* () {
yield* electronApp.quit;
return yield* Effect.interrupt;
}
// Chromium opens these caches at the first event-loop turn, before ready.
yield* DesktopUserData.clearStaleGpuCaches({
userDataPath,
electronVersion: process.versions.electron,
});

const pendingSecondInstances: Array<readonly string[]> = [];
let handleSecondInstance = (argv: readonly string[]) => {
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/app/DesktopPreReadyFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export const make = FileSystem.makeNoop({
syncFs("makeDirectory", path, () => {
NodeFS.mkdirSync(path, { recursive: options?.recursive ?? false });
}),
remove: (path, options) =>
syncFs("remove", path, () =>
NodeFS.rmSync(path, {
recursive: options?.recursive ?? false,
force: options?.force ?? false,
}),
),
writeFileString: (path, data, options) =>
syncFs("writeFileString", path, () =>
NodeFS.writeFileSync(path, data, { flag: options?.flag ?? "w" }),
Expand Down
100 changes: 99 additions & 1 deletion apps/desktop/src/app/DesktopUserData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ 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 * as PlatformError from "effect/PlatformError";

import { resolveUserDataPath } from "./DesktopUserData.ts";
import { clearStaleGpuCaches, resolveUserDataPath } from "./DesktopUserData.ts";

it.effect.each(["t3code", "T3 Code (Alpha)"])(
"preserves Windows credential keys from %s without copying browser databases",
Expand Down Expand Up @@ -40,3 +41,100 @@ it.effect.each(["t3code", "T3 Code (Alpha)"])(
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

const gpuCacheDirectories = [
"GPUCache",
"GrShaderCache",
"ShaderCache",
"DawnGraphiteCache",
"DawnWebGPUCache",
"GPUPersistentCache",
];

it.effect.each([
{ profile: "written by Electron 44.10.0", marker: "44.10.0" },
{ profile: "with no recorded Electron version", marker: undefined },
])("clears GPU caches $profile once when Electron 44.10.1 starts", ({ marker }) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const userDataPath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-gpu-cache-" });
for (const name of gpuCacheDirectories) {
yield* fs.makeDirectory(path.join(userDataPath, name), { recursive: true });
yield* fs.writeFileString(path.join(userDataPath, name, "data_0"), "44.10.0 shaders");
}
yield* fs.makeDirectory(path.join(userDataPath, "Local Storage"), { recursive: true });
yield* fs.writeFileString(path.join(userDataPath, "Local Storage", "LOCK"), "profile");
yield* fs.writeFileString(path.join(userDataPath, "Local State"), "profile");
if (marker !== undefined) {
yield* fs.writeFileString(path.join(userDataPath, "gpu-cache-electron-version"), marker);
}

yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" });

const survivingCaches: Array<string> = [];
for (const name of gpuCacheDirectories) {
if (yield* fs.exists(path.join(userDataPath, name))) survivingCaches.push(name);
}
assert.deepStrictEqual(survivingCaches, []);
assert.equal(
yield* fs.readFileString(path.join(userDataPath, "Local Storage", "LOCK")),
"profile",
);
assert.equal(yield* fs.readFileString(path.join(userDataPath, "Local State")), "profile");

yield* fs.makeDirectory(path.join(userDataPath, "GPUCache"));
yield* fs.writeFileString(path.join(userDataPath, "GPUCache", "data_0"), "44.10.1 shaders");
yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" });
assert.equal(
yield* fs.readFileString(path.join(userDataPath, "GPUCache", "data_0")),
"44.10.1 shaders",
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("keeps GPU caches built on the first launch of a fresh install", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const appData = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-fresh-" });
const userDataPath = path.join(appData, "t3code-v2");

yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" });
yield* fs.makeDirectory(path.join(userDataPath, "GPUCache"));
yield* fs.writeFileString(path.join(userDataPath, "GPUCache", "data_0"), "44.10.1 shaders");
yield* clearStaleGpuCaches({ userDataPath, electronVersion: "44.10.1" });

assert.equal(
yield* fs.readFileString(path.join(userDataPath, "GPUCache", "data_0")),
"44.10.1 shaders",
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("retries on the next launch when a GPU cache cannot be removed", () =>
Effect.gen(function* () {
const writes: Array<string> = [];
const fileSystem = FileSystem.makeNoop({
remove: (path) =>
Effect.fail(
PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "remove",
pathOrDescriptor: path,
}),
),
writeFileString: (path) =>
Effect.sync(() => {
writes.push(path);
}),
});

yield* clearStaleGpuCaches({ userDataPath: "/profile", electronVersion: "44.10.1" }).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
);

assert.deepStrictEqual(writes, []);
}).pipe(Effect.provide(NodeServices.layer)),
);
40 changes: 40 additions & 0 deletions apps/desktop/src/app/DesktopUserData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,43 @@ export const resolveUserDataPath = Effect.fn("desktop.userData.resolveUserDataPa
return destinationPath;
},
);

const GPU_CACHE_DIRECTORIES = [
"GPUCache",
"GrShaderCache",
"ShaderCache",
"DawnGraphiteCache",
"DawnWebGPUCache",
"GPUPersistentCache",
];

// Keyed on Electron, not Chromium: Electron patches ship Chromium backports under the same Chrome version.
export const clearStaleGpuCaches = Effect.fn("desktop.userData.clearStaleGpuCaches")(
function* (input: { readonly userDataPath: string; readonly electronVersion: string }) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const markerPath = path.join(input.userDataPath, "gpu-cache-electron-version");
const cachedVersion = yield* fs.readFileString(markerPath).pipe(
Effect.catchIf(
(error) => error.reason._tag === "NotFound",
() => Effect.undefined,
),
);
if (cachedVersion?.trim() === input.electronVersion) return;
for (const name of GPU_CACHE_DIRECTORIES) {
yield* fs.remove(path.join(input.userDataPath, name), { recursive: true, force: true });
}
yield* fs.makeDirectory(input.userDataPath, { recursive: true });
yield* fs.writeFileString(markerPath, input.electronVersion);
},
(effect, input) =>
effect.pipe(
Effect.catch((error) =>
Effect.logWarning("could not clear stale GPU caches", {
userDataPath: input.userDataPath,
electronVersion: input.electronVersion,
error: error.message,
}),
),
),
);
67 changes: 67 additions & 0 deletions apps/desktop/src/ipc/DesktopIpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { assert, it } from "@effect/vitest";
import { vi } from "vite-plus/test";

vi.mock("electron", () => ({}));

import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopIpc from "./DesktopIpc.ts";

it.effect("answers only the main window's top frame", () =>
Effect.gen(function* () {
const mainFrame = {};
const invoke = new Map<string, DesktopIpc.DesktopIpcHandleListener>();
const sync = new Map<string, DesktopIpc.DesktopIpcSyncListener>();
const ipc = yield* DesktopIpc.DesktopIpc.pipe(
Effect.provide(
DesktopIpc.layer({
removeHandler: (channel) => void invoke.delete(channel),
handle: (channel, listener) => void invoke.set(channel, listener),
removeAllListeners: (channel) => void sync.delete(channel),
on: (channel, listener) => void sync.set(channel, listener),
}).pipe(
Layer.provide(
Layer.mock(ElectronWindow.ElectronWindow, {
main: Effect.succeedSome({
webContents: { id: 1, mainFrame },
} as unknown as Electron.BrowserWindow),
}),
),
),
),
);
yield* ipc.handle(
DesktopIpc.makeIpcMethod({
channel: "token",
payload: Schema.Void,
result: Schema.String,
handler: () => Effect.succeed("secret"),
}),
);
yield* ipc.handleSync({ channel: "token-sync", handler: () => Effect.succeed("secret") });

const senders = {
app: { sender: { id: 1 }, senderFrame: mainFrame },
appSubframe: { sender: { id: 1 }, senderFrame: {} },
previewGuest: { sender: { id: 2 }, senderFrame: {} },
};
const results: Record<string, unknown> = {};
for (const [name, sender] of Object.entries(senders)) {
const event = { ...sender, returnValue: "unset" as unknown };
sync.get("token-sync")!(event);
results[name] = [
yield* Effect.promise(() =>
Promise.resolve(invoke.get("token")!(sender, undefined)).catch(() => "rejected"),
),
event.returnValue,
];
}
assert.deepEqual(results, {
app: ["secret", "secret"],
appSubframe: ["rejected", undefined],
previewGuest: ["rejected", undefined],
});
}).pipe(Effect.scoped),
);
Loading
Loading