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
197 changes: 197 additions & 0 deletions apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ import {
type PreviewAutomationSnapshot,
type PreviewAutomationStatus,
} from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Scope from "effect/Scope";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PubSub from "effect/PubSub";
Expand Down Expand Up @@ -2675,6 +2677,201 @@ it.live(
).pipe(Effect.provide(layer)),
);

// Holds the next profile save at its temp-file cleanup, after the rename, until released.
const gateSaveCleanup = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const cleanupStarted = yield* Deferred.make<void>();
const releaseCleanup = yield* Deferred.make<void>();
let paused = false;
return {
cleanupStarted,
releaseCleanup,
pauseNext: () => {
paused = true;
},
fs: FileSystem.FileSystem.of({
...fs,
remove: (target, options) =>
fs.remove(target, options).pipe(
Effect.tap(() => {
if (!paused) return Effect.void;
paused = false;
return Deferred.succeed(cleanupStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseCleanup)),
);
}),
),
}),
};
});

it.live("an agent opens a reported profile after a restart, before any client reports again", () =>
Effect.gen(function* () {
const gate = yield* gateSaveCleanup;
const persistent = (name: string) =>
({ id: `profile-${name.toLowerCase()}`, name, kind: "persistent" }) as const;
// Together the two clients report more profiles than one report may hold.
const fromA = [
persistent("Work"),
...Array.from({ length: 12 }, (_, n) => persistent(`A${n}`)),
];
const fromB = [
persistent("Shared"),
...Array.from({ length: 12 }, (_, n) => persistent(`B${n}`)),
];
yield* Effect.gen(function* () {
const browser = yield* ServerBrowser.ServerBrowser;
const clientA = yield* Scope.make();
const reportFromA = yield* browser.profileReporter.pipe(
Effect.provideService(Scope.Scope, clientA),
);
const reportFromB = yield* browser.profileReporter;
const reportFromC = yield* browser.profileReporter;
yield* reportFromA({ profiles: fromA, defaultProfileId: "profile-work" });
gate.pauseNext();
const savingB = yield* reportFromB({
profiles: fromB,
defaultProfileId: "profile-shared",
}).pipe(Effect.forkScoped({ startImmediately: true }));
// A save that ends without reaching the gate fails the test instead of hanging it.
yield* Effect.raceFirst(
Deferred.await(gate.cleanupStarted),
Fiber.join(savingB).pipe(
Effect.andThen(Effect.die("save finished before the cleanup gate")),
),
);
// C's save waits behind B's while the client that reported Work disconnects, as at shutdown.
const savingC = yield* reportFromC({ profiles: [], defaultProfileId: "profile-shared" }).pipe(
Effect.forkScoped({ startImmediately: true }),
);
yield* Scope.close(clientA, Exit.void);
yield* Deferred.succeed(gate.releaseCleanup, undefined);
yield* Fiber.join(savingB);
yield* Fiber.join(savingC);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.fresh(ServerBrowser.layer).pipe(
Layer.provide(Layer.succeed(FileSystem.FileSystem, gate.fs)),
),
),
);

const restarted = yield* Effect.gen(function* () {
const broker = yield* Broker.PreviewAutomationBroker;
yield* ServerBrowser.ServerBrowser;
yield* Effect.yieldNow;
return yield* broker.invoke<PreviewAutomationStatus>({
scope,
operation: "open",
input: { reuseExistingTab: false, show: false, profileId: "Work" },
});
}).pipe(Effect.provide(Layer.fresh(ServerBrowser.layer)));

expect(contextRequests).toEqual([{ profileId: "profile-work", isolated: false }]);
expect(restarted.tabs).toMatchObject([{ profileId: "profile-work" }]);
expect(restarted).toMatchObject({
defaultProfileId: "profile-shared",
profiles: [
{ id: "default", name: "Default" },
{ id: "incognito", name: "Incognito", incognito: true },
...[...fromA, ...fromB].map(({ id, name }) => ({ id, name })),
],
});
}).pipe(Effect.provide(dependencies)),
);

