Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions apps/web/src/AppRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { RouterProvider } from "@tanstack/react-router";
import { describe, expect, it } from "vite-plus/test";

import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { ServerBootReload } from "./components/ServerBootReload";
import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";
Expand All @@ -16,9 +17,10 @@ describe("AppRoot", () => {
const children = Children.toArray(
(root as ReactElement<{ readonly children: ReactNode }>).props.children,
);
expect(children).toHaveLength(3);
expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider);
expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts);
expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost);
expect(children).toHaveLength(4);
expect(isValidElement(children[0]) && children[0].type).toBe(ServerBootReload);
expect(isValidElement(children[1]) && children[1].type).toBe(RouterProvider);
expect(isValidElement(children[2]) && children[2].type).toBe(PreviewAutomationHosts);
expect(isValidElement(children[3]) && children[3].type).toBe(ElectronBrowserHost);
});
});
2 changes: 2 additions & 0 deletions apps/web/src/AppRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";
import { ServerBootReload } from "./components/ServerBootReload";

/**
* Owns renderer-wide providers. The Electron browser host intentionally sits
Expand All @@ -13,6 +14,7 @@ import type { AppRouter } from "./router";
export function AppRoot({ router }: { readonly router: AppRouter }) {
return (
<AppAtomRegistryProvider>
<ServerBootReload />
<RouterProvider router={router} />
<PreviewAutomationHosts />
<ElectronBrowserHost />
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/components/ServerBootReload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useAtomValue } from "@effect/atom-react";
import { useEffect, useRef } from "react";

import { INITIAL_SERVER_BOOT_RELOAD_STATE, observeServerBoot } from "../serverBootReload";
import { primaryServerReadyAtom } from "../state/server";

export function ServerBootReload() {
const bootIdentity = useAtomValue(primaryServerReadyAtom)?.at ?? null;
const stateRef = useRef(INITIAL_SERVER_BOOT_RELOAD_STATE);

useEffect(() => {
if (bootIdentity === null) return;

const transition = observeServerBoot(stateRef.current, bootIdentity);
stateRef.current = transition.state;
if (transition.shouldReload) window.location.reload();
}, [bootIdentity]);

return null;
}
28 changes: 28 additions & 0 deletions apps/web/src/serverBootReload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vite-plus/test";

import { INITIAL_SERVER_BOOT_RELOAD_STATE, observeServerBoot } from "./serverBootReload";

function observeAll(bootIdentities: ReadonlyArray<string>) {
let state = INITIAL_SERVER_BOOT_RELOAD_STATE;
let reloads = 0;
for (const bootIdentity of bootIdentities) {
const transition = observeServerBoot(state, bootIdentity);
state = transition.state;
if (transition.shouldReload) reloads += 1;
}
return reloads;
}

describe("observeServerBoot", () => {
it("does not reload when reconnecting to the same server boot", () => {
expect(observeAll(["boot-a", "boot-a"])).toBe(0);
});

it("reloads exactly once when the server boot changes", () => {
expect(observeAll(["boot-a", "boot-b"])).toBe(1);
});

it("does not loop when either boot identity is delivered repeatedly", () => {
expect(observeAll(["boot-a", "boot-a", "boot-b", "boot-b", "boot-a"])).toBe(1);
});
});
33 changes: 33 additions & 0 deletions apps/web/src/serverBootReload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export interface ServerBootReloadState {
readonly initialBootIdentity: string | null;
readonly reloadPending: boolean;
}

export interface ServerBootReloadTransition {
readonly state: ServerBootReloadState;
readonly shouldReload: boolean;
}

export const INITIAL_SERVER_BOOT_RELOAD_STATE: ServerBootReloadState = {
initialBootIdentity: null,
reloadPending: false,
};

export function observeServerBoot(
state: ServerBootReloadState,
bootIdentity: string,
): ServerBootReloadTransition {
if (state.initialBootIdentity === null) {
return {
state: { initialBootIdentity: bootIdentity, reloadPending: false },
shouldReload: false,
};
}
if (state.reloadPending || state.initialBootIdentity === bootIdentity) {
return { state, shouldReload: false };
}
return {
state: { ...state, reloadPending: true },
shouldReload: true,
};
}
9 changes: 9 additions & 0 deletions apps/web/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type EditorId,
type ServerConfig,
type ServerConfigStreamEvent,
type ServerLifecycleReadyPayload,
type ServerLifecycleWelcomePayload,
type ServerProvider,
type ServerSettings,
Expand All @@ -29,6 +30,7 @@ export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
interface PrimaryServerState {
readonly config: ServerConfig | null;
readonly latestEvent: ServerConfigStreamEvent | null;
readonly ready: ServerLifecycleReadyPayload | null;
readonly welcome: ServerLifecycleWelcomePayload | null;
}

Expand All @@ -37,6 +39,7 @@ export const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = {
config: null,
latestEvent: null,
ready: null,
welcome: null,
};

Expand All @@ -51,10 +54,12 @@ export const primaryServerStateAtom = Atom.make((get): PrimaryServerState => {
AsyncResult.value(get(serverEnvironment.configProjection(target))),
);
const welcome = Option.getOrNull(AsyncResult.value(get(serverEnvironment.welcome(target))));
const ready = Option.getOrNull(AsyncResult.value(get(serverEnvironment.ready(target))));

return {
config: get(serverEnvironment.configValueAtom(environmentId)),
latestEvent: configProjection?.latestEvent ?? null,
ready,
welcome,
};
}).pipe(Atom.withLabel("web-primary-server-state"));
Expand All @@ -71,6 +76,10 @@ export const primaryServerWelcomeAtom = Atom.make(
(get): ServerLifecycleWelcomePayload | null => get(primaryServerStateAtom).welcome,
).pipe(Atom.withLabel("web-primary-server-welcome"));

export const primaryServerReadyAtom = Atom.make(
(get): ServerLifecycleReadyPayload | null => get(primaryServerStateAtom).ready,
).pipe(Atom.withLabel("web-primary-server-ready"));

export const primaryServerSettingsAtom = Atom.make(
(get): ServerSettings => get(primaryServerConfigAtom)?.settings ?? DEFAULT_SERVER_SETTINGS,
).pipe(Atom.withLabel("web-primary-server-settings"));
Expand Down
3 changes: 3 additions & 0 deletions docs/user/background-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ npx t3@latest service uninstall

Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first.
If a remote update is already in progress, wait for it to finish before retrying a local update.
An open web app reconnects and reloads itself after the server restarts so it uses the server's
current frontend. Brief connection interruptions that do not restart the server do not reload the
page.

The systemd unit runs a small stable launcher. Exact T3 Code versions are installed separately, so
a failed remote candidate can return to the previous version without rewriting the unit. The
Expand Down
22 changes: 22 additions & 0 deletions packages/client-runtime/src/state/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
EnvironmentId,
type ServerConfig,
type ServerConfigStreamEvent,
type ServerLifecycleReadyPayload,
type ServerLifecycleWelcomePayload,
WS_METHODS,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -35,6 +36,7 @@ import {
isLegacyUpdateHandoffLoss,
matchesServerUpdateReadyEvent,
nudgeReconnectDuringUpdateRestart,
projectServerReady,
projectServerWelcome,
resolveServerConfigValue,
resolveServerUpdateProgressResult,
Expand Down Expand Up @@ -301,6 +303,26 @@ describe("server state projection", () => {
expect(emitted).toEqual([]);
});

it("projects ready events from the lifecycle stream", () => {
const ready = {
at: "2026-08-12T12:00:00.000Z",
environment: {} as ServerLifecycleReadyPayload["environment"],
} as ServerLifecycleReadyPayload;
const [afterWelcome, welcomeEmitted] = projectServerReady(Option.none(), {
type: "welcome",
payload: {},
});
const [afterReady, readyEmitted] = projectServerReady(afterWelcome, {
type: "ready",
payload: ready,
});

expect(Option.isNone(afterWelcome)).toBe(true);
expect(welcomeEmitted).toEqual([]);
expect(Option.getOrThrow(afterReady)).toBe(ready);
expect(readyEmitted).toEqual([ready]);
});

it("prefers an active session config over cache until a live event arrives", () => {
const config = (source: string, serverVersion: string) =>
({
Expand Down
24 changes: 24 additions & 0 deletions packages/client-runtime/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type EnvironmentId,
type ServerConfig,
type ServerConfigStreamEvent,
type ServerLifecycleReadyPayload,
type ServerLifecycleWelcomePayload,
type ServerLifecycleStreamReadyEvent,
type ServerSelfUpdateProgressEvent,
Expand Down Expand Up @@ -442,6 +443,23 @@ export function projectServerWelcome(
return [Option.some(welcome), [welcome]];
}

export function projectServerReady(
current: Option.Option<ServerLifecycleReadyPayload>,
event: {
readonly type: "welcome" | "ready";
readonly payload: unknown;
},
): readonly [
Option.Option<ServerLifecycleReadyPayload>,
ReadonlyArray<ServerLifecycleReadyPayload>,
] {
if (event.type !== "ready") {
return [current, []];
}
const ready = event.payload as ServerLifecycleReadyPayload;
return [Option.some(ready), [ready]];
}

export function resolveServerConfigValue(
projection: ServerConfigProjection | null,
initialConfig: ServerConfig | null,
Expand Down Expand Up @@ -723,6 +741,12 @@ export function createServerEnvironmentAtoms<R, E>(
Stream.mapAccum(Option.none<ServerLifecycleWelcomePayload>, projectServerWelcome),
),
}),
ready: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
label: "environment-data:server:ready",
tag: WS_METHODS.subscribeServerLifecycle,
transform: (stream) =>
stream.pipe(Stream.mapAccum(Option.none<ServerLifecycleReadyPayload>, projectServerReady)),
}),
refreshProviders: createEnvironmentRpcCommand(runtime, {
label: "environment-data:server:refresh-providers",
tag: WS_METHODS.serverRefreshProviders,
Expand Down
Loading