From 2b27a4b3a617823c794dc6b48e4cd3fd1fa62670 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 00:36:06 -0700 Subject: [PATCH 01/13] feat(preview): show lifetimes and stop thread processes Show each managed preview expiry next to its terminal process. Offer a thread-only stop command when a preview or subprocess remains, with grouped menus across clients and cancellation that interrupts recovery. Carry-Group: incubator Carry-Observation: Preview lifecycle metadata and cancellation share one service and typed client contract, so the core change must travel together. --- .../components/MaterialMenuPopup.android.tsx | 97 ++-- .../features/threads/thread-list-v2-items.tsx | 116 +++- .../threads/thread-persistence-menu.test.ts | 39 ++ .../threads/thread-persistence-menu.ts | 31 + .../mobile/src/lib/inline-menu-groups.test.ts | 40 ++ apps/mobile/src/lib/inline-menu-groups.ts | 23 + apps/server/src/auth/RpcAuthorization.test.ts | 8 +- apps/server/src/auth/RpcAuthorization.ts | 2 + .../src/environment/ServerEnvironment.ts | 1 + .../src/mcp/toolkits/preview/handlers.ts | 15 + .../src/mcp/toolkits/preview/tools.test.ts | 2 +- apps/server/src/mcp/toolkits/preview/tools.ts | 14 + apps/server/src/preview/Hosting.test.ts | 534 ++++++++++++++++++ apps/server/src/preview/Hosting.ts | 209 +++++-- apps/server/src/ws.ts | 37 ++ apps/web/src/components/Sidebar.tsx | 31 +- .../sidebar/SidebarThreadHoverContent.tsx | 32 +- .../threadTerminalPresentation.test.ts | 72 +++ .../sidebar/threadTerminalPresentation.ts | 36 ++ .../components/threadActionMenu.logic.test.ts | 51 ++ .../src/components/threadActionMenu.logic.ts | 33 +- apps/web/src/hooks/useThreadActionMenu.ts | 23 + apps/web/src/state/previewHosting.ts | 64 +++ packages/client-runtime/src/rpc/client.ts | 1 + packages/client-runtime/src/state/preview.ts | 25 +- packages/contracts/src/environment.ts | 2 + packages/contracts/src/previewHosting.ts | 11 + packages/contracts/src/rpc.ts | 18 + 28 files changed, 1441 insertions(+), 126 deletions(-) create mode 100644 apps/mobile/src/lib/inline-menu-groups.test.ts create mode 100644 apps/mobile/src/lib/inline-menu-groups.ts create mode 100644 apps/web/src/components/sidebar/threadTerminalPresentation.test.ts create mode 100644 apps/web/src/components/sidebar/threadTerminalPresentation.ts create mode 100644 apps/web/src/state/previewHosting.ts diff --git a/apps/mobile/src/components/MaterialMenuPopup.android.tsx b/apps/mobile/src/components/MaterialMenuPopup.android.tsx index 4f9fe3d1ada4..cb16e49c31ef 100644 --- a/apps/mobile/src/components/MaterialMenuPopup.android.tsx +++ b/apps/mobile/src/components/MaterialMenuPopup.android.tsx @@ -4,12 +4,15 @@ import { DropdownMenu, DropdownMenuItem, Host, + HorizontalDivider, RNHostView, Text, } from "@expo/ui/jetpack-compose"; import { defaultMinSize, padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; import { View } from "react-native"; +import { Fragment } from "react"; import { resolveScaledTextRole } from "../lib/appearancePreferences"; +import { flattenInlineMenuGroups } from "../lib/inline-menu-groups"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -77,53 +80,55 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) { {props.title} ) : null} - {props.actions.map((action, index) => ( - props.onPress(action)} - > - {action.image && isAppSymbolName(action.image) ? ( - - - - ) : null} - - - - {action.title} - - {action.subtitle ? ( - - {action.subtitle} + {flattenInlineMenuGroups(props.actions).map(({ action, separatorBefore }, index) => ( + + {separatorBefore ? : null} + props.onPress(action)} + > + {action.image && isAppSymbolName(action.image) ? ( + + + + ) : null} + + + + {action.title} - ) : null} - - - {(action.subactions?.length ?? 0) > 0 ? ( - - - - ) : action.state === "on" ? ( - - - - ) : null} - + {action.subtitle ? ( + + {action.subtitle} + + ) : null} + + + {(action.subactions?.length ?? 0) > 0 ? ( + + + + ) : action.state === "on" ? ( + + + + ) : null} + + ))} ); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 2677474d69fd..addff6f1bf5f 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -11,6 +11,7 @@ import { RowPressable } from "../../components/RowPressable"; import { CustomSnoozeSheet } from "./CustomSnoozeSheet"; import { appAtomRegistry } from "../../state/atom-registry"; import { threadArrangementOpenAtom } from "../../state/thread-order"; +import { environmentServerConfigsAtom } from "../../state/server"; import type { ThreadMoveDestination } from "./threadOrder"; import type { EnvironmentProject, @@ -20,10 +21,14 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import type { EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; +import { useAtomValue } from "@effect/atom-react"; import { actionRunningPresentation } from "@t3tools/shared/actionResume"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; -import { Alert, Pressable, useWindowDimensions, View } from "react-native"; +import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import type { ThreadListProvider } from "../../state/thread-list-environments"; @@ -39,6 +44,7 @@ import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { threadEnvironment } from "../../state/threads"; import { terminalEnvironment } from "../../state/terminal"; +import { previewEnvironment } from "../../state/preview"; import { useAtomCommand } from "../../state/use-atom-command"; import { useThreadPr } from "../../state/use-thread-pr"; import { useSwipeRowDormant } from "../home/swipe-row-activation"; @@ -60,6 +66,7 @@ import { PersistentThreadIcon } from "./PersistentThreadIcon"; import { buildThreadPersistenceMenuItems, persistenceIntentForMenuEvent, + withThreadMenuDividers, } from "./thread-persistence-menu"; import { resolveWorktreeCleanupStatus, shouldShowActionWaitingIndicator } from "./thread-status"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -595,6 +602,38 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { reportFailure: false, }); const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); + const stopThreadProcesses = useAtomCommand(previewEnvironment.hostingStopThread, { + reportFailure: false, + }); + const hasRunningSubprocess = useAtomValue( + terminalEnvironment.metadata({ environmentId: thread.environmentId, input: null }), + useCallback( + (result) => + Option.exists(AsyncResult.value(result), (terminals) => + terminals.some( + (terminal) => terminal.threadId === thread.id && terminal.hasRunningSubprocess, + ), + ), + [thread.id], + ), + ); + const hasPreviewLease = useAtomValue( + previewEnvironment.hostingLeases({ environmentId: thread.environmentId, input: {} }), + useCallback( + (result) => + Option.exists(AsyncResult.value(result), (leases) => + leases.some((lease) => lease.threadId === thread.id), + ), + [thread.id], + ), + ); + const supportsProcessControls = useAtomValue( + environmentServerConfigsAtom, + (configs) => + configs.get(thread.environmentId)?.environment.capabilities.previewHostingProcessControl === + true, + ); + const hasManagedProcesses = supportsProcessControls && (hasRunningSubprocess || hasPreviewLease); const { providerDrivers, providerIconUrl } = useMemo(() => { const provider = props.providers?.find( @@ -703,6 +742,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } }, [closeTerminal, runningAction, thread.environmentId, thread.id]); + const handleStopThreadProcesses = useCallback(async () => { + const result = await stopThreadProcesses({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not stop all previews and processes", + error instanceof Error ? error.message : "The previews and processes could not be stopped.", + ); + } + }, [stopThreadProcesses, thread.environmentId, thread.id]); + const handleRetryWorktreeCleanup = useCallback(async () => { const result = await retryWorktreeCleanup({ environmentId: thread.environmentId, @@ -874,11 +927,21 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const withPersistence = useCallback( (actions: ReadonlyArray) => buildThreadPersistenceMenuItems({ - actions, + actions: hasManagedProcesses + ? [ + { + id: "stop-thread-processes", + title: "Stop all previews & processes", + image: "stop.fill", + attributes: { destructive: true }, + }, + ...actions, + ] + : actions, persistent: thread.persistent === true, supported: props.persistenceSupported, }), - [props.persistenceSupported, thread.persistent], + [hasManagedProcesses, props.persistenceSupported, thread.persistent], ); const snoozableCardMenuActions = useMemo( () => @@ -975,6 +1038,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { copyTextWithHaptic(thread.id, { target: "thread-id" }); } if (nativeEvent.event === "cancel-action") void handleCancelAction(); + if (nativeEvent.event === "stop-thread-processes") void handleStopThreadProcesses(); if (nativeEvent.event === "retry-worktree-cleanup") void handleRetryWorktreeCleanup(); if (nativeEvent.event === "keep-worktree") handleKeepWorktree(); const persistenceIntent = persistenceIntentForMenuEvent(nativeEvent.event); @@ -1000,6 +1064,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { thread, handleArchive, handleCancelAction, + handleStopThreadProcesses, handleKeepWorktree, handlePersistence, handleRetryWorktreeCleanup, @@ -1471,27 +1536,30 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? FAILED_CLEANUP_MENU_ACTIONS : cleanupPending ? [] - : [ - ...(thread.branch - ? [ - { - id: "new-thread-on-branch", - title: getThreadListV2NewBranchMenuTitle(thread.branch), - image: "square.and.pencil", - }, - ] - : []), - { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, - ...(snoozedRow - ? snoozedMenuActions - : !props.settlementSupported - ? legacyMenuActions - : canUnsettle - ? slimMenuActions - : swipeActions.secondary === "snooze" - ? snoozableCardMenuActions - : cardMenuActions), - ] + : withThreadMenuDividers( + [ + ...(thread.branch + ? [ + { + id: "new-thread-on-branch", + title: getThreadListV2NewBranchMenuTitle(thread.branch), + image: "square.and.pencil", + }, + ] + : []), + { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, + ...(snoozedRow + ? snoozedMenuActions + : !props.settlementSupported + ? legacyMenuActions + : canUnsettle + ? slimMenuActions + : swipeActions.secondary === "snooze" + ? snoozableCardMenuActions + : cardMenuActions), + ], + Platform.OS === "ios" || Platform.OS === "android", + ) } onPressAction={handleMenuAction} shouldOpenOnLongPress={cleanupFailed || !cleanupPending} diff --git a/apps/mobile/src/features/threads/thread-persistence-menu.test.ts b/apps/mobile/src/features/threads/thread-persistence-menu.test.ts index 9f85d7cca72d..510b2a470369 100644 --- a/apps/mobile/src/features/threads/thread-persistence-menu.test.ts +++ b/apps/mobile/src/features/threads/thread-persistence-menu.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildThreadPersistenceMenuItems, persistenceIntentForMenuEvent, + withThreadMenuDividers, } from "./thread-persistence-menu.ts"; describe("buildThreadPersistenceMenuItems", () => { @@ -41,3 +42,41 @@ describe("buildThreadPersistenceMenuItems", () => { expect(persistenceIntentForMenuEvent("archive")).toBeNull(); }); }); + +describe("thread menu dividers", () => { + const actions = [ + { id: "new-thread-on-branch", title: "New thread on feature" }, + { id: "annotate", title: "Annotate thread" }, + { id: "mark-persistent", title: "Mark as persistent thread" }, + { id: "stop-thread-processes", title: "Stop all previews & processes" }, + { id: "archive", title: "Archive" }, + ]; + + it("keeps persistence and stop together between inline native dividers", () => { + const groups = withThreadMenuDividers(actions, true); + expect(groups.map((group) => group.subactions?.map((action) => action.id))).toEqual([ + ["new-thread-on-branch"], + ["annotate"], + ["mark-persistent", "stop-thread-processes"], + ["archive"], + ]); + expect(groups.every((group) => group.displayInline === true)).toBe(true); + }); + + it("ends the persistence group when there is nothing to stop", () => { + const groups = withThreadMenuDividers( + actions.filter((action) => action.id !== "stop-thread-processes"), + true, + ); + expect(groups.map((group) => group.subactions?.map((action) => action.id))).toEqual([ + ["new-thread-on-branch"], + ["annotate"], + ["mark-persistent"], + ["archive"], + ]); + }); + + it("keeps commands directly accessible when inline groups are unsupported", () => { + expect(withThreadMenuDividers(actions, false)).toEqual(actions); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-persistence-menu.ts b/apps/mobile/src/features/threads/thread-persistence-menu.ts index 14440616248e..c42bc55b2557 100644 --- a/apps/mobile/src/features/threads/thread-persistence-menu.ts +++ b/apps/mobile/src/features/threads/thread-persistence-menu.ts @@ -6,6 +6,37 @@ export function persistenceIntentForMenuEvent(event: string): boolean | null { return null; } +/** Inline native groups place dividers without turning commands into submenus. */ +export function withThreadMenuDividers( + actions: ReadonlyArray, + supportsInlineGroups: boolean, +): MenuAction[] { + if (!supportsInlineGroups) return [...actions]; + const groups: MenuAction[][] = []; + let current: MenuAction[] = []; + actions.forEach((action, index) => { + current.push(action); + const endsGroup = + action.id === "new-thread-on-branch" || + action.id === "annotate" || + action.id === "stop-thread-processes" || + ((action.id === "mark-persistent" || action.id === "disable-persistence") && + actions[index + 1]?.id !== "stop-thread-processes"); + if (endsGroup) { + groups.push(current); + current = []; + } + }); + if (current.length > 0) groups.push(current); + if (groups.length < 2) return [...actions]; + return groups.map((subactions, index) => ({ + id: `thread-menu-group-${index}`, + title: "", + displayInline: true, + subactions, + })); +} + export function buildThreadPersistenceMenuItems(input: { readonly actions: ReadonlyArray; readonly persistent: boolean; diff --git a/apps/mobile/src/lib/inline-menu-groups.test.ts b/apps/mobile/src/lib/inline-menu-groups.test.ts new file mode 100644 index 000000000000..d1e364029621 --- /dev/null +++ b/apps/mobile/src/lib/inline-menu-groups.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; +import { flattenInlineMenuGroups } from "./inline-menu-groups"; + +describe("Android inline menu groups", () => { + it("keeps commands at the same level with dividers between groups", () => { + const rows = flattenInlineMenuGroups([ + { title: "", displayInline: true, subactions: [{ id: "new", title: "New thread" }] }, + { + title: "", + displayInline: true, + subactions: [ + { id: "persistent", title: "Mark persistent" }, + { id: "stop", title: "Stop processes" }, + ], + }, + { id: "archive", title: "Archive" }, + ]); + expect(rows.map(({ action, separatorBefore }) => [action.id, separatorBefore])).toEqual([ + ["new", false], + ["persistent", true], + ["stop", false], + ["archive", true], + ]); + }); + + it("preserves real submenus and skips empty or hidden groups", () => { + const submenu = { id: "copy", title: "Copy", subactions: [{ id: "id", title: "Thread ID" }] }; + expect( + flattenInlineMenuGroups([ + { title: "", displayInline: true, subactions: [] }, + { + title: "", + displayInline: true, + subactions: [{ title: "Hidden", attributes: { hidden: true } }], + }, + submenu, + ]), + ).toEqual([{ action: submenu, separatorBefore: false }]); + }); +}); diff --git a/apps/mobile/src/lib/inline-menu-groups.ts b/apps/mobile/src/lib/inline-menu-groups.ts new file mode 100644 index 000000000000..bdb24a479bd8 --- /dev/null +++ b/apps/mobile/src/lib/inline-menu-groups.ts @@ -0,0 +1,23 @@ +import type { MenuAction } from "@react-native-menu/menu"; + +/** Android renders UIKit-style inline groups as direct commands with dividers. */ +export function flattenInlineMenuGroups( + actions: ReadonlyArray, +): Array<{ action: MenuAction; separatorBefore: boolean }> { + const rows: Array<{ action: MenuAction; separatorBefore: boolean }> = []; + let afterGroup = false; + for (const action of actions) { + if (action.attributes?.hidden) continue; + if (action.displayInline && action.subactions) { + const group = flattenInlineMenuGroups(action.subactions); + if (group.length === 0) continue; + group[0]!.separatorBefore = rows.length > 0; + rows.push(...group); + afterGroup = true; + } else { + rows.push({ action, separatorBefore: afterGroup }); + afterGroup = false; + } + } + return rows; +} diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index c485b6c5427d..888b3936929b 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -61,13 +61,19 @@ describe("RPC authorization scopes", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope); }); - it("keeps hosted preview recovery read-only for listing and terminal-authorized for launch", () => { + it("keeps hosted preview observation read-only and process control terminal-authorized", () => { expect(requiredScopeForRpcMethod(WS_METHODS.previewHostingList)).toBe( AuthOrchestrationReadScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.previewHostingRecover)).toBe( AuthTerminalOperateScope, ); + expect(requiredScopeForRpcMethod(WS_METHODS.subscribePreviewHosting)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.previewHostingStopThread)).toBe( + AuthTerminalOperateScope, + ); }); it("requires permission to operate on a thread before uploading feedback", () => { diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 665cb5cc735e..69cd65e7d5dc 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -193,6 +193,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.previewList]: AuthOrchestrationReadScope, [WS_METHODS.previewHostingList]: AuthOrchestrationReadScope, [WS_METHODS.previewHostingRecover]: AuthTerminalOperateScope, + [WS_METHODS.previewHostingStopThread]: AuthTerminalOperateScope, + [WS_METHODS.subscribePreviewHosting]: AuthOrchestrationReadScope, [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope, [WS_METHODS.previewClaimRecovery]: AuthOrchestrationOperateScope, [WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index db16e7174c07..efddcc37686f 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -239,6 +239,7 @@ export const make = Effect.gen(function* () { usageModelAliases: true, threadPinning: true, threadPersistence: true, + previewHostingProcessControl: true, threadPinReorder: true, threadActiveReorder: true, threadAutoSettleOptOut: true, diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index a678249b4011..cdb54869c47c 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -192,6 +192,21 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( const isPreviewHostingError = Schema.is(PreviewHosting.PreviewHostingError); const handlers = { + preview_stop_thread: () => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.requireMcpCapability("preview"); + const hosting = yield* PreviewHosting.PreviewHosting; + return yield* hosting.stopThread(scope.threadId).pipe( + Effect.as({}), + Effect.mapError( + () => + new ContractPreviewHostingError({ + reason: "unavailable", + message: "Some previews or processes could not be stopped. Please try again.", + }), + ), + ); + }), preview_host: (input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.requireMcpCapability("preview"); diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts index 63a26e3b8330..c327fb6ba5d2 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.test.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -43,7 +43,7 @@ it("exports provider-compatible object schemas with described parameters", () => if (tool.name === "preview_navigate") { expect(schemaHasMultipleAllOfDescriptions(schema)).toBe(false); } - if (tool.name !== "preview_host") { + if (tool.name !== "preview_host" && tool.name !== "preview_stop_thread") { expect( schema.properties?.tabId, `${tool.name} must allow an explicit collaborative browser tab target`, diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 5cb4ba577c50..439ef4707ef2 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -80,6 +80,18 @@ const PreviewHostTool = Tool.make("preview_host", { .annotate(Tool.OpenWorld, true) .annotate(Tool.Destructive, true); +const PreviewStopThreadTool = Tool.make("preview_stop_thread", { + description: + "Permanently cancel every managed preview lease and stop all app-managed terminal processes in this agent's own thread. Stopped previews cannot restart when their old links are opened. Ordinary terminal history is preserved, and other threads and agent providers are untouched.", + success: PreviewActionResult, + failure: Schema.Union([PreviewHostingError, PreviewAutomationUnavailableError]), + dependencies: [McpInvocationContext.McpInvocationContext, PreviewHosting.PreviewHosting], +}) + .annotate(Tool.Title, "Stop thread previews and processes") + .annotate(Tool.OpenWorld, true) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true); + const PreviewOpenTool = browserTool( Tool.make("preview_open", { description: @@ -260,6 +272,7 @@ const PreviewRecordingStopTool = safeBrowserTool( export const PreviewToolkit = Toolkit.make( PreviewHostTool, + PreviewStopThreadTool, PreviewStatusTool, PreviewOpenTool, PreviewNavigateTool, @@ -278,6 +291,7 @@ export const PreviewToolkit = Toolkit.make( export const PreviewStandardToolkit = Toolkit.make( PreviewHostTool, + PreviewStopThreadTool, PreviewStatusTool, PreviewOpenTool, PreviewNavigateTool, diff --git a/apps/server/src/preview/Hosting.test.ts b/apps/server/src/preview/Hosting.test.ts index 0e392af3daaa..a30b68981910 100644 --- a/apps/server/src/preview/Hosting.test.ts +++ b/apps/server/src/preview/Hosting.test.ts @@ -19,6 +19,7 @@ import { PreviewHostingLeaseId, ThreadId, type DiscoveredLocalServer, + type PreviewHostingLeaseMetadata, type TerminalOpenInput, type TerminalSummary, } from "@t3tools/contracts"; @@ -36,6 +37,7 @@ const encodePersistedHostingState = Schema.encodeSync( }), ), ); +const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); interface TerminalHarness { readonly opens: TerminalOpenInput[]; @@ -233,6 +235,45 @@ function testTerminalHarness(overrides: Partial = {}): Terminal }; } +function terminalFixture( + threadId: string, + terminalId: string, + status: TerminalSummary["status"] = "running", + hasRunningSubprocess = true, +): TerminalSummary { + return { + threadId, + terminalId, + cwd: "/workspace", + worktreePath: "/workspace", + status, + pid: status === "running" ? 100 : null, + exitCode: null, + exitSignal: null, + hasRunningSubprocess, + label: terminalId, + updatedAt: "1970-01-01T00:00:00.000Z", + }; +} + +function leaseFixture( + overrides: Partial = {}, +): PreviewHosting.PreviewHostingLease { + return { + id: "preview-lease", + threadId: "thread-1", + terminalId: "preview-terminal", + command: "pnpm dev --port 5173", + cwd: "/workspace", + worktreePath: "/workspace", + url: PREVIEW_URL, + handedOffAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-02T00:00:00.000Z", + status: "active", + ...overrides, + }; +} + function hostingLayer( config: ServerConfig.ServerConfig["Service"], harness: TerminalHarness, @@ -306,6 +347,499 @@ function failFirstExpiredStateWriteLayer( } describe("PreviewHosting", () => { + it.effect( + "stops a preview immediately while recovery is waiting for a server that never becomes ready", + () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "preview-hosting-stop-readiness-", + }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const preview = leaseFixture(); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString( + `${config.stateDir}/preview-hosting.json`, + encodePersistedHostingState({ version: 1, leases: [preview] }), + ); + const wrote = yield* Deferred.make(); + const harness = testTerminalHarness({ + summaries: [terminalFixture("thread-1", "ordinary-terminal")], + onWrite: () => Deferred.succeed(wrote, undefined).pipe(Effect.asVoid), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const pendingRecovery = yield* Effect.forkScoped( + hosting.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(preview.id), + url: preview.url, + }), + ); + yield* Deferred.await(wrote); + yield* hosting.stopThread("thread-1"); + assert.isNull(yield* Fiber.join(pendingRecovery)); + assert.deepEqual(harness.summaries, []); + assert.deepEqual(yield* hosting.list(), []); + assert.include( + harness.closes.map((terminal) => terminal.terminalId), + "ordinary-terminal", + ); + }).pipe(Effect.provide(hostingLayer(config, harness, false))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "subscribes to initial and changing environment-wide lease summaries without launch secrets", + () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "preview-hosting-subscribe-" }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const first = leaseFixture({ + command: "SECRET=fixture-secret pnpm dev", + env: { SECRET: "fixture-secret" }, + }); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString( + `${config.stateDir}/preview-hosting.json`, + encodePersistedHostingState({ version: 1, leases: [first] }), + ); + const harness = testTerminalHarness(); + const snapshots: Array> = []; + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const unsubscribe = yield* hosting.subscribe((leases) => + Effect.sync(() => { + snapshots.push(leases); + }), + ); + assert.deepEqual(snapshots, [ + [ + { + leaseId: first.id, + threadId: ThreadId.make(first.threadId), + terminalId: first.terminalId, + url: first.url, + handedOffAt: first.handedOffAt, + expiresAt: first.expiresAt, + status: "active", + }, + ], + ]); + const second = yield* hosting.launch({ + threadId: "thread-2", + command: "pnpm dev --port 5174", + cwd: "/workspace/two", + env: { SECRET: "another-fixture-secret" }, + url: "http://localhost:5174/", + }); + assert.isTrue( + snapshots.some((leases) => + leases.some((lease) => lease.leaseId === second.id && lease.status === "starting"), + ), + ); + assert.isTrue( + snapshots + .at(-1) + ?.some((lease) => lease.leaseId === second.id && lease.status === "active"), + ); + assert.sameMembers(snapshots.at(-1)?.map((lease) => lease.threadId) ?? [], [ + "thread-1", + "thread-2", + ]); + const serializedSnapshots = yield* encodeUnknownJson(snapshots); + assert.notInclude(serializedSnapshots, "fixture-secret"); + assert.notInclude(serializedSnapshots, '"command"'); + assert.notInclude(serializedSnapshots, '"env"'); + assert.notInclude(serializedSnapshots, '"cwd"'); + yield* hosting.stopThread("thread-1"); + assert.deepEqual( + snapshots.at(-1)?.map((lease) => lease.leaseId), + [second.id], + ); + unsubscribe(); + const count = snapshots.length; + yield* hosting.stopThread("thread-2"); + assert.equal(snapshots.length, count); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "cannot miss a lease mutation while its initial subscription snapshot is being delivered", + () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "preview-hosting-subscribe-race-", + }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const initialStarted = yield* Deferred.make(); + const releaseInitial = yield* Deferred.make(); + const mutationStarted = yield* Deferred.make(); + const harness = testTerminalHarness(); + const snapshots: Array> = []; + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const pendingSubscription = yield* Effect.forkScoped( + hosting.subscribe((leases) => + Effect.gen(function* () { + snapshots.push(leases); + if (snapshots.length === 1) { + yield* Deferred.succeed(initialStarted, undefined); + yield* Deferred.await(releaseInitial); + } + }), + ), + ); + yield* Deferred.await(initialStarted); + const pendingLaunch = yield* Effect.forkScoped( + Deferred.succeed(mutationStarted, undefined).pipe( + Effect.andThen( + hosting.launch({ + threadId: "thread-1", + command: "pnpm dev --port 5173", + cwd: "/workspace", + url: PREVIEW_URL, + }), + ), + ), + ); + yield* Deferred.await(mutationStarted); + yield* Deferred.succeed(releaseInitial, undefined); + const unsubscribe = yield* Fiber.join(pendingSubscription); + const lease = yield* Fiber.join(pendingLaunch); + assert.deepEqual(snapshots[0], []); + assert.equal(snapshots.at(-1)?.[0]?.leaseId, lease.id); + assert.equal(snapshots.at(-1)?.[0]?.status, "active"); + unsubscribe(); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect.each([1, 3])( + "keeps an in-flight recovery cancelled even if terminal close fails (metadata phase: %s)", + (pauseAt) => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "preview-hosting-stop-recover-race-", + }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const preview = leaseFixture(); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString( + `${config.stateDir}/preview-hosting.json`, + encodePersistedHostingState({ version: 1, leases: [preview] }), + ); + const refreshStarted = yield* Deferred.make(); + const releaseRefresh = yield* Deferred.make(); + const cancelled = yield* Deferred.make(); + let refreshCount = 0; + const harness = testTerminalHarness({ + summaries: [terminalFixture("thread-1", "ordinary-terminal")], + failClose: (input) => pauseAt === 3 && input.terminalId === preview.terminalId, + onRefreshMetadata: () => + Effect.suspend(() => { + refreshCount++; + return refreshCount === pauseAt + ? Deferred.succeed(refreshStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRefresh)), + ) + : Effect.void; + }), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const unsubscribe = yield* hosting.subscribe((leases) => + leases.length === 0 + ? Deferred.succeed(cancelled, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + const pendingRecovery = yield* Effect.forkScoped( + hosting.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(preview.id), + url: preview.url, + }), + ); + yield* Deferred.await(refreshStarted); + const pendingStop = yield* Effect.forkScoped( + hosting.stopThread("thread-1").pipe(Effect.result), + ); + yield* Deferred.await(cancelled); + yield* Deferred.succeed(releaseRefresh, undefined); + assert.isNull(yield* Fiber.join(pendingRecovery)); + assert.equal( + (yield* Fiber.join(pendingStop))._tag, + pauseAt === 3 ? "Failure" : "Success", + ); + assert.equal(harness.opens.length, pauseAt === 3 ? 1 : 0); + assert.equal(harness.writes.length, pauseAt === 3 ? 1 : 0); + assert.include( + harness.closes.map((terminal) => terminal.terminalId), + "ordinary-terminal", + ); + assert.deepEqual(yield* hosting.list(), []); + unsubscribe(); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "cancels running and stopped previews and closes every terminal in only the owning thread", + () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "preview-hosting-stop-thread-" }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const running = leaseFixture(); + const stopped = leaseFixture({ + id: "stopped-preview", + terminalId: "stopped-preview-terminal", + url: "http://localhost:5174/", + }); + const other = leaseFixture({ + id: "other-preview", + threadId: "thread-2", + terminalId: "other-preview-terminal", + url: "http://localhost:5175/", + }); + const otherTerminal = terminalFixture("thread-2", other.terminalId); + const harness = testTerminalHarness({ + summaries: [ + terminalFixture("thread-1", running.terminalId), + terminalFixture("thread-1", stopped.terminalId, "exited", false), + terminalFixture("thread-1", "command-terminal"), + terminalFixture("thread-1", "idle-shell", "running", false), + terminalFixture("thread-1", "stopped-shell", "exited", false), + otherTerminal, + ], + }); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString( + `${config.stateDir}/preview-hosting.json`, + encodePersistedHostingState({ version: 1, leases: [running, stopped, other] }), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + assert.equal((yield* hosting.list("thread-1")).length, 2); + yield* hosting.stopThread("thread-1"); + assert.deepEqual(yield* hosting.list("thread-1"), []); + assert.deepEqual(yield* hosting.list("thread-2"), [other]); + for (const lease of [running, stopped]) { + assert.isNull( + yield* hosting.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(lease.id), + url: lease.url, + }), + ); + } + assert.deepEqual(harness.summaries, [otherTerminal]); + assert.sameMembers( + harness.closes.map((terminal) => terminal.terminalId), + [ + running.terminalId, + stopped.terminalId, + "command-terminal", + "idle-shell", + "stopped-shell", + ], + ); + assert.isTrue(harness.closes.every((terminal) => terminal.threadId === "thread-1")); + assert.sameMembers( + harness.historyDeletes.map((terminal) => terminal.terminalId), + [running.terminalId, stopped.terminalId], + ); + assert.deepEqual(harness.opens, []); + assert.equal( + yield* fs.readFileString(`${config.stateDir}/preview-hosting.json`), + `${encodePersistedHostingState({ version: 1, leases: [other] })}\n`, + ); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const restarted = yield* PreviewHosting.PreviewHosting; + assert.isNull( + yield* restarted.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(running.id), + url: running.url, + }), + ); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("attempts every ordinary terminal even when preview and terminal cleanup fail", () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "preview-hosting-stop-failure-" }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const preview = leaseFixture(); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString( + `${config.stateDir}/preview-hosting.json`, + encodePersistedHostingState({ version: 1, leases: [preview] }), + ); + let shouldFail = true; + const harness = testTerminalHarness({ + summaries: [ + terminalFixture("thread-1", preview.terminalId), + terminalFixture("thread-1", "failing-command"), + terminalFixture("thread-1", "other-command"), + terminalFixture("thread-2", "unrelated-command"), + ], + failClose: (input) => + shouldFail && + (input.terminalId === preview.terminalId || input.terminalId === "failing-command"), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const result = yield* hosting.stopThread("thread-1").pipe(Effect.result); + assert.equal(result._tag, "Failure"); + assert.include( + harness.closes.map((terminal) => terminal.terminalId), + "failing-command", + ); + assert.include( + harness.closes.map((terminal) => terminal.terminalId), + "other-command", + ); + assert.notInclude( + harness.closes.map((terminal) => terminal.terminalId), + "unrelated-command", + ); + assert.deepEqual(yield* hosting.list("thread-1"), []); + assert.include( + yield* fs.readFileString(`${config.stateDir}/preview-hosting.json`), + '"status":"expired"', + ); + assert.isNull( + yield* hosting.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(preview.id), + url: preview.url, + }), + ); + shouldFail = false; + yield* hosting.stopThread("thread-1"); + assert.deepEqual(harness.summaries, [terminalFixture("thread-2", "unrelated-command")]); + assert.deepEqual(yield* hosting.list(), []); + assert.notInclude( + harness.historyDeletes.map((terminal) => terminal.terminalId), + "failing-command", + ); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "keeps cancelled previews unrecoverable and stops ordinary terminals after a persistence failure", + () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "preview-hosting-stop-persist-" }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const preview = leaseFixture(); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString( + `${config.stateDir}/preview-hosting.json`, + encodePersistedHostingState({ version: 1, leases: [preview] }), + ); + const failedWrite = yield* Deferred.make(); + const releaseFailure = yield* Deferred.make(); + const persistedEmptyState = yield* Deferred.make(); + yield* Deferred.succeed(releaseFailure, undefined); + const harness = testTerminalHarness({ + summaries: [ + terminalFixture("thread-1", preview.terminalId), + terminalFixture("thread-1", "command-terminal"), + ], + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const result = yield* hosting.stopThread("thread-1").pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") assert.equal(result.failure._tag, "PreviewHostingError"); + assert.isTrue(yield* Deferred.isDone(failedWrite)); + assert.isTrue(yield* Deferred.isDone(persistedEmptyState)); + assert.sameMembers( + harness.closes.map((terminal) => terminal.terminalId), + [preview.terminalId, "command-terminal"], + ); + assert.deepEqual(harness.summaries, []); + assert.isNull( + yield* hosting.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(preview.id), + url: preview.url, + }), + ); + assert.equal( + yield* fs.readFileString(`${config.stateDir}/preview-hosting.json`), + `${encodePersistedHostingState({ version: 1, leases: [] })}\n`, + ); + }).pipe( + Effect.provide( + hostingLayer( + config, + harness, + true, + [], + true, + failFirstExpiredStateWriteLayer(failedWrite, releaseFailure, persistedEmptyState), + ), + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("launches a named terminal and fixes its 24-hour expiry at handoff", () => Effect.gen(function* () { yield* TestClock.setTime(1_000); diff --git a/apps/server/src/preview/Hosting.ts b/apps/server/src/preview/Hosting.ts index 96efd16f073a..1f253c19dccd 100644 --- a/apps/server/src/preview/Hosting.ts +++ b/apps/server/src/preview/Hosting.ts @@ -5,6 +5,7 @@ import { ThreadId, PreviewHostingLeaseId, type PreviewHostingLeaseSummary as ContractPreviewHostingLeaseSummary, + type PreviewHostingLeaseMetadata, type TerminalOpenInput, type DiscoveredLocalServer, } from "@t3tools/contracts"; @@ -129,6 +130,9 @@ export class PreviewHosting extends Context.Service< readonly list: ( threadId?: string, ) => Effect.Effect, PreviewHostingError>; + readonly subscribe: ( + listener: (leases: ReadonlyArray) => Effect.Effect, + ) => Effect.Effect<() => void, PreviewHostingError>; readonly ownsTerminal: ( threadId: string, terminalId: string, @@ -136,6 +140,9 @@ export class PreviewHosting extends Context.Service< readonly removeThread: ( threadId: string, ) => Effect.Effect; + readonly stopThread: ( + threadId: string, + ) => Effect.Effect; readonly protectedWorkspacePaths: () => Effect.Effect< ReadonlyArray, PreviewHostingError @@ -267,6 +274,44 @@ const make = Effect.gen(function* () { ), ); const leasesRef = yield* SynchronizedRef.make>(initialLeases); + const listeners = new Set< + (leases: ReadonlyArray) => Effect.Effect + >(); + + const activeLeases = ( + leases: ReadonlyArray, + currentTime: number, + threadId?: string, + ) => + leases + .filter( + (lease) => + lease.status !== "expired" && + Date.parse(lease.expiresAt) > currentTime && + (threadId === undefined || lease.threadId === threadId), + ) + .toSorted((left, right) => left.expiresAt.localeCompare(right.expiresAt)); + + const leaseSummaries = (leases: ReadonlyArray) => + Effect.map(DateTime.now, (now) => + activeLeases(leases, DateTime.toEpochMillis(now)).map((lease) => ({ + ...toPreviewHostingLeaseSummary(lease), + terminalId: lease.terminalId, + })), + ); + + const publishLeases = Effect.fnUntraced(function* (leases: ReadonlyArray) { + if (listeners.size === 0) return; + const summaries = yield* leaseSummaries(leases); + yield* Effect.forEach([...listeners], (listener) => listener(summaries), { + concurrency: "unbounded", + discard: true, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to deliver preview lease update", { cause }), + ), + ); + }); const persistState = (leases: ReadonlyArray) => writeFileStringAtomically({ @@ -299,6 +344,7 @@ const make = Effect.gen(function* () { const [result, next] = f(current); yield* persistState(next); yield* SynchronizedRef.set(leasesRef, next); + yield* publishLeases(next); if (wakeWorker) yield* Queue.offer(wakeups, undefined); return result; }), @@ -391,7 +437,15 @@ const make = Effect.gen(function* () { let timeoutMs = 0; const result = yield* Effect.scoped( Effect.gen(function* () { - const ready = yield* Deferred.make(); + const ready = yield* Deferred.make(); + yield* Effect.acquireRelease( + subscribe((leases) => + leases.some((entry) => entry.leaseId === lease.id) + ? Effect.void + : Deferred.succeed(ready, null).pipe(Effect.asVoid), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ); const listener = (servers: ReadonlyArray) => { const expectedUrl = discoveryUrl(lease.url); const responding = servers.find((server) => discoveryUrl(server.url) === expectedUrl); @@ -413,7 +467,7 @@ const make = Effect.gen(function* () { const currentTime = yield* nowMillis; const remainingLeaseMs = Math.max(0, Date.parse(lease.expiresAt) - currentTime); timeoutMs = Math.min(READY_TIMEOUT_MS, remainingLeaseMs); - if (timeoutMs === 0) return Option.none(); + if (timeoutMs === 0) return Option.none(); return yield* Deferred.await(ready).pipe(Effect.timeoutOption(Duration.millis(timeoutMs))); }), ); @@ -460,13 +514,16 @@ const make = Effect.gen(function* () { const launchCommandIfNeeded = (lease: PreviewHostingLease) => Effect.gen(function* () { const summary = yield* terminalSummary(lease); - if (summary?.status === "running" && summary.hasRunningSubprocess) return; + const latest = yield* findLease(lease.id); + if (latest === null || latest.status === "expired") return false; + if (summary?.status === "running" && summary.hasRunningSubprocess) return true; yield* terminals.open(openInput(lease)); yield* terminals.write({ threadId: lease.threadId, terminalId: lease.terminalId, data: lease.command.endsWith("\n") ? lease.command : `${lease.command}\n`, }); + return true; }); const closeOwnedTerminal = (lease: PreviewHostingLease) => @@ -512,8 +569,16 @@ const make = Effect.gen(function* () { const startsHandoff = lease.status === "starting"; yield* verifyLaunchOwnership(lease); - yield* launchCommandIfNeeded(lease); - yield* waitForReady(lease); + if (!(yield* launchCommandIfNeeded(lease))) { + const latest = yield* findLease(lease.id); + if (latest !== null) yield* expireLocked(latest); + return null; + } + if ((yield* waitForReady(lease)) === null) { + const latest = yield* findLease(lease.id); + if (latest !== null) yield* expireLocked(latest); + return null; + } const afterReady = yield* findLease(lease.id); const completedAt = yield* nowMillis; if ( @@ -544,8 +609,16 @@ const make = Effect.gen(function* () { : {}), status: "active" as const, }; - yield* setLease(active); - return active; + const committed = yield* changeLeases((leases) => { + const latest = leases.find((entry) => entry.id === lease.id); + if (latest === undefined || latest.status === "expired") return [null, leases]; + return [active, leases.map((entry) => (entry.id === lease.id ? active : entry))]; + }); + if (committed === null) { + const latest = yield* findLease(lease.id); + if (latest !== null) yield* expireLocked(latest); + } + return committed; }), ); @@ -557,17 +630,23 @@ const make = Effect.gen(function* () { // A concurrent launch may already have handed this reservation off. if (latest === null || latest.status === "active") return; const expired = { ...latest, status: "expired" as const }; - yield* SynchronizedRef.update(leasesRef, (leases) => - leases.map((entry) => (entry.id === expired.id ? expired : entry)), - ); - yield* setLease(expired, false).pipe( - Effect.catch((error) => - Effect.logWarning("failed to persist failed preview lease cleanup state", { - threadId: lease.threadId, - terminalId: lease.terminalId, - error: error.message, - }), - ), + yield* persistLock.withPermit( + Effect.gen(function* () { + const next = (yield* SynchronizedRef.get(leasesRef)).map((entry) => + entry.id === expired.id ? expired : entry, + ); + yield* persistState(next).pipe( + Effect.catch((error) => + Effect.logWarning("failed to persist failed preview lease cleanup state", { + threadId: lease.threadId, + terminalId: lease.terminalId, + error: error.message, + }), + ), + ); + yield* SynchronizedRef.set(leasesRef, next); + yield* publishLeases(next); + }), ); yield* expireLocked(expired).pipe( Effect.catch((error) => @@ -812,16 +891,24 @@ const make = Effect.gen(function* () { if (startupError !== null) return yield* startupError; const currentTime = yield* nowMillis; const leases = yield* SynchronizedRef.get(leasesRef); - return leases - .filter( - (lease) => - lease.status !== "expired" && - Date.parse(lease.expiresAt) > currentTime && - (threadId === undefined || lease.threadId === threadId), - ) - .toSorted((left, right) => left.expiresAt.localeCompare(right.expiresAt)); + return activeLeases(leases, currentTime, threadId); }); + const subscribe: PreviewHosting["Service"]["subscribe"] = (listener) => + persistLock.withPermit( + Effect.gen(function* () { + if (startupError !== null) return yield* startupError; + const summaries = yield* leaseSummaries(yield* SynchronizedRef.get(leasesRef)); + // Registration and the initial snapshot share the mutation lock so no + // lease update can fall between them. + yield* listener(summaries); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + ); + const ownsTerminal: PreviewHosting["Service"]["ownsTerminal"] = (threadId, terminalId) => Effect.gen(function* () { if (startupError !== null) return yield* startupError; @@ -836,30 +923,64 @@ const make = Effect.gen(function* () { ); }); + const removeThreadLocked = Effect.fnUntraced(function* (threadId: string) { + if (startupError !== null) return yield* startupError; + const { owned, cancellation } = yield* persistLock.withPermit( + Effect.uninterruptible( + Effect.gen(function* () { + const current = yield* SynchronizedRef.get(leasesRef); + const owned = current.filter((lease) => lease.threadId === threadId); + const next = current.map((lease) => + lease.threadId === threadId ? { ...lease, status: "expired" as const } : lease, + ); + const cancellation = yield* (owned.length === 0 ? Effect.void : persistState(next)).pipe( + Effect.result, + ); + // Even a failed disk write must prevent an in-flight recovery from + // restarting a preview while best-effort terminal cleanup runs. + yield* SynchronizedRef.set(leasesRef, next); + yield* publishLeases(next); + yield* Queue.offer(wakeups, undefined); + return { owned, cancellation }; + }), + ), + ); + const results = yield* Effect.forEach( + owned, + (lease) => + withLeaseLock( + lease.id, + Effect.gen(function* () { + const latest = yield* findLease(lease.id); + if (latest === null || latest.threadId !== threadId) return; + yield* closeOwnedTerminal(latest); + yield* removeLease(latest.id); + }), + ).pipe(Effect.result), + { concurrency: "unbounded" }, + ); + if (cancellation._tag === "Failure") return yield* cancellation.failure; + const failure = results.find((result) => result._tag === "Failure"); + if (failure?._tag === "Failure") return yield* failure.failure; + }); + const removeThread: PreviewHosting["Service"]["removeThread"] = (threadId) => + launchGate.withPermit(removeThreadLocked(threadId)); + + const stopThread: PreviewHosting["Service"]["stopThread"] = (threadId) => launchGate.withPermit( Effect.gen(function* () { - if (startupError !== null) return yield* startupError; - const owned = (yield* SynchronizedRef.get(leasesRef)).filter( - (lease) => lease.threadId === threadId, + const previews = yield* removeThreadLocked(threadId).pipe(Effect.result); + const sessions = (yield* terminals.metadata).filter( + (terminal) => terminal.threadId === threadId, ); const results = yield* Effect.forEach( - owned, - (lease) => - withLeaseLock( - lease.id, - Effect.gen(function* () { - const latest = yield* findLease(lease.id); - if (latest === null || latest.threadId !== threadId) return; - const expired = - latest.status === "expired" ? latest : { ...latest, status: "expired" as const }; - if (expired !== latest) yield* setLease(expired); - yield* closeOwnedTerminal(expired); - yield* removeLease(expired.id); - }), - ).pipe(Effect.result), + sessions, + (terminal) => + terminals.close({ threadId, terminalId: terminal.terminalId }).pipe(Effect.result), { concurrency: "unbounded" }, ); + if (previews._tag === "Failure") return yield* previews.failure; const failure = results.find((result) => result._tag === "Failure"); if (failure?._tag === "Failure") return yield* failure.failure; }), @@ -937,8 +1058,10 @@ const make = Effect.gen(function* () { launch, recover, list, + subscribe, ownsTerminal, removeThread, + stopThread, protectedWorkspacePaths, }); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 923d54f0df1f..e5af95e1790b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -93,6 +93,7 @@ import { type ProviderDriverKind, type ProviderInstanceId, PreviewHostingError as ContractPreviewHostingError, + type PreviewHostingLeaseMetadata, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -3629,6 +3630,42 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "preview" }, ), + [WS_METHODS.previewHostingStopThread]: (input) => + observeRpcEffect( + WS_METHODS.previewHostingStopThread, + previewHosting.stopThread(input.threadId).pipe( + Effect.mapError( + () => + new ContractPreviewHostingError({ + reason: "unavailable", + message: "Some previews or processes could not be stopped. Please try again.", + }), + ), + ), + { "rpc.aggregate": "preview" }, + ), + [WS_METHODS.subscribePreviewHosting]: (_input) => + observeRpcStream( + WS_METHODS.subscribePreviewHosting, + Stream.callback< + ReadonlyArray, + PreviewHosting.PreviewHostingError + >((queue) => + Effect.acquireRelease( + previewHosting.subscribe((leases) => Queue.offer(queue, leases)), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ).pipe( + Stream.mapError( + () => + new ContractPreviewHostingError({ + reason: "unavailable", + message: "Preview hosting is unavailable on this server.", + }), + ), + ), + { "rpc.aggregate": "preview" }, + ), [WS_METHODS.previewReportStatus]: (input) => observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { "rpc.aggregate": "preview", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 178660b5573b..a82a26d8add1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -261,6 +261,11 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { + usePreviewProcessControlsSupported, + useStopThreadProcesses, + useThreadPreviewLeases, +} from "../state/previewHosting"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button, InlineButton } from "./ui/button"; import { @@ -1005,7 +1010,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onCancelRename: () => void; isRenaming: boolean; renamingTitle: string; - onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onContextMenu: ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + hasStoppableProcesses: boolean, + ) => void; onSettle: (threadRef: ScopedThreadRef) => void; onActionSweepStart: ( threadRef: ScopedThreadRef, @@ -1070,6 +1079,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const terminalProcessCount = runningTerminalIds.length; + const previews = useThreadPreviewLeases(threadRef); + const supportsProcessControls = usePreviewProcessControlsSupported(thread.environmentId); + const hasStoppableProcesses = + supportsProcessControls && (runningTerminalIds.length > 0 || previews.length > 0); // Unsent composer text on this thread. The open thread shows its own // composer, so the marker only decorates rows you have navigated away from. const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive; @@ -1303,9 +1316,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { (event: ReactMouseEvent) => { event.preventDefault(); if (cleanup !== null) return; - onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); + onContextMenu(threadRef, { x: event.clientX, y: event.clientY }, hasStoppableProcesses); }, - [cleanup, onContextMenu, threadRef], + [cleanup, hasStoppableProcesses, onContextMenu, threadRef], ); const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { @@ -2414,6 +2427,7 @@ export default function Sidebar() { reportFailure: false, }); const closeTerminal = useAtomCommand(terminalEnvironment.close, "cancel Project Action"); + const stopThreadProcesses = useStopThreadProcesses(); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ @@ -4566,7 +4580,11 @@ export default function Sidebar() { ); const handleThreadContextMenu = useCallback( - (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + hasStoppableProcesses: boolean, + ) => { void (async () => { const api = readLocalApi(); if (!api) return; @@ -4635,6 +4653,7 @@ export default function Sidebar() { isRegeneratingTitle, isRunning: !threadRuntimeCanArchive(thread.runtime), hasRunningAction: thread.actionResume?.outcome === "running", + hasStoppableProcesses, supports: { settlement: supportsSettlement, autoSettleOptOut: supportsAutoSettleOptOut, @@ -4806,6 +4825,9 @@ export default function Sidebar() { }); return; } + case "stop-thread-processes": + await stopThreadProcesses(threadRef); + return; case "mark-unread": markThreadUnread(threadRef); return; @@ -4893,6 +4915,7 @@ export default function Sidebar() { [ archiveThread, closeTerminal, + stopThreadProcesses, attemptPin, attemptSettle, attemptSnooze, diff --git a/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx b/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx index 7b582cde1cfc..57b302b2453a 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx @@ -1,5 +1,6 @@ import { ArrowRightLeftIcon, CircleAlertIcon, GitBranchIcon, TerminalIcon } from "lucide-react"; import type { ProjectIconOverride } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentIconColor } from "@t3tools/contracts/settings"; import { actionRunningPresentation } from "@t3tools/shared/actionResume"; @@ -14,6 +15,9 @@ import { RotateCcwClockIcon } from "../icons/RotateCcwClockIcon"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { ThreadPullRequestsMiniList } from "../ThreadStatusIndicators"; import { MiddleTruncate } from "../ui/middle-truncate"; +import { useKnownTerminalSessions } from "../../state/terminalSessions"; +import { useThreadPreviewLeases } from "../../state/previewHosting"; +import { threadTerminalProcessLabels } from "./threadTerminalPresentation"; export interface SidebarThreadHoverContentProps { thread: SidebarThreadSummary; @@ -48,6 +52,17 @@ export function SidebarThreadHoverContent(props: SidebarThreadHoverContentProps) const supportsMultiplePullRequests = useSupportsMultiplePullRequests(props.thread.environmentId); const driverKind = props.providerEntry?.driverKind ?? null; const projectDisplayName = props.projectDisplayName ?? props.projectTitle; + const sessions = useKnownTerminalSessions({ + environmentId: props.thread.environmentId, + threadId: props.thread.id, + }); + const previews = useThreadPreviewLeases( + scopeThreadRef(props.thread.environmentId, props.thread.id), + ); + const terminalLabels = threadTerminalProcessLabels( + sessions.flatMap((session) => (session.state.summary ? [session.state.summary] : [])), + previews, + ); const previousProviderNames = props.thread.providerInstanceHistory .filter((instanceId) => instanceId !== props.modelInstanceId) .map( @@ -138,14 +153,21 @@ export function SidebarThreadHoverContent(props: SidebarThreadHoverContentProps) ) : null} - {props.terminalStatus ? ( -
+ {props.terminalStatus || terminalLabels.length > 0 ? ( +
-
- {terminalProcessLabel(props.terminalProcessCount)} +
+ {props.terminalStatus ? ( +
{terminalProcessLabel(props.terminalProcessCount)}
+ ) : null} + {terminalLabels.map((terminal) => ( +
+ {terminal.label} +
+ ))}
) : null} diff --git a/apps/web/src/components/sidebar/threadTerminalPresentation.test.ts b/apps/web/src/components/sidebar/threadTerminalPresentation.test.ts new file mode 100644 index 000000000000..3dfc5e0cc2d9 --- /dev/null +++ b/apps/web/src/components/sidebar/threadTerminalPresentation.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + ThreadId, + type PreviewHostingLeaseMetadata, + type TerminalSummary, +} from "@t3tools/contracts"; +import { formatPreviewExpiry, threadTerminalProcessLabels } from "./threadTerminalPresentation"; + +const expiresAt = new Date(2026, 9, 5, 18, 2).toISOString(); +const preview: PreviewHostingLeaseMetadata = { + leaseId: "preview-lease", + threadId: ThreadId.make("thread-a"), + terminalId: "preview-terminal", + url: "http://localhost:5173/", + handedOffAt: new Date(2026, 9, 4, 18, 2).toISOString(), + expiresAt, + status: "active", +}; +const terminal: TerminalSummary = { + threadId: "thread-a", + terminalId: "preview-terminal", + cwd: "/workspace", + worktreePath: null, + status: "running", + pid: 100, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: true, + label: "node", + updatedAt: preview.handedOffAt, +}; + +describe("thread terminal hover labels", () => { + it("associates each process with its own preview's local expiry", () => { + expect( + threadTerminalProcessLabels( + [ + terminal, + { ...terminal, terminalId: "test-terminal", label: "vitest" }, + { ...terminal, terminalId: "idle-terminal", hasRunningSubprocess: false }, + ], + [preview], + ), + ).toEqual([ + { terminalId: "preview-terminal", label: "node (preview till Oct 5, 1802)" }, + { terminalId: "test-terminal", label: "vitest" }, + ]); + }); + + it("identifies a stopped preview whose recovery lifetime can still be cancelled", () => { + expect(threadTerminalProcessLabels([], [preview])).toEqual([ + { + terminalId: "preview-terminal", + label: "localhost:5173 (preview till Oct 5, 1802; stopped)", + }, + ]); + }); + + it("identifies a preview that is still starting", () => { + expect(threadTerminalProcessLabels([], [{ ...preview, status: "starting" }])).toEqual([ + { + terminalId: "preview-terminal", + label: "localhost:5173 (preview till Oct 5, 1802; starting)", + }, + ]); + }); + + it("formats midnight as 0000 and tolerates an invalid expiry", () => { + expect(formatPreviewExpiry(new Date(2026, 9, 5, 0, 0).toISOString())).toBe("Oct 5, 0000"); + expect(formatPreviewExpiry("invalid")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/sidebar/threadTerminalPresentation.ts b/apps/web/src/components/sidebar/threadTerminalPresentation.ts new file mode 100644 index 000000000000..f8787e753503 --- /dev/null +++ b/apps/web/src/components/sidebar/threadTerminalPresentation.ts @@ -0,0 +1,36 @@ +import type { PreviewHostingLeaseMetadata, TerminalSummary } from "@t3tools/contracts"; + +/** Preview handoffs use a fixed expiry, shown in the viewer's local time. */ +export function formatPreviewExpiry(expiresAt: string): string | null { + const date = new Date(expiresAt); + if (!Number.isFinite(date.getTime())) return null; + const day = date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const time = `${String(date.getHours()).padStart(2, "0")}${String(date.getMinutes()).padStart(2, "0")}`; + return `${day}, ${time}`; +} + +export function threadTerminalProcessLabels( + terminals: ReadonlyArray, + previews: ReadonlyArray, +): ReadonlyArray<{ terminalId: string; label: string }> { + const previewsByTerminal = new Map(previews.map((preview) => [preview.terminalId, preview])); + const running = terminals.filter((terminal) => terminal.hasRunningSubprocess); + const labels = running.map((terminal) => { + const preview = previewsByTerminal.get(terminal.terminalId); + const expiry = preview ? formatPreviewExpiry(preview.expiresAt) : null; + return { + terminalId: terminal.terminalId, + label: expiry ? `${terminal.label} (preview till ${expiry})` : terminal.label, + }; + }); + const runningIds = new Set(running.map((terminal) => terminal.terminalId)); + for (const preview of previews) { + if (runningIds.has(preview.terminalId)) continue; + const expiry = formatPreviewExpiry(preview.expiresAt); + labels.push({ + terminalId: preview.terminalId, + label: `${new URL(preview.url).host} (preview${expiry ? ` till ${expiry}` : ""}; ${preview.status === "starting" ? "starting" : "stopped"})`, + }); + } + return labels; +} diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 0f56707ce358..e3cd7228a46b 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { buildDraftActionMenuItems, buildThreadActionMenuItems, + buildStopThreadProcessesMenuItem, + withThreadActionMenuDividers, type ThreadActionMenuState, } from "./threadActionMenu.logic"; @@ -18,6 +20,7 @@ const baseState: ThreadActionMenuState = { isRegeneratingTitle: false, isRunning: false, hasRunningAction: false, + hasStoppableProcesses: false, supports: { settlement: true, autoSettleOptOut: true, @@ -52,6 +55,54 @@ function allIds(state: ThreadActionMenuState): string[] { } describe("buildThreadActionMenuItems", () => { + it("offers stopping only when the thread owns a preview or running subprocess", () => { + expect(buildStopThreadProcessesMenuItem(false)).toBeNull(); + expect(ids(baseState)).not.toContain("stop-thread-processes"); + const items = buildThreadActionMenuItems({ ...baseState, hasStoppableProcesses: true }); + const persistenceIndex = items.findIndex((item) => item.id === "mark-persistent"); + expect(items[persistenceIndex + 1]).toEqual({ + id: "stop-thread-processes", + label: "Stop all previews & processes", + destructive: true, + }); + expect(items[persistenceIndex + 2]?.separatorBefore).toBe(true); + }); + + it("separates branch creation and persistence without separating persistence from stop", () => { + const items = buildThreadActionMenuItems({ ...baseState, branch: "feature/preview" }); + expect(items[1]?.separatorBefore).toBe(true); + const persistenceIndex = items.findIndex((item) => item.id === "mark-persistent"); + expect(items[persistenceIndex + 1]?.separatorBefore).toBe(true); + const withoutPersistence = buildThreadActionMenuItems({ + ...baseState, + hasStoppableProcesses: true, + supports: { ...baseState.supports, persistence: false }, + }); + const stopIndex = withoutPersistence.findIndex((item) => item.id === "stop-thread-processes"); + expect(withoutPersistence[stopIndex + 1]?.separatorBefore).toBe(true); + }); + + it("groups legacy thread commands with dividers after creation, annotation, and stop", () => { + const commands = [ + "new-thread-on-branch", + "rename", + "annotate", + "mark-unread", + "mark-persistent", + "stop-thread-processes", + "copy-path", + ]; + const items = withThreadActionMenuDividers(commands.map((id) => ({ id, label: id }))); + expect(items.filter((item) => item.separatorBefore).map((item) => item.id)).toEqual([ + "rename", + "mark-unread", + "copy-path", + ]); + const withoutStop = withThreadActionMenuDividers( + commands.filter((id) => id !== "stop-thread-processes").map((id) => ({ id, label: id })), + ); + expect(withoutStop.find((item) => item.id === "copy-path")?.separatorBefore).toBe(true); + }); it("hides lifecycle items when the environment lacks the capabilities", () => { expect( ids({ diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index b513285cb369..79c005bba9cf 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -26,6 +26,7 @@ export type ThreadActionMenuId = | "rename" | "regenerate-title" | "cancel-action" + | "stop-thread-processes" | "mark-unread" | "copy" | "copy-path" @@ -99,6 +100,7 @@ export interface ThreadActionMenuState { /** Archive rejects a thread with an attached provider, so disable it here rather than let the action fail. */ readonly isRunning: boolean; readonly hasRunningAction: boolean; + readonly hasStoppableProcesses: boolean; readonly supports: { readonly settlement: boolean; /** Server understands thread.auto-settle.set. */ @@ -113,6 +115,32 @@ export interface ThreadActionMenuState { readonly handoffsOverflow?: boolean; } +export function buildStopThreadProcessesMenuItem(hasStoppableProcesses: boolean) { + return hasStoppableProcesses + ? { + id: "stop-thread-processes" as const, + label: "Stop all previews & processes", + destructive: true, + } + : null; +} + +/** The native bridge expresses each group divider on the following command. */ +export function withThreadActionMenuDividers( + items: ReadonlyArray>, +): ReadonlyArray> { + return items.map((item, index) => { + const previousId = items[index - 1]?.id; + const startsGroup = + previousId === "new-thread-on-branch" || + previousId === "annotate" || + previousId === "stop-thread-processes" || + ((previousId === "mark-persistent" || previousId === "disable-persistence") && + item.id !== "stop-thread-processes"); + return startsGroup ? { ...item, separatorBefore: true } : item; + }); +} + /** * Single source for the per-thread action menu: the sidebar row's right-click * menu and the chat header menu share labels, ordering, and capability gating. @@ -121,7 +149,8 @@ export interface ThreadActionMenuState { export function buildThreadActionMenuItems( state: ThreadActionMenuState, ): ReadonlyArray> { - return [ + const stopProcesses = buildStopThreadProcessesMenuItem(state.hasStoppableProcesses); + const items: Array> = [ ...(state.branch ? [ { @@ -153,6 +182,7 @@ export function buildThreadActionMenuItems( }, ] : []), + ...(stopProcesses ? [stopProcesses] : []), // Both lifecycle actions stay available on pinned threads: settling // clears the pin ("done" beats "keep on top"), and snoozing hides the // card until wake with the pin intact. @@ -278,4 +308,5 @@ export function buildThreadActionMenuItems( disabled: state.isPersistent, }, ]; + return withThreadActionMenuDividers(items); } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 04db96bc1da2..d5c8771a37dd 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -19,6 +19,12 @@ import { import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { threadEnvironment } from "../state/threads"; import { terminalEnvironment } from "../state/terminal"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { + usePreviewProcessControlsSupported, + useStopThreadProcesses, + useThreadPreviewLeases, +} from "../state/previewHosting"; import { useAtomCommand } from "../state/use-atom-command"; import { readEnvironmentSupportsAutoSettleOptOut, @@ -105,6 +111,17 @@ export function useThreadActionMenu(input: { reportFailure: false, }); const closeTerminal = useAtomCommand(terminalEnvironment.close, "cancel Project Action"); + const stopThreadProcesses = useStopThreadProcesses(); + const previews = useThreadPreviewLeases(threadRef); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: threadRef?.environmentId ?? null, + threadId: threadRef?.threadId ?? null, + }); + const supportsProcessControls = usePreviewProcessControlsSupported( + threadRef?.environmentId ?? null, + ); + const hasStoppableProcesses = + supportsProcessControls && (runningTerminalIds.length > 0 || previews.length > 0); const handleNewThread = useNewThreadHandler(); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); @@ -166,6 +183,7 @@ export function useThreadActionMenu(input: { isRegeneratingTitle, isRunning: !threadRuntimeCanArchive(thread.runtime), hasRunningAction: thread.actionResume?.outcome === "running", + hasStoppableProcesses, supports, snoozePresets, handoffs: handoffDescriptors, @@ -295,6 +313,9 @@ export function useThreadActionMenu(input: { case "mark-unread": markThreadUnread(threadRef); return; + case "stop-thread-processes": + await stopThreadProcesses(threadRef); + return; case "copy-path": { const workspacePath = thread.worktreePath ?? projectCwd; if (!workspacePath) { @@ -373,6 +394,8 @@ export function useThreadActionMenu(input: { [ archiveThread, closeTerminal, + stopThreadProcesses, + hasStoppableProcesses, confirmThreadArchive, confirmThreadDelete, confirmAndUnpinThread, diff --git a/apps/web/src/state/previewHosting.ts b/apps/web/src/state/previewHosting.ts new file mode 100644 index 000000000000..c329c8ed8e24 --- /dev/null +++ b/apps/web/src/state/previewHosting.ts @@ -0,0 +1,64 @@ +import type { + EnvironmentId, + PreviewHostingLeaseMetadata, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useMemo } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { previewEnvironment } from "./preview"; +import { useEnvironmentQuery } from "./query"; +import { useAtomCommand } from "./use-atom-command"; +import { environmentServerConfigsAtom } from "./server"; + +const EMPTY_PREVIEW_LEASES: ReadonlyArray = []; + +export function usePreviewProcessControlsSupported(environmentId: EnvironmentId | null) { + return useAtomValue( + environmentServerConfigsAtom, + (configs) => + environmentId !== null && + configs.get(environmentId)?.environment.capabilities.previewHostingProcessControl === true, + ); +} + +export function useThreadPreviewLeases(threadRef: ScopedThreadRef | null) { + const leases = useEnvironmentQuery( + threadRef === null + ? null + : previewEnvironment.hostingLeases({ environmentId: threadRef.environmentId, input: {} }), + ).data; + return useMemo( + () => leases?.filter((lease) => lease.threadId === threadRef?.threadId) ?? EMPTY_PREVIEW_LEASES, + [leases, threadRef?.threadId], + ); +} + +export function useStopThreadProcesses() { + const stop = useAtomCommand(previewEnvironment.hostingStopThread, { reportFailure: false }); + return useCallback( + async (threadRef: ScopedThreadRef) => { + const result = await stop({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not stop all previews and processes", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return result; + }, + [stop], + ); +} diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 4ce45cf3ff1b..84c407488ab2 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -57,6 +57,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeTerminalEvents | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents + | typeof WS_METHODS.subscribePreviewHosting | typeof WS_METHODS.subscribeDiscoveredLocalServers | typeof WS_METHODS.subscribeDeviceState | typeof WS_METHODS.subscribeResourceTelemetry diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index a40e62b899bb..b9d45c2499a5 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -1,4 +1,6 @@ -import { WS_METHODS } from "@t3tools/contracts"; +import { type PreviewHostingLeaseMetadata, WS_METHODS } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -7,7 +9,9 @@ import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, + createEnvironmentSubscriptionAtomFamily, } from "./runtime.ts"; +import { getInitialServerConfig, subscribe } from "../rpc/client.ts"; export const previewAutomationHostFocusConcurrencyKey = (value: { readonly environmentId: string; @@ -30,6 +34,25 @@ export function createPreviewEnvironmentAtoms( JSON.stringify([environmentId, input.threadId]), }; return { + hostingLeases: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:preview:hosting-leases", + subscribe: (_input: {}) => + Stream.unwrap( + getInitialServerConfig().pipe( + Effect.map((config) => + config.environment.capabilities.previewHostingProcessControl === true + ? subscribe(WS_METHODS.subscribePreviewHosting, {}) + : Stream.succeed([] as ReadonlyArray), + ), + ), + ), + }), + hostingStopThread: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:hosting-stop-thread", + tag: WS_METHODS.previewHostingStopThread, + scheduler: hostingScheduler, + concurrency: lifecycleConcurrency, + }), hostingList: createEnvironmentRpcCommand(runtime, { label: "environment-data:preview:hosting-list", tag: WS_METHODS.previewHostingList, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index efd4ac0d8223..7d9e3429e575 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -152,6 +152,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadPinning: Schema.optionalKey(Schema.Boolean), /** Server understands thread.persistence.set and enforces archive/delete protection. */ threadPersistence: Schema.optionalKey(Schema.Boolean), + /** Server streams hosted preview lifetimes and can stop a thread's previews and terminals. */ + previewHostingProcessControl: Schema.optionalKey(Schema.Boolean), /** Server understands thread.pin.reorder (and orderKey on thread.pin). Same version-skew contract as threadSettlement. */ threadPinReorder: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/previewHosting.ts b/packages/contracts/src/previewHosting.ts index c69750f33206..1d2891391e8d 100644 --- a/packages/contracts/src/previewHosting.ts +++ b/packages/contracts/src/previewHosting.ts @@ -45,6 +45,12 @@ export const PreviewHostingLeaseSummary = Schema.Struct({ }); export type PreviewHostingLeaseSummary = typeof PreviewHostingLeaseSummary.Type; +export const PreviewHostingLeaseMetadata = Schema.Struct({ + ...PreviewHostingLeaseSummary.fields, + terminalId: TrimmedNonEmptyString.check(Schema.isMaxLength(128)), +}); +export type PreviewHostingLeaseMetadata = typeof PreviewHostingLeaseMetadata.Type; + export const PreviewHostingRecoverInput = Schema.Struct({ threadId: ThreadId, leaseId: PreviewHostingLeaseId, @@ -57,6 +63,11 @@ export const PreviewHostingListInput = Schema.Struct({ }); export type PreviewHostingListInput = typeof PreviewHostingListInput.Type; +export const PreviewHostingStopThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type PreviewHostingStopThreadInput = typeof PreviewHostingStopThreadInput.Type; + export class PreviewHostingError extends Schema.TaggedError()( "PreviewHostingError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index c7aff5897d13..8225f9c9fdd5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -281,10 +281,12 @@ import { } from "./previewAutomation.ts"; import { PreviewHostingError, + PreviewHostingLeaseMetadata, PreviewHostingLeaseSummary, PreviewHostingListInput, PreviewHostingRecoverInput, PreviewHostingRecoverResult, + PreviewHostingStopThreadInput, } from "./previewHosting.ts"; import { ServerConfigStreamEvent, @@ -440,6 +442,7 @@ export const WS_METHODS = { previewList: "preview.list", previewHostingList: "previewHosting.list", previewHostingRecover: "previewHosting.recover", + previewHostingStopThread: "previewHosting.stopThread", previewReportStatus: "preview.reportStatus", previewClaimRecovery: "preview.claimRecovery", previewAutomationConnect: "previewAutomation.connect", @@ -556,6 +559,7 @@ export const WS_METHODS = { subscribeTerminalEvents: "subscribeTerminalEvents", subscribeTerminalMetadata: "subscribeTerminalMetadata", subscribePreviewEvents: "subscribePreviewEvents", + subscribePreviewHosting: "subscribePreviewHosting", subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers", subscribeDeviceState: "subscribeDeviceState", subscribeServerConfig: "subscribeServerConfig", @@ -1505,6 +1509,18 @@ const WsPreviewHostingRecoverRpc = Rpc.make(WS_METHODS.previewHostingRecover, { error: Schema.Union([PreviewHostingError, EnvironmentAuthorizationError]), }); +const WsPreviewHostingStopThreadRpc = Rpc.make(WS_METHODS.previewHostingStopThread, { + payload: PreviewHostingStopThreadInput, + error: Schema.Union([PreviewHostingError, EnvironmentAuthorizationError]), +}); + +const WsSubscribePreviewHostingRpc = Rpc.make(WS_METHODS.subscribePreviewHosting, { + payload: Schema.Struct({}), + success: Schema.Array(PreviewHostingLeaseMetadata), + error: Schema.Union([PreviewHostingError, EnvironmentAuthorizationError]), + stream: true, +}); + const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { payload: PreviewReportStatusInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), @@ -1957,6 +1973,8 @@ export const WsRpcGroup = RpcGroup.make( WsPreviewListRpc, WsPreviewHostingListRpc, WsPreviewHostingRecoverRpc, + WsPreviewHostingStopThreadRpc, + WsSubscribePreviewHostingRpc, WsPreviewReportStatusRpc, WsPreviewClaimRecoveryRpc, WsPreviewAutomationConnectRpc, From ae457389646d0106fadafefb6c1cbbc87adf3d66 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 00:36:16 -0700 Subject: [PATCH 02/13] feat(web): add preview controls to legacy thread menus Offer the shared thread preview and process stop command in the legacy sidebar, with dividers after new-thread, annotation, and persistence controls. Carry-Group: legacy-sidebar Carry-Observation: Optional legacy sidebar integration reuses the core preview control service. --- apps/web/src/components/LegacySidebar.tsx | 53 ++++++++++++++++++----- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index a9479f05bd3b..ddf8431d5f63 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -32,7 +32,11 @@ import { } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { SidebarDraftBlock } from "./Sidebar"; -import { buildDraftActionMenuItems } from "./threadActionMenu.logic"; +import { + buildDraftActionMenuItems, + buildStopThreadProcessesMenuItem, + withThreadActionMenuDividers, +} from "./threadActionMenu.logic"; import { discardComposerDraft } from "../lib/discardComposerDraft"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; @@ -126,6 +130,11 @@ import { } from "./thread-annotation/ThreadAnnotation"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { + usePreviewProcessControlsSupported, + useStopThreadProcesses, + useThreadPreviewLeases, +} from "../state/previewHosting"; import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { openDiscoveredPort } from "./preview/openDiscoveredPort"; import { useAtomCommand } from "../state/use-atom-command"; @@ -433,6 +442,7 @@ interface SidebarThreadRowProps { handleThreadContextMenu: ( threadRef: ScopedThreadRef, position: { x: number; y: number }, + hasStoppableProcesses: boolean, ) => Promise; clearSelection: () => void; commitRename: ( @@ -517,6 +527,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); + const previews = useThreadPreviewLeases(threadRef); + const supportsProcessControls = usePreviewProcessControlsSupported(thread.environmentId); + const hasStoppableProcesses = + supportsProcessControls && (runningTerminalIds.length > 0 || previews.length > 0); const isMobile = useIsMobile(); const discoveredPorts = useThreadDiscoveredPorts({ environmentId: thread.environmentId, @@ -825,10 +839,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP } void (async () => { const result = await settlePromise(() => - handleThreadContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, - }), + handleThreadContextMenu( + threadRef, + { + x: event.clientX, + y: event.clientY, + }, + hasStoppableProcesses, + ), ); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); @@ -847,6 +865,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, + hasStoppableProcesses, isSelected, threadRef, ], @@ -1551,6 +1570,7 @@ interface SidebarProjectThreadListProps { handleThreadContextMenu: ( threadRef: ScopedThreadRef, position: { x: number; y: number }, + hasStoppableProcesses: boolean, ) => Promise; clearSelection: () => void; commitRename: ( @@ -1841,6 +1861,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const resolveThreadAnnotation = useAtomCommand(threadEnvironment.resolveAnnotation, { reportFailure: false, }); + const stopThreadProcesses = useStopThreadProcesses(); const updateSettings = useUpdateClientSettings(); const sidebarThreadPreviewCount = useClientSettings( (settings) => settings.sidebarThreadPreviewCount, @@ -3002,7 +3023,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ]); const handleThreadContextMenu = useCallback( - async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + async ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + hasStoppableProcesses: boolean, + ) => { const api = readLocalApi(); if (!api) return; const threadKey = scopedThreadKey(threadRef); @@ -3021,6 +3046,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec persistent: thread.persistent === true, supported: supportsPersistence, }); + const stopProcessesAction = buildStopThreadProcessesMenuItem(hasStoppableProcesses); + const canAnnotate = supportsThreadAnnotations && thread.latestUserMessageAt !== null; const creatorRef = thread.creatorThreadId ? scopeThreadRef(thread.environmentId, thread.creatorThreadId) : null; @@ -3031,14 +3058,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const handoffDescriptors = handoffs.slice(0, handoffsMenuLimit).map(describeHandoff); const clicked = await api.contextMenu.show( protectLegacyThreadActions( - [ + withThreadActionMenuDividers([ ...(thread.branch ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] : []), { id: "rename", label: "Rename thread" }, - ...(supportsThreadAnnotations && thread.latestUserMessageAt !== null - ? [{ id: "annotate", label: "Annotate thread…" }] - : []), + ...(canAnnotate ? [{ id: "annotate", label: "Annotate thread…" }] : []), { id: "mark-unread", label: "Mark unread" }, ...(creatorGroupingEligible ? [ @@ -3056,6 +3081,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec : []), ...(canOpenCreator ? [{ id: "open-creator", label: "Open creator thread" }] : []), ...(persistenceAction ? [persistenceAction] : []), + ...(stopProcessesAction ? [stopProcessesAction] : []), { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, { id: "open-dashboard", label: "Open dashboard", icon: "layout-dashboard" }, @@ -3074,7 +3100,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec destructive: true, icon: "trash", }, - ], + ]), thread.persistent === true, ), position, @@ -3177,6 +3203,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec markThreadUnread(threadRef); return; } + if (clicked === "stop-thread-processes") { + await stopThreadProcesses(threadRef); + return; + } if (clicked === "mark-persistent" || clicked === "disable-persistence") { const result = await setThreadPersistence(threadRef, clicked === "mark-persistent"); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { @@ -3261,6 +3291,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec setOpenMobile, setThreadPersistence, startThreadRename, + stopThreadProcesses, updateThreadMetadata, navigateToThread, ], From 697313fc2b6dc5ade242648848b32e0dadc760dd Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 12:33:44 -0700 Subject: [PATCH 03/13] fix(client): load preview leases after connection and reconnect Carry-Group: incubator Preview lease subscriptions now wait for the selected session and recheck its capability after reconnecting. Focused tests cover startup, reconnect, unsupported servers, and session-tagged fallback values. Implemented with GPT-6.1-Sol in the Codex harness. --- .../client-runtime/src/rpc/client.test.ts | 39 +++ packages/client-runtime/src/rpc/client.ts | 18 ++ .../client-runtime/src/state/preview.test.ts | 265 +++++++++++++++++- packages/client-runtime/src/state/preview.ts | 24 +- 4 files changed, 330 insertions(+), 16 deletions(-) diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 8dda4440e673..6bc38b3907f8 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -6,6 +6,7 @@ import { ThreadId, type PreviewAutomationStreamEvent, type RelayClientInstallProgressEvent, + type ServerConfig, type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, WS_METHODS, @@ -92,6 +93,44 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { }); describe("environment RPC", () => { + it.effect("tags unsupported capability values with their session without opening an RPC", () => + Effect.gen(function* () { + const client = {} as WsRpcProtocolClient; + const unsupportedSession = { + ...session(client), + initialConfig: Effect.succeed({ environment: { capabilities: {} } } as ServerConfig), + }; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(unsupportedSession)); + let inputs = 0; + const result = yield* subscribeDynamicWithSession( + WS_METHODS.subscribePreviewHosting, + () => + Effect.sync(() => { + inputs += 1; + return {}; + }), + { + capability: { + supports: (config) => + config.environment.capabilities.previewHostingProcessControl === true, + unsupportedValue: [], + }, + }, + ).pipe( + Stream.runHead, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(Option.isSome(result)).toBe(true); + if (Option.isSome(result)) { + expect(result.value[0]).toBe(unsupportedSession); + expect(result.value[1]).toEqual([]); + } + expect(inputs).toBe(0); + }), + ); + it.effect("registers a fresh preview host after completion without replaying requests", () => Effect.gen(function* () { const firstCompleted = yield* Deferred.make(); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 84c407488ab2..39b76ae2e6bc 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -2,6 +2,7 @@ import { EnvironmentAuthorizationError, ORCHESTRATION_V2_WS_METHODS, WS_METHODS, + type ServerConfig, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -194,6 +195,11 @@ export function runStream( } interface SubscriptionOptions { + /** Check each session's bootstrap config before opening a capability-dependent RPC. */ + readonly capability?: { + readonly supports: (config: ServerConfig) => boolean; + readonly unsupportedValue: EnvironmentRpcStreamValue; + }; /** Reports protocol or programming defects without changing their recovery policy. */ readonly onDefect?: ( cause: Cause.Cause>, @@ -260,6 +266,18 @@ function subscribeDynamicMapped( Stream.suspend(() => Stream.unwrap( Effect.gen(function* () { + if (options?.capability !== undefined) { + // Connection setup owns bootstrap failures. Keep the + // subscription alive for the supervisor's next session. + const config = yield* Effect.option(session.initialConfig); + if (Option.isNone(config)) return Stream.empty; + if (!options.capability.supports(config.value)) { + return mapStream( + session, + Stream.succeed(options.capability.unsupportedValue), + ); + } + } const input = yield* makeInput(session); const completeObservation = yield* observer.observe({ environmentId: supervisor.target.environmentId, diff --git a/packages/client-runtime/src/state/preview.test.ts b/packages/client-runtime/src/state/preview.test.ts index 7a83bccde98a..ceef310c42cc 100644 --- a/packages/client-runtime/src/state/preview.test.ts +++ b/packages/client-runtime/src/state/preview.test.ts @@ -1,6 +1,267 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + ThreadId, + WS_METHODS, + type PreviewHostingLeaseMetadata, + type ServerConfig, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Latch from "effect/Latch"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { RpcClientError } from "effect/unstable/rpc"; -import { previewAutomationHostFocusConcurrencyKey } from "./preview.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { + createPreviewEnvironmentAtoms, + previewAutomationHostFocusConcurrencyKey, +} from "./preview.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("preview-environment"), + label: "Preview environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const LEASE: PreviewHostingLeaseMetadata = { + leaseId: "preview-lease", + threadId: ThreadId.make("thread-1"), + terminalId: "preview-terminal", + url: "http://localhost:5173/", + handedOffAt: "2026-10-05T00:00:00.000Z", + expiresAt: "2026-10-06T00:00:00.000Z", + status: "active", +}; + +function config(supported?: boolean): ServerConfig { + return { + environment: { + capabilities: supported === undefined ? {} : { previewHostingProcessControl: supported }, + }, + } as ServerConfig; +} + +const makeSession = Effect.fn("PreviewTest.makeSession")(function* ( + supported: boolean | undefined, + initialLeases: ReadonlyArray = [LEASE], +) { + const leases = yield* Queue.unbounded>(); + const stopped = yield* Deferred.make(); + const disconnected = yield* Deferred.make(); + let subscriptions = 0; + let configReads = 0; + const client = { + [WS_METHODS.subscribePreviewHosting]: () => { + subscriptions += 1; + return Stream.concat(Stream.succeed(initialLeases), Stream.fromQueue(leases)).pipe( + Stream.merge(Stream.fromEffect(Deferred.await(disconnected)), { haltStrategy: "either" }), + Stream.ensuring(Deferred.succeed(stopped, undefined)), + ); + }, + } as unknown as WsRpcProtocolClient; + const session: RpcSession = { + client, + initialConfig: Effect.sync(() => { + configReads += 1; + return config(supported); + }), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + return { + session, + leases, + stopped, + disconnected, + subscriptions: () => subscriptions, + configReads: () => configReads, + }; +}); + +const makeHarness = Effect.fn("PreviewTest.makeHarness")(function* ( + initialSession: Option.Option = Option.none(), +) { + const session = yield* SubscriptionRef.make(initialSession); + const streamStarted = Latch.makeUnsafe(); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = ( + _environmentId, + stream, + ) => + Stream.suspend(() => { + streamStarted.openUnsafe(); + return Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + }); + const environments = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + followStream, + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments), + ); + const atom = createPreviewEnvironmentAtoms(runtime).hostingLeases({ + environmentId: TARGET.environmentId, + input: {}, + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const observed: Array< + AsyncResult.AsyncResult, unknown> + > = []; + const unmount = registry.subscribe(atom, (result) => observed.push(result), { immediate: true }); + yield* Effect.addFinalizer(() => Effect.sync(unmount)); + return { atom, registry, session, streamStarted, observed }; +}); + +function waitForLeases( + registry: AtomRegistry.AtomRegistry, + atom: Atom.Atom, E>>, + expected: ReadonlyArray, +) { + return AtomRegistry.toStream(registry, atom).pipe( + Stream.filter((result) => AsyncResult.isFailure(result) || AsyncResult.isSuccess(result)), + Stream.filter( + (result) => + AsyncResult.isFailure(result) || + (AsyncResult.isSuccess(result) && result.value === expected), + ), + Stream.runHead, + Effect.tap((result) => + Effect.sync(() => { + expect(Option.isSome(result) && AsyncResult.isSuccess(result.value)).toBe(true); + if (Option.isSome(result) && AsyncResult.isSuccess(result.value)) { + expect(result.value.value).toEqual(expected); + } + }), + ), + ); +} + +describe("preview lease subscriptions", () => { + it.effect("waits for the first session when mounted before connection startup", () => + Effect.scoped( + Effect.gen(function* () { + const first = yield* makeSession(true); + const harness = yield* makeHarness(); + yield* harness.streamStarted.await; + yield* Effect.yieldNow; + + expect(harness.observed.some(AsyncResult.isFailure)).toBe(false); + expect(first.configReads()).toBe(0); + expect(first.subscriptions()).toBe(0); + + const leases = [LEASE]; + yield* Queue.offer(first.leases, leases); + yield* SubscriptionRef.set(harness.session, Option.some(first.session)); + yield* waitForLeases(harness.registry, harness.atom, leases); + expect(first.configReads()).toBe(1); + expect(first.subscriptions()).toBe(1); + expect(harness.observed.some(AsyncResult.isFailure)).toBe(false); + }), + ), + ); + + it.effect("recovers after transport loss and closes subscriptions on session replacement", () => + Effect.scoped( + Effect.gen(function* () { + const firstLeases = [LEASE]; + const replacementLeases = [{ ...LEASE, leaseId: "replacement-lease" }]; + const first = yield* makeSession(true, firstLeases); + const replacement = yield* makeSession(true, replacementLeases); + const unsupported = yield* makeSession(undefined); + const harness = yield* makeHarness(Option.some(first.session)); + yield* waitForLeases(harness.registry, harness.atom, firstLeases); + + yield* Deferred.fail( + first.disconnected, + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "Test transport disconnected", + cause: new Error("Test transport disconnected"), + }), + }), + ); + yield* Deferred.await(first.stopped); + yield* SubscriptionRef.set(harness.session, Option.none()); + yield* SubscriptionRef.set(harness.session, Option.some(replacement.session)); + yield* waitForLeases(harness.registry, harness.atom, replacementLeases); + + yield* SubscriptionRef.set(harness.session, Option.some(unsupported.session)); + yield* Deferred.await(replacement.stopped); + const empty = yield* AtomRegistry.toStream(harness.registry, harness.atom).pipe( + Stream.filter( + (result) => + AsyncResult.isFailure(result) || + (AsyncResult.isSuccess(result) && result.value.length === 0), + ), + Stream.runHead, + ); + expect(Option.isSome(empty) && AsyncResult.isSuccess(empty.value)).toBe(true); + expect(first.subscriptions()).toBe(1); + expect(replacement.subscriptions()).toBe(1); + expect(replacement.configReads()).toBe(1); + expect(unsupported.configReads()).toBe(1); + expect(unsupported.subscriptions()).toBe(0); + expect(harness.observed.some(AsyncResult.isFailure)).toBe(false); + }), + ), + ); + + it.effect.each([undefined, false])( + "emits empty leases without a preview RPC when the server capability is %s", + (supported) => + Effect.scoped( + Effect.gen(function* () { + const unsupported = yield* makeSession(supported); + const harness = yield* makeHarness(Option.some(unsupported.session)); + const result = yield* AtomRegistry.toStream(harness.registry, harness.atom).pipe( + Stream.filter((result) => !AsyncResult.isInitial(result)), + Stream.runHead, + ); + expect(Option.isSome(result) && AsyncResult.isSuccess(result.value)).toBe(true); + if (Option.isSome(result) && AsyncResult.isSuccess(result.value)) { + expect(result.value.value).toEqual([]); + } + expect(unsupported.configReads()).toBe(1); + expect(unsupported.subscriptions()).toBe(0); + + const leases = [LEASE]; + const supportedSession = yield* makeSession(true, leases); + yield* SubscriptionRef.set(harness.session, Option.some(supportedSession.session)); + yield* waitForLeases(harness.registry, harness.atom, leases); + expect(supportedSession.subscriptions()).toBe(1); + }), + ), + ); +}); describe("preview state commands", () => { it("keeps focus updates from replacement host connections independent", () => { diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index b9d45c2499a5..d486d1f9090c 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -1,6 +1,4 @@ -import { type PreviewHostingLeaseMetadata, WS_METHODS } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Stream from "effect/Stream"; +import { WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -11,7 +9,7 @@ import { createEnvironmentRpcSubscriptionAtomFamily, createEnvironmentSubscriptionAtomFamily, } from "./runtime.ts"; -import { getInitialServerConfig, subscribe } from "../rpc/client.ts"; +import { subscribe } from "../rpc/client.ts"; export const previewAutomationHostFocusConcurrencyKey = (value: { readonly environmentId: string; @@ -36,16 +34,14 @@ export function createPreviewEnvironmentAtoms( return { hostingLeases: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:preview:hosting-leases", - subscribe: (_input: {}) => - Stream.unwrap( - getInitialServerConfig().pipe( - Effect.map((config) => - config.environment.capabilities.previewHostingProcessControl === true - ? subscribe(WS_METHODS.subscribePreviewHosting, {}) - : Stream.succeed([] as ReadonlyArray), - ), - ), - ), + subscribe: (input: {}) => + subscribe(WS_METHODS.subscribePreviewHosting, input, { + capability: { + supports: (config) => + config.environment.capabilities.previewHostingProcessControl === true, + unsupportedValue: [], + }, + }), }), hostingStopThread: createEnvironmentRpcCommand(runtime, { label: "environment-data:preview:hosting-stop-thread", From 404bff3902bd779abd8116f8d966b933553f47da Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 13:05:54 -0700 Subject: [PATCH 04/13] feat(preview): confirm thread shutdown with progress feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry-Group: incubator Show immediate shutdown feedback in web, desktop, and native mobile, retaining it through completion and for at least three seconds. The stop service now awaits the selected thread’s existing terminal cleanup fibers and reports failed termination. Validation: focused backend and mobile timing tests, scoped typechecks and lint, and Browser checks for three-second and delayed shutdown feedback. Implemented with GPT-6.1-Sol in the Codex harness. --- apps/mobile/src/Stack.tsx | 2 + .../StopThreadProcessesFeedbackOverlay.tsx | 11 +++ .../features/threads/thread-list-v2-items.tsx | 9 ++- .../stop-thread-processes-feedback.test.ts | 64 ++++++++++++++++ .../state/stop-thread-processes-feedback.ts | 45 +++++++++++ apps/server/src/preview/Hosting.test.ts | 75 +++++++++++++++++++ apps/server/src/preview/Hosting.ts | 7 +- apps/server/src/terminal/Manager.test.ts | 44 +++++++++++ apps/server/src/terminal/Manager.ts | 40 ++++++++++ apps/web/src/state/previewHosting.ts | 19 ++++- 10 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/features/threads/StopThreadProcessesFeedbackOverlay.tsx create mode 100644 apps/mobile/src/state/stop-thread-processes-feedback.test.ts create mode 100644 apps/mobile/src/state/stop-thread-processes-feedback.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 452d1a4dbed5..ff7c9351cef9 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -43,6 +43,7 @@ import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentCompo import { ReviewSheet } from "./features/review/ReviewSheet"; import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen"; +import { StopThreadProcessesFeedbackOverlay } from "./features/threads/StopThreadProcessesFeedbackOverlay"; import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; @@ -614,6 +615,7 @@ function RootStackLayout(props: { return ( + {}; + +export function StopThreadProcessesFeedbackOverlay() { + const progress = useAtomValue(stopThreadProcessesFeedbackAtom); + return ; +} diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index addff6f1bf5f..3b14fbf4e6a1 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -45,6 +45,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { threadEnvironment } from "../../state/threads"; import { terminalEnvironment } from "../../state/terminal"; import { previewEnvironment } from "../../state/preview"; +import { beginStopThreadProcessesFeedback } from "../../state/stop-thread-processes-feedback"; import { useAtomCommand } from "../../state/use-atom-command"; import { useThreadPr } from "../../state/use-thread-pr"; import { useSwipeRowDormant } from "../home/swipe-row-activation"; @@ -743,10 +744,16 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, [closeTerminal, runningAction, thread.environmentId, thread.id]); const handleStopThreadProcesses = useCallback(async () => { + const finishFeedback = beginStopThreadProcessesFeedback(thread.title); const result = await stopThreadProcesses({ environmentId: thread.environmentId, input: { threadId: thread.id }, }); + if (result._tag === "Success") { + finishFeedback("success"); + } else { + finishFeedback("error"); + } if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = Cause.squash(result.cause); Alert.alert( @@ -754,7 +761,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { error instanceof Error ? error.message : "The previews and processes could not be stopped.", ); } - }, [stopThreadProcesses, thread.environmentId, thread.id]); + }, [stopThreadProcesses, thread.environmentId, thread.id, thread.title]); const handleRetryWorktreeCleanup = useCallback(async () => { const result = await retryWorktreeCleanup({ diff --git a/apps/mobile/src/state/stop-thread-processes-feedback.test.ts b/apps/mobile/src/state/stop-thread-processes-feedback.test.ts new file mode 100644 index 000000000000..5fa98ab85076 --- /dev/null +++ b/apps/mobile/src/state/stop-thread-processes-feedback.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { appAtomRegistry } from "./atom-registry"; +import { + beginStopThreadProcessesFeedback, + stopThreadProcessesFeedbackAtom, +} from "./stop-thread-processes-feedback"; + +const feedback = () => appAtomRegistry.get(stopThreadProcessesFeedbackAtom); + +describe("stop thread processes feedback", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runAllTimers(); + vi.useRealTimers(); + }); + + it("shows progress immediately and keeps a fast success visible for three seconds total", () => { + const finish = beginStopThreadProcessesFeedback("Selected thread"); + expect(feedback()).toMatchObject({ phase: "running", description: "Selected thread" }); + vi.advanceTimersByTime(500); + finish("success"); + vi.advanceTimersByTime(2_499); + expect(feedback().phase).toBe("success"); + vi.advanceTimersByTime(1); + expect(feedback().phase).toBe("idle"); + }); + + it("never clears pending feedback or claims success before slow shutdown completes", () => { + const finish = beginStopThreadProcessesFeedback("Selected thread"); + vi.advanceTimersByTime(8_000); + expect(feedback().phase).toBe("running"); + finish("success"); + vi.runOnlyPendingTimers(); + expect(feedback().phase).toBe("idle"); + }); + + it("reports unsuccessful shutdown without claiming completion", () => { + const finish = beginStopThreadProcessesFeedback("Selected thread"); + finish("error"); + expect(feedback()).toMatchObject({ + phase: "error", + label: "Could not confirm previews and processes stopped", + }); + vi.advanceTimersByTime(3_000); + expect(feedback().phase).toBe("idle"); + }); + + it("keeps a newer selected thread's feedback when an older request completes", () => { + const finishFirst = beginStopThreadProcessesFeedback("First thread"); + finishFirst("success"); + vi.advanceTimersByTime(1_000); + const finishSecond = beginStopThreadProcessesFeedback("Second thread"); + finishFirst("error"); + vi.advanceTimersByTime(3_000); + expect(feedback()).toMatchObject({ phase: "running", description: "Second thread" }); + finishSecond("success"); + vi.runOnlyPendingTimers(); + expect(feedback().phase).toBe("idle"); + }); +}); diff --git a/apps/mobile/src/state/stop-thread-processes-feedback.ts b/apps/mobile/src/state/stop-thread-processes-feedback.ts new file mode 100644 index 000000000000..8b8793cac9df --- /dev/null +++ b/apps/mobile/src/state/stop-thread-processes-feedback.ts @@ -0,0 +1,45 @@ +import { Atom } from "effect/unstable/reactivity"; + +import { appAtomRegistry } from "./atom-registry"; +import type { GitActionProgress } from "./use-vcs-action-state"; + +const EMPTY_FEEDBACK: GitActionProgress = { phase: "idle", label: null, description: null }; +const MIN_VISIBLE_MS = 3_000; + +export const stopThreadProcessesFeedbackAtom = Atom.make(EMPTY_FEEDBACK).pipe(Atom.keepAlive); +let currentRequest: object | null = null; +let dismissTimer: ReturnType | null = null; + +/** Feedback lives above recycled rows and stays pending until shutdown completes. */ +export function beginStopThreadProcessesFeedback(threadTitle: string) { + const request = {}; + currentRequest = request; + if (dismissTimer !== null) clearTimeout(dismissTimer); + dismissTimer = null; + const startedAt = Date.now(); + appAtomRegistry.set(stopThreadProcessesFeedbackAtom, { + phase: "running", + label: "Stopping all previews and processes…", + description: threadTitle, + }); + + return (phase: "success" | "error") => { + if (currentRequest !== request) return; + appAtomRegistry.set(stopThreadProcessesFeedbackAtom, { + phase, + label: + phase === "success" + ? "All previews and processes stopped" + : "Could not confirm previews and processes stopped", + description: threadTitle, + }); + dismissTimer = setTimeout( + () => { + dismissTimer = null; + currentRequest = null; + appAtomRegistry.set(stopThreadProcessesFeedbackAtom, EMPTY_FEEDBACK); + }, + Math.max(0, MIN_VISIBLE_MS - (Date.now() - startedAt)), + ); + }; +} diff --git a/apps/server/src/preview/Hosting.test.ts b/apps/server/src/preview/Hosting.test.ts index a30b68981910..24b59d2cf1f7 100644 --- a/apps/server/src/preview/Hosting.test.ts +++ b/apps/server/src/preview/Hosting.test.ts @@ -62,6 +62,9 @@ interface TerminalHarness { readonly terminalId?: string | undefined; }) => Effect.Effect; readonly onClose?: () => Effect.Effect; + readonly onWaitForThreadShutdown?: ( + threadId: string, + ) => Effect.Effect; } function terminalLayer(harness: TerminalHarness) { @@ -182,6 +185,7 @@ function terminalLayer(harness: TerminalHarness) { }, metadata: Effect.sync(() => harness.liveSummaries ?? harness.summaries), history: () => Effect.die("Startup transcripts must remain local."), + waitForThreadShutdown: (threadId) => harness.onWaitForThreadShutdown?.(threadId) ?? Effect.void, refreshMetadata: (harness.onRefreshMetadata?.() ?? Effect.void).pipe( Effect.as(harness.summaries), ), @@ -347,6 +351,77 @@ function failFirstExpiredStateWriteLayer( } describe("PreviewHosting", () => { + it.effect.each([false, true])( + "waits for thread cleanup before completing even when terminal close fails (%s)", + (failClose) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "preview-hosting-stop-drain-" }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const cleanupStarted = yield* Deferred.make(); + const finishCleanup = yield* Deferred.make(); + const otherTerminal = terminalFixture("thread-2", "unrelated-terminal"); + const harness = testTerminalHarness({ + summaries: [terminalFixture("thread-1", "command-terminal"), otherTerminal], + failClose, + onWaitForThreadShutdown: (threadId) => + Effect.gen(function* () { + assert.equal(threadId, "thread-1"); + yield* Deferred.succeed(cleanupStarted, undefined); + yield* Deferred.await(finishCleanup); + }), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const stopping = yield* hosting + .stopThread("thread-1") + .pipe(Effect.result, Effect.forkScoped); + yield* Deferred.await(cleanupStarted); + assert.isUndefined(stopping.pollUnsafe()); + assert.deepEqual(harness.closes, [ + { threadId: "thread-1", terminalId: "command-terminal" }, + ]); + assert.include(harness.summaries, otherTerminal); + yield* Deferred.succeed(finishCleanup, undefined); + assert.equal((yield* Fiber.join(stopping))._tag, failClose ? "Failure" : "Success"); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("surfaces failed process cleanup instead of reporting a successful stop", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "preview-hosting-stop-kill-failure-", + }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const failure = new TerminalManager.TerminalShutdownError({ + threadId: "thread-1", + terminalIds: ["command-terminal"], + }); + const harness = testTerminalHarness({ + summaries: [terminalFixture("thread-1", "command-terminal")], + onWaitForThreadShutdown: () => Effect.fail(failure), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const result = yield* hosting.stopThread("thread-1").pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") assert.equal(result.failure, failure); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect( "stops a preview immediately while recovery is waiting for a server that never becomes ready", () => diff --git a/apps/server/src/preview/Hosting.ts b/apps/server/src/preview/Hosting.ts index 1f253c19dccd..c7d703de228b 100644 --- a/apps/server/src/preview/Hosting.ts +++ b/apps/server/src/preview/Hosting.ts @@ -142,7 +142,10 @@ export class PreviewHosting extends Context.Service< ) => Effect.Effect; readonly stopThread: ( threadId: string, - ) => Effect.Effect; + ) => Effect.Effect< + void, + PreviewHostingError | TerminalManager.TerminalError | TerminalManager.TerminalShutdownError + >; readonly protectedWorkspacePaths: () => Effect.Effect< ReadonlyArray, PreviewHostingError @@ -980,9 +983,11 @@ const make = Effect.gen(function* () { terminals.close({ threadId, terminalId: terminal.terminalId }).pipe(Effect.result), { concurrency: "unbounded" }, ); + const shutdown = yield* terminals.waitForThreadShutdown(threadId).pipe(Effect.result); if (previews._tag === "Failure") return yield* previews.failure; const failure = results.find((result) => result._tag === "Failure"); if (failure?._tag === "Failure") return yield* failure.failure; + if (shutdown._tag === "Failure") return yield* shutdown.failure; }), ); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 41a4f5131659..21a4277c7f39 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1987,6 +1987,50 @@ it.layer( }), ); + it.effect("waits for the selected thread's cleanup without waiting for another thread", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); + yield* manager.open(openInput()); + yield* manager.open(openInput({ threadId: "thread-2" })); + yield* manager.close({ threadId: "thread-1" }); + expect(ptyAdapter.processes[0]?.killSignals).toEqual(["SIGTERM"]); + + const waiting = yield* manager.waitForThreadShutdown("thread-1").pipe(Effect.forkScoped); + yield* TestClock.adjust("5 millis"); + expect(waiting.pollUnsafe()).toBeUndefined(); + yield* manager.close({ threadId: "thread-2" }); + yield* TestClock.adjust("5 millis"); + yield* Fiber.join(waiting); + + expect(ptyAdapter.processes[0]?.killSignals).toEqual(["SIGTERM", "SIGKILL"]); + expect(ptyAdapter.processes[1]?.killSignals).toEqual(["SIGTERM"]); + expect(yield* manager.refreshMetadata).toEqual([ + expect.objectContaining({ threadId: "thread-2" }), + ]); + yield* TestClock.adjust("5 millis"); + yield* manager.waitForThreadShutdown("thread-2"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("reports failed termination after the asynchronous close has returned", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.killFailure = new Error("simulated signal failure"); + yield* manager.close({ threadId: "thread-1" }); + + const result = yield* manager.waitForThreadShutdown("thread-1").pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "TerminalShutdownError"); + assert.equal(result.failure.threadId, "thread-1"); + assert.deepEqual(result.failure.terminalIds, [DEFAULT_TERMINAL_ID]); + } + yield* manager.waitForThreadShutdown("thread-2"); + }), + ); + it.effect("publishes closed events when terminals are explicitly closed", () => Effect.gen(function* () { const { manager, getEvents } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 09c44cf887f2..055a645bb6e0 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -150,6 +150,18 @@ class TerminalProcessSignalError extends Schema.TaggedError()( + "TerminalShutdownError", + { + threadId: Schema.String, + terminalIds: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `Failed to stop terminals: ${this.terminalIds.join(", ")}.`; + } +} + /** * TerminalManager - Service tag for terminal session orchestration. */ @@ -210,6 +222,10 @@ export class TerminalManager extends Context.Service< * When `terminalId` is omitted, closes all sessions for the thread. */ readonly close: (input: TerminalCloseInput) => Effect.Effect; + /** Await already requested process cleanup for this thread and report failed termination. */ + readonly waitForThreadShutdown: ( + threadId: string, + ) => Effect.Effect; /** Archive cleanup also removes untracked histories, except retained preview terminals. */ readonly closeThreadExcept: ( threadId: string, @@ -3315,6 +3331,29 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), ); + const waitForThreadShutdown: TerminalManager["Service"]["waitForThreadShutdown"] = Effect.fn( + "terminal.waitForThreadShutdown", + )( + function* (threadId) { + const state = yield* readManagerState; + const fibers = [...state.terminatingProcesses.entries()].flatMap(([process, terminal]) => { + const fiber = state.killFibers.get(process); + return terminal.threadId === threadId && fiber !== undefined ? [fiber] : []; + }); + yield* Effect.forEach(fibers, Fiber.await, { concurrency: "unbounded", discard: true }); + const remaining = [...(yield* readManagerState).terminatingProcesses.values()].filter( + (terminal) => terminal.threadId === threadId, + ); + if (remaining.length > 0) { + return yield* new TerminalShutdownError({ + threadId, + terminalIds: [...new Set(remaining.map((terminal) => terminal.terminalId))], + }); + } + }, + (effect, threadId) => withThreadLock(threadId, effect), + ); + const history: TerminalManager["Service"]["history"] = (input) => { return flushPersist(input.threadId, input.terminalId).pipe( Effect.andThen(readHistory(input.threadId, input.terminalId)), @@ -3330,6 +3369,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func history, restart, close, + waitForThreadShutdown, closeThreadExcept: (threadId, retainedTerminalIds, retainedTerminalPrefixes) => close({ threadId, deleteHistory: true }, retainedTerminalIds, retainedTerminalPrefixes), closeIdle, diff --git a/apps/web/src/state/previewHosting.ts b/apps/web/src/state/previewHosting.ts index c329c8ed8e24..2567cbbfdb2c 100644 --- a/apps/web/src/state/previewHosting.ts +++ b/apps/web/src/state/previewHosting.ts @@ -43,13 +43,28 @@ export function useStopThreadProcesses() { const stop = useAtomCommand(previewEnvironment.hostingStopThread, { reportFailure: false }); return useCallback( async (threadRef: ScopedThreadRef) => { + const startedAt = performance.now(); + const toastId = toastManager.add({ + type: "loading", + title: "Stopping all previews & processes…", + }); const result = await stop({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + if (result._tag === "Success") { + toastManager.update(toastId, { + type: "success", + title: "All previews & processes stopped", + // Zero disables auto-dismiss, so use one millisecond once the minimum has elapsed. + timeout: Math.max(1, 3_000 - (performance.now() - startedAt)), + }); + } else if (isAtomCommandInterrupted(result)) { + toastManager.close(toastId); + } else { const error = squashAtomCommandFailure(result); - toastManager.add( + toastManager.update( + toastId, stackedThreadToast({ type: "error", title: "Could not stop all previews and processes", From f47f9b38837e804c58d2c18e0e32bfd33d385ee6 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 13:56:44 -0700 Subject: [PATCH 05/13] fix(preview): make thread shutdown atomic and retryable Keep failed terminal handles visible and retry their termination on a later stop. Hold the thread lock while selecting, closing, and awaiting cleanup so concurrent opens and restarts cannot escape that shutdown interval. Validation: 135 focused backend tests, scoped server typecheck, lint, and formatting passed. Implemented with GPT-6.1-Sol in the Codex harness. Carry-Group: incubator --- apps/server/src/preview/Hosting.test.ts | 115 +++++++++- apps/server/src/preview/Hosting.ts | 13 +- apps/server/src/terminal/Manager.test.ts | 151 ++++++++++++- apps/server/src/terminal/Manager.ts | 276 +++++++++++------------ 4 files changed, 399 insertions(+), 156 deletions(-) diff --git a/apps/server/src/preview/Hosting.test.ts b/apps/server/src/preview/Hosting.test.ts index 24b59d2cf1f7..7935b1dbc1f9 100644 --- a/apps/server/src/preview/Hosting.test.ts +++ b/apps/server/src/preview/Hosting.test.ts @@ -27,6 +27,8 @@ import * as ServerConfig from "../config.ts"; import * as PortScanner from "./PortScanner.ts"; import * as PreviewHosting from "./Hosting.ts"; import * as TerminalManager from "../terminal/Manager.ts"; +import type * as PtyAdapter from "../terminal/PtyAdapter.ts"; +import * as ProcessRunner from "../processRunner.ts"; const PREVIEW_URL = "http://localhost:5173/field-examples"; const encodePersistedHostingState = Schema.encodeSync( @@ -68,7 +70,7 @@ interface TerminalHarness { } function terminalLayer(harness: TerminalHarness) { - return Layer.mock(TerminalManager.TerminalManager)({ + const service = { open: (input) => { harness.opens.push(input); return (harness.onOpen?.(input) ?? Effect.void).pipe( @@ -189,6 +191,25 @@ function terminalLayer(harness: TerminalHarness) { refreshMetadata: (harness.onRefreshMetadata?.() ?? Effect.void).pipe( Effect.as(harness.summaries), ), + } satisfies Partial; + return Layer.mock(TerminalManager.TerminalManager)({ + ...service, + shutdownThread: (threadId) => + Effect.gen(function* () { + const sessions = (yield* service.metadata).filter( + (terminal) => terminal.threadId === threadId, + ); + const results = yield* Effect.forEach( + sessions, + (terminal) => + service.close({ threadId, terminalId: terminal.terminalId }).pipe(Effect.result), + { concurrency: "unbounded" }, + ); + const shutdown = yield* service.waitForThreadShutdown(threadId).pipe(Effect.result); + const failure = results.find((result) => result._tag === "Failure"); + if (failure?._tag === "Failure") return yield* failure.failure; + if (shutdown._tag === "Failure") return yield* shutdown.failure; + }), }); } @@ -422,6 +443,98 @@ describe("PreviewHosting", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("a second Stop retries a failed terminal without touching another thread", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "preview-hosting-stop-retry-" }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const processes: Array< + PtyAdapter.PtyProcess & { + killSignals: Array; + killFailure: Error | undefined; + } + > = []; + const terminalsLayer = Layer.effect( + TerminalManager.TerminalManager, + TerminalManager.makeWithOptions({ + logsDir: config.terminalLogsDir, + processKillGraceMs: 0, + processTable: Effect.succeed([]), + ptyAdapter: { + spawn: () => + Effect.sync(() => { + const ptyProcess = { + pid: 9000 + processes.length, + killSignals: [] as Array, + killFailure: undefined as Error | undefined, + write: () => {}, + resize: () => {}, + kill: (signal?: string) => { + ptyProcess.killSignals.push(signal); + if (ptyProcess.killFailure !== undefined) throw ptyProcess.killFailure; + }, + onData: () => () => {}, + onExit: () => () => {}, + }; + processes.push(ptyProcess); + return ptyProcess; + }), + }, + }), + ).pipe(Layer.provide(ProcessRunner.layer)); + const dependencies = Layer.mergeAll( + ServerConfig.layer(config), + terminalsLayer, + discoveryLayer(true, [], testTerminalHarness()), + ).pipe(Layer.provideMerge(NodeServices.layer)); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const terminals = yield* TerminalManager.TerminalManager; + yield* terminals.open({ + threadId: "thread-1", + terminalId: "command-terminal", + cwd: root, + }); + yield* terminals.open({ + threadId: "thread-2", + terminalId: "unrelated-terminal", + cwd: root, + }); + const first = processes[0]!; + const other = processes[1]!; + first.killFailure = new Error("transient signal failure"); + + const failure = yield* hosting.stopThread("thread-1").pipe(Effect.result); + assert.equal(failure._tag, "Failure"); + if (failure._tag === "Failure") { + assert.equal(failure.failure._tag, "TerminalShutdownError"); + } + assert.deepEqual(first.killSignals, ["SIGTERM"]); + assert.deepEqual(other.killSignals, []); + assert.sameMembers( + (yield* terminals.metadata).map((terminal) => terminal.threadId), + ["thread-1", "thread-2"], + ); + + first.killFailure = undefined; + yield* hosting.stopThread("thread-1"); + assert.deepEqual(first.killSignals, ["SIGTERM", "SIGTERM", "SIGKILL"]); + assert.deepEqual(other.killSignals, []); + assert.deepEqual( + (yield* terminals.metadata).map((terminal) => terminal.threadId), + ["thread-2"], + ); + yield* terminals.close({ threadId: "thread-2" }); + yield* terminals.waitForThreadShutdown("thread-2"); + }).pipe(Effect.provide(PreviewHosting.layer.pipe(Layer.provideMerge(dependencies)))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect( "stops a preview immediately while recovery is waiting for a server that never becomes ready", () => diff --git a/apps/server/src/preview/Hosting.ts b/apps/server/src/preview/Hosting.ts index c7d703de228b..903568008bda 100644 --- a/apps/server/src/preview/Hosting.ts +++ b/apps/server/src/preview/Hosting.ts @@ -974,19 +974,8 @@ const make = Effect.gen(function* () { launchGate.withPermit( Effect.gen(function* () { const previews = yield* removeThreadLocked(threadId).pipe(Effect.result); - const sessions = (yield* terminals.metadata).filter( - (terminal) => terminal.threadId === threadId, - ); - const results = yield* Effect.forEach( - sessions, - (terminal) => - terminals.close({ threadId, terminalId: terminal.terminalId }).pipe(Effect.result), - { concurrency: "unbounded" }, - ); - const shutdown = yield* terminals.waitForThreadShutdown(threadId).pipe(Effect.result); + const shutdown = yield* terminals.shutdownThread(threadId).pipe(Effect.result); if (previews._tag === "Failure") return yield* previews.failure; - const failure = results.find((result) => result._tag === "Failure"); - if (failure?._tag === "Failure") return yield* failure.failure; if (shutdown._tag === "Failure") return yield* shutdown.failure; }), ); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 21a4277c7f39..389d9905bdeb 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -247,6 +247,7 @@ interface CreateManagerOptions { >[0]["resolveProviderInstanceEnvironment"]; managedBinaryCacheDir?: string; managedBinaryToolsDir?: string; + unregisterTerminal?: Parameters[0]["unregisterTerminal"]; } interface ManagerFixture { @@ -304,6 +305,9 @@ const createManager = ( managedBinaryCacheDir: options.managedBinaryCacheDir, managedBinaryToolsDir: options.managedBinaryToolsDir, }), + ...(options.unregisterTerminal === undefined + ? {} + : { unregisterTerminal: options.unregisterTerminal }), }); const eventsRef = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => @@ -1940,7 +1944,7 @@ it.layer( it.effect("keeps a closing terminal in blocker metadata until kill escalation finishes", () => Effect.gen(function* () { - const { manager } = yield* createManager(5, { processKillGraceMs: 10 }); + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); yield* manager.open(openInput()); const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); @@ -1954,6 +1958,9 @@ it.layer( hasRunningSubprocess: true, }), ]); + expect(yield* manager.metadata).toEqual(yield* manager.refreshMetadata); + yield* manager.close({ threadId: "thread-1" }); + expect(ptyAdapter.processes[0]?.killSignals).toEqual(["SIGTERM"]); yield* TestClock.adjust("10 millis"); yield* Fiber.join(closeFiber); @@ -1968,6 +1975,12 @@ it.layer( Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); + const original = (yield* manager.metadata)[0]; + const metadataEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.subscribeMetadata((event) => + Ref.update(metadataEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; @@ -1976,6 +1989,31 @@ it.layer( yield* manager.close({ threadId: "thread-1" }); expect(process.killSignals).toEqual(["SIGTERM"]); + const metadata = yield* manager.metadata; + expect(metadata).toEqual([ + expect.objectContaining({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + status: "running", + hasRunningSubprocess: true, + pid: process.pid, + cwd: original?.cwd, + worktreePath: original?.worktreePath, + label: original?.label, + }), + ]); + expect((yield* Ref.get(metadataEvents)).at(-1)).toEqual({ + type: "upsert", + terminal: metadata[0], + }); + const snapshots: TerminalMetadataStreamEvent[] = []; + const unsubscribeSnapshot = yield* manager.subscribeMetadata((event) => + Effect.sync(() => { + snapshots.push(event); + }), + ); + unsubscribeSnapshot(); + expect(snapshots).toEqual([{ type: "snapshot", terminals: metadata }]); expect(yield* manager.refreshMetadata).toEqual([ expect.objectContaining({ threadId: "thread-1", @@ -1987,6 +2025,117 @@ it.layer( }), ); + it.effect("retries only failed handles for the selected thread without duplicating cleanup", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }); + yield* manager.open(openInput()); + yield* manager.open(openInput({ threadId: "thread-2" })); + const first = ptyAdapter.processes[0]!; + const second = ptyAdapter.processes[1]!; + first.killFailure = new Error("simulated signal failure"); + second.killFailure = new Error("another thread's signal failure"); + yield* manager.close({ threadId: "thread-1" }); + yield* manager.close({ threadId: "thread-2" }); + expect((yield* manager.waitForThreadShutdown("thread-1").pipe(Effect.result))._tag).toBe( + "Failure", + ); + expect((yield* manager.waitForThreadShutdown("thread-2").pipe(Effect.result))._tag).toBe( + "Failure", + ); + + first.killFailure = undefined; + yield* manager.close({ threadId: "thread-1" }); + yield* manager.close({ threadId: "thread-1" }); + yield* manager.waitForThreadShutdown("thread-1"); + + expect(first.killSignals).toEqual(["SIGTERM", "SIGTERM", "SIGKILL"]); + expect(second.killSignals).toEqual(["SIGTERM"]); + expect(yield* manager.metadata).toEqual([ + expect.objectContaining({ threadId: "thread-2", hasRunningSubprocess: true }), + ]); + }), + ); + + it.effect("delivers successful cleanup after an in-flight retained metadata update", () => + Effect.gen(function* () { + const { manager } = yield* createManager(5, { processKillGraceMs: 10 }); + yield* manager.open(openInput()); + const upsertStarted = yield* Deferred.make(); + const releaseUpsert = yield* Deferred.make(); + const removed = yield* Deferred.make(); + const events: TerminalMetadataStreamEvent[] = []; + const unsubscribe = yield* manager.subscribeMetadata((event) => + Effect.gen(function* () { + if (event.type === "upsert") { + yield* Deferred.succeed(upsertStarted, undefined); + yield* Deferred.await(releaseUpsert); + } + events.push(event); + if (event.type === "remove") yield* Deferred.succeed(removed, undefined); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const closing = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); + yield* Deferred.await(upsertStarted); + yield* TestClock.adjust("10 millis"); + expect(yield* manager.metadata).toEqual([]); + yield* Deferred.succeed(releaseUpsert, undefined); + yield* Fiber.join(closing); + yield* manager.waitForThreadShutdown("thread-1"); + yield* Deferred.await(removed); + + expect(events.map((event) => event.type)).toEqual(["snapshot", "upsert", "remove"]); + expect(events.at(-1)).toEqual({ + type: "remove", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps opens and restarts outside the selected thread's shutdown interval", () => + Effect.gen(function* () { + const cleanupStarted = yield* Deferred.make(); + const finishCleanup = yield* Deferred.make(); + let blockCleanup = true; + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + unregisterTerminal: (terminal) => + terminal.threadId === "thread-1" && blockCleanup + ? Deferred.succeed(cleanupStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishCleanup)), + ) + : Effect.void, + }); + yield* manager.open(openInput()); + const stopping = yield* manager.shutdownThread("thread-1").pipe(Effect.forkScoped); + yield* Deferred.await(cleanupStarted); + const opening = yield* manager + .open(openInput({ terminalId: "new-terminal" })) + .pipe(Effect.forkScoped); + const restarting = yield* manager.restart(restartInput()).pipe(Effect.forkScoped); + + yield* manager.open(openInput({ threadId: "thread-2" })); + expect(stopping.pollUnsafe()).toBeUndefined(); + expect(opening.pollUnsafe()).toBeUndefined(); + expect(restarting.pollUnsafe()).toBeUndefined(); + expect(ptyAdapter.processes).toHaveLength(2); + expect(ptyAdapter.processes[0]?.killSignals).toEqual([]); + expect(ptyAdapter.processes[1]?.killSignals).toEqual([]); + + blockCleanup = false; + yield* Deferred.succeed(finishCleanup, undefined); + yield* Fiber.join(stopping); + yield* Fiber.join(opening); + yield* Fiber.join(restarting); + expect(ptyAdapter.processes[0]?.killSignals).toEqual(["SIGTERM", "SIGKILL"]); + expect(ptyAdapter.processes[1]?.killSignals).toEqual([]); + expect(ptyAdapter.processes).toHaveLength(4); + yield* manager.shutdownThread("thread-1"); + yield* manager.shutdownThread("thread-2"); + }), + ); + it.effect("waits for the selected thread's cleanup without waiting for another thread", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 055a645bb6e0..c4aa866c9b92 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -59,6 +59,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts"; @@ -222,6 +223,10 @@ export class TerminalManager extends Context.Service< * When `terminalId` is omitted, closes all sessions for the thread. */ readonly close: (input: TerminalCloseInput) => Effect.Effect; + /** Close and await this thread's terminals without allowing concurrent opens or restarts. */ + readonly shutdownThread: ( + threadId: string, + ) => Effect.Effect; /** Await already requested process cleanup for this thread and report failed termination. */ readonly waitForThreadShutdown: ( threadId: string, @@ -1642,6 +1647,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); const threadLocks = yield* KeyedLock.make(); const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>(); + const terminalMetadataListeners = new Set< + (event: TerminalMetadataStreamEvent) => Effect.Effect + >(); + const metadataDeliveryLock = yield* Semaphore.make(1); const workerScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); @@ -1650,6 +1659,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func for (const listener of terminalEventListeners) { yield* listener(event).pipe(Effect.ignoreCause({ log: true })); } + if (shouldPublishTerminalMetadataEvent(event)) { + yield* publishMetadataUpdate(event); + } }); const historyPath = (threadId: string, terminalId: string) => { @@ -1763,6 +1775,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId: string, terminalId: string, ) { + const existing = (yield* readManagerState).killFibers.get(process); + if (existing !== undefined && existing.pollUnsafe() === undefined) return; + const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe( Effect.tap((completed) => completed @@ -1773,7 +1788,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const terminatingProcesses = new Map(state.terminatingProcesses); terminatingProcesses.delete(process); return [undefined, { ...state, terminatingProcesses }] as const; - }) + }).pipe(Effect.andThen(publishMetadataUpdate({ threadId, terminalId }))) : Effect.void, ), Effect.asVoid, @@ -2899,20 +2914,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); const readAllTerminalMetadata = () => - readManagerState.pipe( - Effect.map((state) => - [...state.sessions.values()] - .map(summary) - .sort( - (left, right) => - right.updatedAt.localeCompare(left.updatedAt) || - left.threadId.localeCompare(right.threadId) || - left.terminalId.localeCompare(right.terminalId), - ), - ), - ); - - const readDrainTerminalMetadata = () => readManagerState.pipe( Effect.map((state) => { const terminals = new Map( @@ -2937,8 +2938,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func readonly threadId: string; readonly terminalId: string; }) => - getSession(input.threadId, input.terminalId).pipe( - Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), + readManagerState.pipe( + Effect.map((state) => { + const terminating = [...state.terminatingProcesses.values()].findLast( + (terminal) => + terminal.threadId === input.threadId && terminal.terminalId === input.terminalId, + ); + if (terminating !== undefined) return terminating; + const session = state.sessions.get(toSessionKey(input.threadId, input.terminalId)); + return session === undefined ? null : summary(session); + }), ); const subscribe: TerminalManager["Service"]["subscribe"] = (listener) => @@ -3010,87 +3019,35 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); }; - const metadataEventFromTerminalEvent = ( - event: TerminalEvent, - ): Effect.Effect => { - if (!shouldPublishTerminalMetadataEvent(event)) { - return Effect.succeed(null); - } - - if (event.type === "closed") { - return Effect.succeed({ - type: "remove" as const, - threadId: event.threadId, - terminalId: event.terminalId, - }); - } - - return readTerminalMetadata({ - threadId: event.threadId, - terminalId: event.terminalId, - }).pipe( - Effect.map((terminal) => - terminal - ? { - type: "upsert" as const, - terminal, - } - : null, - ), - ); - }; - - const offerMetadataEvent = ( - listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, - event: TerminalEvent, - ) => - metadataEventFromTerminalEvent(event).pipe( - Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)), - ); - - const subscribeMetadata: TerminalManager["Service"]["subscribeMetadata"] = (listener) => { - let unsubscribe: (() => void) | null = null; - - return Effect.gen(function* () { - const bufferedEvents: TerminalEvent[] = []; - let deliverLive = false; - - unsubscribe = yield* subscribe((event) => { - if (!deliverLive) { - bufferedEvents.push(event); - return Effect.void; + const publishMetadataUpdate = (input: { + readonly threadId: string; + readonly terminalId: string; + }): Effect.Effect => + metadataDeliveryLock.withPermit( + Effect.gen(function* () { + // Read after acquiring the delivery lock: a completed kill must not be + // followed by an older close update that restores its retained summary. + const terminal = yield* readTerminalMetadata(input); + const event: TerminalMetadataStreamEvent = terminal + ? { type: "upsert", terminal } + : { type: "remove", threadId: input.threadId, terminalId: input.terminalId }; + for (const listener of terminalMetadataListeners) { + yield* listener(event).pipe(Effect.ignoreCause({ log: true })); } + }), + ); - return offerMetadataEvent(listener, event); - }); - - const terminals = yield* readAllTerminalMetadata(); - yield* listener({ - type: "snapshot", - terminals, - }); - - for (const event of bufferedEvents) { - yield* offerMetadataEvent(listener, event); - } - - deliverLive = true; - return () => { - unsubscribe?.(); - unsubscribe = null; - }; - }).pipe( - Effect.catchCause((cause) => - Effect.flatMap( - Effect.sync(() => { - unsubscribe?.(); - unsubscribe = null; - }), - () => Effect.failCause(cause), - ), - ), + const subscribeMetadata: TerminalManager["Service"]["subscribeMetadata"] = (listener) => + metadataDeliveryLock.withPermit( + Effect.gen(function* () { + const terminals = yield* readAllTerminalMetadata(); + yield* listener({ type: "snapshot", terminals }); + terminalMetadataListeners.add(listener); + return () => { + terminalMetadataListeners.delete(listener); + }; + }), ); - }; const write: TerminalManager["Service"]["write"] = Effect.fn("terminal.write")(function* (input) { const terminalId = input.terminalId; @@ -3249,6 +3206,53 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), ); + const closeUnlocked = ( + input: TerminalCloseInput, + retainedTerminalIds: ReadonlyArray = [], + retainedTerminalPrefixes: ReadonlyArray = [], + ): Effect.Effect => + Effect.gen(function* () { + // Failed handles outlive their sessions. A later close retries them, + // while startKillEscalation leaves any current cleanup fiber alone. + const terminating = [...(yield* readManagerState).terminatingProcesses.entries()].filter( + ([, terminal]) => + terminal.threadId === input.threadId && + (input.terminalId === undefined || terminal.terminalId === input.terminalId) && + !retainedTerminalIds.includes(terminal.terminalId) && + !retainedTerminalPrefixes.some((prefix) => terminal.terminalId.startsWith(prefix)), + ); + yield* Effect.forEach( + terminating, + ([process, terminal]) => + startKillEscalation(process, terminal.threadId, terminal.terminalId), + { discard: true }, + ); + + if (input.terminalId) { + yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true); + return; + } + + const threadSessions = yield* sessionsForThread(input.threadId); + yield* Effect.forEach( + threadSessions.filter( + (session) => + !retainedTerminalIds.includes(session.terminalId) && + !retainedTerminalPrefixes.some((prefix) => session.terminalId.startsWith(prefix)), + ), + (session) => closeSession(input.threadId, session.terminalId, false), + { discard: true }, + ); + + if (input.deleteHistory) { + yield* deleteAllHistoryForThread( + input.threadId, + retainedTerminalIds, + retainedTerminalPrefixes, + ); + } + }); + const close = ( input: TerminalCloseInput, retainedTerminalIds: ReadonlyArray = [], @@ -3256,31 +3260,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ) => withThreadLock( input.threadId, - Effect.gen(function* () { - if (input.terminalId) { - yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true); - return; - } - - const threadSessions = yield* sessionsForThread(input.threadId); - yield* Effect.forEach( - threadSessions.filter( - (session) => - !retainedTerminalIds.includes(session.terminalId) && - !retainedTerminalPrefixes.some((prefix) => session.terminalId.startsWith(prefix)), - ), - (session) => closeSession(input.threadId, session.terminalId, false), - { discard: true }, - ); - - if (input.deleteHistory) { - yield* deleteAllHistoryForThread( - input.threadId, - retainedTerminalIds, - retainedTerminalPrefixes, - ); - } - }), + closeUnlocked(input, retainedTerminalIds, retainedTerminalPrefixes), ); const closeIdle: TerminalManager["Service"]["closeIdle"] = (input) => @@ -3331,28 +3311,39 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), ); - const waitForThreadShutdown: TerminalManager["Service"]["waitForThreadShutdown"] = Effect.fn( - "terminal.waitForThreadShutdown", - )( - function* (threadId) { - const state = yield* readManagerState; - const fibers = [...state.terminatingProcesses.entries()].flatMap(([process, terminal]) => { - const fiber = state.killFibers.get(process); - return terminal.threadId === threadId && fiber !== undefined ? [fiber] : []; + const waitForThreadShutdownUnlocked = Effect.fn("terminal.waitForThreadShutdown")(function* ( + threadId: string, + ): Effect.fn.Return { + const state = yield* readManagerState; + const fibers = [...state.terminatingProcesses.entries()].flatMap(([process, terminal]) => { + const fiber = state.killFibers.get(process); + return terminal.threadId === threadId && fiber !== undefined ? [fiber] : []; + }); + yield* Effect.forEach(fibers, Fiber.await, { concurrency: "unbounded", discard: true }); + const remaining = [...(yield* readManagerState).terminatingProcesses.values()].filter( + (terminal) => terminal.threadId === threadId, + ); + if (remaining.length > 0) { + return yield* new TerminalShutdownError({ + threadId, + terminalIds: [...new Set(remaining.map((terminal) => terminal.terminalId))], }); - yield* Effect.forEach(fibers, Fiber.await, { concurrency: "unbounded", discard: true }); - const remaining = [...(yield* readManagerState).terminatingProcesses.values()].filter( - (terminal) => terminal.threadId === threadId, - ); - if (remaining.length > 0) { - return yield* new TerminalShutdownError({ - threadId, - terminalIds: [...new Set(remaining.map((terminal) => terminal.terminalId))], - }); - } - }, - (effect, threadId) => withThreadLock(threadId, effect), - ); + } + }); + + const waitForThreadShutdown: TerminalManager["Service"]["waitForThreadShutdown"] = (threadId) => + withThreadLock(threadId, waitForThreadShutdownUnlocked(threadId)); + + const shutdownThread: TerminalManager["Service"]["shutdownThread"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const closeResult = yield* closeUnlocked({ threadId }).pipe(Effect.result); + const shutdown = yield* waitForThreadShutdownUnlocked(threadId).pipe(Effect.result); + if (closeResult._tag === "Failure") return yield* closeResult.failure; + if (shutdown._tag === "Failure") return yield* shutdown.failure; + }), + ); const history: TerminalManager["Service"]["history"] = (input) => { return flushPersist(input.threadId, input.terminalId).pipe( @@ -3369,6 +3360,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func history, restart, close, + shutdownThread, waitForThreadShutdown, closeThreadExcept: (threadId, retainedTerminalIds, retainedTerminalPrefixes) => close({ threadId, deleteHistory: true }, retainedTerminalIds, retainedTerminalPrefixes), @@ -3376,7 +3368,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func subscribe, subscribeMetadata, metadata: readAllTerminalMetadata(), - refreshMetadata: pollSubprocessActivity().pipe(Effect.andThen(readDrainTerminalMetadata())), + refreshMetadata: pollSubprocessActivity().pipe(Effect.andThen(readAllTerminalMetadata())), }); }); From 053c0257757762d29ef1d682357cd9b175982c1e Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 14:43:56 -0700 Subject: [PATCH 06/13] fix(mcp): label the thread preview stop tool Register lifecycle labels, branding, and a summary action for preview_stop_thread so every published MCP tool has a presentation definition. Validation: 14 registry and tool presentation tests, shared typecheck, scoped lint and formatting passed. Implemented with GPT-6.1-Sol in the Codex harness. Carry-Group: incubator --- packages/shared/src/t3McpToolPresentation.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index f373181aaabb..ea530d732b4d 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -147,6 +147,11 @@ const T3_MCP_TOOLS: Readonly> = { ), t3_worktree_status: tool(["Get", "Getting", "Got", "thread worktree status"], "worktree-status"), preview_host: tool(["Host", "Hosting", "Hosted", "a preview server"], "browser", "browser"), + preview_stop_thread: tool( + ["Stop", "Stopping", "Stopped", "thread previews and processes"], + "browser", + "browser", + ), preview_status: tool(["Get", "Getting", "Got", "preview browser status"], "browser", "browser"), preview_open: tool( ["Open", "Opening", "Opened", "a page in the preview browser"], From 634fb63d5ad0dc4caa1ecfce5aca68b959738fcd Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 14:53:39 -0700 Subject: [PATCH 07/13] fix(mobile): clear interrupted process stop feedback Dismiss the global shutdown feedback when the environment interrupts the stop request, instead of showing a failure that cannot be confirmed. Retire interrupted feedback so a late completion cannot revive it, and preserve feedback from a newer request. Validation: six focused feedback tests, mobile typecheck, scoped lint, formatting, and diff checks passed. Implemented with GPT-6.1-Sol in the Codex harness. Carry-Group: incubator --- .../features/threads/thread-list-v2-items.tsx | 4 ++-- .../stop-thread-processes-feedback.test.ts | 22 +++++++++++++++++++ .../state/stop-thread-processes-feedback.ts | 9 +++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 3b14fbf4e6a1..a328c8e5e421 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -751,10 +751,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }); if (result._tag === "Success") { finishFeedback("success"); + } else if (isAtomCommandInterrupted(result)) { + finishFeedback("interrupted"); } else { finishFeedback("error"); - } - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = Cause.squash(result.cause); Alert.alert( "Could not stop all previews and processes", diff --git a/apps/mobile/src/state/stop-thread-processes-feedback.test.ts b/apps/mobile/src/state/stop-thread-processes-feedback.test.ts index 5fa98ab85076..669ee1b6a8f2 100644 --- a/apps/mobile/src/state/stop-thread-processes-feedback.test.ts +++ b/apps/mobile/src/state/stop-thread-processes-feedback.test.ts @@ -49,6 +49,28 @@ describe("stop thread processes feedback", () => { expect(feedback().phase).toBe("idle"); }); + it("clears interrupted feedback immediately without allowing late completion to restore it", () => { + const finish = beginStopThreadProcessesFeedback("Selected thread"); + vi.advanceTimersByTime(500); + finish("interrupted"); + expect(feedback()).toEqual({ phase: "idle", label: null, description: null }); + finish("success"); + vi.advanceTimersByTime(3_000); + expect(feedback().phase).toBe("idle"); + }); + + it("keeps newer feedback and its dismissal when an older request is interrupted", () => { + const finishFirst = beginStopThreadProcessesFeedback("First thread"); + vi.advanceTimersByTime(1_000); + const finishSecond = beginStopThreadProcessesFeedback("Second thread"); + finishSecond("success"); + finishFirst("interrupted"); + vi.advanceTimersByTime(2_999); + expect(feedback()).toMatchObject({ phase: "success", description: "Second thread" }); + vi.advanceTimersByTime(1); + expect(feedback().phase).toBe("idle"); + }); + it("keeps a newer selected thread's feedback when an older request completes", () => { const finishFirst = beginStopThreadProcessesFeedback("First thread"); finishFirst("success"); diff --git a/apps/mobile/src/state/stop-thread-processes-feedback.ts b/apps/mobile/src/state/stop-thread-processes-feedback.ts index 8b8793cac9df..c62f24fcca02 100644 --- a/apps/mobile/src/state/stop-thread-processes-feedback.ts +++ b/apps/mobile/src/state/stop-thread-processes-feedback.ts @@ -23,8 +23,15 @@ export function beginStopThreadProcessesFeedback(threadTitle: string) { description: threadTitle, }); - return (phase: "success" | "error") => { + return (phase: "success" | "error" | "interrupted") => { if (currentRequest !== request) return; + if (phase === "interrupted") { + if (dismissTimer !== null) clearTimeout(dismissTimer); + dismissTimer = null; + currentRequest = null; + appAtomRegistry.set(stopThreadProcessesFeedbackAtom, EMPTY_FEEDBACK); + return; + } appAtomRegistry.set(stopThreadProcessesFeedbackAtom, { phase, label: From a85a8a0175b1f199cd8f0462254d5d0f790c7c7e Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 15:24:16 -0700 Subject: [PATCH 08/13] fix(terminal): confirm exit before completing thread shutdown Wait for actual PTY exit callbacks before reporting that retained processes stopped. Bound confirmation after force-kill, keep unconfirmed processes visible and retryable, and remove their metadata if an exit arrives later. Add regression cases for cached and delayed exit, timeout and retry, thread locking and isolation, and exit subscription cleanup. Clarify the PTY adapter's termination contract and make test PTYs emit actual exit events. Formatting and diff checks passed. Tests and typechecks run in GitHub CI only at the maintainer's request. Implemented with GPT-6.1-Sol in the Codex harness. Carry-Group: incubator --- apps/server/src/preview/Hosting.test.ts | 18 +- apps/server/src/terminal/Manager.test.ts | 219 +++++++++++++++++- apps/server/src/terminal/Manager.ts | 214 ++++++++++------- .../src/terminal/NodePtyAdapter.test.ts | 36 +++ apps/server/src/terminal/PtyAdapter.ts | 2 + 5 files changed, 403 insertions(+), 86 deletions(-) diff --git a/apps/server/src/preview/Hosting.test.ts b/apps/server/src/preview/Hosting.test.ts index 7935b1dbc1f9..78564b00a8c8 100644 --- a/apps/server/src/preview/Hosting.test.ts +++ b/apps/server/src/preview/Hosting.test.ts @@ -466,6 +466,8 @@ describe("PreviewHosting", () => { ptyAdapter: { spawn: () => Effect.sync(() => { + const exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); + let exitEvent: PtyAdapter.PtyExitEvent | undefined; const ptyProcess = { pid: 9000 + processes.length, killSignals: [] as Array, @@ -475,9 +477,23 @@ describe("PreviewHosting", () => { kill: (signal?: string) => { ptyProcess.killSignals.push(signal); if (ptyProcess.killFailure !== undefined) throw ptyProcess.killFailure; + if (signal === "SIGKILL" && exitEvent === undefined) { + exitEvent = { exitCode: 0, signal: 9 }; + for (const listener of exitListeners) listener(exitEvent); + exitListeners.clear(); + } }, onData: () => () => {}, - onExit: () => () => {}, + onExit: (callback: (event: PtyAdapter.PtyExitEvent) => void) => { + if (exitEvent !== undefined) { + callback(exitEvent); + return () => {}; + } + exitListeners.add(callback); + return () => { + exitListeners.delete(callback); + }; + }, }; processes.push(ptyProcess); return ptyProcess; diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 389d9905bdeb..19df03035c07 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -59,8 +59,15 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { killFailure: unknown | undefined; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); + private exitEvent: PtyAdapter.PtyExitEvent | undefined; killed = false; exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined; + exitOnKill: string | undefined = "SIGKILL"; + onKill: ((signal: string | undefined) => void) | undefined; + + get exitListenerCount(): number { + return this.exitListeners.size; + } constructor(pid: number) { this.pid = pid; @@ -86,6 +93,10 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { if (this.killFailure !== undefined) { throw this.killFailure; } + this.onKill?.(signal); + if (this.exitOnKill !== undefined && signal === this.exitOnKill) { + this.emitExit({ exitCode: 0, signal: signal === "SIGKILL" ? 9 : null }); + } } onData(callback: (data: string) => void): () => void { @@ -96,7 +107,11 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { } onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { - if (this.exitOnSubscribe) callback(this.exitOnSubscribe); + const exited = this.exitEvent ?? this.exitOnSubscribe; + if (exited !== undefined) { + callback(exited); + return () => {}; + } this.exitListeners.add(callback); return () => { this.exitListeners.delete(callback); @@ -110,9 +125,12 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { } emitExit(event: PtyAdapter.PtyExitEvent): void { + if (this.exitEvent !== undefined) return; + this.exitEvent = event; for (const listener of this.exitListeners) { listener(event); } + this.exitListeners.clear(); } } @@ -239,6 +257,7 @@ interface CreateManagerOptions { >; subprocessPollIntervalMs?: number; processKillGraceMs?: number; + processExitWaitMs?: number; maxRetainedInactiveSessions?: number; historyByteLimit?: number; ptyAdapter?: FakePtyAdapter; @@ -293,6 +312,7 @@ const createManager = ( ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), processKillGraceMs: options.processKillGraceMs ?? 1, + processExitWaitMs: options.processExitWaitMs ?? 10, ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), @@ -1971,6 +1991,203 @@ it.layer( }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("accepts an exit replayed while detaching a terminal without sending signals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.exitOnSubscribe = { exitCode: 0, signal: null }; + + yield* manager.shutdownThread("thread-1"); + + expect(process.killSignals).toEqual([]); + expect(process.exitListenerCount).toBe(0); + expect(yield* manager.metadata).toEqual([]); + }), + ); + + it.effect.each(["SIGTERM", "SIGKILL"])( + "accepts synchronous %s exit confirmation and releases its subscription", + (signal) => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.exitOnKill = signal; + + yield* manager.shutdownThread("thread-1"); + + expect(process.killSignals).toEqual( + signal === "SIGTERM" ? ["SIGTERM"] : ["SIGTERM", "SIGKILL"], + ); + expect(process.exitListenerCount).toBe(0); + expect(yield* manager.metadata).toEqual([]); + }), + ); + + it.effect("holds shutdown until delayed SIGKILL exit while keeping other threads usable", () => + Effect.gen(function* () { + const forceKillSent = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + processExitWaitMs: 100, + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.exitOnKill = undefined; + process.onKill = (signal) => { + if (signal === "SIGKILL") Deferred.doneUnsafe(forceKillSent, Effect.void); + }; + const stopping = yield* manager.shutdownThread("thread-1").pipe(Effect.forkScoped); + yield* Deferred.await(forceKillSent); + expect(process.killSignals).toEqual(["SIGTERM", "SIGKILL"]); + expect(process.exitListenerCount).toBe(1); + const opening = yield* manager + .open(openInput({ terminalId: "new-terminal" })) + .pipe(Effect.forkScoped); + const restarting = yield* manager.restart(restartInput()).pipe(Effect.forkScoped); + yield* manager.open(openInput({ threadId: "thread-2" })); + + expect(stopping.pollUnsafe()).toBeUndefined(); + expect(opening.pollUnsafe()).toBeUndefined(); + expect(restarting.pollUnsafe()).toBeUndefined(); + expect(process.killSignals).toEqual(["SIGTERM", "SIGKILL"]); + expect(ptyAdapter.processes).toHaveLength(2); + expect(ptyAdapter.processes[1]?.killSignals).toEqual([]); + expect(yield* manager.metadata).toEqual( + expect.arrayContaining([ + expect.objectContaining({ threadId: "thread-1", pid: process.pid, status: "running" }), + expect.objectContaining({ threadId: "thread-2", status: "running" }), + ]), + ); + + process.emitExit({ exitCode: 0, signal: 9 }); + yield* Fiber.join(stopping); + yield* Fiber.join(opening); + yield* Fiber.join(restarting); + expect(process.exitListenerCount).toBe(0); + expect(ptyAdapter.processes[1]?.killSignals).toEqual([]); + yield* manager.shutdownThread("thread-1"); + yield* manager.shutdownThread("thread-2"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("times out unconfirmed exit and retries only the selected retained process", () => + Effect.gen(function* () { + const forceKillSent = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + processExitWaitMs: 10, + }); + yield* manager.open(openInput()); + yield* manager.open(openInput({ threadId: "thread-2" })); + const original = (yield* manager.metadata).find( + (terminal) => terminal.threadId === "thread-1", + ); + const process = ptyAdapter.processes[0]!; + process.exitOnKill = undefined; + process.onKill = (signal) => { + if (signal === "SIGKILL") Deferred.doneUnsafe(forceKillSent, Effect.void); + }; + const stopping = yield* manager + .shutdownThread("thread-1") + .pipe(Effect.result, Effect.forkScoped); + yield* Deferred.await(forceKillSent); + expect(stopping.pollUnsafe()).toBeUndefined(); + + yield* TestClock.adjust("10 millis"); + const result = yield* Fiber.join(stopping); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure._tag).toBe("TerminalShutdownError"); + if (result.failure._tag === "TerminalShutdownError") { + expect(result.failure.terminalIds).toEqual([DEFAULT_TERMINAL_ID]); + } + } + expect(process.exitListenerCount).toBe(1); + expect(yield* manager.metadata).toContainEqual( + expect.objectContaining({ + threadId: "thread-1", + pid: process.pid, + cwd: original?.cwd, + label: original?.label, + status: "running", + hasRunningSubprocess: true, + }), + ); + + process.exitOnKill = "SIGKILL"; + yield* manager.shutdownThread("thread-1"); + + expect(process.killSignals).toEqual(["SIGTERM", "SIGKILL", "SIGTERM", "SIGKILL"]); + expect(process.exitListenerCount).toBe(0); + expect(ptyAdapter.processes[1]?.killSignals).toEqual([]); + expect(yield* manager.metadata).toEqual([ + expect.objectContaining({ threadId: "thread-2", status: "running" }), + ]); + yield* manager.shutdownThread("thread-2"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect( + "removes retained metadata when actual exit arrives after the confirmation timeout", + () => + Effect.gen(function* () { + const forceKillSent = yield* Deferred.make(); + const removed = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + processExitWaitMs: 10, + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.exitOnKill = undefined; + process.onKill = (signal) => { + if (signal === "SIGKILL") Deferred.doneUnsafe(forceKillSent, Effect.void); + }; + const unsubscribe = yield* manager.subscribeMetadata((event) => + event.type === "remove" + ? Deferred.succeed(removed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const stopping = yield* manager + .shutdownThread("thread-1") + .pipe(Effect.result, Effect.forkScoped); + yield* Deferred.await(forceKillSent); + yield* TestClock.adjust("10 millis"); + expect((yield* Fiber.join(stopping))._tag).toBe("Failure"); + expect(yield* manager.metadata).toHaveLength(1); + expect(process.exitListenerCount).toBe(1); + + process.emitExit({ exitCode: 0, signal: 9 }); + yield* Deferred.await(removed); + yield* manager.waitForThreadShutdown("thread-1"); + + expect(yield* manager.metadata).toEqual([]); + expect(process.killSignals).toEqual(["SIGTERM", "SIGKILL"]); + expect(process.exitListenerCount).toBe(0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("releases a retained exit subscription when the manager scope closes", () => + Effect.gen(function* () { + const ptyAdapter = new FakePtyAdapter(); + yield* Effect.scoped( + Effect.gen(function* () { + const { manager } = yield* createManager(5, { ptyAdapter }); + yield* manager.open(openInput()); + ptyAdapter.processes[0]!.killFailure = new Error("signal failure"); + expect((yield* manager.shutdownThread("thread-1").pipe(Effect.result))._tag).toBe( + "Failure", + ); + expect(ptyAdapter.processes[0]?.exitListenerCount).toBe(1); + }), + ); + expect(ptyAdapter.processes[0]?.exitListenerCount).toBe(0); + }), + ); + it.effect("keeps a closing terminal blocked when process signaling fails", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index c4aa866c9b92..bd4231352c7a 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -48,6 +48,7 @@ import { acpRegistryManagedBinaryDirectories } from "../provider/acp/AcpRegistry import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Equal from "effect/Equal"; @@ -107,6 +108,7 @@ const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; const MAX_SUBPROCESS_POLL_INTERVAL_MS = 60_000; const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; +const DEFAULT_PROCESS_EXIT_WAIT_MS = 5_000; const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; const DEFAULT_OPEN_COLS = 120; const DEFAULT_OPEN_ROWS = 30; @@ -376,10 +378,15 @@ type DrainProcessEventAction = exitSignal: number | null; }; +interface TerminatingProcess { + readonly terminal: TerminalSummary; + readonly exited: Deferred.Deferred; +} + interface TerminalManagerState { sessions: Map; killFibers: Map>; - terminatingProcesses: Map; + terminatingProcesses: Map; } function truncateTerminalWireLabel(value: string): string { @@ -1438,6 +1445,7 @@ interface TerminalManagerOptions { >; subprocessPollIntervalMs?: number; processKillGraceMs?: number; + processExitWaitMs?: number; maxRetainedInactiveSessions?: number; registerTerminalProcesses?: (input: { readonly threadId: string; @@ -1633,6 +1641,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; + const processExitWaitMs = options.processExitWaitMs ?? DEFAULT_PROCESS_EXIT_WAIT_MS; const maxRetainedInactiveSessions = options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); @@ -1723,53 +1732,81 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func process: PtyAdapter.PtyProcess, threadId: string, terminalId: string, - ) { - const terminated = yield* Effect.try({ - try: () => process.kill("SIGTERM"), - catch: (cause) => - new TerminalProcessSignalError({ - cause, - signal: "SIGTERM", - terminalPid: process.pid, - }), - }).pipe( - Effect.as(true), - Effect.catch((error) => - Effect.logWarning("failed to kill terminal process", { - threadId, - terminalId, - signal: "SIGTERM", - cause: error, - }).pipe(Effect.as(false)), + retainedExit?: Deferred.Deferred, + ): Effect.fn.Return { + const observedExit = + retainedExit ?? (yield* readManagerState).terminatingProcesses.get(process)?.exited; + const exited = observedExit ?? (yield* Deferred.make()); + return yield* Effect.acquireUseRelease( + Effect.sync(() => + observedExit !== undefined + ? () => {} + : process.onExit(() => { + Deferred.doneUnsafe(exited, Effect.void); + }), ), - ); - if (!terminated) { - return false; - } + () => + Effect.gen(function* () { + if (yield* Deferred.isDone(exited)) return true; + + const signal = (name: "SIGTERM" | "SIGKILL") => + Effect.try({ + try: () => process.kill(name), + catch: (cause) => + new TerminalProcessSignalError({ + cause, + signal: name, + terminalPid: process.pid, + }), + }).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.logWarning("failed to kill terminal process", { + threadId, + terminalId, + signal: name, + cause: error, + }).pipe(Effect.as(false)), + ), + ); - yield* Effect.sleep(processKillGraceMs); + if (!(yield* signal("SIGTERM"))) return yield* Deferred.isDone(exited); + if (yield* Deferred.isDone(exited)) return true; + const gracefulExit = yield* Deferred.await(exited).pipe( + Effect.timeoutOption(processKillGraceMs), + ); + if (Option.isSome(gracefulExit) || (yield* Deferred.isDone(exited))) return true; - return yield* Effect.try({ - try: () => process.kill("SIGKILL"), - catch: (cause) => - new TerminalProcessSignalError({ - cause, - signal: "SIGKILL", - terminalPid: process.pid, + if (!(yield* signal("SIGKILL"))) return yield* Deferred.isDone(exited); + if (yield* Deferred.isDone(exited)) return true; + const forcedExit = yield* Deferred.await(exited).pipe( + Effect.timeoutOption(processExitWaitMs), + ); + if (Option.isSome(forcedExit) || (yield* Deferred.isDone(exited))) return true; + yield* Effect.logWarning("terminal process exit was not confirmed", { + threadId, + terminalId, + terminalPid: process.pid, + }); + return false; }), - }).pipe( - Effect.as(true), - Effect.catch((error) => - Effect.logWarning("failed to force-kill terminal process", { - threadId, - terminalId, - signal: "SIGKILL", - cause: error, - }).pipe(Effect.as(false)), - ), + (unsubscribe) => Effect.sync(unsubscribe), ); }); + const completeProcessShutdown = Effect.fn("terminal.completeProcessShutdown")(function* ( + process: PtyAdapter.PtyProcess, + ): Effect.fn.Return { + const terminal = yield* modifyManagerState((state) => { + const terminating = state.terminatingProcesses.get(process); + if (terminating === undefined) return [undefined, state] as const; + const terminatingProcesses = new Map(state.terminatingProcesses); + terminatingProcesses.delete(process); + return [terminating.terminal, { ...state, terminatingProcesses }] as const; + }); + if (terminal !== undefined) yield* publishMetadataUpdate(terminal); + }); + const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* ( process: PtyAdapter.PtyProcess, threadId: string, @@ -1779,18 +1816,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (existing !== undefined && existing.pollUnsafe() === undefined) return; const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe( - Effect.tap((completed) => - completed - ? modifyManagerState((state) => { - if (!state.terminatingProcesses.has(process)) { - return [undefined, state] as const; - } - const terminatingProcesses = new Map(state.terminatingProcesses); - terminatingProcesses.delete(process); - return [undefined, { ...state, terminatingProcesses }] as const; - }).pipe(Effect.andThen(publishMetadataUpdate({ threadId, terminalId }))) - : Effect.void, - ), + Effect.tap((completed) => (completed ? completeProcessShutdown(process) : Effect.void)), Effect.asVoid, Effect.ensuring( modifyManagerState((state) => { @@ -2242,26 +2268,44 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (!process) return; const updatedAt = yield* nowIso; - yield* modifyManagerState((state) => { - const terminatingProcesses = new Map(state.terminatingProcesses); - terminatingProcesses.set(process, { - ...summary(session), - status: "running", - hasRunningSubprocess: true, - }); - cleanupProcessHandles(session); - session.process = null; - session.pid = null; - session.hasRunningSubprocess = false; - session.childCommandLabel = null; - session.status = "exited"; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.updatedAt = updatedAt; - return [undefined, { ...state, terminatingProcesses }] as const; - }); + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const exited = yield* Deferred.make(); + // Keep an exit witness after detaching the session, including between + // failed cleanup attempts. A late exit also clears the visible blocker. + const unsubscribe = process.onExit(() => { + Deferred.doneUnsafe(exited, Effect.void); + }); + yield* modifyManagerState((state) => { + const terminatingProcesses = new Map(state.terminatingProcesses); + terminatingProcesses.set(process, { + terminal: { + ...summary(session), + status: "running", + hasRunningSubprocess: true, + }, + exited, + }); + cleanupProcessHandles(session); + session.process = null; + session.pid = null; + session.hasRunningSubprocess = false; + session.childCommandLabel = null; + session.status = "exited"; + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + session.updatedAt = updatedAt; + return [undefined, { ...state, terminatingProcesses }] as const; + }); + yield* restore(Deferred.await(exited)).pipe( + Effect.andThen(completeProcessShutdown(process)), + Effect.ensuring(Effect.sync(unsubscribe)), + Effect.forkIn(workerScope), + ); + }), + ); yield* clearKillFiber(process); yield* unregisterTerminal({ @@ -2713,9 +2757,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); yield* Effect.forEach( terminatingProcesses, - ([process, terminal]) => + ([process, { terminal, exited }]) => clearKillFiber(process).pipe( - Effect.andThen(runKillEscalation(process, terminal.threadId, terminal.terminalId)), + Effect.andThen( + runKillEscalation(process, terminal.threadId, terminal.terminalId, exited), + ), ), { concurrency: "unbounded", discard: true }, ); @@ -2922,7 +2968,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func summary(session), ]), ); - for (const terminal of state.terminatingProcesses.values()) { + for (const { terminal } of state.terminatingProcesses.values()) { terminals.set(toSessionKey(terminal.threadId, terminal.terminalId), terminal); } return [...terminals.values()].sort( @@ -2941,10 +2987,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func readManagerState.pipe( Effect.map((state) => { const terminating = [...state.terminatingProcesses.values()].findLast( - (terminal) => + ({ terminal }) => terminal.threadId === input.threadId && terminal.terminalId === input.terminalId, ); - if (terminating !== undefined) return terminating; + if (terminating !== undefined) return terminating.terminal; const session = state.sessions.get(toSessionKey(input.threadId, input.terminalId)); return session === undefined ? null : summary(session); }), @@ -3215,7 +3261,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func // Failed handles outlive their sessions. A later close retries them, // while startKillEscalation leaves any current cleanup fiber alone. const terminating = [...(yield* readManagerState).terminatingProcesses.entries()].filter( - ([, terminal]) => + ([, { terminal }]) => terminal.threadId === input.threadId && (input.terminalId === undefined || terminal.terminalId === input.terminalId) && !retainedTerminalIds.includes(terminal.terminalId) && @@ -3223,7 +3269,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); yield* Effect.forEach( terminating, - ([process, terminal]) => + ([process, { terminal }]) => startKillEscalation(process, terminal.threadId, terminal.terminalId), { discard: true }, ); @@ -3315,14 +3361,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId: string, ): Effect.fn.Return { const state = yield* readManagerState; - const fibers = [...state.terminatingProcesses.entries()].flatMap(([process, terminal]) => { + const fibers = [...state.terminatingProcesses.entries()].flatMap(([process, { terminal }]) => { const fiber = state.killFibers.get(process); return terminal.threadId === threadId && fiber !== undefined ? [fiber] : []; }); yield* Effect.forEach(fibers, Fiber.await, { concurrency: "unbounded", discard: true }); - const remaining = [...(yield* readManagerState).terminatingProcesses.values()].filter( - (terminal) => terminal.threadId === threadId, - ); + const remaining = [...(yield* readManagerState).terminatingProcesses.values()] + .map(({ terminal }) => terminal) + .filter((terminal) => terminal.threadId === threadId); if (remaining.length > 0) { return yield* new TerminalShutdownError({ threadId, diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index 7c6101dfa38e..9b7fcd742569 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -192,6 +192,42 @@ it.effect.each(["win32", "linux", "darwin"] as const)( }).pipe(Effect.provide(makeTestLayer(platform))), ); +it.effect.each(["win32", "linux", "darwin"] as const)( + "confirms exit only on the native event and replays it to late subscribers on %s", + (platform) => + Effect.gen(function* () { + const adapter = yield* PtyAdapter.PtyAdapter; + const process = yield* adapter.spawn(spawnInput); + const nativeProcess = spawn.mock.results.at(-1)!.value; + const exits: PtyAdapter.PtyExitEvent[] = []; + const unsubscribedExits: PtyAdapter.PtyExitEvent[] = []; + const unsubscribe = process.onExit((event) => { + unsubscribedExits.push(event); + }); + unsubscribe(); + const unsubscribeExit = process.onExit((event) => { + exits.push(event); + }); + + process.kill("SIGKILL"); + assert.deepEqual(exits, []); + assert.equal(nativeProcess.events.listenerCount("exit"), 1); + + nativeProcess.events.emit("exit", { exitCode: 0, signal: 9 }); + assert.deepEqual(exits, [{ exitCode: 0, signal: 9 }]); + assert.deepEqual(unsubscribedExits, []); + assert.equal(nativeProcess.events.listenerCount("exit"), 0); + unsubscribeExit(); + const lateExits: PtyAdapter.PtyExitEvent[] = []; + const unsubscribeLate = process.onExit((event) => { + lateExits.push(event); + }); + assert.deepEqual(lateExits, [{ exitCode: 0, signal: 9 }]); + unsubscribeLate(); + assert.equal(nativeProcess.events.listenerCount("exit"), 0); + }).pipe(Effect.provide(makeTestLayer(platform))), +); + it.effect("spawns through the public adapter with the provided host references", () => Effect.gen(function* () { spawn.mockClear(); diff --git a/apps/server/src/terminal/PtyAdapter.ts b/apps/server/src/terminal/PtyAdapter.ts index c56dfa93efe6..3e3fa5927b3a 100644 --- a/apps/server/src/terminal/PtyAdapter.ts +++ b/apps/server/src/terminal/PtyAdapter.ts @@ -38,8 +38,10 @@ export interface PtyProcess { readonly pid: number; write(data: string): void; resize(cols: number, rows: number): void; + /** Requests termination; returning does not confirm that the process exited. */ kill(signal?: string): void; onData(callback: (data: string) => void): () => void; + /** Reports actual exit, replaying it immediately for a late subscriber. */ onExit(callback: (event: PtyExitEvent) => void): () => void; } From 8793e9ef8b7c3e1ba2385a216b586f4316d24b3e Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 15:39:06 -0700 Subject: [PATCH 09/13] fix(mobile): preserve concurrent process stop feedback Track each shutdown independently so finishing or interrupting one request cannot clear another pending stop. Show aggregate progress and completed outcomes, retaining each result for its own minimum visibility. Add deterministic concurrency regression cases. Source formatting and diff checks completed; validation runs in GitHub CI only at the maintainer request. Implemented with GPT-6.1-Sol in the Codex harness. Carry-Group: incubator --- .../stop-thread-processes-feedback.test.ts | 71 ++++++++++++++++ .../state/stop-thread-processes-feedback.ts | 81 ++++++++++++------- 2 files changed, 123 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/state/stop-thread-processes-feedback.test.ts b/apps/mobile/src/state/stop-thread-processes-feedback.test.ts index 669ee1b6a8f2..bd9c65c4c979 100644 --- a/apps/mobile/src/state/stop-thread-processes-feedback.test.ts +++ b/apps/mobile/src/state/stop-thread-processes-feedback.test.ts @@ -59,6 +59,77 @@ describe("stop thread processes feedback", () => { expect(feedback().phase).toBe("idle"); }); + it("keeps an older shutdown pending when a newer request succeeds and expires", () => { + const finishFirst = beginStopThreadProcessesFeedback("First thread"); + vi.advanceTimersByTime(500); + const finishSecond = beginStopThreadProcessesFeedback("Second thread"); + vi.advanceTimersByTime(500); + finishSecond("success"); + expect(feedback()).toMatchObject({ phase: "running", description: "1 stopping · 1 stopped" }); + vi.advanceTimersByTime(2_499); + expect(feedback().description).toBe("1 stopping · 1 stopped"); + vi.advanceTimersByTime(1); + expect(feedback()).toMatchObject({ phase: "running", description: "First thread" }); + vi.advanceTimersByTime(4_500); + expect(feedback().phase).toBe("running"); + finishFirst("success"); + expect(feedback()).toMatchObject({ phase: "success", description: "First thread" }); + vi.runOnlyPendingTimers(); + expect(feedback().phase).toBe("idle"); + }); + + it("interrupts a newer request without clearing an older shutdown or losing its result", () => { + const finishFirst = beginStopThreadProcessesFeedback("First thread"); + const finishSecond = beginStopThreadProcessesFeedback("Second thread"); + finishSecond("interrupted"); + expect(feedback()).toMatchObject({ phase: "running", description: "First thread" }); + finishSecond("error"); + expect(feedback().phase).toBe("running"); + finishFirst("error"); + expect(feedback()).toMatchObject({ phase: "error", description: "First thread" }); + vi.advanceTimersByTime(3_000); + expect(feedback().phase).toBe("idle"); + }); + + it("shows every completed outcome while another shutdown remains pending", () => { + const finishFirst = beginStopThreadProcessesFeedback("First thread"); + const finishSecond = beginStopThreadProcessesFeedback("Second thread"); + const finishThird = beginStopThreadProcessesFeedback("Third thread"); + const finishFourth = beginStopThreadProcessesFeedback("Fourth thread"); + finishFirst("success"); + finishSecond("success"); + finishThird("error"); + expect(feedback()).toMatchObject({ + phase: "running", + description: "1 stopping · 2 stopped · 1 failed", + }); + vi.advanceTimersByTime(2_999); + expect(feedback().description).toBe("1 stopping · 2 stopped · 1 failed"); + vi.advanceTimersByTime(1); + expect(feedback()).toMatchObject({ phase: "running", description: "Fourth thread" }); + finishFourth("success"); + vi.runOnlyPendingTimers(); + expect(feedback().phase).toBe("idle"); + }); + + it("retains overlapping results until each request's own minimum visibility expires", () => { + const finishFirst = beginStopThreadProcessesFeedback("First thread"); + vi.advanceTimersByTime(1_000); + const finishSecond = beginStopThreadProcessesFeedback("Second thread"); + vi.advanceTimersByTime(500); + finishFirst("error"); + expect(feedback()).toMatchObject({ phase: "running", description: "1 stopping · 1 failed" }); + vi.advanceTimersByTime(1_000); + finishSecond("success"); + expect(feedback()).toMatchObject({ phase: "error", description: "1 stopped · 1 failed" }); + vi.advanceTimersByTime(500); + expect(feedback()).toMatchObject({ phase: "success", description: "Second thread" }); + vi.advanceTimersByTime(999); + expect(feedback().phase).toBe("success"); + vi.advanceTimersByTime(1); + expect(feedback().phase).toBe("idle"); + }); + it("keeps newer feedback and its dismissal when an older request is interrupted", () => { const finishFirst = beginStopThreadProcessesFeedback("First thread"); vi.advanceTimersByTime(1_000); diff --git a/apps/mobile/src/state/stop-thread-processes-feedback.ts b/apps/mobile/src/state/stop-thread-processes-feedback.ts index c62f24fcca02..65e6c7a5268e 100644 --- a/apps/mobile/src/state/stop-thread-processes-feedback.ts +++ b/apps/mobile/src/state/stop-thread-processes-feedback.ts @@ -7,46 +7,69 @@ const EMPTY_FEEDBACK: GitActionProgress = { phase: "idle", label: null, descript const MIN_VISIBLE_MS = 3_000; export const stopThreadProcessesFeedbackAtom = Atom.make(EMPTY_FEEDBACK).pipe(Atom.keepAlive); -let currentRequest: object | null = null; -let dismissTimer: ReturnType | null = null; + +interface FeedbackRequest { + readonly threadTitle: string; + readonly startedAt: number; + phase: "running" | "success" | "error"; +} + +const requests = new Set(); + +function publishFeedback() { + if (requests.size === 0) { + appAtomRegistry.set(stopThreadProcessesFeedbackAtom, EMPTY_FEEDBACK); + return; + } + const counts = { running: 0, success: 0, error: 0 }; + for (const request of requests) counts[request.phase] += 1; + const phase = counts.running > 0 ? "running" : counts.error > 0 ? "error" : "success"; + appAtomRegistry.set(stopThreadProcessesFeedbackAtom, { + phase, + label: + phase === "running" + ? "Stopping all previews and processes…" + : phase === "success" + ? "All previews and processes stopped" + : "Could not confirm previews and processes stopped", + description: + requests.size === 1 + ? (requests.values().next().value?.threadTitle ?? null) + : [ + counts.running > 0 ? `${counts.running} stopping` : null, + counts.success > 0 ? `${counts.success} stopped` : null, + counts.error > 0 ? `${counts.error} failed` : null, + ] + .filter((count) => count !== null) + .join(" · "), + }); +} /** Feedback lives above recycled rows and stays pending until shutdown completes. */ export function beginStopThreadProcessesFeedback(threadTitle: string) { - const request = {}; - currentRequest = request; - if (dismissTimer !== null) clearTimeout(dismissTimer); - dismissTimer = null; - const startedAt = Date.now(); - appAtomRegistry.set(stopThreadProcessesFeedbackAtom, { + const request: FeedbackRequest = { + threadTitle, + startedAt: Date.now(), phase: "running", - label: "Stopping all previews and processes…", - description: threadTitle, - }); + }; + requests.add(request); + publishFeedback(); return (phase: "success" | "error" | "interrupted") => { - if (currentRequest !== request) return; + if (!requests.has(request) || request.phase !== "running") return; if (phase === "interrupted") { - if (dismissTimer !== null) clearTimeout(dismissTimer); - dismissTimer = null; - currentRequest = null; - appAtomRegistry.set(stopThreadProcessesFeedbackAtom, EMPTY_FEEDBACK); + requests.delete(request); + publishFeedback(); return; } - appAtomRegistry.set(stopThreadProcessesFeedbackAtom, { - phase, - label: - phase === "success" - ? "All previews and processes stopped" - : "Could not confirm previews and processes stopped", - description: threadTitle, - }); - dismissTimer = setTimeout( + request.phase = phase; + publishFeedback(); + setTimeout( () => { - dismissTimer = null; - currentRequest = null; - appAtomRegistry.set(stopThreadProcessesFeedbackAtom, EMPTY_FEEDBACK); + requests.delete(request); + publishFeedback(); }, - Math.max(0, MIN_VISIBLE_MS - (Date.now() - startedAt)), + Math.max(0, MIN_VISIBLE_MS - (Date.now() - request.startedAt)), ); }; } From 5a3c87882f3e7568b04bf9bb8404312dfe8691af Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 15:41:06 -0700 Subject: [PATCH 10/13] fix(terminal): prevent attachment from reviving terminating processes Reject automatic attachment while the same terminal still has an unconfirmed retained exit witness and no live session process. Keep retained processes visible and retryable, and allow normal attachment after confirmed exit or to an intentional live replacement. Add deterministic regressions for termination grace, repeated attachment after failed shutdown, processless replacements and thread isolation. Source formatting and diff checks completed; tests run in GitHub CI only at the maintainer request. Implemented with GPT-6.1-Sol in the Codex harness. Carry-Group: incubator --- apps/server/src/terminal/Manager.test.ts | 212 +++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 19 ++ 2 files changed, 231 insertions(+) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 19df03035c07..7be94d91fb71 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -512,6 +512,218 @@ it.layer( }), ); + it.effect("does not recreate a detached terminal from metadata during normal kill grace", () => + Effect.gen(function* () { + const termSent = yield* Deferred.make(); + const removed = yield* Deferred.make(); + let environmentResolutions = 0; + const { manager, ptyAdapter, logsDir } = yield* createManager(5, { + processKillGraceMs: 10, + resolveProviderInstanceEnvironment: () => + Effect.sync(() => { + environmentResolutions += 1; + return {}; + }), + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.onKill = (signal) => { + if (signal === "SIGTERM") Deferred.doneUnsafe(termSent, Effect.void); + }; + const unsubscribeMetadata = yield* manager.subscribeMetadata((event) => + event.type === "remove" + ? Deferred.succeed(removed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeMetadata)); + + yield* manager.close({ threadId: "thread-1", deleteHistory: true }); + yield* Deferred.await(termSent); + const retainedMetadata = yield* manager.metadata; + const historyPath = yield* historyLogPath(logsDir); + expect(yield* pathExists(historyPath)).toBe(false); + const rejectedEvents: TerminalAttachStreamEvent[] = []; + const error = yield* manager + .attachStream( + { + ...openInput({ cwd: "/missing-terminal-directory" }), + providerInstanceId: ProviderInstanceId.make("test-provider"), + restartIfNotRunning: true, + }, + (event) => + Effect.sync(() => { + rejectedEvents.push(event); + }), + ) + .pipe(Effect.flip); + + expect(error._tag).toBe("TerminalNotRunningError"); + expect(environmentResolutions).toBe(0); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(process.killSignals).toEqual(["SIGTERM"]); + expect(yield* manager.metadata).toEqual(retainedMetadata); + expect(yield* pathExists(historyPath)).toBe(false); + + yield* TestClock.adjust("10 millis"); + yield* Deferred.await(removed); + yield* manager.waitForThreadShutdown("thread-1"); + const snapshots: TerminalAttachStreamEvent[] = []; + const unsubscribe = yield* manager.attachStream(openInput(), (event) => + Effect.sync(() => { + snapshots.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(snapshots).toEqual([ + expect.objectContaining({ + type: "snapshot", + snapshot: expect.objectContaining({ + status: "running", + pid: ptyAdapter.processes[1]?.pid, + }), + }), + ]); + expect(rejectedEvents).toEqual([]); + ptyAdapter.processes[1]!.exitOnKill = "SIGTERM"; + yield* manager.shutdownThread("thread-1"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect( + "keeps failed retained terminals visible without respawning on repeated attachment", + () => + Effect.gen(function* () { + const removed = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.killFailure = new Error("signal failure"); + const unsubscribeMetadata = yield* manager.subscribeMetadata((event) => + event.type === "remove" && event.threadId === "thread-1" + ? Deferred.succeed(removed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeMetadata)); + yield* manager.close({ threadId: "thread-1" }); + expect((yield* manager.waitForThreadShutdown("thread-1").pipe(Effect.result))._tag).toBe( + "Failure", + ); + const retainedMetadata = yield* manager.metadata; + const rejectedEvents: TerminalAttachStreamEvent[] = []; + for (let attempt = 0; attempt < 3; attempt += 1) { + const error = yield* manager + .attachStream(openInput(), (event) => + Effect.sync(() => { + rejectedEvents.push(event); + }), + ) + .pipe(Effect.flip); + expect(error._tag).toBe("TerminalNotRunningError"); + expect(yield* manager.metadata).toEqual(retainedMetadata); + } + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(process.killSignals).toEqual(["SIGTERM"]); + expect(retainedMetadata).toEqual([ + expect.objectContaining({ + threadId: "thread-1", + pid: process.pid, + status: "running", + hasRunningSubprocess: true, + }), + ]); + + const unsubscribeOtherThread = yield* manager.attachStream( + openInput({ threadId: "thread-2" }), + () => Effect.void, + ); + const unsubscribeOtherId = yield* manager.attachStream( + openInput({ terminalId: "other-terminal" }), + () => Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeOtherThread)); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeOtherId)); + expect(ptyAdapter.spawnInputs).toHaveLength(3); + expect(process.killSignals).toEqual(["SIGTERM"]); + + process.emitExit({ exitCode: 0, signal: null }); + yield* Deferred.await(removed); + yield* manager.waitForThreadShutdown("thread-1"); + const unsubscribe = yield* manager.attachStream(openInput(), () => Effect.void); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + expect(ptyAdapter.spawnInputs).toHaveLength(4); + expect(process.killSignals).toEqual(["SIGTERM"]); + expect(rejectedEvents).toEqual([]); + yield* manager.shutdownThread("thread-1"); + yield* manager.shutdownThread("thread-2"); + }), + ); + + it.effect( + "blocks processless session attachment but attaches to an explicit live replacement", + () => + Effect.gen(function* () { + const replacementVisible = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }); + yield* manager.open(openInput()); + const original = ptyAdapter.processes[0]!; + original.killFailure = new Error("signal failure"); + ptyAdapter.spawnFailures.push(new Error("replacement spawn failed")); + const failedRestart = yield* manager.restart(restartInput()); + expect(failedRestart.status).toBe("error"); + expect((yield* manager.waitForThreadShutdown("thread-1").pipe(Effect.result))._tag).toBe( + "Failure", + ); + const retainedMetadata = yield* manager.metadata; + const rejectedEvents: TerminalAttachStreamEvent[] = []; + const error = yield* manager + .attachStream({ ...openInput(), restartIfNotRunning: true }, (event) => + Effect.sync(() => { + rejectedEvents.push(event); + }), + ) + .pipe(Effect.flip); + + expect(error._tag).toBe("TerminalNotRunningError"); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.processes).toHaveLength(1); + expect(yield* manager.metadata).toEqual(retainedMetadata); + expect(original.killSignals).toEqual(["SIGTERM"]); + + const replacement = yield* manager.restart(restartInput()); + const attachedEvents: TerminalAttachStreamEvent[] = []; + const unsubscribe = yield* manager.attachStream( + { ...openInput(), restartIfNotRunning: true }, + (event) => + Effect.sync(() => { + attachedEvents.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + expect(ptyAdapter.spawnInputs).toHaveLength(3); + expect(attachedEvents).toEqual([ + expect.objectContaining({ type: "snapshot", snapshot: replacement }), + ]); + expect(original.killSignals).toEqual(["SIGTERM"]); + const unsubscribeMetadata = yield* manager.subscribeMetadata((event) => + event.type === "upsert" && event.terminal.pid === replacement.pid + ? Deferred.succeed(replacementVisible, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeMetadata)); + + original.emitExit({ exitCode: 0, signal: null }); + yield* Deferred.await(replacementVisible); + expect(yield* manager.metadata).toEqual([ + expect.objectContaining({ status: "running", pid: replacement.pid }), + ]); + expect(rejectedEvents).toEqual([]); + yield* manager.shutdownThread("thread-1"); + }), + ); + it.effect("keeps attach streams live when a terminal id is closed and reopened", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index bd4231352c7a..ed1f9c7c0852 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -2914,6 +2914,25 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const terminalId = input.terminalId; const existing = yield* getSession(input.threadId, terminalId); + if (Option.isNone(existing) || existing.value.process === null) { + const terminating = (yield* readManagerState).terminatingProcesses.values(); + // Retained metadata keeps Stop available; mounting its viewport must + // not turn a closed session back into a new process before exit. + if ( + [...terminating].some( + ({ terminal, exited }) => + terminal.threadId === input.threadId && + terminal.terminalId === terminalId && + !Deferred.isDoneUnsafe(exited), + ) + ) { + return yield* new TerminalNotRunningError({ + threadId: input.threadId, + terminalId, + }); + } + } + if (Option.isNone(existing)) { if (!input.cwd || !startIfNeeded) { return yield* new TerminalSessionLookupError({ From 65b28fe9f4a37aacd71032dcb1a60164f43d1f9e Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 15:51:14 -0700 Subject: [PATCH 11/13] fix(terminal): reject attachments queued across shutdown Capture attachment lifecycle before waiting for the thread lock. Keep a temporary count of shutdown operations through lock finalization so metadata removal or an interrupted concurrent stop cannot let an automatic attachment recreate a terminal. Add deterministic coverage for queued attachment, delayed metadata removal, concurrent idle close and interrupted shutdown. Fresh attachment remains possible after shutdown completes. Source formatting and diff checks passed; test execution is delegated to GitHub CI at the maintainer request. Implemented with GPT-6.1-Sol in the Codex harness. Carry-Group: incubator --- apps/server/src/terminal/Manager.test.ts | 168 ++++++++++++++++++++++- apps/server/src/terminal/Manager.ts | 65 +++++++-- 2 files changed, 222 insertions(+), 11 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 7be94d91fb71..cc50ad80fd24 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -512,6 +512,171 @@ it.layer( }), ); + it.effect.each(["awaiting-exit", "metadata-removed"] as const)( + "rejects attachment queued during shutdown when %s and permits a fresh attachment afterward", + (phase) => + Effect.gen(function* () { + const forceKillSent = yield* Deferred.make(); + const removalStarted = yield* Deferred.make(); + const releaseRemoval = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + processExitWaitMs: 100, + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.exitOnKill = undefined; + process.onKill = (signal) => { + if (signal === "SIGKILL") Deferred.doneUnsafe(forceKillSent, Effect.void); + }; + const unsubscribeMetadata = yield* manager.subscribeMetadata((event) => + event.type === "remove" + ? Deferred.succeed(removalStarted, undefined).pipe( + Effect.andThen( + phase === "metadata-removed" ? Deferred.await(releaseRemoval) : Effect.void, + ), + ) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeMetadata)); + const stopping = yield* manager.shutdownThread("thread-1").pipe(Effect.forkScoped); + yield* Deferred.await(forceKillSent); + if (phase === "metadata-removed") { + process.emitExit({ exitCode: 0, signal: 9 }); + yield* Deferred.await(removalStarted); + expect(yield* manager.metadata).toEqual([]); + } + const rejectedEvents: TerminalAttachStreamEvent[] = []; + const attaching = yield* manager + .attachStream(openInput(), (event) => + Effect.sync(() => { + rejectedEvents.push(event); + }), + ) + .pipe(Effect.result, Effect.forkScoped({ startImmediately: true })); + expect(attaching.pollUnsafe()).toBeUndefined(); + expect(stopping.pollUnsafe()).toBeUndefined(); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + + if (phase === "awaiting-exit") process.emitExit({ exitCode: 0, signal: 9 }); + else yield* Deferred.succeed(releaseRemoval, undefined); + yield* Fiber.join(stopping); + const result = yield* Fiber.join(attaching); + expect(result._tag === "Failure" ? result.failure._tag : null).toBe( + "TerminalNotRunningError", + ); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(rejectedEvents).toEqual([]); + expect(yield* manager.metadata).toEqual([]); + + const unsubscribe = yield* manager.attachStream(openInput(), () => Effect.void); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(rejectedEvents).toEqual([]); + ptyAdapter.processes[1]!.exitOnKill = "SIGTERM"; + yield* manager.shutdownThread("thread-1"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("does not auto-open a session observed alive before concurrent idle close", () => + Effect.gen(function* () { + const checking = yield* Deferred.make(); + const finishCheck = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + subprocessInspector: () => + Deferred.succeed(checking, undefined).pipe( + Effect.andThen(Deferred.await(finishCheck)), + Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), + ), + }); + yield* manager.open(openInput()); + const closing = yield* manager.closeIdle({ threadId: "thread-1" }).pipe(Effect.forkScoped); + yield* Deferred.await(checking); + const attaching = yield* manager + .attachStream(openInput(), () => Effect.void) + .pipe(Effect.result, Effect.forkScoped({ startImmediately: true })); + expect(attaching.pollUnsafe()).toBeUndefined(); + expect(ptyAdapter.processes[0]?.killSignals).toEqual([]); + + yield* Deferred.succeed(finishCheck, undefined); + yield* Fiber.join(closing); + yield* manager.waitForThreadShutdown("thread-1"); + const result = yield* Fiber.join(attaching); + expect(result._tag === "Failure" ? result.failure._tag : null).toBe( + "TerminalNotRunningError", + ); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(yield* manager.metadata).toEqual([]); + + const unsubscribe = yield* manager.attachStream(openInput(), () => Effect.void); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + yield* manager.shutdownThread("thread-1"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect( + "keeps concurrent shutdown protected after interruption and clears it after completion", + () => + Effect.gen(function* () { + const firstCleanup = yield* Deferred.make(); + const secondCleanup = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const releaseSecond = yield* Deferred.make(); + const gates = [ + { started: firstCleanup, release: releaseFirst }, + { started: secondCleanup, release: releaseSecond }, + ]; + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + unregisterTerminal: ({ threadId }) => + Effect.suspend(() => { + const gate = threadId === "thread-1" ? gates.shift() : undefined; + return gate === undefined + ? Effect.void + : Deferred.succeed(gate.started, undefined).pipe( + Effect.andThen(Deferred.await(gate.release)), + ); + }), + }); + yield* manager.open(openInput()); + const first = yield* manager.shutdownThread("thread-1").pipe(Effect.forkScoped); + yield* Deferred.await(firstCleanup); + const second = yield* manager + .shutdownThread("thread-1") + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* Fiber.interrupt(first); + yield* Deferred.await(secondCleanup); + const attaching = yield* manager + .attachStream(openInput({ terminalId: "new-terminal" }), () => Effect.void) + .pipe(Effect.result, Effect.forkScoped({ startImmediately: true })); + expect(attaching.pollUnsafe()).toBeUndefined(); + const unsubscribeOther = yield* manager.attachStream( + openInput({ threadId: "thread-2" }), + () => Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeOther)); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + + yield* Deferred.succeed(releaseSecond, undefined); + yield* Fiber.join(second); + const result = yield* Fiber.join(attaching); + expect(result._tag === "Failure" ? result.failure._tag : null).toBe( + "TerminalNotRunningError", + ); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + const unsubscribe = yield* manager.attachStream( + openInput({ terminalId: "new-terminal" }), + () => Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + expect(ptyAdapter.spawnInputs).toHaveLength(3); + yield* manager.shutdownThread("thread-1"); + yield* manager.shutdownThread("thread-2"); + }), + ); + it.effect("does not recreate a detached terminal from metadata during normal kill grace", () => Effect.gen(function* () { const termSent = yield* Deferred.make(); @@ -606,8 +771,7 @@ it.layer( : Effect.void, ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribeMetadata)); - yield* manager.close({ threadId: "thread-1" }); - expect((yield* manager.waitForThreadShutdown("thread-1").pipe(Effect.result))._tag).toBe( + expect((yield* manager.shutdownThread("thread-1").pipe(Effect.result))._tag).toBe( "Failure", ); const retainedMetadata = yield* manager.metadata; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index ed1f9c7c0852..668e3a9de5af 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1655,6 +1655,8 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminatingProcesses: new Map(), }); const threadLocks = yield* KeyedLock.make(); + // Includes queued Stops and remains active through thread-lock finalization. + const pendingThreadShutdowns = new Map(); const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>(); const terminalMetadataListeners = new Set< (event: TerminalMetadataStreamEvent) => Effect.Effect @@ -2907,13 +2909,45 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), ); - const openOrAttachForStream = (input: TerminalAttachInput, startIfNeeded = true) => - withThreadLock( + const openOrAttachForStream = Effect.fn("terminal.openOrAttachForStream")(function* ( + input: TerminalAttachInput, + startIfNeeded = true, + ): Effect.fn.Return { + const observed = yield* Effect.sync(() => { + const state = SynchronizedRef.getUnsafe(managerStateRef); + const session = state.sessions.get(toSessionKey(input.threadId, input.terminalId)); + return { + session, + terminating: + pendingThreadShutdowns.has(input.threadId) || + ((session === undefined || session.process === null) && + [...state.terminatingProcesses.values()].some( + ({ terminal, exited }) => + terminal.threadId === input.threadId && + terminal.terminalId === input.terminalId && + !Deferred.isDoneUnsafe(exited), + )), + }; + }); + return yield* withThreadLock( input.threadId, Effect.gen(function* () { const terminalId = input.terminalId; const existing = yield* getSession(input.threadId, terminalId); + // A queued attachment belongs to the lifecycle it observed before + // waiting, even if Stop has removed its metadata by lock acquisition. + if ( + observed.terminating || + (observed.session !== undefined && + (Option.isNone(existing) || existing.value !== observed.session)) + ) { + return yield* new TerminalNotRunningError({ + threadId: input.threadId, + terminalId, + }); + } + if (Option.isNone(existing) || existing.value.process === null) { const terminating = (yield* readManagerState).terminatingProcesses.values(); // Retained metadata keeps Stop available; mounting its viewport must @@ -2977,6 +3011,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return snapshot(session); }), ); + }); const readAllTerminalMetadata = () => readManagerState.pipe( @@ -3400,14 +3435,26 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func withThreadLock(threadId, waitForThreadShutdownUnlocked(threadId)); const shutdownThread: TerminalManager["Service"]["shutdownThread"] = (threadId) => - withThreadLock( - threadId, - Effect.gen(function* () { - const closeResult = yield* closeUnlocked({ threadId }).pipe(Effect.result); - const shutdown = yield* waitForThreadShutdownUnlocked(threadId).pipe(Effect.result); - if (closeResult._tag === "Failure") return yield* closeResult.failure; - if (shutdown._tag === "Failure") return yield* shutdown.failure; + Effect.acquireUseRelease( + Effect.sync(() => { + pendingThreadShutdowns.set(threadId, (pendingThreadShutdowns.get(threadId) ?? 0) + 1); }), + () => + withThreadLock( + threadId, + Effect.gen(function* () { + const closeResult = yield* closeUnlocked({ threadId }).pipe(Effect.result); + const shutdown = yield* waitForThreadShutdownUnlocked(threadId).pipe(Effect.result); + if (closeResult._tag === "Failure") return yield* closeResult.failure; + if (shutdown._tag === "Failure") return yield* shutdown.failure; + }), + ), + () => + Effect.sync(() => { + const remaining = (pendingThreadShutdowns.get(threadId) ?? 1) - 1; + if (remaining === 0) pendingThreadShutdowns.delete(threadId); + else pendingThreadShutdowns.set(threadId, remaining); + }), ); const history: TerminalManager["Service"]["history"] = (input) => { From b7b28ff8012049d11c49d74964f764482dfac608 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 15:54:38 -0700 Subject: [PATCH 12/13] test(terminal): pin shutdown during attachment regression Hold the closed-event consumer awaited by shutdown itself instead of an independently published metadata-removal event. This proves the attachment is queued while Stop owns the lock after metadata disappears, regardless of exit-observer scheduling. Source formatting and diff checks completed. Test execution runs in GitHub CI only at the maintainer request. Implemented with GPT-6.1-Sol in the Codex harness. Carry-Group: incubator --- apps/server/src/terminal/Manager.test.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index cc50ad80fd24..2f7e0974356a 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -518,7 +518,8 @@ it.layer( Effect.gen(function* () { const forceKillSent = yield* Deferred.make(); const removalStarted = yield* Deferred.make(); - const releaseRemoval = yield* Deferred.make(); + const closeStarted = yield* Deferred.make(); + const releaseClose = yield* Deferred.make(); const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0, processExitWaitMs: 100, @@ -531,17 +532,24 @@ it.layer( }; const unsubscribeMetadata = yield* manager.subscribeMetadata((event) => event.type === "remove" - ? Deferred.succeed(removalStarted, undefined).pipe( - Effect.andThen( - phase === "metadata-removed" ? Deferred.await(releaseRemoval) : Effect.void, - ), - ) + ? Deferred.succeed(removalStarted, undefined).pipe(Effect.asVoid) : Effect.void, ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribeMetadata)); + const unsubscribeEvents = yield* manager.subscribe((event) => + phase === "metadata-removed" && event.type === "closed" + ? Deferred.succeed(closeStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseClose)), + ) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeEvents)); const stopping = yield* manager.shutdownThread("thread-1").pipe(Effect.forkScoped); yield* Deferred.await(forceKillSent); if (phase === "metadata-removed") { + // Close owns the thread lock while delivering this event, so a slow + // event consumer pins Stop independently of the exit observers. + yield* Deferred.await(closeStarted); process.emitExit({ exitCode: 0, signal: 9 }); yield* Deferred.await(removalStarted); expect(yield* manager.metadata).toEqual([]); @@ -559,7 +567,7 @@ it.layer( expect(ptyAdapter.spawnInputs).toHaveLength(1); if (phase === "awaiting-exit") process.emitExit({ exitCode: 0, signal: 9 }); - else yield* Deferred.succeed(releaseRemoval, undefined); + else yield* Deferred.succeed(releaseClose, undefined); yield* Fiber.join(stopping); const result = yield* Fiber.join(attaching); expect(result._tag === "Failure" ? result.failure._tag : null).toBe( From 21761e8d3594ff96722cc0392f1623f1450bb020 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 5 Oct 2026 16:29:51 -0700 Subject: [PATCH 13/13] fix(preview): isolate shutdown from unrelated thread launches Stopping a slow process held the environment-wide preview reservation gate until its terminal exited, delaying unrelated launches. Order reservations and cleanup by thread, while retaining the short shared gate for atomic port ownership and keeping readiness cancellable. Added deterministic regressions for blocked shutdown, same-thread ordering, concurrent port reservations, and immediate launch/recovery cancellation. Source formatting and diff checks passed; tests run in GitHub CI only at the maintainer request. Implemented with GPT-6.1-Sol in the Codex harness. Carry-Group: incubator --- apps/server/src/preview/Hosting.test.ts | 194 ++++++++++++++++++++++-- apps/server/src/preview/Hosting.ts | 12 +- 2 files changed, 192 insertions(+), 14 deletions(-) diff --git a/apps/server/src/preview/Hosting.test.ts b/apps/server/src/preview/Hosting.test.ts index 78564b00a8c8..3efb658d18c4 100644 --- a/apps/server/src/preview/Hosting.test.ts +++ b/apps/server/src/preview/Hosting.test.ts @@ -372,6 +372,88 @@ function failFirstExpiredStateWriteLayer( } describe("PreviewHosting", () => { + it.effect.each([false, true])( + "lets other threads launch while shutdown blocks new launches in the stopping thread (%s)", + (failClose) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "preview-hosting-stop-isolation-", + }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const shutdownStarted = yield* Deferred.make(); + const finishShutdown = yield* Deferred.make(); + let shutdownFinished = false; + const harness = testTerminalHarness({ + summaries: [terminalFixture("thread-1", "command-terminal")], + failClose, + onWaitForThreadShutdown: () => + Effect.gen(function* () { + yield* Deferred.succeed(shutdownStarted, undefined); + yield* Deferred.await(finishShutdown); + shutdownFinished = true; + }), + onOpen: (input) => + Effect.sync(() => { + if (input.threadId === "thread-1") assert.isTrue(shutdownFinished); + }), + }); + // Keep path normalization synchronous so the immediately-started launch + // reaches reservation ordering before the unrelated launch begins. + const fileSystemLayer = Layer.succeed(FileSystem.FileSystem, { + ...fs, + realPath: (filePath) => Effect.succeed(filePath), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const stopping = yield* hosting + .stopThread("thread-1") + .pipe(Effect.result, Effect.forkScoped); + yield* Deferred.await(shutdownStarted); + const sameThread = yield* hosting + .launch({ + threadId: "thread-1", + command: "pnpm dev --port 5173", + cwd: "/workspace/one", + worktreePath: "/workspace/one", + url: PREVIEW_URL, + }) + .pipe(Effect.forkScoped({ startImmediately: true })); + const other = yield* hosting.launch({ + threadId: "thread-2", + command: "pnpm dev --port 5174", + cwd: "/workspace/two", + worktreePath: "/workspace/two", + url: "http://localhost:5174/", + }); + + assert.equal(other.status, "active"); + assert.isFalse(shutdownFinished); + assert.isUndefined(stopping.pollUnsafe()); + assert.deepEqual(yield* hosting.list("thread-1"), []); + assert.deepEqual( + harness.opens.map((terminal) => terminal.threadId), + ["thread-2"], + ); + yield* Deferred.succeed(finishShutdown, undefined); + assert.equal((yield* Fiber.join(stopping))._tag, failClose ? "Failure" : "Success"); + const restarted = yield* Fiber.join(sameThread); + assert.equal(restarted.status, "active"); + assert.deepEqual(yield* hosting.list("thread-1"), [restarted]); + assert.deepEqual(yield* hosting.list("thread-2"), [other]); + assert.deepEqual( + harness.opens.map((terminal) => terminal.threadId), + ["thread-2", "thread-1"], + ); + }).pipe(Effect.provide(hostingLayer(config, harness, true, [], true, fileSystemLayer))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect.each([false, true])( "waits for thread cleanup before completing even when terminal close fails (%s)", (failClose) => @@ -551,9 +633,9 @@ describe("PreviewHosting", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect( - "stops a preview immediately while recovery is waiting for a server that never becomes ready", - () => + it.effect.each(["launch", "recover"] as const)( + "stops a preview immediately while %s is waiting for a server that never becomes ready", + (operation) => Effect.gen(function* () { yield* TestClock.setTime(1_000); const fs = yield* FileSystem.FileSystem; @@ -578,16 +660,33 @@ describe("PreviewHosting", () => { yield* Effect.scoped( Effect.gen(function* () { const hosting = yield* PreviewHosting.PreviewHosting; - const pendingRecovery = yield* Effect.forkScoped( - hosting.recover({ - threadId: "thread-1", - leaseId: PreviewHostingLeaseId.make(preview.id), - url: preview.url, - }), - ); + const pending = yield* ( + operation === "recover" + ? hosting.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(preview.id), + url: preview.url, + }) + : hosting.launch({ + threadId: preview.threadId, + command: preview.command, + cwd: preview.cwd, + worktreePath: preview.worktreePath, + url: preview.url, + }) + ).pipe(Effect.result, Effect.forkScoped); yield* Deferred.await(wrote); yield* hosting.stopThread("thread-1"); - assert.isNull(yield* Fiber.join(pendingRecovery)); + const cancelled = yield* Fiber.join(pending); + if (operation === "recover") { + assert.equal(cancelled._tag, "Success"); + if (cancelled._tag === "Success") assert.isNull(cancelled.success); + } else { + assert.equal(cancelled._tag, "Failure"); + if (cancelled._tag === "Failure") { + assert.equal(cancelled.failure._tag, "PreviewHostingError"); + } + } assert.deepEqual(harness.summaries, []); assert.deepEqual(yield* hosting.list(), []); assert.include( @@ -1415,6 +1514,79 @@ describe("PreviewHosting", () => { }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), ); + it.effect("reserves discovery ports atomically across concurrent thread launches", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "preview-hosting-concurrent-reservation-", + }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const reservationStarted = yield* Deferred.make(); + const finishReservation = yield* Deferred.make(); + let blockedFirstReservation = false; + const fileSystemLayer = Layer.succeed(FileSystem.FileSystem, { + ...fs, + realPath: (filePath) => Effect.succeed(filePath), + writeFileString: (filePath, data, options) => + Effect.suspend(() => { + const write = fs.writeFileString(filePath, data, options); + if (blockedFirstReservation || !data.includes('"status":"starting"')) return write; + blockedFirstReservation = true; + return Deferred.succeed(reservationStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishReservation)), + Effect.andThen(write), + ); + }), + }); + const harness = testTerminalHarness(); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const first = yield* hosting + .launch({ + threadId: "thread-1", + command: "pnpm dev --port 5173", + cwd: "/workspace/one", + worktreePath: "/workspace/one", + url: PREVIEW_URL, + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(reservationStarted); + // This launch reaches the reservation gate while the first lease's + // durable write is blocked, before that lease appears in memory. + const second = yield* hosting + .launch({ + threadId: "thread-2", + command: "pnpm dev --port 5173", + cwd: "/workspace/two", + worktreePath: "/workspace/two", + url: "http://127.0.0.1:5173/another-page", + }) + .pipe(Effect.result, Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(finishReservation, undefined); + + const lease = yield* Fiber.join(first); + const conflict = yield* Fiber.join(second); + assert.equal(lease.status, "active"); + assert.equal(conflict._tag, "Failure"); + if (conflict._tag === "Failure") { + assert.equal(conflict.failure._tag, "PreviewHostingError"); + if (conflict.failure._tag === "PreviewHostingError") { + assert.equal(conflict.failure.operation, "validate"); + assert.match(conflict.failure.detail ?? "", /discovery port 5173/); + } + } + assert.deepEqual(yield* hosting.list(), [lease]); + assert.equal(harness.opens.length, 1); + assert.equal(harness.writes.length, 1); + }).pipe(Effect.provide(hostingLayer(config, harness, true, [], true, fileSystemLayer))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("reserves a discovery port across threads regardless of path or loopback alias", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/preview/Hosting.ts b/apps/server/src/preview/Hosting.ts index 903568008bda..36e8a53046a8 100644 --- a/apps/server/src/preview/Hosting.ts +++ b/apps/server/src/preview/Hosting.ts @@ -1,3 +1,4 @@ +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { isLoopbackHost } from "@t3tools/shared/preview"; import { PREVIEW_URL_MAX_LENGTH, @@ -686,6 +687,9 @@ const make = Effect.gen(function* () { } }); + // Only same-thread reservations wait for terminal cleanup. Readiness stays + // outside this lock so cleanup can cancel launches and recoveries immediately. + const threadLocks = yield* KeyedLock.make(); const launchGate = yield* Semaphore.make(1); const launch: PreviewHosting["Service"]["launch"] = (requestedInput) => Effect.gen(function* () { @@ -716,7 +720,7 @@ const make = Effect.gen(function* () { ); const leaseForCleanup = yield* SynchronizedRef.make(null); const operation = Effect.gen(function* () { - const selected = yield* launchGate.withPermit( + const reservation = launchGate.withPermit( withWorkspaceLease( path.resolve(input.worktreePath ?? input.cwd), Effect.gen(function* () { @@ -841,6 +845,7 @@ const make = Effect.gen(function* () { }), ), ); + const selected = yield* threadLocks.withLock(input.threadId, reservation); // The durable reservation is visible to cleanup before the workspace lock // is released. TerminalManager.open takes the same lock itself, so readiness @@ -968,10 +973,11 @@ const make = Effect.gen(function* () { }); const removeThread: PreviewHosting["Service"]["removeThread"] = (threadId) => - launchGate.withPermit(removeThreadLocked(threadId)); + threadLocks.withLock(threadId, removeThreadLocked(threadId)); const stopThread: PreviewHosting["Service"]["stopThread"] = (threadId) => - launchGate.withPermit( + threadLocks.withLock( + threadId, Effect.gen(function* () { const previews = yield* removeThreadLocked(threadId).pipe(Effect.result); const shutdown = yield* terminals.shutdownThread(threadId).pipe(Effect.result);