it.live("warns at startup about a saved profile catalog it cannot read or decode", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { stateDir } = yield* ServerConfig.ServerConfig;
const savedPath = path.join(stateDir, "server-browser", "reported-profiles.json");
const startupWarnings = (saved: "missing" | "empty" | "invalid" | "unreadable") =>
Effect.gen(function* () {
const messages: string[] = [];
yield* fs.remove(savedPath, { recursive: true, force: true });
yield* fs.makeDirectory(path.dirname(savedPath), { recursive: true });
if (saved === "empty") yield* fs.writeFileString(savedPath, "");
if (saved === "invalid") yield* fs.writeFileString(savedPath, "{");
if (saved === "unreadable") yield* fs.makeDirectory(savedPath);
const logger = Logger.make(({ message }) => {
messages.push(String(message));
});
yield* ServerBrowser.ServerBrowser.pipe(
Effect.provide(
Layer.fresh(ServerBrowser.layer).pipe(
Layer.provide(Logger.layer([logger], { mergeWithExisting: false })),
),
),
);
return messages.filter((message) => message.includes("saved browser profiles"));
});

expect(yield* startupWarnings("missing")).toEqual([]);
expect(yield* startupWarnings("empty")).toEqual([]);
expect(yield* startupWarnings("invalid")).toEqual([
expect.stringContaining("ignoring invalid saved browser profiles"),
]);
expect(yield* startupWarnings("unreadable")).toEqual([
expect.stringContaining("failed to read saved browser profiles"),
]);
}).pipe(Effect.provide(dependencies)),
);

it.live("a report interrupted while an earlier save holds the lock is still saved", () =>
Effect.gen(function* () {
const gate = yield* gateSaveCleanup;
const work = { id: "profile-work", name: "Work", kind: "persistent" } as const;
const late = { id: "profile-late", name: "Late", kind: "persistent" } as const;
yield* Effect.gen(function* () {
const report = yield* (yield* ServerBrowser.ServerBrowser).profileReporter;
gate.pauseNext();
const saving = yield* report({ profiles: [work], defaultProfileId: "profile-work" }).pipe(
Effect.forkScoped({ startImmediately: true }),
);
yield* Effect.raceFirst(
Deferred.await(gate.cleanupStarted),
Fiber.join(saving).pipe(
Effect.andThen(Effect.die("save finished before the cleanup gate")),
),
);
// The client disconnects while its report waits for the save in progress.
const waiting = yield* report({
profiles: [work, late],
defaultProfileId: "profile-late",
}).pipe(Effect.forkScoped({ startImmediately: true }));
waiting.interruptUnsafe();
yield* Deferred.succeed(gate.releaseCleanup, undefined);
yield* Fiber.join(saving);
yield* Fiber.await(waiting);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.fresh(ServerBrowser.layer).pipe(
Layer.provide(Layer.succeed(FileSystem.FileSystem, gate.fs)),
),
),
);

const restarted = yield* Effect.gen(function* () {
const broker = yield* Broker.PreviewAutomationBroker;
yield* ServerBrowser.ServerBrowser;
yield* Effect.yieldNow;
return yield* broker.invoke<PreviewAutomationStatus>({
scope,
operation: "open",
input: { reuseExistingTab: false, show: false, profileId: "Late" },
});
}).pipe(Effect.provide(Layer.fresh(ServerBrowser.layer)));

expect(restarted).toMatchObject({
defaultProfileId: "profile-late",
tabs: [{ profileId: "profile-late" }],
});
}).pipe(Effect.provide(dependencies)),
);

