authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 16:37:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 16:37:46-07:00
log5754121c23f73201d47a96feb97eca7b96334a2a
tree41aeea924b2334a4ce889e4e8454e970f232a64a
parent741377ce5fb1578916659cbb93fd403b26dbeb62
signature Signed by PGP key B5690EEEBB952194

fix(server): environment-hosted browser tabs behave like a normal browser (#16963)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

29 files changed, 1202 insertions(+), 44 deletions(-)

apps/server/src/orchestration-v2/EffectOutbox.ts+5
......@@ -95,6 +95,10 @@ export const OrchestrationEffectRequestV2 = Schema.Union([
9595 Schema.Struct({
9696 type: Schema.Literal("terminal.cleanup"),
9797 }),
98 /** Closes a deleted thread's preview sessions, ending their server browser tabs. */
99 Schema.Struct({
100 type: Schema.Literal("preview.cleanup"),
101 }),
98102 Schema.Struct({
99103 type: Schema.Literal("attachment.cleanup"),
100104 attachmentIds: Schema.Array(Schema.String),
......@@ -120,6 +124,7 @@ export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [
120124 "provider-thread.rollback",
121125 "checkpoint.capture",
122126 "terminal.cleanup",
127 "preview.cleanup",
123128 "attachment.cleanup",
124129 "thread-title.generate",
125130 "delegated-tasks.stop",
apps/server/src/orchestration-v2/EffectWorker.ts+11
......@@ -439,6 +439,17 @@ export const layerExecutor: Layer.Layer<
439439 }),
440440 ),
441441 );
442 case "preview.cleanup":
443 return resourceCleanup.cleanupPreviews(effect.threadId).pipe(
444 Effect.mapError(
445 (cause) =>
446 new OrchestrationEffectExecutionError({
447 effectId: effect.id,
448 effectType: effect.request.type,
449 cause,
450 }),
451 ),
452 );
442453 case "attachment.cleanup":
443454 return resourceCleanup.cleanupAttachments(effect.request.attachmentIds).pipe(
444455 Effect.mapError(
apps/server/src/orchestration-v2/ResourceCleanupService.ts+15-1
......@@ -1,3 +1,4 @@
1import { ThreadId } from "@t3tools/contracts";
12import * as Context from "effect/Context";
23import * as Effect from "effect/Effect";
34import * as FileSystem from "effect/FileSystem";
......@@ -6,12 +7,13 @@ import * as Schema from "effect/Schema";
67
78import { resolveAttachmentPathById } from "../attachmentStore.ts";
89import * as ServerConfig from "../config.ts";
10import * as PreviewManager from "../preview/Manager.ts";
911import * as TerminalManager from "../terminal/Manager.ts";
1012
1113export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupError>()(
1214 "ResourceCleanupError",
1315 {
14 operation: Schema.Literals(["terminal", "attachment"]),
16 operation: Schema.Literals(["terminal", "preview", "attachment"]),
1517 threadId: Schema.optional(Schema.String),
1618 attachmentId: Schema.optional(Schema.String),
1719 cause: Schema.Defect(),
......@@ -20,12 +22,15 @@ export class ResourceCleanupError extends Schema.TaggedError<ResourceCleanupErro
2022
2123export class ResourceCleanupService extends Context.Reference<{
2224 readonly cleanupTerminals: (threadId: string) => Effect.Effect<void, ResourceCleanupError>;
25 /** Closes every preview session of the thread; server browser tabs end with them. */
26 readonly cleanupPreviews: (threadId: string) => Effect.Effect<void, ResourceCleanupError>;
2327 readonly cleanupAttachments: (
2428 attachmentIds: ReadonlyArray<string>,
2529 ) => Effect.Effect<void, ResourceCleanupError>;
2630}>("t3/orchestration-v2/ResourceCleanupService", {
2731 defaultValue: () => ({
2832 cleanupTerminals: () => Effect.void,
33 cleanupPreviews: () => Effect.void,
2934 cleanupAttachments: () => Effect.void,
3035 }),
3136}) {}
......@@ -34,6 +39,7 @@ export const layer = Layer.effect(
3439 ResourceCleanupService,
3540 Effect.gen(function* () {
3641 const terminals = yield* TerminalManager.TerminalManager;
42 const previews = yield* PreviewManager.PreviewManager;
3743 const fileSystem = yield* FileSystem.FileSystem;
3844 const config = yield* ServerConfig.ServerConfig;
3945 return {
......@@ -45,6 +51,14 @@ export const layer = Layer.effect(
4551 (cause) => new ResourceCleanupError({ operation: "terminal", threadId, cause }),
4652 ),
4753 ),
54 cleanupPreviews: (threadId: string) =>
55 previews
56 .close({ threadId: ThreadId.make(threadId) })
57 .pipe(
58 Effect.mapError(
59 (cause) => new ResourceCleanupError({ operation: "preview", threadId, cause }),
60 ),
61 ),
4862 cleanupAttachments: (attachmentIds: ReadonlyArray<string>) =>
4963 Effect.forEach(
5064 attachmentIds,
apps/server/src/orchestration-v2/ThreadDeletion.test.ts+1
......@@ -291,6 +291,7 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio
291291 revokeMcpCredential: true,
292292 },
293293 { type: "terminal.cleanup" },
294 { type: "preview.cleanup" },
294295 { type: "attachment.cleanup", attachmentIds: ["shared_file"] },
295296 ],
296297 );
apps/server/src/orchestration-v2/ThreadDeletion.ts+6
......@@ -215,6 +215,12 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion")
215215 threadId: command.threadId,
216216 request: { type: "terminal.cleanup" },
217217 });
218 effects.push({
219 id: `effect:${command.commandId}:preview.cleanup`,
220 commandId: command.commandId,
221 threadId: command.threadId,
222 request: { type: "preview.cleanup" },
223 });
218224 const attachmentIds = Array.from(new Set(input.attachmentIds));
219225 if (attachmentIds.length > 0) {
220226 effects.push({
apps/server/src/preview/DesktopBrowserChannel.ts+7
......@@ -54,6 +54,8 @@ export class DesktopBrowserChannel extends Context.Service<
5454 readonly awaitAttached: (key: DesktopTabKey, timeout: Duration.Input) => Effect.Effect<boolean>;
5555 /** Desktop tabs as they detach. */
5656 readonly detached: Stream.Stream<DesktopTabKey>;
57 /** Desktop tabs as they attach, including a tab coming back after its DevTools close. */
58 readonly attached: Stream.Stream<DesktopTabKey>;
5759 readonly isAttached: (key: DesktopTabKey) => Effect.Effect<boolean>;
5860 /**
5961 * A one-connection CDP endpoint for an attached tab. Closing the scope
......@@ -83,6 +85,7 @@ const make = Effect.gen(function* () {
8385 available: false,
8486 awaitAttached: () => Effect.succeed(false),
8587 detached: Stream.empty,
88 attached: Stream.empty,
8689 isAttached: () => Effect.succeed(false),
8790 endpoint: () => Effect.die("No desktop app is attached to this server."),
8891 pointer: () => Effect.void,
......@@ -216,6 +219,10 @@ const make = Effect.gen(function* () {
216219 Stream.filter((change) => !change.attached),
217220 Stream.map((change) => change.key),
218221 ),
222 attached: Stream.fromPubSub(changes).pipe(
223 Stream.filter((change) => change.attached),
224 Stream.map((change) => change.key),
225 ),
219226 isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))),
220227 endpoint,
221228 pointer: (key, pointer) => command({ type: "pointer", ...key, ...pointer }),
apps/server/src/preview/Manager.ts+3
......@@ -317,6 +317,9 @@ export const make = Effect.gen(function* PreviewManagerMake() {
317317 title: input.navStatus.title,
318318 code: input.navStatus.code,
319319 description: input.navStatus.description,
320 ...(input.navStatus.download === undefined
321 ? {}
322 : { download: input.navStatus.download }),
320323 }
321324 : {
322325 type: "navigated",
apps/server/src/preview/PortScanner.test.ts+114
......@@ -14,6 +14,7 @@ import * as Duration from "effect/Duration";
1414import * as Effect from "effect/Effect";
1515import * as Exit from "effect/Exit";
1616import * as Fiber from "effect/Fiber";
17import * as FileSystem from "effect/FileSystem";
1718import * as Layer from "effect/Layer";
1819import * as PlatformError from "effect/PlatformError";
1920import * as Scope from "effect/Scope";
......@@ -53,13 +54,18 @@ const layerTestIntegrationNet = Layer.succeed(Net.NetService, {
5354 findAvailablePort: (preferred) => Effect.succeed(preferred),
5455});
5556
57/** A host without `/proc`, so a missing `lsof` falls back to common ports. */
58const layerNoProc = FileSystem.layerNoop({});
59
5660const layerProbeFailure = (
5761 run: ProcessRunner.ProcessRunner["Service"]["run"],
5862 fetch: typeof globalThis.fetch = globalThis.fetch,
63 fileSystem: Layer.Layer<FileSystem.FileSystem> = layerNoProc,
5964) =>
6065 PortScanner.layer.pipe(
6166 Layer.provide(
6267 Layer.mergeAll(
68 fileSystem,
6369 Layer.succeed(ProcessRunner.ProcessRunner, { run }),
6470 Layer.succeed(Net.NetService, {
6571 canListenOnHost: () => Effect.succeed(true),
......@@ -77,6 +83,7 @@ const layerProbeFailure = (
7783const layerTestPortDiscovery = PortScanner.layer.pipe(
7884 Layer.provide(
7985 Layer.mergeAll(
86 layerNoProc,
8087 layerTestProcessRunner,
8188 layerTestIntegrationNet,
8289 Layer.succeed(HostProcessPlatform, "win32"),
......@@ -94,6 +101,7 @@ const layerLsofScanner = (input: {
94101 PortScanner.layer.pipe(
95102 Layer.provide(
96103 Layer.mergeAll(
104 layerNoProc,
97105 Layer.succeed(ProcessRunner.ProcessRunner, {
98106 run: () =>
99107 Effect.succeed({
......@@ -671,6 +679,112 @@ effectIt.effect("does not swallow process probe defects", () =>
671679 }),
672680);
673681
682// /proc/net/tcp: 127.0.0.1:8765 and 0.0.0.0:22 listening, an established
683// connection, and a non-loopback listener; /proc/net/tcp6 adds [::]:3001.
684const PROC_NET_TCP = ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
685 0: 0100007F:223D 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5001 1 0000000000000000 100 0 0 10 0
686 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 5002 1 0000000000000000 100 0 0 10 0
687 2: 0100007F:223D 0100007F:C350 01 00000000:00000000 00:00000000 00000000 1000 0 5003 1 0000000000000000 20 4 30 10 -1
688 3: 0A00000F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5005 1 0000000000000000 100 0 0 10 0
689`;
690const PROC_NET_TCP6 = ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
691 0: 00000000000000000000000000000000:0BB9 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5004 1 0000000000000000 100 0 0 10 0
692`;
693
694effectIt.effect(
695 "reads Linux listeners from /proc when lsof is missing, and stops spawning it",
696 () => {
697 let lsofSpawns = 0;
698 const fdWalks: string[] = [];
699 const procFiles: Record<string, string> = {
700 "/proc/net/tcp": PROC_NET_TCP,
701 "/proc/net/tcp6": PROC_NET_TCP6,
702 "/proc/4242/comm": "python3\n",
703 };
704 const procFds: Record<string, Record<string, string>> = {
705 "4242": { "0": "/dev/null", "3": "socket:[5001]" },
706 "77": { "5": "socket:[9999]" },
707 };
708 const missing = FileSystem.makeNoop({});
709 const fileSystem = FileSystem.layerNoop({
710 readFileString: (path) => {
711 const content = procFiles[path];
712 return content === undefined ? missing.readFileString(path) : Effect.succeed(content);
713 },
714 readDirectory: (path) => {
715 if (path === "/proc") return Effect.succeed(["self", "77", "4242"]);
716 const pid = /^\/proc\/(\d+)\/fd$/.exec(path)?.[1];
717 const fds = pid === undefined ? undefined : procFds[pid];
718 if (pid === undefined || fds === undefined) return missing.readDirectory(path);
719 fdWalks.push(pid);
720 return Effect.succeed(Object.keys(fds));
721 },
722 readLink: (path) => {
723 const [, pid, fd] = /^\/proc\/(\d+)\/fd\/(\d+)$/.exec(path) ?? [];
724 const target = pid && fd ? procFds[pid]?.[fd] : undefined;
725 return target === undefined ? missing.readLink(path) : Effect.succeed(target);
726 },
727 });
728 const fetchFn = (() =>
729 Promise.resolve(
730 new Response("hello", { headers: { "content-type": "text/html" } }),
731 )) as typeof globalThis.fetch;
732 const layer = layerProbeFailure(
733 (input) => {
734 lsofSpawns += 1;
735 return processProbeFailure(input);
736 },
737 fetchFn,
738 fileSystem,
739 );
740
741 return Effect.gen(function* () {
742 const scanner = yield* PortScanner.PortDiscovery;
743 const first = yield* scanner.scan();
744 expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([
745 { port: 22, pid: null, processName: null },
746 { port: 3001, pid: null, processName: null },
747 { port: 8765, pid: 4242, processName: "python3" },
748 ]);
749 yield* scanner.scan();
750 expect(lsofSpawns).toBe(1);
751 // Ports 22 and 3001 have no readable owner, so the second scan walks
752 // again; it stops at 77, which holds no listener, once nothing is left.
753 expect(fdWalks).toEqual(["77", "4242", "77", "4242"]);
754 yield* scanner.scan();
755 yield* scanner.scan();
756 // Unresolved owners are retried a bounded number of times.
757 expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]);
758 }).pipe(Effect.provide(layer));
759 },
760);
761
762effectIt.effect("keeps spawning lsof after a spawn failure that is not a missing command", () => {
763 let lsofSpawns = 0;
764 const layer = layerProbeFailure((input) => {
765 lsofSpawns += 1;
766 return Effect.fail(
767 new ProcessRunner.ProcessSpawnError({
768 command: input.command,
769 argumentCount: input.args.length,
770 cwd: input.cwd,
771 cause: PlatformError.systemError({
772 _tag: "Unknown",
773 module: "ChildProcess",
774 method: "spawn",
775 description: "EAGAIN",
776 }),
777 }),
778 );
779 });
780 return Effect.gen(function* () {
781 const scanner = yield* PortScanner.PortDiscovery;
782 yield* scanner.scan();
783 yield* scanner.scan();
784 expect(lsofSpawns).toBe(2);
785 }).pipe(Effect.provide(layer));
786});
787
674788effectIt.effect("does not swallow process probe interruption", () =>
675789 Effect.gen(function* () {
676790 const layer = layerProbeFailure(() => Effect.interrupt);
apps/server/src/preview/PortScanner.ts+157-4
......@@ -5,8 +5,11 @@
55 * stable line-prefixed field format; this is the only `lsof` flag set we rely
66 * on).
77 *
8 * Windows / lsof missing: checks a curated list of common dev ports through
9 * the shared Net service.
8 * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and
9 * finds their processes through `/proc/<pid>/fd`.
10 *
11 * Windows listener failures, or neither source: checks a curated list of
12 * common dev ports through the shared Net service.
1013 *
1114 * Listening ports are published only after a bounded HTTP(S) probe finds a
1215 * successful HTML document or a redirect to one.
......@@ -30,8 +33,10 @@ import * as Clock from "effect/Clock";
3033import * as Context from "effect/Context";
3134import * as Duration from "effect/Duration";
3235import * as Effect from "effect/Effect";
36import * as FileSystem from "effect/FileSystem";
3337import * as Layer from "effect/Layer";
3438import * as Option from "effect/Option";
39import * as PlatformError from "effect/PlatformError";
3540import * as Ref from "effect/Ref";
3641import * as Schedule from "effect/Schedule";
3742import * as Scope from "effect/Scope";
......@@ -72,6 +77,10 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([
7277
7378const POLL_INTERVAL = Duration.seconds(3);
7479const LSOF_TIMEOUT_MS = 5_000;
80/** File descriptors one `/proc` walk may read before it stops looking for socket owners. */
81const PROC_FD_WALK_LIMIT = 50_000;
82/** Scans that retry the fd walk for a listening socket whose owner was not found. */
83const SOCKET_OWNER_ATTEMPTS = 3;
7584const WINDOWS_LISTENER_TIMEOUT_MS = 5_000;
7685const WEB_PROBE_TIMEOUT = Duration.seconds(1);
7786const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15));
......@@ -265,6 +274,42 @@ const parseWindowsListenerOutput = (
265274 return [...seen.values()].toSorted((left, right) => left.port - right.port);
266275};
267276
277/** `/proc/net/tcp` state for a listening socket. */
278const PROC_TCP_LISTEN = "0A";
279
280/**
281 * Whether a `/proc/net/tcp{,6}` address is one the preview can reach on
282 * loopback: the wildcard or a loopback address, as `lsof` parsing accepts.
283 * Each 32-bit word of the hex address is little-endian.
284 */
285const procLocalAddress = (hex: string): boolean => {
286 if (hex.length === 8) {
287 return hex === "00000000" || hex.endsWith("7F");
288 }
289 if (hex.length !== 32) return false;
290 if (hex === "0".repeat(32) || hex === `${"0".repeat(24)}01000000`) return true;
291 // IPv4-mapped (::ffff:a.b.c.d): wildcard or 127.0.0.0/8.
292 return hex.startsWith(`${"0".repeat(16)}FFFF0000`) && procLocalAddress(hex.slice(24));
293};
294
295/** Listening loopback ports in `/proc/net/tcp{,6}` content, with their socket inodes. */
296const parseProcNetTcp = (
297 raw: string,
298): ReadonlyArray<{ readonly port: number; readonly inode: string }> => {
299 const listeners: Array<{ port: number; inode: string }> = [];
300 for (const line of raw.split("\n").slice(1)) {
301 const fields = line.trim().split(/\s+/);
302 const [address, portHex] = fields[1]?.split(":") ?? [];
303 if (fields[3] !== PROC_TCP_LISTEN || !address || !portHex) continue;
304 if (!procLocalAddress(address.toUpperCase())) continue;
305 const port = Number.parseInt(portHex, 16);
306 const inode = fields[9];
307 if (!Number.isInteger(port) || port <= 0 || port >= 65536 || !inode) continue;
308 listeners.push({ port, inode });
309 }
310 return listeners;
311};
312
268313const serversEqual = (
269314 left: ReadonlyArray<DiscoveredLocalServer>,
270315 right: ReadonlyArray<DiscoveredLocalServer>,
......@@ -289,10 +334,14 @@ const serversEqual = (
289334 return true;
290335};
291336
337const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean =>
338 PlatformError.isPlatformError(error.cause) && error.cause.reason._tag === "NotFound";
339
292340/** @public Service construction is part of the canonical Effect module API. */
293341export const make = Effect.gen(function* PortDiscoveryMake() {
294342 const net = yield* Net.NetService;
295343 const processRunner = yield* ProcessRunner.ProcessRunner;
344 const fileSystem = yield* FileSystem.FileSystem;
296345 const hostPlatform = yield* HostProcessPlatform;
297346 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
298347 const stateRef = yield* Ref.make<ScannerState>({
......@@ -302,6 +351,101 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
302351 });
303352 const webProbeCacheRef = yield* Ref.make<ReadonlyMap<string, WebProbeCacheEntry>>(new Map());
304353 const scanSemaphore = yield* Semaphore.make(1);
354 const lsofMissingRef = yield* Ref.make(false);
355 /** Confirmed owners of listening socket inodes. */
356 const socketOwnersRef = yield* Ref.make<
357 ReadonlyMap<string, { readonly pid: number; readonly processName: string | null }>
358 >(new Map());
359 /** Fd walks that found no owner, per socket inode; retried up to `SOCKET_OWNER_ATTEMPTS`. */
360 const socketOwnerMissesRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
361
362 /** Maps socket inodes to the processes holding them, by `/proc/<pid>/fd`. */
363 const findSocketOwners = Effect.fn("PortDiscovery.findSocketOwners")(function* (
364 inodes: ReadonlySet<string>,
365 ) {
366 const owners = new Map<string, { pid: number; processName: string | null }>();
367 const pids = (yield* fileSystem
368 .readDirectory("/proc")
369 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))).filter((entry) =>
370 /^\d+$/.test(entry),
371 );
372 let budget = PROC_FD_WALK_LIMIT;
373 for (const pidText of pids) {
374 if (owners.size === inodes.size || budget <= 0) break;
375 // Other users' fds are unreadable; their ports still list, without a pid.
376 const fds = yield* fileSystem
377 .readDirectory(`/proc/${pidText}/fd`)
378 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));
379 const walked = fds.slice(0, budget);
380 budget -= walked.length;
381 const links = yield* Effect.forEach(
382 walked,
383 (fd) =>
384 fileSystem.readLink(`/proc/${pidText}/fd/${fd}`).pipe(Effect.orElseSucceed(() => "")),
385 { concurrency: 16 },
386 );
387 const held = links.flatMap((link) => /^socket:\[(\d+)\]$/.exec(link)?.[1] ?? []);
388 const matched = held.filter((inode) => inodes.has(inode) && !owners.has(inode));
389 if (matched.length === 0) continue;
390 const processName = yield* fileSystem.readFileString(`/proc/${pidText}/comm`).pipe(
391 Effect.map((name) => name.trim() || null),
392 Effect.orElseSucceed(() => null),
393 );
394 for (const inode of matched) owners.set(inode, { pid: Number(pidText), processName });
395 }
396 return owners;
397 });
398
399 /**
400 * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null
401 * when neither file is readable, so discovery falls back to common ports.
402 */
403 const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* (
404 terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner>,
405 ) {
406 if (hostPlatform !== "linux") return null;
407 const tables = yield* Effect.forEach(["/proc/net/tcp", "/proc/net/tcp6"], (path) =>
408 fileSystem.readFileString(path).pipe(Effect.option),
409 );
410 if (tables.every(Option.isNone)) return null;
411 const listeners = tables.flatMap((table) =>
412 Option.isSome(table) ? parseProcNetTcp(table.value) : [],
413 );
414 const known = yield* Ref.get(socketOwnersRef);
415 const knownMisses = yield* Ref.get(socketOwnerMissesRef);
416 const inodes = new Set(listeners.map((listener) => listener.inode));
417 // Listeners rarely change, so the fd walk runs only for sockets without a
418 // confirmed owner, and gives up on one after a few walks miss it (another
419 // user's process, or one that exited mid-walk).
420 const unseen = new Set(
421 [...inodes].filter(
422 (inode) => !known.has(inode) && (knownMisses.get(inode) ?? 0) < SOCKET_OWNER_ATTEMPTS,
423 ),
424 );
425 const found = unseen.size === 0 ? new Map() : yield* findSocketOwners(unseen);
426 const owners = new Map([...known, ...found].filter(([inode]) => inodes.has(inode)));
427 const misses = new Map([...knownMisses].filter(([inode]) => inodes.has(inode)));
428 for (const inode of unseen) {
429 if (!found.has(inode)) misses.set(inode, (misses.get(inode) ?? 0) + 1);
430 }
431 yield* Ref.set(socketOwnersRef, owners);
432 yield* Ref.set(socketOwnerMissesRef, misses);
433 const seen = new Map<number, DiscoveredLocalServer>();
434 for (const { port, inode } of listeners) {
435 const owner = owners.get(inode) ?? null;
436 const existing = seen.get(port);
437 if (existing && (existing.pid !== null || owner === null)) continue;
438 seen.set(port, {
439 host: "localhost",
440 port,
441 url: `http://localhost:${port}`,
442 processName: owner?.processName ?? null,
443 pid: owner?.pid ?? null,
444 terminal: owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null),
445 });
446 }
447 return [...seen.values()].toSorted((left, right) => left.port - right.port);
448 });
305449
306450 const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () {
307451 const results = yield* Effect.forEach(
......@@ -514,6 +658,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
514658 return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls);
515659 }
516660 const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");
661 if (yield* Ref.get(lsofMissingRef)) {
662 const fromProc = yield* scanProcListeners(terminalByProcessId);
663 return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls);
664 }
517665 const lsofResult = yield* processRunner
518666 .run({
519667 command: "lsof",
......@@ -525,7 +673,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
525673 .pipe(
526674 Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)),
527675 Effect.catchTags({
528 ProcessSpawnError: recoverLsofProbeFailure,
676 // A missing lsof stays missing; later scans skip straight to the fallback.
677 ProcessSpawnError: (error) =>
678 (isCommandNotFound(error) ? Ref.set(lsofMissingRef, true) : Effect.void).pipe(
679 Effect.andThen(recoverLsofProbeFailure(error)),
680 ),
529681 ProcessStdinError: recoverLsofProbeFailure,
530682 ProcessOutputLimitError: recoverLsofProbeFailure,
531683 ProcessReadError: recoverLsofProbeFailure,
......@@ -533,7 +685,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
533685 }),
534686 );
535687 if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls);
536 return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls);
688 const fromProc = yield* scanProcListeners(terminalByProcessId);
689 return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls);
537690 });
538691
539692 const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")(
apps/server/src/preview/PreviewBrowserHost.test.ts+31-5
......@@ -20,7 +20,11 @@ const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000)
2020const SANDBOX_ABORT =
2121 "[1005/163106.285700:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:129] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/";
2222
23const lddReporting = (stdout: string) => {
23// What the real loader prints when the browser's `--version` cannot start it.
24const LOADER_ERROR =
25 "chrome-headless-shell: error while loading shared libraries: libglib-2.0.so.0: cannot open shared object file: No such file or directory";
26
27const hostReporting = (output: { readonly version: string; readonly ldd: string }) => {
2428 const commands: Array<string> = [];
2529 const spawner = ChildProcessSpawner.make((command) =>
2630 Effect.sync(() => {
......@@ -36,9 +40,9 @@ const lddReporting = (stdout: string) => {
3640 kill: () => Effect.void,
3741 unref: Effect.succeed(Effect.void),
3842 stdin: Sink.drain,
39 stdout: Stream.make(new TextEncoder().encode(stdout)),
43 stdout: Stream.empty,
4044 stderr: Stream.empty,
41 all: Stream.empty,
45 all: Stream.make(new TextEncoder().encode(name === "ldd" ? output.ldd : output.version)),
4246 getInputFd: () => Sink.drain,
4347 getOutputFd: () => Stream.empty,
4448 });
......@@ -47,8 +51,16 @@ const lddReporting = (stdout: string) => {
4751 return { spawner, commands };
4852};
4953
50const diagnose = (input: { platform: NodeJS.Platform; output: string; ldd: string }) => {
51 const { spawner, commands } = lddReporting(input.ldd);
54const diagnose = (input: {
55 platform: NodeJS.Platform;
56 output: string;
57 ldd: string;
58 version?: string;
59}) => {
60 const { spawner, commands } = hostReporting({
61 ldd: input.ldd,
62 version: input.version ?? LOADER_ERROR,
63 });
5264 return PreviewBrowserHost.diagnoseLaunchFailure({
5365 executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell",
5466 output: input.output,
......@@ -89,12 +101,26 @@ describe("diagnoseLaunchFailure", () => {
89101 }),
90102 );
91103
104 it.effect("trusts the browser's own start over ldd", () =>
105 Effect.gen(function* () {
106 // NixOS: ldd lists libraries that nix-ld still provides to the real loader.
107 const { error } = yield* diagnose({
108 platform: "linux",
109 output: "crashed",
110 ldd: LDD_IN_BARE_DEBIAN,
111 version: "Google Chrome for Testing 154.0.8037.92\n",
112 });
113 expect(error).toBeUndefined();
114 }),
115 );
116
92117 it.effect("leaves other failures alone", () =>
93118 Effect.gen(function* () {
94119 const linux = yield* diagnose({
95120 platform: "linux",
96121 output: "crashed",
97122 ldd: "\tlibc.so.6 => /lib/libc.so.6\n",
123 version: "Google Chrome for Testing 154.0.8037.92\n",
98124 });
99125 expect(linux.error).toBeUndefined();
100126 const mac = yield* diagnose({ platform: "darwin", output: "crashed", ldd: "" });
apps/server/src/preview/PreviewBrowserHost.ts+23-10
......@@ -90,11 +90,13 @@ export class PreviewBrowserLibrariesError extends Schema.TaggedError<PreviewBrow
9090export type PreviewBrowserHostError = PreviewBrowserSandboxError | PreviewBrowserLibrariesError;
9191
9292const MISSING_LIBRARY = /^\s*(\S+) => not found$/gm;
93/** glibc's loader abort, naming the first library it could not load. */
94const LOADER_ERROR = /error while loading shared libraries: ([^:\s]+)/;
9395
9496/**
9597 * After a launch fails, names the host setup it is missing: the sandbox, from
96 * Chrome's own abort message, or shared libraries, from `ldd`. Undefined when
97 * neither explains it. Linux only; other hosts never need either.
98 * Chrome's own abort message, or shared libraries the loader refuses. Undefined
99 * when neither explains it. Linux only; other hosts never need either.
98100 */
99101export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunchFailure")(
100102 function* (input: {
......@@ -114,18 +116,29 @@ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunc
114116 },
115117);
116118
117/** Shared libraries the loader cannot find for `executable`, by `ldd`. */
119/**
120 * Shared libraries the loader cannot find for `executable`. The browser's own
121 * `--version` decides whether any are missing; `ldd` only lists them after the
122 * real loader fails, because it can report libraries a host provides another
123 * way (NixOS's nix-ld).
124 */
118125export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries")(function* (
119126 executable: string,
120127) {
121128 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
122 const report = yield* spawner
123 .string(ChildProcess.make("ldd", [executable], { stdin: "ignore", stderr: "ignore" }))
124 .pipe(
125 Effect.timeout("5 seconds"),
126 Effect.orElseSucceed(() => ""),
127 );
128 return [...report.matchAll(MISSING_LIBRARY)].map((match) => match[1]!);
129 const run = (command: string, args: ReadonlyArray<string>) =>
130 spawner
131 .string(ChildProcess.make(command, args, { stdin: "ignore" }), { includeStderr: true })
132 .pipe(
133 Effect.timeout("5 seconds"),
134 Effect.orElseSucceed(() => ""),
135 );
136 const loaderError = LOADER_ERROR.exec(yield* run(executable, ["--version"]))?.[1];
137 if (loaderError === undefined) return [];
138 const listed = [...(yield* run("ldd", [executable])).matchAll(MISSING_LIBRARY)].map(
139 (match) => match[1]!,
140 );
141 return listed.length > 0 ? listed : [loaderError];
129142});
130143
131144/**
apps/server/src/preview/ServerBrowser.test.ts+254-1
......@@ -16,6 +16,7 @@ import * as Fiber from "effect/Fiber";
1616import * as FileSystem from "effect/FileSystem";
1717import * as Layer from "effect/Layer";
1818import * as Option from "effect/Option";
19import * as PubSub from "effect/PubSub";
1920import * as Queue from "effect/Queue";
2021import * as Stream from "effect/Stream";
2122import type { BrowserContext, Page } from "playwright-core";
......@@ -31,6 +32,7 @@ import * as PreviewBrowser from "./PreviewBrowser.ts";
3132
3233// Keep the manager, broker, ownership, refs, and viewer paths real; replace Chromium I/O only.
3334vi.mock("./ServerBrowserContexts.ts", () => ({
35 presentAsChrome: async () => {},
3436 ServerBrowserContexts: class {
3537 private readonly onClose: ((context: BrowserContext) => void) | undefined;
3638 constructor(options: { onContextClose?: (context: BrowserContext) => void }) {
......@@ -153,7 +155,7 @@ let clipboardBinding: ClipboardBinding | null = null;
153155let contextFailure: Error | null = null;
154156/** Server tabs the fake desktop renders, and the endpoints the server connected to. */
155157let desktopRendersNext = false;
156/** Pages the fake desktop takes back; the channel's detached stream emits them. */
158/** Pages the fake desktop takes back or returns; the channel's streams emit them. */
157159const desktopDetaches = new NodeEvents.EventEmitter();
158160const desktopTabs = new Set<string>();
159161const desktopRenders = (tabId: string) => {
......@@ -209,6 +211,17 @@ const dependencies = Layer.mergeAll(
209211 (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)),
210212 ),
211213 ),
214 attached: Stream.callback<{ threadId: string; tabId: string }>((queue) =>
215 Effect.acquireRelease(
216 Effect.sync(() => {
217 const onAttach = (key: { threadId: string; tabId: string }) =>
218 Queue.offerUnsafe(queue, key);
219 desktopDetaches.on("attach", onAttach);
220 return onAttach;
221 }),
222 (onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)),
223 ),
224 ),
212225 isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)),
213226 endpoint: (key) =>
214227 Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () =>
......@@ -726,6 +739,225 @@ it.live("a popup becomes the agent's own tab and keeps its opener page", () =>
726739 ).pipe(Effect.provide(layer)),
727740);
728741
742it.live("a tab opened to a file the browser cannot show reports the file to download", () =>
743 Effect.scoped(
744 Effect.gen(function* () {
745 const browser = yield* ServerBrowser.ServerBrowser;
746 const manager = yield* Manager.PreviewManager;
747 const events = yield* manager.subscribeEvents;
748 yield* Effect.yieldNow;
749 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
750 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true));
751 const page = contexts[0]!.page;
752 const pdf = "https://example.com/paper.pdf";
753 const request = { url: () => pdf, method: () => "GET", isNavigationRequest: () => true };
754 // What Chromium reports when a navigation turns into a download.
755 page.emit("requestfailed", {
756 ...request,
757 frame: () => page,
758 failure: () => ({ errorText: "net::ERR_ABORTED" }),
759 });
760 // Stands in for Chromium, which writes the file itself.
761 const saved = yield* Queue.unbounded<string>();
762 const written = Promise.withResolvers<void>();
763 page.emit("download", {
764 failure: async () => null,
765 saveAs: (path: string) => {
766 Queue.offerUnsafe(saved, path);
767 return written.promise;
768 },
769 suggestedFilename: () => "paper.pdf",
770 url: () => pdf,
771 });
772 const fs = yield* FileSystem.FileSystem;
773 yield* fs.writeFileString(yield* Queue.take(saved), "%PDF");
774 written.resolve();
775 let status = yield* PubSub.take(events);
776 while (status.type !== "failed") status = yield* PubSub.take(events);
777 expect(status).toMatchObject({ url: pdf, download: { fileName: "paper.pdf" } });
778 // The tab shows the file, so no separate download toast is offered.
779 expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "download")).toBe(
780 false,
781 );
782 }),
783 ).pipe(Effect.provide(layer)),
784);
785
786it.live("a file a blank tab opened is offered to download if the tab moves on while it saves", () =>
787 Effect.scoped(
788 Effect.gen(function* () {
789 const browser = yield* ServerBrowser.ServerBrowser;
790 const manager = yield* Manager.PreviewManager;
791 yield* Effect.yieldNow;
792 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
793 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true));
794 yield* viewer.input({ type: "takeControl" });
795 const page = contexts[0]!.page;
796 const pdf = "https://example.com/paper.pdf";
797 const navigation = {
798 url: () => pdf,
799 method: () => "GET",
800 isNavigationRequest: () => true,
801 frame: () => page,
802 failure: () => ({ errorText: "net::ERR_ABORTED" }),
803 };
804 page.emit("request", navigation);
805 page.emit("requestfailed", navigation);
806 const saved = yield* Queue.unbounded<string>();
807 const written = Promise.withResolvers<void>();
808 page.emit("download", {
809 failure: async () => null,
810 saveAs: (path: string) => {
811 Queue.offerUnsafe(saved, path);
812 return written.promise;
813 },
814 suggestedFilename: () => "paper.pdf",
815 url: () => pdf,
816 });
817 const fs = yield* FileSystem.FileSystem;
818 yield* fs.writeFileString(yield* Queue.take(saved), "%PDF");
819 // The person navigates elsewhere while the file is still being written.
820 page.emit("request", {
821 url: () => "https://example.com/next",
822 method: () => "GET",
823 isNavigationRequest: () => true,
824 frame: () => page,
825 });
826 written.resolve();
827 let offered = yield* Queue.take(viewer.output);
828 while (offered._tag !== "download") offered = yield* Queue.take(viewer.output);
829 expect(offered).toMatchObject({ _tag: "download", fileName: "paper.pdf" });
830 }),
831 ).pipe(Effect.provide(layer)),
832);
833
834it.live("a download from a superseded navigation leaves the newer navigation loading", () =>
835 Effect.scoped(
836 Effect.gen(function* () {
837 const { broker, tabId } = yield* ready;
838 const page = contexts[0]!.page;
839 const pdf = "https://example.com/paper.pdf";
840 const navigation = {
841 url: () => pdf,
842 method: () => "GET",
843 isNavigationRequest: () => true,
844 frame: () => page,
845 failure: () => ({ errorText: "net::ERR_ABORTED" }),
846 };
847 page.emit("request", navigation);
848 page.emit("requestfailed", navigation);
849 const saved = yield* Queue.unbounded<string>();
850 const written = Promise.withResolvers<void>();
851 page.emit("download", {
852 failure: async () => null,
853 saveAs: (path: string) => {
854 Queue.offerUnsafe(saved, path);
855 return written.promise;
856 },
857 suggestedFilename: () => "paper.pdf",
858 url: () => pdf,
859 });
860 const fs = yield* FileSystem.FileSystem;
861 yield* fs.writeFileString(yield* Queue.take(saved), "%PDF");
862 // The person navigates elsewhere while the file is still being written.
863 page.emit("request", {
864 url: () => "https://example.com/next",
865 method: () => "GET",
866 isNavigationRequest: () => true,
867 frame: () => page,
868 });
869 written.resolve();
870 const status = () =>
871 broker.invoke<PreviewAutomationStatus>({ scope, tabId, operation: "status", input: {} });
872 while ((yield* status()).downloads?.length !== 1) yield* Effect.sleep("5 millis");
873 expect((yield* status()).loading).toBe(true);
874 }),
875 ).pipe(Effect.provide(layer)),
876);
877
878it.live(
879 "a late failure from a request that predates tracking leaves a newer navigation loading",
880 () =>
881 Effect.scoped(
882 Effect.gen(function* () {
883 const { broker, tabId } = yield* ready;
884 const page = contexts[0]!.page;
885 const navigation = (url: string) => ({
886 url: () => url,
887 method: () => "GET",
888 isNavigationRequest: () => true,
889 frame: () => page,
890 failure: () => ({ errorText: "net::ERR_CONNECTION_REFUSED" }),
891 });
892 page.emit("request", navigation("https://example.com/next"));
893 // A popup's first request can start before the tab listens for requests.
894 page.emit("requestfailed", navigation("https://example.com/first"));
895 const status = yield* broker.invoke<PreviewAutomationStatus>({
896 scope,
897 tabId,
898 operation: "status",
899 input: {},
900 });
901 expect(status.loading).toBe(true);
902 }),
903 ).pipe(Effect.provide(layer)),
904);
905
906it.live("an aborted navigation that no download explains stops loading", () =>
907 Effect.scoped(
908 Effect.gen(function* () {
909 const { broker, tabId } = yield* ready;
910 const page = contexts[0]!.page;
911 const navigation = {
912 url: () => "https://example.com/cancelled",
913 method: () => "GET",
914 isNavigationRequest: () => true,
915 frame: () => page,
916 failure: () => ({ errorText: "net::ERR_ABORTED" }),
917 };
918 const status = () =>
919 broker.invoke<PreviewAutomationStatus>({ scope, tabId, operation: "status", input: {} });
920 vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
921 try {
922 page.emit("request", navigation);
923 page.emit("requestfailed", navigation);
924 expect((yield* status()).loading).toBe(true);
925 vi.advanceTimersByTime(5_000);
926 } finally {
927 vi.useRealTimers();
928 }
929 expect((yield* status()).loading).toBe(false);
930 }),
931 ).pipe(Effect.provide(layer)),
932);
933
934it.live("a popup from a person's click is shown to that person, with its opener kept", () =>
935 Effect.scoped(
936 Effect.gen(function* () {
937 const browser = yield* ServerBrowser.ServerBrowser;
938 const manager = yield* Manager.PreviewManager;
939 yield* Effect.yieldNow;
940 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
941 const watcher = yield* browser.attachViewer(viewerInput(opened.tabId, false));
942 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true));
943 yield* viewer.input({ type: "takeControl" });
944 const opener = contexts[0]!.page;
945 const popup = makeContext();
946 opener.emit("popup", popup.page);
947 let shown = yield* Queue.take(viewer.output);
948 while (shown._tag !== "popup") shown = yield* Queue.take(viewer.output);
949 const sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions;
950 expect(sessions.map((session) => session.tabId)).toEqual(
951 expect.arrayContaining([opened.tabId, shown.tabId]),
952 );
953 expect((yield* Queue.clear(watcher.output)).some((item) => item._tag === "popup")).toBe(
954 false,
955 );
956 expect(opener.close).not.toHaveBeenCalled();
957 }),
958 ).pipe(Effect.provide(layer)),
959);
960
729961it.live("closing a tab while a viewer is still opening it does not leave its page behind", () =>
730962 Effect.scoped(
731963 Effect.gen(function* () {
......@@ -1090,3 +1322,24 @@ it.live("a desktop page the desktop takes back reconnects instead of closing", (
10901322 }),
10911323 ).pipe(Effect.provide(layer)),
10921324);
1325
1326it.live("a desktop page that comes back reconnects without waiting for a viewer", () =>
1327 Effect.scoped(
1328 Effect.gen(function* () {
1329 const manager = yield* Manager.PreviewManager;
1330 yield* ServerBrowser.ServerBrowser;
1331 yield* Effect.yieldNow;
1332 desktopRendersNext = true;
1333 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
1334 while (desktopConnections.length === 0) yield* Effect.yieldNow;
1335 while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow;
1336 // DevTools opened, then closed: the desktop withdraws the page and returns it.
1337 desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId });
1338 while (releasedDesktopTabs.length === 0) yield* Effect.yieldNow;
1339 desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId: opened.tabId });
1340 // Without a viewer or agent, the server drives the page again, so its URL keeps reaching clients.
1341 while (desktopConnections.length < 2) yield* Effect.yieldNow;
1342 expect(desktopConnections).toHaveLength(2);
1343 }),
1344 ).pipe(Effect.provide(layer)),
1345);
apps/server/src/preview/ServerBrowser.ts+141-7
......@@ -36,7 +36,11 @@ import {
3636 SERVER_BROWSER_AUTOMATION_CLIENT_ID,
3737 type PreviewAppearancePreference,
3838} from "@t3tools/contracts";
39import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
39import {
40 HostProcessArchitecture,
41 HostProcessEnvironment,
42 HostProcessPlatform,
43} from "@t3tools/shared/hostProcess";
4044import { normalizePreviewUrl } from "@t3tools/shared/preview";
4145import { resolvePreviewViewport } from "@t3tools/shared/previewViewport";
4246import * as NodeCrypto from "node:crypto";
......@@ -73,7 +77,7 @@ import * as PreviewManager from "./Manager.ts";
7377import * as ServerBrowserPage from "./ServerBrowserPage.ts";
7478import * as PreviewBrowser from "./PreviewBrowser.ts";
7579import * as PreviewBrowserHost from "./PreviewBrowserHost.ts";
76import { ServerBrowserContexts } from "./ServerBrowserContexts.ts";
80import { presentAsChrome, ServerBrowserContexts } from "./ServerBrowserContexts.ts";
7781import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts";
7882
7983const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID;
......@@ -185,6 +189,8 @@ export type ServerBrowserViewerOutput =
185189 readonly accept: string;
186190 }
187191 | { readonly _tag: "fileChooserClosed"; readonly id: string }
192 /** The page this viewer controls opened a new tab; the viewer shows it, as a browser would. */
193 | { readonly _tag: "popup"; readonly tabId: string }
188194 | {
189195 readonly _tag: "download";
190196 readonly id: string;
......@@ -311,6 +317,17 @@ interface ServerTab {
311317 closing: boolean;
312318 recording: Recording | null;
313319 initialNavigation: Promise<void> | null;
320 /** Counts main-frame navigation requests, so late events can tell they were superseded. */
321 navigationGeneration: number;
322 /**
323 * The latest main-frame navigation, when it aborted, to tell a navigation's
324 * download from a page's. The timer settles it if no download follows.
325 */
326 abortedNavigation: {
327 readonly url: string;
328 readonly generation: number;
329 readonly timer: ReturnType<typeof setTimeout>;
330 } | null;
314331 /** The latest queued start, so a stop can find a recording still starting. */
315332 recordingStart: Promise<Recording> | null;
316333 /** Serializes captures and recording start/stop. */
......@@ -336,6 +353,8 @@ const AGENT_TAB_IDLE_MS = 30 * 60 * 1000;
336353const IDLE_SWEEP_INTERVAL = "1 minute";
337354
338355const DOWNLOAD_LIMIT = 20;
356/** How long an aborted navigation waits for the download that usually explains it. */
357const ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS = 5_000;
339358const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024;
340359
341360const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`;
......@@ -435,6 +454,7 @@ const CLIPBOARD_SCRIPT = `(() => {
435454
436455const make = Effect.gen(function* () {
437456 const config = yield* ServerConfig.ServerConfig;
457 const host = { platform: yield* HostProcessPlatform, arch: yield* HostProcessArchitecture };
438458 const manager = yield* PreviewManager.PreviewManager;
439459 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
440460 const environment = yield* ServerEnvironment.ServerEnvironment;
......@@ -646,6 +666,7 @@ const make = Effect.gen(function* () {
646666 if (tabs.get(key) !== tab) return;
647667 tabs.delete(key);
648668 tab.closing = true;
669 clearAbortedNavigation(tab);
649670 // A desktop page outlives the connection unless its session closed with it.
650671 const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone";
651672 for (const viewer of tab.viewers) viewer.push({ _tag: end });
......@@ -723,6 +744,9 @@ const make = Effect.gen(function* () {
723744 if (!desktop) await prepareContext(context);
724745 const page = adopted?.page ?? desktop?.page ?? (await context.newPage());
725746 const cdp = await context.newCDPSession(page);
747 // The desktop's page is already a normal browser. A popup's first request
748 // went out before it became a tab; everything after presents as Chrome.
749 if (!desktop) await presentAsChrome(cdp, host);
726750 page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS);
727751 page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
728752 const control = new SessionControl(snapshot.automationOwner ?? null, () =>
......@@ -755,6 +779,8 @@ const make = Effect.gen(function* () {
755779 recording: null,
756780 recordingStart: null,
757781 initialNavigation: null,
782 navigationGeneration: 0,
783 abortedNavigation: null,
758784 captureLock: Promise.resolve(),
759785 capturing: 0,
760786 };
......@@ -764,8 +790,11 @@ const make = Effect.gen(function* () {
764790 await applyRendering(tab, snapshot);
765791 const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) =>
766792 request.isNavigationRequest() && request.frame() === page.mainFrame();
793 const navigationGenerations = new WeakMap<object, number>();
767794 page.on("request", (request) => {
768795 if (!isMainNavigation(request)) return;
796 navigationGenerations.set(request, ++tab.navigationGeneration);
797 clearAbortedNavigation(tab);
769798 tab.loading = true;
770799 report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" });
771800 });
......@@ -787,7 +816,28 @@ const make = Effect.gen(function* () {
787816 errorText,
788817 timestamp: new Date().toISOString(),
789818 });
790 if (!isMainNavigation(request) || errorText.includes("ERR_ABORTED")) return;
819 if (!isMainNavigation(request)) return;
820 // A navigation a newer one replaced leaves the status to the newer one. A
821 // popup's first request can predate the listener, so it is untracked and
822 // superseded by any tracked one.
823 const generation = navigationGenerations.get(request) ?? 0;
824 if (generation !== tab.navigationGeneration) return;
825 // An aborted navigation is usually a download; its `download` event settles the status.
826 if (errorText.includes("ERR_ABORTED")) {
827 const url = request.url();
828 const navigationGeneration = tab.navigationGeneration;
829 clearAbortedNavigation(tab);
830 tab.abortedNavigation = {
831 url,
832 generation: navigationGeneration,
833 timer: setTimeout(() => {
834 if (tab.abortedNavigation?.generation !== navigationGeneration) return;
835 tab.abortedNavigation = null;
836 if (!tab.closing) settleAbortedNavigation(tab, navigationGeneration, url, undefined);
837 }, ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS),
838 };
839 return;
840 }
791841 tab.loading = false;
792842 const { code, description } = ServerBrowserPage.parseNetError(errorText);
793843 report(tab, {
......@@ -854,9 +904,46 @@ const make = Effect.gen(function* () {
854904 NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"),
855905 );
856906
907 const clearAbortedNavigation = (tab: ServerTab) => {
908 if (tab.abortedNavigation !== null) clearTimeout(tab.abortedNavigation.timer);
909 tab.abortedNavigation = null;
910 };
911
912 /**
913 * Settles the status of a navigation that aborted, usually into a download.
914 * A tab opened straight to the file has no page to show, so it reports the
915 * file with its download; a page that linked to it keeps showing itself.
916 */
917 const settleAbortedNavigation = (
918 tab: ServerTab,
919 generation: number,
920 url: string,
921 offered: { readonly id: string; readonly fileName: string } | undefined,
922 ) => {
923 // A newer navigation owns the status.
924 if (tab.navigationGeneration !== generation) return;
925 tab.loading = false;
926 if (tab.page.url() !== "about:blank") {
927 void reportLoaded(tab);
928 return;
929 }
930 report(tab, {
931 _tag: "LoadFailed",
932 url: url.slice(0, 2048),
933 title: "",
934 code: -3,
935 description: "ERR_ABORTED",
936 ...(offered === undefined ? {} : { download: offered }),
937 });
938 };
939
857940 const saveDownload = async (tab: ServerTab, download: Download) => {
858941 const id = NodeCrypto.randomUUID();
859942 const path = NodePath.join(downloadDir(tab), id);
943 const navigation =
944 download.url() === tab.abortedNavigation?.url ? tab.abortedNavigation.generation : null;
945 if (navigation !== null) clearAbortedNavigation(tab);
946 let offered: { readonly id: string; readonly fileName: string } | undefined;
860947 try {
861948 if (await download.failure()) return;
862949 await NodeFSP.mkdir(downloadDir(tab), { recursive: true });
......@@ -878,6 +965,12 @@ const make = Effect.gen(function* () {
878965 for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) {
879966 await NodeFSP.rm(evicted.path, { force: true }).catch(constVoid);
880967 }
968 offered = { id, fileName: saved.fileName };
969 // A tab whose own address was the file shows it in place (see
970 // settleAbortedNavigation), unless it navigated on while the file saved.
971 const shownInTab =
972 navigation === tab.navigationGeneration && tab.page.url() === "about:blank";
973 if (shownInTab) return;
881974 // Only the person driving the page gets the file offered; agents read it from status.
882975 const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller);
883976 controller?.push({
......@@ -889,6 +982,10 @@ const make = Effect.gen(function* () {
889982 } catch (cause) {
890983 await NodeFSP.rm(path, { force: true }).catch(constVoid);
891984 runFork(Effect.logWarning("server preview download failed", { cause }));
985 } finally {
986 if (navigation !== null && !tab.closing) {
987 settleAbortedNavigation(tab, navigation, download.url(), offered);
988 }
892989 }
893990 };
894991
......@@ -1009,6 +1106,10 @@ const make = Effect.gen(function* () {
10091106 return;
10101107 }
10111108 const url = popup.url();
1109 // Captured now: the person whose click opened the popup gets switched to it.
1110 const controller = [...opener.viewers].find(
1111 (viewer) => viewer.id === opener.control.controller,
1112 );
10121113 await Effect.runPromise(
10131114 manager.open({
10141115 threadId: opener.threadId,
......@@ -1025,9 +1126,16 @@ const make = Effect.gen(function* () {
10251126 openerTabId: opener.tabId,
10261127 }),
10271128 }),
1028 ).catch(async () => {
1029 await popup.close().catch(constVoid);
1030 });
1129 ).then(
1130 (snapshot) => {
1131 // The opener stays open behind it, so `window.opener` can still report back.
1132 if (opener.control.agentId === null)
1133 controller?.push({ _tag: "popup", tabId: snapshot.tabId });
1134 },
1135 async () => {
1136 await popup.close().catch(constVoid);
1137 },
1138 );
10311139 };
10321140
10331141 const ensureTab = (snapshot: PreviewSessionSnapshot): Promise<ServerTab> => {
......@@ -1187,6 +1295,12 @@ const make = Effect.gen(function* () {
11871295 }
11881296 await navigation.catch((cause: unknown) => {
11891297 const message = cause instanceof Error ? cause.message : String(cause);
1298 if (message.includes("Download is starting")) {
1299 throw new ServerBrowserPage.ServerBrowserOperationError(
1300 "PreviewAutomationExecutionError",
1301 `${url} is a file this browser cannot show, so it was downloaded instead. preview_status lists it under downloads.`,
1302 );
1303 }
11901304 if (/ERR_[A-Z_]+/.test(message)) {
11911305 throw new ServerBrowserPage.ServerBrowserOperationError(
11921306 "PreviewAutomationExecutionError",
......@@ -1693,7 +1807,11 @@ const make = Effect.gen(function* () {
16931807 ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput),
16941808 );
16951809 case "evaluate":
1696 return ServerBrowserPage.evaluate(tab.cdp, input as PreviewAutomationEvaluateInput);
1810 return ServerBrowserPage.evaluate(
1811 tab.cdp,
1812 input as PreviewAutomationEvaluateInput,
1813 request.timeoutMs,
1814 );
16971815 case "waitFor":
16981816 return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput);
16991817 case "recordingStart": {
......@@ -2142,6 +2260,22 @@ const make = Effect.gen(function* () {
21422260 ),
21432261 Effect.forkScoped,
21442262 );
2263 // The page came back, for example after its DevTools closed. Reconnect now
2264 // rather than on the next viewer or agent, or the tab's URL and title stop
2265 // reaching every client in the meantime.
2266 yield* desktopChannel.attached.pipe(
2267 Stream.runForEach((key) =>
2268 Effect.gen(function* () {
2269 if (tabs.has(tabKey(key.threadId, key.tabId))) return;
2270 const { sessions } = yield* manager.list({ threadId: ThreadId.make(key.threadId) });
2271 const snapshot = sessions.find(
2272 (session) => session.tabId === key.tabId && session.runtime === "server",
2273 );
2274 if (snapshot) yield* Effect.promise(() => ensureTab(snapshot).catch(constVoid));
2275 }).pipe(Effect.ignore),
2276 ),
2277 Effect.forkScoped,
2278 );
21452279 yield* Effect.sync(closeIdleAgentTabs).pipe(
21462280 Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)),
21472281 Effect.forkScoped,
apps/server/src/preview/ServerBrowserContexts.ts+64-2
......@@ -3,8 +3,9 @@ import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
33import { constVoid } from "effect/Function";
44import * as NodeFSP from "node:fs/promises";
55import * as NodeModule from "node:module";
6import * as NodeOS from "node:os";
67import * as NodePath from "node:path";
7import type { Browser, BrowserContext } from "playwright-core";
8import type { Browser, BrowserContext, CDPSession } from "playwright-core";
89
910import { sandboxDisabled } from "./PreviewBrowserHost.ts";
1011
......@@ -23,6 +24,61 @@ interface Options {
2324 readonly onContextClose?: (context: BrowserContext) => void;
2425}
2526
27const UA_PLATFORMS: Partial<Record<NodeJS.Platform, string>> = {
28 darwin: "macOS",
29 linux: "Linux",
30 win32: "Windows",
31};
32
33/**
34 * The headless shell's identity as the Chrome it is built from: its user agent
35 * without `HeadlessChrome`, and client hints whose brands agree with it. Sites
36 * block a user agent that disagrees with `Sec-CH-UA`, so both change together.
37 */
38const chromeIdentity = (
39 headlessUserAgent: string,
40 host: { readonly platform: NodeJS.Platform; readonly arch: string; readonly release: string },
41) => {
42 const version = /HeadlessChrome\/((\d+)[\d.]*)/.exec(headlessUserAgent);
43 const fullVersion = version?.[1];
44 const major = version?.[2];
45 if (!version || !fullVersion || !major) return null;
46 // Chrome's GREASE brand and order for this major version, as the shell reports them.
47 const brands = (names: (version: string) => string) => [
48 { brand: "Chromium", version: names(major) },
49 { brand: "Google Chrome", version: names(major) },
50 { brand: "Not A(Brand", version: names("99") },
51 ];
52 return {
53 // Chrome reduces its user agent to the major version.
54 userAgent: headlessUserAgent.replace(version[0], `Chrome/${major}.0.0.0`),
55 userAgentMetadata: {
56 brands: brands((value) => value),
57 fullVersionList: brands((value) => (value === major ? fullVersion : `${value}.0.0.0`)),
58 platform: UA_PLATFORMS[host.platform] ?? "Unknown",
59 platformVersion: host.platform === "linux" ? host.release : "",
60 architecture: host.arch.startsWith("arm") ? "arm" : "x86",
61 bitness: host.arch.endsWith("64") ? "64" : "32",
62 model: "",
63 mobile: false,
64 wow64: false,
65 },
66 };
67};
68
69/**
70 * Presents a headless page as plain Chrome. Applies to the page, its frames,
71 * and its workers; a popup gets its own call once it becomes a tab.
72 */
73export const presentAsChrome = async (
74 cdp: CDPSession,
75 host: { readonly platform: NodeJS.Platform; readonly arch: string },
76) => {
77 const { userAgent } = await cdp.send("Browser.getVersion");
78 const identity = chromeIdentity(userAgent, { ...host, release: NodeOS.release() });
79 if (identity) await cdp.send("Emulation.setUserAgentOverride", identity);
80};
81
2682/** Persistent human profiles keep their storage; isolated agents share a browser, never a context. */
2783export class ServerBrowserContexts {
2884 private readonly options: Options;
......@@ -42,7 +98,13 @@ export class ServerBrowserContexts {
4298 return {
4399 executablePath,
44100 env,
45 args: ["--disable-gpu", "--force-device-scale-factor=2"],
101 // People drive these tabs, so they must not announce automation (navigator.webdriver).
102 args: [
103 "--disable-gpu",
104 "--force-device-scale-factor=2",
105 "--disable-blink-features=AutomationControlled",
106 ],
107 ignoreDefaultArgs: ["--enable-automation"],
46108 headless: true,
47109 // Only an explicit operator opt-out disables sandboxing. Launch errors never do.
48110 chromiumSandbox: !sandboxDisabled(env),
apps/server/src/preview/ServerBrowserPage.test.ts+31
......@@ -7,6 +7,7 @@ import {
77} from "playwright-core";
88import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test";
99
10import { presentAsChrome } from "./ServerBrowserContexts.ts";
1011import * as ServerBrowserPage from "./ServerBrowserPage.ts";
1112
1213describe("server browser element refs", () => {
......@@ -245,4 +246,34 @@ describe("server browser element refs", () => {
245246 ]);
246247 expect(await page.evaluate("window.seen")).toEqual(["click", "hover", "drop"]);
247248 });
249
250 it("presents a headless page as Chrome, with client hints that agree", async () => {
251 await presentAsChrome(cdp, { platform: "linux", arch: "x64" });
252 // userAgentData exists only in secure contexts; https comes from a route.
253 let secChUa: string | undefined;
254 await page.route("https://example.test/", (route) => {
255 secChUa = route.request().headers()["sec-ch-ua"];
256 return route.fulfill({ contentType: "text/html", body: "<p>hi</p>" });
257 });
258 await page.goto("https://example.test/");
259 const identity = (await page.evaluate(`(async () => ({
260 userAgent: navigator.userAgent,
261 brands: navigator.userAgentData.brands.map((brand) => brand.brand),
262 full: (await navigator.userAgentData.getHighEntropyValues(["fullVersionList"]))
263 .fullVersionList.map((brand) => brand.brand),
264 }))()`)) as { userAgent: string; brands: string[]; full: string[] };
265 expect(identity.userAgent).not.toContain("Headless");
266 expect(identity.userAgent).toMatch(/ Chrome\/\d+\.0\.0\.0 /);
267 expect(identity.brands).toContain("Google Chrome");
268 expect([...identity.brands, ...identity.full].join()).not.toContain("Headless");
269 expect(secChUa).toContain('"Google Chrome"');
270 expect(secChUa).not.toContain("Headless");
271 });
272
273 it("stops an evaluation at its deadline so the page answers the next one", async () => {
274 await expect(
275 ServerBrowserPage.evaluate(cdp, { expression: "for (;;) {}" }, 200),
276 ).rejects.toMatchObject({ tag: "PreviewAutomationTimeoutError" });
277 expect(await ServerBrowserPage.evaluate(cdp, { expression: "1 + 1" }, 2_000)).toBe(2);
278 });
248279});
apps/server/src/preview/ServerBrowserPage.ts+26-2
......@@ -410,12 +410,36 @@ export const scroll = async (page: Page, input: PreviewAutomationScrollInput) =>
410410 await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta);
411411};
412412
413export const evaluate = async (cdp: CDPSession, input: PreviewAutomationEvaluateInput) => {
414 const result = await cdp.send("Runtime.evaluate", {
413/**
414 * Runs an agent's expression, bounded by its request's deadline. Past it, the
415 * page's script is terminated and the call fails, so the tab's control queue
416 * frees for the next request instead of waiting on the script.
417 */
418export const evaluate = async (
419 cdp: CDPSession,
420 input: PreviewAutomationEvaluateInput,
421 timeoutMs: number,
422) => {
423 let timer: ReturnType<typeof setTimeout> | undefined;
424 const expired = new Promise<never>((_, reject) => {
425 timer = setTimeout(() => {
426 // Stops a busy script; an awaited promise is abandoned and settles unread.
427 void cdp.send("Runtime.terminateExecution").catch(constVoid);
428 reject(
429 new ServerBrowserOperationError(
430 "PreviewAutomationTimeoutError",
431 `Evaluation did not finish within ${timeoutMs}ms and was stopped.`,
432 ),
433 );
434 }, timeoutMs);
435 });
436 const evaluation = cdp.send("Runtime.evaluate", {
415437 expression: input.expression,
416438 awaitPromise: input.awaitPromise ?? true,
417439 returnByValue: input.returnByValue ?? true,
418440 });
441 void evaluation.catch(constVoid);
442 const result = await Promise.race([evaluation, expired]).finally(() => clearTimeout(timer));
419443 if (result.exceptionDetails) {
420444 throw new ServerBrowserOperationError(
421445 "PreviewAutomationExecutionError",
apps/server/src/preview/ServerBrowserStream.ts+1
......@@ -156,6 +156,7 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) =>
156156 case "download":
157157 case "fileChooser":
158158 case "fileChooserClosed":
159 case "popup":
159160 case "pointer":
160161 case "probe": {
161162 const { _tag: type, ...data } = output;
apps/server/src/project/ProjectService.deletion.test.ts+14-4
......@@ -197,9 +197,13 @@ it.effect("retries a partial project deletion without repeating child events or
197197 assert.lengthOf(partialEvents, 1);
198198 assert.equal(partialEvents[0]?.stream_id, firstThreadId);
199199 assert.equal(partialEvents[0]?.event_type, "thread.deleted");
200 assert.lengthOf(partialCleanup, 1);
201 assert.equal(partialCleanup[0]?.thread_id, firstThreadId);
202 assert.equal(partialCleanup[0]?.effect_type, "terminal.cleanup");
200 assert.deepEqual(
201 partialCleanup.map((effect) => [effect.thread_id, effect.effect_type]),
202 [
203 [firstThreadId, "preview.cleanup"],
204 [firstThreadId, "terminal.cleanup"],
205 ],
206 );
203207
204208 const deletedProject = yield* service.delete(input);
205209 assert.isNotNull(deletedProject.deletedAt);
......@@ -220,7 +224,7 @@ it.effect("retries a partial project deletion without repeating child events or
220224 assert.deepEqual(finalEvents[0], partialEvents[0]);
221225 assert.equal(finalEvents[2]?.command_id, commandId);
222226 const finalCleanup = yield* readCleanup;
223 assert.lengthOf(finalCleanup, 2);
227 assert.lengthOf(finalCleanup, 4);
224228 assert.deepEqual(
225229 finalCleanup.filter((effect) => effect.thread_id === firstThreadId),
226230 partialCleanup,
......@@ -230,6 +234,12 @@ it.effect("retries a partial project deletion without repeating child events or
230234 assert.deepEqual(
231235 finalCleanup.filter((effect) => effect.thread_id === threadId),
232236 [
237 {
238 effect_id: `effect:${expectedCommandId}:preview.cleanup`,
239 thread_id: threadId,
240 command_id: expectedCommandId,
241 effect_type: "preview.cleanup",
242 },
233243 {
234244 effect_id: `effect:${expectedCommandId}:terminal.cleanup`,
235245 thread_id: threadId,
apps/web/src/browser/ServerBrowserSurface.tsx+5
......@@ -153,6 +153,8 @@ export function ServerBrowserSurface(props: {
153153 readonly onFirstFrame?: () => void;
154154 readonly onViewport?: (viewport: PreviewStreamViewport) => void;
155155 readonly onControl?: (control: PreviewStreamControl | null) => void;
156 /** The page opened a new tab from this viewer's click; the viewer should switch to it. */
157 readonly onPopup?: (tabId: string) => void;
156158 readonly className?: string;
157159 readonly ref?: Ref<ServerBrowserHandle>;
158160}) {
......@@ -166,6 +168,7 @@ export function ServerBrowserSurface(props: {
166168 onFirstFrame,
167169 onViewport,
168170 onControl,
171 onPopup,
169172 className,
170173 ref,
171174 } = props;
......@@ -218,6 +221,7 @@ export function ServerBrowserSurface(props: {
218221 onViewport?.(viewport),
219222 );
220223 const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next));
224 const popupOpened = useEffectEvent((popupTabId: string) => onPopup?.(popupTabId));
221225 // Frame cap in device px, fixed per socket. It grows with the surface and
222226 // never shrinks, so only outgrowing it reconnects.
223227 const [cap, setCap] = useState<{ width: number; height: number } | null>(null);
......@@ -397,6 +401,7 @@ export function ServerBrowserSurface(props: {
397401 },
398402 onClipboard: (text) => void copyPageText(text),
399403 onDownload: offerDownload,
404 onPopup: popupOpened,
400405 onFileChooser: setFileChooser,
401406 onViewport: (viewport) => {
402407 viewportRef.current = viewport;
apps/web/src/components/preview/PreviewUnreachable.tsx+44
......@@ -13,6 +13,50 @@ interface Props {
1313 onReload: () => void;
1414}
1515
16/**
17 * A server tab's address was a file its browser cannot show (a PDF), so the
18 * browser downloaded it. Offers the address in the user's own browser, or the
19 * file the server already saved.
20 */
21export function PreviewFileNotShown({
22 url,
23 fileName,
24 downloadUrl,
25 onOpen,
26}: {
27 url: string;
28 fileName: string;
29 downloadUrl: string | null;
30 onOpen: () => void;
31}) {
32 const host = safeHost(url) ?? url;
33 return (
34 <div className="scrollbar-gutter-both relative flex h-full min-h-0 w-full overflow-y-auto bg-background">
35 <div className="mx-auto flex w-full max-w-xl flex-1 flex-col px-8 py-12 sm:py-16">
36 <ErrorIcon className="mb-6 size-12 text-muted-foreground/70" />
37 <h1 className="mb-3 text-2xl font-semibold leading-tight text-foreground">
38 This file can&rsquo;t be shown here
39 </h1>
40 <p className="text-sm leading-relaxed text-muted-foreground">
41 <span className="font-semibold text-foreground">{fileName}</span> from {host} was
42 downloaded instead.
43 </p>
44 <div className="mt-auto flex items-center gap-2 pt-8">
45 <div className="flex-1" />
46 <Button type="button" variant="outline" size="sm" onClick={onOpen}>
47 Open in browser
48 </Button>
49 {downloadUrl ? (
50 <Button type="button" size="sm" render={<a href={downloadUrl} download={fileName} />}>
51 Download
52 </Button>
53 ) : null}
54 </div>
55 </div>
56 </div>
57 );
58}
59
1660/** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */
1761export function PreviewUnreachable({ url, code, description, onReload }: Props) {
1862 const [showDetails, setShowDetails] = useState(false);
apps/web/src/components/preview/PreviewView.tsx+45-7
......@@ -1,6 +1,7 @@
11"use client";
22
33import { scopedThreadKey } from "@t3tools/client-runtime/environment";
4import { previewStreamDownloadUrl } from "@t3tools/client-runtime/preview/server-browser-stream";
45import {
56 isAtomCommandInterrupted,
67 squashAtomCommandFailure,
......@@ -16,7 +17,7 @@ import {
1617 PREVIEW_ZOOM_LEVELS,
1718 type PreviewAdjustInput,
1819} from "@t3tools/contracts";
19import { normalizePreviewUrl } from "@t3tools/shared/preview";
20import { normalizePreviewUrl, resolveAddressBarInput } from "@t3tools/shared/preview";
2021import { useCallback, useEffect, useRef, useState } from "react";
2122
2223import {
......@@ -37,6 +38,7 @@ import {
3738import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
3839import { useEnvironmentHttpBaseUrl } from "~/state/environments";
3940import { previewEnvironment } from "~/state/preview";
41import { usePreviewStreamAccess } from "~/state/previewStream";
4042import { useAtomCommand } from "~/state/use-atom-command";
4143import {
4244 browserMiniPlayerSource,
......@@ -49,6 +51,7 @@ import { useRightPanelStore } from "~/rightPanelStore";
4951import { previewBridge } from "./previewBridge";
5052import { subscribePreviewAction } from "./previewActionBus";
5153import { openPreviewSession } from "./openPreviewSession";
54import { showPreviewPopup } from "./showPreviewPopup";
5255import { PreviewChromeRow } from "./PreviewChromeRow";
5356import { PreviewEmptyState } from "./PreviewEmptyState";
5457import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu";
......@@ -61,7 +64,7 @@ import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar";
6164import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout";
6265import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
6366import { BrowserSettingsReadError } from "~/browser/openFileInPreview";
64import { PreviewUnreachable } from "./PreviewUnreachable";
67import { PreviewFileNotShown, PreviewUnreachable } from "./PreviewUnreachable";
6568import { revealInFileExplorerLabel } from "./fileExplorerLabel";
6669import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
6770import { Badge } from "~/components/ui/badge";
......@@ -103,6 +106,31 @@ function previewProfileName(
103106
104107const localApi = typeof window === "undefined" ? null : ensureLocalApi();
105108
109/** Resolves stream access only while a server tab shows a file it downloaded. */
110function ServerTabFileNotShown(props: {
111 readonly threadRef: ScopedThreadRef;
112 readonly tabId: string;
113 readonly url: string;
114 readonly download: { readonly id: string; readonly fileName: string };
115}) {
116 const access = usePreviewStreamAccess(props.threadRef.environmentId);
117 return (
118 <PreviewFileNotShown
119 url={props.url}
120 fileName={props.download.fileName}
121 downloadUrl={
122 access
123 ? previewStreamDownloadUrl(
124 { access, threadId: props.threadRef.threadId, tabId: props.tabId },
125 props.download.id,
126 )
127 : null
128 }
129 onOpen={() => void localApi?.shell.openExternal(props.url).catch(() => undefined)}
130 />
131 );
132}
133
106134/**
107135 * Single-tab preview surface: chrome row on top, one webview below, empty
108136 * state when no session exists for the thread.
......@@ -259,12 +287,12 @@ export function PreviewView({
259287 const handleSubmitUrl = useCallback(
260288 async (next: string) => {
261289 try {
262 const normalized = normalizePreviewUrl(next);
263 if (await navigateToResolvedUrl(normalized)) {
264 recordVisitForThread(threadRef, normalized);
290 const resolved = resolveAddressBarInput(next);
291 if (await navigateToResolvedUrl(resolved)) {
292 recordVisitForThread(threadRef, resolved);
265293 }
266294 } catch {
267 // Server-side `failed` event renders the unreachable view.
295 // Only empty input or an unsupported scheme lands here; the bar keeps the text.
268296 }
269297 },
270298 [navigateToResolvedUrl, threadRef],
......@@ -995,6 +1023,7 @@ export function PreviewView({
9951023 onControl={(control) =>
9961024 setServerControlledTabId(control?.controller === "you" ? runtimeTabId : null)
9971025 }
1026 onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "panel")}
9981027 // Stays connected under the empty state so a URL picked there reaches the page.
9991028 className={cn(
10001029 "absolute inset-0 h-full w-full",
......@@ -1043,7 +1072,16 @@ export function PreviewView({
10431072 controller={controller}
10441073 />
10451074 ) : null}
1046 {navStatus._tag === "LoadFailed" ? (
1075 {navStatus._tag === "LoadFailed" && navStatus.download ? (
1076 <div className="absolute inset-0 z-10 bg-background">
1077 <ServerTabFileNotShown
1078 threadRef={threadRef}
1079 tabId={snapshot?.tabId ?? ""}
1080 url={navStatus.url}
1081 download={navStatus.download}
1082 />
1083 </div>
1084 ) : navStatus._tag === "LoadFailed" ? (
10471085 <div className="absolute inset-0 z-10 bg-background">
10481086 <PreviewUnreachable
10491087 url={navStatus.url}
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+2
......@@ -49,6 +49,7 @@ import { DeviceStreamView } from "../device/DeviceStreamView";
4949import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream";
5050import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream";
5151import { previewBridge } from "./previewBridge";
52import { showPreviewPopup } from "./showPreviewPopup";
5253import {
5354 clampPreviewMiniPlayerPosition,
5455 NO_PREVIEW_MINI_PLAYER_OBSTACLES,
......@@ -226,6 +227,7 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly
226227 followSize={false}
227228 controlPosition="bottom"
228229 onViewport={setStreamViewport}
230 onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "floating")}
229231 className="size-full"
230232 />
231233 </div>
apps/web/src/components/preview/showPreviewPopup.ts created+24
......@@ -0,0 +1,24 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2
3import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
4import { whenPreviewTabKnown } from "~/previewStateStore";
5import { useRightPanelStore } from "~/rightPanelStore";
6
7/**
8 * Switches the viewer to a tab the page they drive just opened, as a browser
9 * does for a link click or `window.open`. A floating opener floats the popup;
10 * otherwise it becomes the panel's active tab. The opener stays open.
11 */
12export function showPreviewPopup(
13 threadRef: ScopedThreadRef,
14 popupTabId: string,
15 from: "panel" | "floating",
16): void {
17 whenPreviewTabKnown(threadRef, popupTabId, () => {
18 if (from === "floating") {
19 usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(popupTabId));
20 } else {
21 useRightPanelStore.getState().openBrowser(threadRef, popupTabId);
22 }
23 });
24}
apps/web/src/hooks/useThreadActions.ts+3
......@@ -50,6 +50,7 @@ import {
5050 readThreadShells,
5151} from "../state/entities";
5252import { useUiStateStore } from "../uiStateStore";
53import { clearThreadPreviewState } from "../previewStateStore";
5354import { useTerminalUiStateStore } from "../terminalUiStateStore";
5455import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
5556import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
......@@ -449,6 +450,7 @@ export function useThreadActions() {
449450 });
450451 if (result._tag === "Success") {
451452 refreshArchivedThreadsForEnvironment(target.environmentId);
453 clearThreadPreviewState(target);
452454 }
453455 return result;
454456 }
......@@ -555,6 +557,7 @@ export function useThreadActions() {
555557 threadRef,
556558 );
557559 clearTerminalUiState(threadRef);
560 clearThreadPreviewState(threadRef);
558561
559562 if (shouldNavigateToFallback) {
560563 const fallbackThread = fallbackThreadId
apps/web/src/previewStateStore.ts+40
......@@ -214,6 +214,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven
214214 title: event.title,
215215 code: event.code,
216216 description: event.description,
217 ...(event.download === undefined ? {} : { download: event.download }),
217218 },
218219 updatedAt: event.createdAt,
219220 };
......@@ -457,6 +458,35 @@ export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void {
457458 });
458459}
459460
461/**
462 * Runs `action` once the thread's preview state has the tab, which a popup's
463 * `opened` event may deliver after the stream that announced it. Gives up
464 * after `timeoutMs`. Returns a cancel function.
465 */
466export function whenPreviewTabKnown(
467 ref: ScopedThreadRef,
468 tabId: string,
469 action: () => void,
470 timeoutMs = 5_000,
471): () => void {
472 const atom = previewStateAtom(scopedThreadKey(ref));
473 if (appAtomRegistry.get(atom).sessions[tabId]) {
474 action();
475 return () => {};
476 }
477 const stop = () => {
478 clearTimeout(timer);
479 unsubscribe();
480 };
481 const unsubscribe = appAtomRegistry.subscribe(atom, (state) => {
482 if (!state.sessions[tabId]) return;
483 stop();
484 action();
485 });
486 const timer = setTimeout(stop, timeoutMs);
487 return stop;
488}
489
460490export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void {
461491 if (url.trim().length === 0) return;
462492 updateThreadPreviewState(ref, (current) => ({
......@@ -470,6 +500,16 @@ export function isPreviewSupportedInRuntime(): boolean {
470500 return Boolean(window.desktopBridge?.preview);
471501}
472502
503/**
504 * Forgets a deleted thread's previews. The server closes their sessions too,
505 * but the desktop host keeps a page for every session held here.
506 */
507export function clearThreadPreviewState(ref: ScopedThreadRef): void {
508 updateThreadPreviewState(ref, (current) =>
509 Object.keys(current.sessions).length === 0 ? current : EMPTY_THREAD_PREVIEW_STATE,
510 );
511}
512
473513export function resetPreviewStateForTests(): void {
474514 for (const threadKey of changedPreviewThreadKeys) {
475515 appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE);
packages/client-runtime/src/preview/serverBrowserStream.ts+7-1
......@@ -139,7 +139,8 @@ export async function uploadPreviewStreamFiles(
139139 if (!response.ok) throw new Error((await response.text()) || "The upload was refused.");
140140}
141141
142const previewStreamDownloadUrl = (
142/** A file a server tab downloaded, served with the stream's own access. */
143export const previewStreamDownloadUrl = (
143144 target: Pick<PreviewStreamTarget, "access" | "threadId" | "tabId">,
144145 id: string,
145146): string =>
......@@ -202,6 +203,8 @@ export interface PreviewStreamEvents {
202203 readonly onClipboard?: (text: string) => void;
203204 /** A file the page downloaded while this viewer had control. */
204205 readonly onDownload?: (download: PreviewStreamDownload) => void;
206 /** The page this viewer controls opened a new tab (a link or `window.open`). */
207 readonly onPopup?: (tabId: string) => void;
205208 /** The page opened a file picker (`null` once answered or replaced). */
206209 readonly onFileChooser?: (chooser: PreviewStreamFileChooser | null) => void;
207210 /** Input sent while disconnected is dropped. */
......@@ -286,6 +289,7 @@ export function createPreviewStreamClient(
286289 accept,
287290 phase,
288291 sequence,
292 tabId,
289293 } = message as Record<string, unknown>;
290294 if (
291295 type === "fileChooser" &&
......@@ -312,6 +316,8 @@ export function createPreviewStreamClient(
312316 typeof sequence === "number"
313317 ) {
314318 events.onPointer?.({ phase, x, y, sequence });
319 } else if (type === "popup" && typeof tabId === "string") {
320 events.onPopup?.(tabId);
315321 } else if (type === "clipboard" && typeof text === "string") {
316322 events.onClipboard?.(text);
317323 } else if (
packages/contracts/src/preview.ts+10
......@@ -143,6 +143,9 @@ export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Typ
143143
144144export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system";
145145
146/** A file a server tab downloaded, served by the tab's download route by this id. */
147const PreviewFileDownload = Schema.Struct({ id: Schema.String, fileName: Schema.String });
148
146149export const PreviewNavStatus = Schema.Union([
147150 Schema.TaggedStruct("Idle", {}),
148151 Schema.TaggedStruct("Loading", {
......@@ -158,6 +161,12 @@ export const PreviewNavStatus = Schema.Union([
158161 title: Title,
159162 code: Schema.Int,
160163 description: Schema.String,
164 /**
165 * Set when the address was a file the browser downloaded instead of a page
166 * it could show (a PDF in a server tab). The server tab's download route
167 * serves it by this id.
168 */
169 download: Schema.optional(PreviewFileDownload),
161170 }),
162171]);
163172export type PreviewNavStatus = typeof PreviewNavStatus.Type;
......@@ -347,6 +356,7 @@ const PreviewFailedEvent = Schema.Struct({
347356 title: Title,
348357 code: Schema.Int,
349358 description: Schema.String,
359 download: Schema.optional(PreviewFileDownload),
350360});
351361
352362const PreviewClosedEvent = Schema.Struct({
packages/shared/src/preview.test.ts+50
......@@ -5,8 +5,58 @@ import {
55 newPreviewTabId,
66 normalizePreviewUrl,
77 PreviewUrlNormalizationError,
8 resolveAddressBarInput,
89} from "./preview.ts";
910
11describe("resolveAddressBarInput", () => {
12 it("opens what users type as addresses", () => {
13 expect(resolveAddressBarInput("localhost:5173")).toBe("http://localhost:5173/");
14 expect(resolveAddressBarInput("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path");
15 expect(resolveAddressBarInput("my-box.tailnet.ts.net")).toBe("https://my-box.tailnet.ts.net/");
16 expect(resolveAddressBarInput("cnn.com")).toBe("https://cnn.com/");
17 expect(resolveAddressBarInput("devbox:8080")).toBe("https://devbox:8080/");
18 expect(resolveAddressBarInput(" https://example.com/a b ")).toBe("https://example.com/a%20b");
19 expect(resolveAddressBarInput("example.com:8080")).toBe("https://example.com:8080/");
20 expect(resolveAddressBarInput("[::1]:3000")).toBe("http://[::1]:3000/");
21 expect(resolveAddressBarInput("192.168.1.5:3000")).toBe("https://192.168.1.5:3000/");
22 });
23
24 it("searches for text that is not an address", () => {
25 expect(resolveAddressBarInput("weather")).toBe("https://duckduckgo.com/?q=weather");
26 expect(resolveAddressBarInput("how to center a div")).toBe(
27 "https://duckduckgo.com/?q=how%20to%20center%20a%20div",
28 );
29 expect(resolveAddressBarInput("what is cnn.com")).toBe(
30 "https://duckduckgo.com/?q=what%20is%20cnn.com",
31 );
32 expect(resolveAddressBarInput("what is https://example.com")).toBe(
33 "https://duckduckgo.com/?q=what%20is%20https%3A%2F%2Fexample.com",
34 );
35 expect(resolveAddressBarInput("what is 10:30")).toBe(
36 "https://duckduckgo.com/?q=what%20is%2010%3A30",
37 );
38 expect(resolveAddressBarInput("note: buy milk")).toBe(
39 "https://duckduckgo.com/?q=note%3A%20buy%20milk",
40 );
41 });
42
43 it("still rejects empty input and unsupported schemes", () => {
44 expect(() => resolveAddressBarInput(" ")).toThrow(PreviewUrlNormalizationError);
45 expect(() => resolveAddressBarInput("ftp://example.com")).toThrow(PreviewUrlNormalizationError);
46 expect(() => resolveAddressBarInput("mailto:alice@example.com")).toThrow(
47 PreviewUrlNormalizationError,
48 );
49 expect(() => resolveAddressBarInput("data:text/plain,hello")).toThrow(
50 PreviewUrlNormalizationError,
51 );
52 // A known scheme with a numeric payload is not a host and port.
53 for (const input of ["ftp:21", "tel:5551234"]) {
54 expect(() => resolveAddressBarInput(input)).toThrow(PreviewUrlNormalizationError);
55 }
56 expect(resolveAddressBarInput("devbox:8080")).toBe("https://devbox:8080/");
57 });
58});
59
1060describe("newPreviewTabId", () => {
1161 it("returns a unique tab id every call", () => {
1262 const a = newPreviewTabId();
packages/shared/src/preview.ts+68
......@@ -57,6 +57,74 @@ function previewUrlProtocol(rawUrl: string): string | undefined {
5757 return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":");
5858}
5959
60const SEARCH_URL = "https://duckduckgo.com/?q=";
61/** A bare host that is always an address: localhost, an IPv4 literal, or a bracketed IPv6 one. */
62const ADDRESS_HOST_PATTERN = /^(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f:.]+\])$/i;
63const BARE_HOST_PORT_PATTERN = /^[^/?#:@]+:\d+(?:[/?#]|$)/;
64/**
65 * Registered schemes that are never a bare host, so `ftp:21` or `tel:5551234`
66 * is rejected rather than read as host and port.
67 */
68const KNOWN_NON_WEB_SCHEMES: ReadonlySet<string> = new Set([
69 "about:",
70 "blob:",
71 "chrome:",
72 "data:",
73 "file:",
74 "ftp:",
75 "javascript:",
76 "mailto:",
77 "sms:",
78 "ssh:",
79 "tel:",
80 "view-source:",
81 "ws:",
82 "wss:",
83]);
84
85/**
86 * Turns what a user typed in an address bar into the URL to open: an address
87 * as `normalizePreviewUrl` reads it, or else a web search for the text. Text
88 * is an address when it has a scheme, or no spaces and a host that has a dot,
89 * a port, or is localhost or an IP. Throws only for empty input or an
90 * explicit unsupported scheme (`ftp://`, `mailto:`, `data:`).
91 */
92export function resolveAddressBarInput(rawInput: string): string {
93 const trimmed = rawInput.trim();
94 if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) || trimmed.length === 0)
95 return normalizePreviewUrl(trimmed);
96 if (!/\s/.test(trimmed)) {
97 // `mailto:x` or `data:...` is an explicit scheme; `example.com:8080` or
98 // `devbox:8080` is a bare host and port. `normalizePreviewUrl` would read
99 // `mailto:alice@example.com` as credentials on a bare host, so reject here.
100 const protocol = previewUrlProtocol(trimmed);
101 if (
102 protocol !== undefined &&
103 (KNOWN_NON_WEB_SCHEMES.has(protocol) || !BARE_HOST_PORT_PATTERN.test(trimmed))
104 ) {
105 if (protocol !== "http:" && protocol !== "https:") {
106 throw new PreviewUrlNormalizationError({
107 inputLength: rawInput.length,
108 reason: "unsupported-protocol",
109 protocol,
110 });
111 }
112 return normalizePreviewUrl(trimmed);
113 }
114 const authority = trimmed.split(/[/?#]/, 1)[0] ?? "";
115 const host = authority.replace(/^[^@]*@/, "").replace(/:\d+$/, "");
116 const hasPort = host.length < authority.replace(/^[^@]*@/, "").length;
117 if (hasPort || ADDRESS_HOST_PATTERN.test(host) || /^[^.]+(?:\.[^.]+)+\.?$/.test(host)) {
118 try {
119 return normalizePreviewUrl(trimmed);
120 } catch {
121 // Not a usable address after all; search for it.
122 }
123 }
124 }
125 return `${SEARCH_URL}${encodeURIComponent(trimmed)}`;
126}
127
60128/**
61129 * Normalise a free-form URL string into a fully-qualified `http(s)://` URL.
62130 *