it.live("profiles reported by one client survive another client's report", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
97 changes: 88 additions & 9 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
// Screencasts ignore emulated device scale; real 2x keeps captures sharp.
// --disable-gpu uses cheaper software compositing while preserving SwiftShader WebGL.
import {
BrowserProfile,
BrowserProfileId,
BUILT_IN_BROWSER_PROFILES,
DEFAULT_BROWSER_PROFILE_ID,
DEFAULT_BROWSER_RECORDING_FRAME_RATE,
FILL_PREVIEW_VIEWPORT,
findBrowserProfile,
INCOGNITO_BROWSER_PROFILE_ID,
isBuiltInBrowserProfileId,
resolveBrowserProfiles,
type BrowserProfile,
type PreviewReportProfilesInput,
PREVIEW_AUTOMATION_SERVER_OPERATIONS,
PreviewViewportSetting as PreviewViewportSettingSchema,
Expand Down Expand Up @@ -46,6 +48,7 @@ import {
type PreviewAppearancePreference,
} from "@t3tools/contracts";
import { AgentScope, agentScopeLauncherScript } from "@t3tools/shared/AgentScope";
import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { normalizePreviewUrl } from "@t3tools/shared/preview";
import { resolvePreviewViewport } from "@t3tools/shared/previewViewport";
Expand All @@ -54,12 +57,15 @@ import * as NodeFSP from "node:fs/promises";
import * as NodePath from "node:path";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import { constVoid } from "effect/Function";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import type * as Path from "effect/Path";
import * as Queue from "effect/Queue";
import * as Schedule from "effect/Schedule";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import * as Exit from "effect/Exit";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
Expand Down Expand Up @@ -90,6 +96,12 @@ import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts";
import { installPdfDocumentNavigation, isPdfAssetUrl } from "./PdfDocumentNavigation.ts";

const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID;
// Each client's report is capped, but the saved catalog is their union, so it has no cap.
const SavedProfilesJson = Schema.fromJsonString(
Schema.Struct({ profiles: Schema.Array(BrowserProfile), defaultProfileId: BrowserProfileId }),
);
const decodeSavedProfiles = Schema.decodeUnknownEffect(SavedProfilesJson);
const encodeSavedProfiles = Schema.encodeSync(SavedProfilesJson);
const RENDER_SCALE = 2;
// Chromium allows three unacked frames, so 100 ms pacing caps viewers near 30 fps.
const SCREENCAST_ACK_PACE_MS = 100;
Expand Down Expand Up @@ -1602,12 +1614,55 @@ const make = Effect.gen(function* () {
? ("agent" as const)
: ("unclaimed" as const);

const resolveReportedProfiles = (input: PreviewReportProfilesInput) => {
const profiles = resolveBrowserProfiles(input.profiles);
return {
profiles,
defaultProfileId:
findBrowserProfile(profiles, input.defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID,
};
};

// Clients report profiles only when they connect, so the catalog at the last report is
// kept on disk for agents and scheduled tasks that open a profile after a restart.
const savedProfilesPath = NodePath.join(
config.stateDir,
"server-browser",
"reported-profiles.json",
);
const fileServices = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
const saveProfilesLock = yield* Semaphore.make(1);
let savedProfiles = yield* (yield* FileSystem.FileSystem).readFileString(savedProfilesPath).pipe(
Effect.tapError((cause) =>
cause.reason._tag === "NotFound"
? Effect.void
: Effect.logWarning("failed to read saved browser profiles", {
path: savedProfilesPath,
cause,
}),
),
Effect.orElseSucceed(() => ""),
);
const reportsByClient = new Map<number, PreviewReportProfilesInput>();
let nextClient = 0;
let reportedProfiles: {
readonly profiles: ReadonlyArray<BrowserProfile>;
readonly defaultProfileId: string;
} | null = null;
} | null = yield* decodeSavedProfiles(savedProfiles).pipe(
Effect.map(resolveReportedProfiles),
Effect.tapError((cause) =>
savedProfiles === ""
? Effect.void
: Effect.logWarning("ignoring invalid saved browser profiles", {
path: savedProfilesPath,
cause,
}),
),
Effect.orElseSucceed(() => null),
);
// Only reports change what is saved, so the order clients disconnect in at shutdown,
// even while a save waits for the lock, cannot shrink the saved catalog.
let catalogToSave = reportedProfiles;
// Agent recordings encode here, so they need the client's Browser recording frame rate. The
// last report outlives the client, which lets agents record while no client is connected.
let recordingFrameRate = DEFAULT_BROWSER_RECORDING_FRAME_RATE;
Expand All @@ -1623,14 +1678,32 @@ const make = Effect.gen(function* () {
latest = report;
}
if (latest === undefined) return;
const profiles = resolveBrowserProfiles([...byId.values()]);
reportedProfiles = {
profiles,
defaultProfileId:
findBrowserProfile(profiles, latest.defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID,
};
reportedProfiles = resolveReportedProfiles({
profiles: [...byId.values()],
defaultProfileId: latest.defaultProfileId,
});
};

const saveReportedProfiles = Effect.fn("ServerBrowser.saveReportedProfiles")(function* () {
if (catalogToSave === null) return;
const contents = encodeSavedProfiles({
profiles: catalogToSave.profiles.filter((profile) => !isBuiltInBrowserProfileId(profile.id)),
defaultProfileId: catalogToSave.defaultProfileId,
});
if (contents === savedProfiles) return;
yield* writeFileStringAtomically({ filePath: savedProfilesPath, contents }).pipe(
Effect.provideContext(fileServices),
Effect.tap(() =>
Effect.sync(() => {
savedProfiles = contents;
}),
),
Effect.catch((cause) =>
Effect.logWarning("failed to save reported browser profiles", { cause }),
),
);
}, saveProfilesLock.withPermits(1));

const profileReporter: ServerBrowser["Service"]["profileReporter"] = Effect.acquireRelease(
Effect.sync(() => nextClient++),
(client) =>
Expand All @@ -1648,7 +1721,13 @@ const make = Effect.gen(function* () {
reportsByClient.delete(client);
reportsByClient.set(client, input);
rebuildReportedProfiles();
}),
catalogToSave = reportedProfiles;
}).pipe(
Effect.andThen(saveReportedProfiles),
// A disconnect, even while an earlier save holds the lock, must not drop this
// report's save or leave the saved-content cache stale.
Effect.uninterruptible,
),
),
);

Expand Down
Loading