From dbdf507aa0bd830768d69e70b040d278dc2c236f Mon Sep 17 00:00:00 2001 From: Adam Blumoff <99273116+adamblumoff@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:20:40 -0500 Subject: [PATCH 1/4] feat(connections): show disk capacity per server --- .../connection/ConnectionEnvironmentRow.tsx | 7 + .../connection/ConnectionHostStorage.tsx | 109 ++++++++++++++ .../resourceTelemetry/HostResources.test.ts | 138 ++++++++++++++++++ .../src/resourceTelemetry/HostResources.ts | 43 +++++- apps/server/src/server.test.ts | 2 + .../settings/ConnectionsSettings.tsx | 2 + .../components/settings/StorageSettings.tsx | 121 +++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 + docs/user/remote-access.md | 8 + packages/client-runtime/package.json | 4 + .../client-runtime/src/hostStorage.test.ts | 68 +++++++++ packages/client-runtime/src/hostStorage.ts | 29 ++++ .../contracts/src/resourceTelemetry.test.ts | 39 +++++ packages/contracts/src/resourceTelemetry.ts | 9 ++ 14 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/connection/ConnectionHostStorage.tsx create mode 100644 apps/server/src/resourceTelemetry/HostResources.test.ts create mode 100644 apps/web/src/components/settings/StorageSettings.tsx create mode 100644 packages/client-runtime/src/hostStorage.test.ts create mode 100644 packages/client-runtime/src/hostStorage.ts create mode 100644 packages/contracts/src/resourceTelemetry.test.ts diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 5555548ff799..ae00cb4b919a 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -16,6 +16,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { serverEnvironment } from "../../state/server"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; +import { ConnectionHostStorage } from "./ConnectionHostStorage"; function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { return connectionStatusText({ @@ -136,6 +137,12 @@ export function ConnectionEnvironmentRow(props: { /> + + {props.expanded ? ( ; + readonly onRefresh?: () => void; +}) { + const { presentation } = props; + const loading = presentation.status === "loading"; + + return ( + + + + Disk containing T3 data + + {presentation.status === "available" + ? presentation.label + : loading + ? "Checking storage…" + : "Storage unavailable"} + + + {props.onRefresh ? ( + + + + ) : null} + + {presentation.status === "available" ? ( + <> + + + + + Checked at {new Date(presentation.sampledAt).toLocaleTimeString()} + + + ) : null} + + ); +} + +function ConnectedHostStorage(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const query = serverEnvironment.hostResources({ environmentId: props.environmentId, input: {} }); + const result = useAtomValue(query); + const refresh = useAtomRefresh(query); + + return ( + + ); +} + +export function ConnectionHostStorage(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly connected: boolean; +}) { + const isFocused = useIsFocused(); + if (!isFocused) return null; + + return props.connected ? ( + + ) : ( + + ); +} diff --git a/apps/server/src/resourceTelemetry/HostResources.test.ts b/apps/server/src/resourceTelemetry/HostResources.test.ts new file mode 100644 index 000000000000..750f8f3190a9 --- /dev/null +++ b/apps/server/src/resourceTelemetry/HostResources.test.ts @@ -0,0 +1,138 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import { ServerConfig } from "../config.ts"; +import * as HostResources from "./HostResources.ts"; + +const TestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-host-resources-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTest = Effect.fn(function* (statfs: typeof HostResources.HostStorageStatFs.Service) { + return yield* HostResources.make().pipe( + Effect.provideService(HostResources.HostStorageStatFs, statfs), + Effect.provideService(HostProcessPlatform, "win32"), + ); +}); + +describe("HostResources storage", () => { + it.effect("samples the configured data filesystem and shares the cache across clients", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig; + const paths: string[] = []; + const service = yield* makeTest((path) => + Effect.sync(() => { + paths.push(path); + return { blocks: 1000n, bsize: 4096n, bavail: BigInt(paths.length) }; + }), + ); + const clients = yield* Effect.all([service.read, service.read], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* TestClock.adjust("200 millis"); + const [first, second] = yield* Fiber.join(clients); + expect(first.storage).toEqual({ totalBytes: 4096000, availableBytes: 4096 }); + expect(second).toEqual(first); + expect(yield* service.read).toEqual(first); + expect(paths).toEqual([stateDir]); + + yield* TestClock.adjust("5 seconds"); + const refreshed = yield* service.read.pipe(Effect.forkChild); + yield* TestClock.adjust("200 millis"); + const next = yield* Fiber.join(refreshed); + expect(next.storage).toEqual({ totalBytes: 4096000, availableBytes: 8192 }); + expect(next.sampledAt).toBeGreaterThan(first.sampledAt); + expect(paths).toEqual([stateDir, stateDir]); + }).pipe(Effect.provide(TestLayer)), + ); + + for (const { name, stats, storage } of [ + { + name: "reports zero available bytes for a full filesystem", + stats: { blocks: 1000n, bsize: 4096n, bavail: 0n }, + storage: { totalBytes: 4096000, availableBytes: 0 }, + }, + { + name: "rejects an unknown zero capacity", + stats: { blocks: 0n, bsize: 4096n, bavail: 0n }, + storage: null, + }, + { + name: "rejects negative available blocks", + stats: { blocks: 1000n, bsize: 4096n, bavail: -1n }, + storage: null, + }, + { + name: "rejects available capacity above the total", + stats: { blocks: 1000n, bsize: 4096n, bavail: 1001n }, + storage: null, + }, + { + name: "rejects nonpositive block size", + stats: { blocks: -1000n, bsize: -4096n, bavail: -1n }, + storage: null, + }, + { + name: "rejects capacity exceeding safe integer precision", + stats: { blocks: BigInt(Number.MAX_SAFE_INTEGER), bsize: 4096n, bavail: 0n }, + storage: null, + }, + ]) { + it.effect(name, () => + Effect.gen(function* () { + const service = yield* makeTest(() => Effect.succeed(stats)); + const reading = yield* service.read.pipe(Effect.forkChild); + yield* TestClock.adjust("200 millis"); + expect((yield* Fiber.join(reading)).storage).toEqual(storage); + }).pipe(Effect.provide(TestLayer)), + ); + } + + it.effect("keeps CPU and memory usable after a storage failure and retries after expiry", () => + Effect.gen(function* () { + let reads = 0; + const service = yield* makeTest(() => + Effect.suspend(() => { + reads++; + return reads === 1 + ? Effect.fail(new HostResources.HostStorageError({ cause: "filesystem unavailable" })) + : Effect.succeed({ blocks: 1000n, bsize: 4096n, bavail: 100n }); + }), + ); + const reading = yield* service.read.pipe(Effect.forkChild); + yield* TestClock.adjust("200 millis"); + const failed = yield* Fiber.join(reading); + expect(failed.storage).toBeNull(); + expect(failed.cpuCount).toBeGreaterThan(0); + expect(failed.totalMemoryBytes).toBeGreaterThan(0); + expect(failed.availableMemoryBytes).toBeGreaterThanOrEqual(0); + expect(yield* service.read).toEqual(failed); + expect(reads).toBe(1); + + yield* TestClock.adjust("5 seconds"); + const retry = yield* service.read.pipe(Effect.forkChild); + yield* TestClock.adjust("200 millis"); + expect((yield* Fiber.join(retry)).storage).toEqual({ + totalBytes: 4096000, + availableBytes: 409600, + }); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("bounds a stuck filesystem reading without failing host resources", () => + Effect.gen(function* () { + const service = yield* makeTest(() => Effect.never); + const reading = yield* service.read.pipe(Effect.forkChild); + yield* TestClock.adjust("1200 millis"); + const result = yield* Fiber.join(reading); + expect(result.storage).toBeNull(); + expect(result.sampledAt).toBe(1200); + expect(result.totalMemoryBytes).toBeGreaterThan(0); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts index 032832dd4869..8276de2c0313 100644 --- a/apps/server/src/resourceTelemetry/HostResources.ts +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -1,5 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off - Effect FileSystem has no free-space query. +import * as NodeFSP from "node:fs/promises"; +import type * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; -import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import { HostStorageSnapshot, type HostResourcesSnapshot } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -7,8 +10,29 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { ServerConfig } from "../config.ts"; + +export class HostStorageError extends Schema.TaggedError()("HostStorageError", { + cause: Schema.Defect(), +}) {} + +export const HostStorageStatFs = Context.Reference< + ( + path: string, + ) => Effect.Effect, HostStorageError> +>("t3/resourceTelemetry/HostStorageStatFs", { + defaultValue: () => (path) => + Effect.tryPromise({ + try: () => NodeFSP.statfs(path, { bigint: true }), + catch: (cause) => new HostStorageError({ cause }), + }), +}); + +const decodeStorage = Schema.decodeUnknownEffect(HostStorageSnapshot); + export class HostResources extends Context.Service< HostResources, { readonly read: Effect.Effect } @@ -42,6 +66,21 @@ export const make = Effect.fn("makeHostResources")(function* () { const fs = yield* FileSystem.FileSystem; const platform = yield* HostProcessPlatform; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const { stateDir } = yield* ServerConfig; + const statfs = yield* HostStorageStatFs; + + const sampleStorage = Effect.fn("HostResources.sampleStorage")( + function* () { + const stats = yield* statfs(stateDir); + if (stats.bsize <= 0n) return null; + return yield* decodeStorage({ + totalBytes: Number(stats.blocks * stats.bsize), + availableBytes: Number(stats.bavail * stats.bsize), + }); + }, + Effect.timeout("1 second"), + Effect.catch(() => Effect.succeed(null)), + ); const sample = Effect.fn("HostResources.sample")(function* () { const previousCpu = readCpu(); @@ -72,12 +111,14 @@ export const make = Effect.fn("makeHostResources")(function* () { ); availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes; } + const storage = yield* sampleStorage(); return { sampledAt: DateTime.toEpochMillis(yield* DateTime.now), cpuUtilization, cpuCount: cpu.count, availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)), totalMemoryBytes, + storage, }; }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2f32b6524d7b..d6f7efa76c13 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6225,6 +6225,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const commandCalls = yield* Ref.make(0); const hostResources = yield* HostResources.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-host-resources-" })), Effect.provideService(HostProcessPlatform, "darwin"), Effect.provide( Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ @@ -6254,6 +6255,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const started = yield* Deferred.make(); const commandCalls = yield* Ref.make(0); const hostResources = yield* HostResources.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-host-resources-" })), Effect.provideService(HostProcessPlatform, "darwin"), Effect.provide( Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index ad7665651171..66a8d866c136 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -61,6 +61,7 @@ import { import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; import { LoadBalancingSettings } from "./LoadBalancingSettings"; +import { StorageSettings } from "./StorageSettings"; import { Input } from "../ui/input"; import { CommandShortcut } from "../ui/command"; import { @@ -3131,6 +3132,7 @@ export function ConnectionsSettings() { return ( + {canManageLocalBackend ? ( <> diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx new file mode 100644 index 000000000000..5b6696078da5 --- /dev/null +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -0,0 +1,121 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { getHostStoragePresentation } from "@t3tools/client-runtime/host-storage"; +import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; + +import type { EnvironmentPresentation } from "~/state/environments"; +import { serverEnvironment } from "~/state/server"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { Button } from "../ui/button"; +import { RefreshIcon } from "../ui/refresh-icon"; +import { SettingsSection } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +function ConnectedStorageReading({ + environmentId, + label, +}: { + environmentId: EnvironmentId; + label: string; +}) { + const query = serverEnvironment.hostResources({ environmentId, input: {} }); + const result = useAtomValue(query); + const refresh = useAtomRefresh(query); + const storage = getHostStoragePresentation(result); + + return ( +
+
+

+ {storage.status === "available" + ? storage.label + : storage.status === "loading" + ? "Reading storage…" + : "Storage unavailable"} +

+ {storage.status === "available" ? ( + <> +
+
+
+

+ Checked{" "} + +

+ + ) : null} +
+ +
+ ); +} + +export function StorageSettings({ + environments, +}: { + environments: ReadonlyArray; +}) { + return ( + + {environments.length === 0 ? ( +

+ Connect an environment to check its disk space. +

+ ) : ( + environments.map((environment) => ( +
+
+

+ + {environment.label} +

+

+ {connectionStatusText(environment.connection)} +

+
+ {environment.connection.phase === "connected" ? ( + + ) : ( +

Storage unavailable

+ )} +
+ )) + )} +

+ Disk containing each server's T3 data. Projects on other drives may have different space + available. +

+
+ ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e32baf6f3987..76f431fca237 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -547,6 +547,12 @@ export const SETTINGS_SEARCH_ITEMS = [ "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint", ], }, + { + id: "disk-storage", + title: "Disk storage", + to: "/settings/connections", + searchTerms: ["disk space capacity free available storage machine environment server"], + }, { id: "remote-environments", title: "Remote environments", diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b10123ab6223..608a78272d27 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -61,6 +61,14 @@ created in Settings can only be copied from the client that created them while its Connections page stays open. If you leave or reload that page, create another link to share. +### Check available disk space + +Check disk space in **Settings → Connections → Disk storage** on web and desktop, +or **Settings → Environments** on mobile. Each reading describes the disk containing +that server's T3 data. Projects on another drive may have different space available; +WSL and containers report the filesystem visible to their server. Refresh the reading +to check again. An offline or unsupported environment shows storage as unavailable. + ### Balance new threads across machines Auto balance is off by default. On web and desktop, enable it in diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 368585556cfe..6be449b23719 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./host-storage": { + "types": "./src/hostStorage.ts", + "default": "./src/hostStorage.ts" + }, "./load-balancing": { "types": "./src/load-balancing.ts", "default": "./src/load-balancing.ts" diff --git a/packages/client-runtime/src/hostStorage.test.ts b/packages/client-runtime/src/hostStorage.test.ts new file mode 100644 index 000000000000..03d9aacacb27 --- /dev/null +++ b/packages/client-runtime/src/hostStorage.test.ts @@ -0,0 +1,68 @@ +import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { getHostStoragePresentation } from "./hostStorage.ts"; + +const snapshot: HostResourcesSnapshot = { + sampledAt: 1_789_000_000_000, + cpuUtilization: 0.1, + cpuCount: 8, + availableMemoryBytes: 8 * 1024 ** 3, + totalMemoryBytes: 16 * 1024 ** 3, + storage: { totalBytes: 457 * 1024 ** 3, availableBytes: 254 * 1024 ** 3 }, +}; + +describe("host storage presentation", () => { + it("formats the available capacity and keeps the server's reading time", () => { + expect(getHostStoragePresentation(AsyncResult.success(snapshot))).toEqual({ + status: "available", + label: "254 GiB available of 457 GiB", + availablePercent: (254 / 457) * 100, + sampledAt: snapshot.sampledAt, + }); + }); + + it("shows a full disk as zero available, not unknown", () => { + expect( + getHostStoragePresentation( + AsyncResult.success({ + ...snapshot, + storage: { totalBytes: 2 * 1024 ** 4, availableBytes: 0 }, + }), + ), + ).toMatchObject({ + status: "available", + label: "0 B available of 2 TiB", + availablePercent: 0, + }); + }); + + it.each([null, undefined])("treats %s storage as unavailable", (storage) => { + const { storage: _storage, ...legacySnapshot } = snapshot; + expect( + getHostStoragePresentation( + AsyncResult.success(storage === undefined ? legacySnapshot : { ...snapshot, storage }), + ), + ).toEqual({ status: "unavailable" }); + }); + + it("does not show the previous capacity while refreshing", () => { + expect(getHostStoragePresentation(AsyncResult.initial())).toEqual({ status: "loading" }); + expect(getHostStoragePresentation(AsyncResult.waiting(AsyncResult.success(snapshot)))).toEqual({ + status: "loading", + }); + }); + + it("does not show a cached reading after a failed request", () => { + expect( + getHostStoragePresentation( + AsyncResult.failure(Cause.fail(new Error("Disconnected")), { + previousSuccess: Option.some(AsyncResult.success(snapshot)), + }), + ), + ).toEqual({ status: "unavailable" }); + }); +}); diff --git a/packages/client-runtime/src/hostStorage.ts b/packages/client-runtime/src/hostStorage.ts new file mode 100644 index 000000000000..83678494eca8 --- /dev/null +++ b/packages/client-runtime/src/hostStorage.ts @@ -0,0 +1,29 @@ +import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; + +const BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] as const; + +function formatStorageBytes(bytes: number): string { + const unit = bytes === 0 ? 0 : Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), 5); + const value = (bytes / 1024 ** unit).toLocaleString(undefined, { maximumFractionDigits: 1 }); + return `${value} ${BYTE_UNITS[unit]}`; +} + +/** A refreshing or failed reading must not present cached capacity as current. */ +export function getHostStoragePresentation( + result: AsyncResult.AsyncResult, +) { + if (AsyncResult.isInitial(result) || result.waiting) { + return { status: "loading" } as const; + } + if (!AsyncResult.isSuccess(result) || result.value.storage == null) { + return { status: "unavailable" } as const; + } + const { totalBytes, availableBytes } = result.value.storage; + return { + status: "available", + label: `${formatStorageBytes(availableBytes)} available of ${formatStorageBytes(totalBytes)}`, + availablePercent: (availableBytes / totalBytes) * 100, + sampledAt: result.value.sampledAt, + } as const; +} diff --git a/packages/contracts/src/resourceTelemetry.test.ts b/packages/contracts/src/resourceTelemetry.test.ts new file mode 100644 index 000000000000..ffe71cd16b3a --- /dev/null +++ b/packages/contracts/src/resourceTelemetry.test.ts @@ -0,0 +1,39 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { HostResourcesSnapshot } from "./resourceTelemetry.ts"; + +const decode = Schema.decodeUnknownSync(HostResourcesSnapshot); +const resources = { + sampledAt: 0, + cpuUtilization: null, + cpuCount: 4, + availableMemoryBytes: 1024, + totalMemoryBytes: 2048, +}; + +describe("HostResourcesSnapshot storage", () => { + it("accepts older servers without storage and unavailable readings", () => { + expect(decode(resources)).toEqual(resources); + expect(decode({ ...resources, storage: null }).storage).toBeNull(); + }); + + it("accepts a full filesystem with zero available bytes", () => { + const storage = { totalBytes: 4096, availableBytes: 0 }; + expect(decode({ ...resources, storage }).storage).toEqual(storage); + }); + + it.each([ + { totalBytes: 0, availableBytes: 0 }, + { totalBytes: -1, availableBytes: 0 }, + { totalBytes: 4096, availableBytes: -1 }, + { totalBytes: 4096, availableBytes: 4097 }, + { totalBytes: 1.5, availableBytes: 0 }, + { totalBytes: 4096, availableBytes: 1.5 }, + { totalBytes: Number.MAX_SAFE_INTEGER + 1, availableBytes: 0 }, + { totalBytes: Infinity, availableBytes: 0 }, + { totalBytes: 4096, availableBytes: NaN }, + ])("rejects invalid capacity %j", (storage) => { + expect(() => decode({ ...resources, storage })).toThrow(); + }); +}); diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index ee9d2b3ac258..79a07d72c747 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -6,6 +6,13 @@ import { DesktopUpdateStateSchema } from "./ipc.ts"; export const RESOURCE_MONITOR_PROTOCOL_VERSION = 3 as const; +/** Capacity of the filesystem containing this environment's T3 data directory. */ +export const HostStorageSnapshot = Schema.Struct({ + totalBytes: PositiveInt, + availableBytes: NonNegativeInt, +}).check(Schema.makeFilter((storage) => storage.availableBytes <= storage.totalBytes)); +export type HostStorageSnapshot = typeof HostStorageSnapshot.Type; + /** Whole-host capacity, independent of T3's process diagnostics. */ export const HostResourcesSnapshot = Schema.Struct({ sampledAt: NonNegativeInt, @@ -13,6 +20,8 @@ export const HostResourcesSnapshot = Schema.Struct({ cpuCount: NonNegativeInt, availableMemoryBytes: NonNegativeInt, totalMemoryBytes: NonNegativeInt, + // Absent on older servers; null when the filesystem could not be sampled. + storage: Schema.optionalKey(Schema.NullOr(HostStorageSnapshot)), }); export type HostResourcesSnapshot = typeof HostResourcesSnapshot.Type; From 5cf9707569c911d0de5a1da779a75ec9abc6d95d Mon Sep 17 00:00:00 2001 From: Adam Blumoff <99273116+adamblumoff@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:11:56 -0500 Subject: [PATCH 2/4] refactor(connections): show disk capacity in server rings --- .../connection/ConnectionEnvironmentRow.tsx | 144 ++++++++-------- .../connection/ConnectionHostStorage.tsx | 155 ++++++++++++------ .../settings/ConnectionsSettings.tsx | 18 +- .../settings/HostStorageIndicator.tsx | 129 +++++++++++++++ .../components/settings/StorageSettings.tsx | 121 -------------- .../src/components/settings/settingsSearch.ts | 3 +- docs/user/remote-access.md | 4 +- 7 files changed, 327 insertions(+), 247 deletions(-) create mode 100644 apps/web/src/components/settings/HostStorageIndicator.tsx delete mode 100644 apps/web/src/components/settings/StorageSettings.tsx diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index ae00cb4b919a..bf944fde5987 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -66,82 +66,84 @@ export function ConnectionEnvironmentRow(props: { return ( - - + + + - - - - - {props.environment.environmentLabel} + + + + + {props.environment.environmentLabel} + + + + {props.environment.displayUrl} + {statusLabel ? ( + + {statusLabel} + {statusTraceId ? ( + <> + {" Trace ID: "} + { + event.stopPropagation(); + copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); + }} + onPress={(event) => { + event.stopPropagation(); + }} + > + {statusTraceId} + + + ) : null} + + ) : null} - - {props.environment.displayUrl} - - {statusLabel ? ( - - {statusLabel} - {statusTraceId ? ( - <> - {" Trace ID: "} - { - event.stopPropagation(); - copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); - }} - onPress={(event) => { - event.stopPropagation(); - }} - > - {statusTraceId} - - - ) : null} - - ) : null} - - - + + - + + {props.expanded ? ( ; @@ -15,58 +20,112 @@ function HostStorageView(props: { }) { const { presentation } = props; const loading = presentation.status === "loading"; + const [open, setOpen] = useState(false); + const usedPercent = + presentation.status === "available" ? 100 - presentation.availablePercent : null; return ( - - - - Disk containing T3 data - - {presentation.status === "available" - ? presentation.label - : loading - ? "Checking storage…" - : "Storage unavailable"} - - - {props.onRefresh ? ( + <> + setOpen(true)} + className="h-11 w-11 items-center justify-center rounded-full active:bg-subtle" + > + + + {usedPercent !== null ? ( + + ) : null} + + + setOpen(false)}> + setOpen(false)} + > event.stopPropagation()} > - + + + Disk containing T3 data + + {presentation.status === "available" + ? presentation.label + : loading + ? "Checking storage…" + : "Storage unavailable"} + + + {props.onRefresh ? ( + + + + ) : null} + + {presentation.status === "available" ? ( + + {Math.round(100 - presentation.availablePercent)}% used or reserved{"\n"} + Checked at {new Date(presentation.sampledAt).toLocaleTimeString()} + + ) : null} + + Other drives may have different space available. + + setOpen(false)} + className="min-h-11 items-center justify-center rounded-xl bg-subtle" + > + Done + - ) : null} - - {presentation.status === "available" ? ( - <> - - - - - Checked at {new Date(presentation.sampledAt).toLocaleTimeString()} - - - ) : null} - + + + ); } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 66a8d866c136..61cee7424587 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -61,7 +61,7 @@ import { import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; import { LoadBalancingSettings } from "./LoadBalancingSettings"; -import { StorageSettings } from "./StorageSettings"; +import { HostStorageIndicator } from "./HostStorageIndicator"; import { Input } from "../ui/input"; import { CommandShortcut } from "../ui/command"; import { @@ -1492,6 +1492,7 @@ function SavedBackendListRow({

{environment.label}

+
{metadataBits.length > 0 ? (

{metadataBits.join(" · ")}

@@ -3132,10 +3133,14 @@ export function ConnectionsSettings() { return ( - {canManageLocalBackend ? ( <> - + : null + } + > {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( ) : ( - + : null + } + > ; + onRefresh?: () => void; +}) { + const reading = + storage.status === "available" + ? storage.label + : storage.status === "loading" + ? "Reading storage…" + : "Storage unavailable"; + const usedPercent = storage.status === "available" ? 100 - storage.availablePercent : null; + + return ( + + + + + } + /> + +
+
+ Disk space + {onRefresh ? ( + + ) : null} +
+

+ {reading} +

+ {storage.status === "available" ? ( +

+ {Math.round(100 - storage.availablePercent)}% used or reserved +
+ Checked {new Date(storage.sampledAt).toLocaleTimeString()} +

+ ) : null} +

+ Disk containing this server's T3 data. Other drives may have different space available. +

+
+
+
+ ); +} + +function ConnectedStorageIndicator({ environment }: { environment: EnvironmentPresentation }) { + const query = serverEnvironment.hostResources({ + environmentId: environment.environmentId, + input: {}, + }); + const result = useAtomValue(query); + const refresh = useAtomRefresh(query); + return ( + + ); +} + +export function HostStorageIndicator({ environment }: { environment: EnvironmentPresentation }) { + return environment.connection.phase === "connected" ? ( + + ) : ( + + ); +} diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx deleted file mode 100644 index 5b6696078da5..000000000000 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; -import { connectionStatusText } from "@t3tools/client-runtime/connection"; -import { getHostStoragePresentation } from "@t3tools/client-runtime/host-storage"; -import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; - -import type { EnvironmentPresentation } from "~/state/environments"; -import { serverEnvironment } from "~/state/server"; -import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { Button } from "../ui/button"; -import { RefreshIcon } from "../ui/refresh-icon"; -import { SettingsSection } from "./settingsLayout"; -import { searchableSetting } from "./settingsSearch"; - -function ConnectedStorageReading({ - environmentId, - label, -}: { - environmentId: EnvironmentId; - label: string; -}) { - const query = serverEnvironment.hostResources({ environmentId, input: {} }); - const result = useAtomValue(query); - const refresh = useAtomRefresh(query); - const storage = getHostStoragePresentation(result); - - return ( -
-
-

- {storage.status === "available" - ? storage.label - : storage.status === "loading" - ? "Reading storage…" - : "Storage unavailable"} -

- {storage.status === "available" ? ( - <> -
-
-
-

- Checked{" "} - -

- - ) : null} -
- -
- ); -} - -export function StorageSettings({ - environments, -}: { - environments: ReadonlyArray; -}) { - return ( - - {environments.length === 0 ? ( -

- Connect an environment to check its disk space. -

- ) : ( - environments.map((environment) => ( -
-
-

- - {environment.label} -

-

- {connectionStatusText(environment.connection)} -

-
- {environment.connection.phase === "connected" ? ( - - ) : ( -

Storage unavailable

- )} -
- )) - )} -

- Disk containing each server's T3 data. Projects on other drives may have different space - available. -

-
- ); -} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 76f431fca237..929f6c005a8c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -549,8 +549,9 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "disk-storage", - title: "Disk storage", + title: "Disk space", to: "/settings/connections", + targetId: "connections-environment", searchTerms: ["disk space capacity free available storage machine environment server"], }, { diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 608a78272d27..5d711ac99d28 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -63,8 +63,8 @@ another link to share. ### Check available disk space -Check disk space in **Settings → Connections → Disk storage** on web and desktop, -or **Settings → Environments** on mobile. Each reading describes the disk containing +Hover or tap the ring beside an environment in **Settings → Connections** on web +and desktop, or tap it in **Settings → Environments** on mobile. Each reading describes the disk containing that server's T3 data. Projects on another drive may have different space available; WSL and containers report the filesystem visible to their server. Refresh the reading to check again. An offline or unsupported environment shows storage as unavailable. From f1541066d3277483e6ddcfa55adcf023d5089e95 Mon Sep 17 00:00:00 2001 From: Adam Blumoff <99273116+adamblumoff@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:37:08 -0500 Subject: [PATCH 3/4] fix(connections): isolate disk reads and cover T3 Connect --- .../connection/CloudEnvironmentRows.tsx | 12 ++- .../connection/ConnectionHostStorage.tsx | 2 +- apps/server/src/auth/RpcAuthorization.ts | 1 + .../resourceTelemetry/HostResources.test.ts | 82 +++++++++---------- .../src/resourceTelemetry/HostResources.ts | 23 ++++-- apps/server/src/server.test.ts | 17 ++++ apps/server/src/ws.ts | 4 + .../settings/HostStorageIndicator.tsx | 2 +- .../client-runtime/src/hostStorage.test.ts | 21 ++--- packages/client-runtime/src/hostStorage.ts | 4 +- packages/client-runtime/src/state/server.ts | 7 ++ .../contracts/src/resourceTelemetry.test.ts | 17 ++-- packages/contracts/src/resourceTelemetry.ts | 8 +- packages/contracts/src/rpc.ts | 9 ++ 14 files changed, 128 insertions(+), 81 deletions(-) diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 806499c2273b..388d4fb86ec6 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -10,7 +10,7 @@ import { resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useState } from "react"; +import { type ReactNode, useCallback, useState } from "react"; import { ActivityIndicator, Pressable, @@ -28,6 +28,7 @@ import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-typ import { serverEnvironment } from "../../state/server"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; +import { ConnectionHostStorage } from "./ConnectionHostStorage"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; @@ -225,6 +226,13 @@ function ConnectedCloudEnvironmentRow(props: { errorExpanded={props.errorExpanded} label={props.environment.environmentLabel} machine={resolveEnvironmentMachineKind(serverConfig)} + storageIndicator={ + + } onValueChange={(enabled) => { if (enabled) { props.onConnect(); @@ -286,6 +294,7 @@ function CloudEnvironmentRowShell(props: { readonly onToggleError: () => void; readonly onValueChange: (enabled: boolean) => void; readonly statusText?: string; + readonly storageIndicator?: ReactNode; readonly value: boolean; }) { const isRetrying = @@ -406,6 +415,7 @@ function CloudEnvironmentRowShell(props: { ) : null} + {props.storageIndicator} { - it.effect("samples the configured data filesystem and shares the cache across clients", () => + it.effect("samples the configured data filesystem again on every explicit read", () => Effect.gen(function* () { const { stateDir } = yield* ServerConfig; const paths: string[] = []; @@ -31,22 +32,17 @@ describe("HostResources storage", () => { return { blocks: 1000n, bsize: 4096n, bavail: BigInt(paths.length) }; }), ); - const clients = yield* Effect.all([service.read, service.read], { - concurrency: "unbounded", - }).pipe(Effect.forkChild); - yield* TestClock.adjust("200 millis"); - const [first, second] = yield* Fiber.join(clients); - expect(first.storage).toEqual({ totalBytes: 4096000, availableBytes: 4096 }); - expect(second).toEqual(first); - expect(yield* service.read).toEqual(first); - expect(paths).toEqual([stateDir]); - - yield* TestClock.adjust("5 seconds"); - const refreshed = yield* service.read.pipe(Effect.forkChild); - yield* TestClock.adjust("200 millis"); - const next = yield* Fiber.join(refreshed); - expect(next.storage).toEqual({ totalBytes: 4096000, availableBytes: 8192 }); - expect(next.sampledAt).toBeGreaterThan(first.sampledAt); + const first = yield* service.readStorage; + expect(first).toEqual({ + sampledAt: 0, + storage: { totalBytes: 4096000, availableBytes: 4096 }, + }); + yield* TestClock.adjust("1 milli"); + const refreshed = yield* service.readStorage; + expect(refreshed).toEqual({ + sampledAt: 1, + storage: { totalBytes: 4096000, availableBytes: 8192 }, + }); expect(paths).toEqual([stateDir, stateDir]); }).pipe(Effect.provide(TestLayer)), ); @@ -86,14 +82,12 @@ describe("HostResources storage", () => { it.effect(name, () => Effect.gen(function* () { const service = yield* makeTest(() => Effect.succeed(stats)); - const reading = yield* service.read.pipe(Effect.forkChild); - yield* TestClock.adjust("200 millis"); - expect((yield* Fiber.join(reading)).storage).toEqual(storage); + expect((yield* service.readStorage).storage).toEqual(storage); }).pipe(Effect.provide(TestLayer)), ); } - it.effect("keeps CPU and memory usable after a storage failure and retries after expiry", () => + it.effect("recovers from a filesystem failure on the next read", () => Effect.gen(function* () { let reads = 0; const service = yield* makeTest(() => @@ -104,35 +98,37 @@ describe("HostResources storage", () => { : Effect.succeed({ blocks: 1000n, bsize: 4096n, bavail: 100n }); }), ); - const reading = yield* service.read.pipe(Effect.forkChild); - yield* TestClock.adjust("200 millis"); - const failed = yield* Fiber.join(reading); - expect(failed.storage).toBeNull(); - expect(failed.cpuCount).toBeGreaterThan(0); - expect(failed.totalMemoryBytes).toBeGreaterThan(0); - expect(failed.availableMemoryBytes).toBeGreaterThanOrEqual(0); - expect(yield* service.read).toEqual(failed); - expect(reads).toBe(1); - - yield* TestClock.adjust("5 seconds"); - const retry = yield* service.read.pipe(Effect.forkChild); - yield* TestClock.adjust("200 millis"); - expect((yield* Fiber.join(retry)).storage).toEqual({ + expect((yield* service.readStorage).storage).toBeNull(); + expect((yield* service.readStorage).storage).toEqual({ totalBytes: 4096000, availableBytes: 409600, }); + expect(reads).toBe(2); }).pipe(Effect.provide(TestLayer)), ); - it.effect("bounds a stuck filesystem reading without failing host resources", () => + it.effect("keeps cached load-balancing reads independent of a stuck storage request", () => Effect.gen(function* () { - const service = yield* makeTest(() => Effect.never); - const reading = yield* service.read.pipe(Effect.forkChild); - yield* TestClock.adjust("1200 millis"); - const result = yield* Fiber.join(reading); - expect(result.storage).toBeNull(); - expect(result.sampledAt).toBe(1200); - expect(result.totalMemoryBytes).toBeGreaterThan(0); + const started = yield* Deferred.make(); + let storageReads = 0; + const service = yield* makeTest(() => + Effect.gen(function* () { + storageReads++; + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + }), + ); + const storage = yield* service.readStorage.pipe(Effect.forkChild); + yield* Deferred.await(started); + const resources = yield* service.read.pipe(Effect.forkChild); + yield* TestClock.adjust("200 millis"); + const first = yield* Fiber.join(resources); + expect(first.sampledAt).toBe(200); + expect(first.totalMemoryBytes).toBeGreaterThan(0); + expect(yield* service.read).toEqual(first); + expect(storageReads).toBe(1); + yield* TestClock.adjust("800 millis"); + expect(yield* Fiber.join(storage)).toEqual({ sampledAt: 1000, storage: null }); }).pipe(Effect.provide(TestLayer)), ); }); diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts index 8276de2c0313..3ca80a92fdff 100644 --- a/apps/server/src/resourceTelemetry/HostResources.ts +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -2,7 +2,11 @@ import * as NodeFSP from "node:fs/promises"; import type * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; -import { HostStorageSnapshot, type HostResourcesSnapshot } from "@t3tools/contracts"; +import { + HostStorageSnapshot, + type HostResourcesSnapshot, + type HostStorageResult, +} from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -35,7 +39,10 @@ const decodeStorage = Schema.decodeUnknownEffect(HostStorageSnapshot); export class HostResources extends Context.Service< HostResources, - { readonly read: Effect.Effect } + { + readonly read: Effect.Effect; + readonly readStorage: Effect.Effect; + } >()("t3/resourceTelemetry/HostResources") {} function readCpu() { @@ -111,14 +118,12 @@ export const make = Effect.fn("makeHostResources")(function* () { ); availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes; } - const storage = yield* sampleStorage(); return { sampledAt: DateTime.toEpochMillis(yield* DateTime.now), cpuUtilization, cpuCount: cpu.count, availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)), totalMemoryBytes, - storage, }; }); @@ -128,7 +133,15 @@ export const make = Effect.fn("makeHostResources")(function* () { lookup: (_key: "host") => sample(), timeToLive: "5 seconds", }); - return HostResources.of({ read: Cache.get(cache, "host") }); + return HostResources.of({ + read: Cache.get(cache, "host"), + // Settings reads storage on demand. Keep it independent of load balancing and + // uncached so an explicit refresh always checks the filesystem again. + readStorage: Effect.gen(function* () { + const storage = yield* sampleStorage(); + return { sampledAt: DateTime.toEpochMillis(yield* DateTime.now), storage }; + }), + }); }); export const layer = Layer.effect(HostResources, make()); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d6f7efa76c13..c0cfd4de3b98 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6193,6 +6193,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("returns disk capacity independently over websocket", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetHostStorage]({})), + ); + assert.isAtLeast(result.sampledAt, 0); + assert.isNotNull(result.storage); + if (result.storage) { + assert.isAbove(result.storage.totalBytes, 0); + assert.isAtLeast(result.storage.availableBytes, 0); + assert.isAtMost(result.storage.availableBytes, result.storage.totalBytes); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("returns cached whole-host resources over websocket", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 740be1330817..da3e7aeb30ee 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2028,6 +2028,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetHostStorage]: (_input) => + observeRpcEffect(WS_METHODS.serverGetHostStorage, hostResources.readStorage, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverGetProcessResourceHistory]: (input) => observeRpcEffect( WS_METHODS.serverGetProcessResourceHistory, diff --git a/apps/web/src/components/settings/HostStorageIndicator.tsx b/apps/web/src/components/settings/HostStorageIndicator.tsx index 2a7305a50bd0..135b10bd1fb0 100644 --- a/apps/web/src/components/settings/HostStorageIndicator.tsx +++ b/apps/web/src/components/settings/HostStorageIndicator.tsx @@ -105,7 +105,7 @@ function StorageRing({ } function ConnectedStorageIndicator({ environment }: { environment: EnvironmentPresentation }) { - const query = serverEnvironment.hostResources({ + const query = serverEnvironment.hostStorage({ environmentId: environment.environmentId, input: {}, }); diff --git a/packages/client-runtime/src/hostStorage.test.ts b/packages/client-runtime/src/hostStorage.test.ts index 03d9aacacb27..4029b9d9a0f3 100644 --- a/packages/client-runtime/src/hostStorage.test.ts +++ b/packages/client-runtime/src/hostStorage.test.ts @@ -1,4 +1,4 @@ -import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import type { HostStorageResult } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -6,12 +6,8 @@ import { describe, expect, it } from "vite-plus/test"; import { getHostStoragePresentation } from "./hostStorage.ts"; -const snapshot: HostResourcesSnapshot = { +const snapshot: HostStorageResult = { sampledAt: 1_789_000_000_000, - cpuUtilization: 0.1, - cpuCount: 8, - availableMemoryBytes: 8 * 1024 ** 3, - totalMemoryBytes: 16 * 1024 ** 3, storage: { totalBytes: 457 * 1024 ** 3, availableBytes: 254 * 1024 ** 3 }, }; @@ -40,13 +36,10 @@ describe("host storage presentation", () => { }); }); - it.each([null, undefined])("treats %s storage as unavailable", (storage) => { - const { storage: _storage, ...legacySnapshot } = snapshot; - expect( - getHostStoragePresentation( - AsyncResult.success(storage === undefined ? legacySnapshot : { ...snapshot, storage }), - ), - ).toEqual({ status: "unavailable" }); + it("treats unavailable storage as unknown", () => { + expect(getHostStoragePresentation(AsyncResult.success({ ...snapshot, storage: null }))).toEqual( + { status: "unavailable" }, + ); }); it("does not show the previous capacity while refreshing", () => { @@ -56,7 +49,7 @@ describe("host storage presentation", () => { }); }); - it("does not show a cached reading after a failed request", () => { + it("shows unavailable after disconnection or an older server rejects the storage RPC", () => { expect( getHostStoragePresentation( AsyncResult.failure(Cause.fail(new Error("Disconnected")), { diff --git a/packages/client-runtime/src/hostStorage.ts b/packages/client-runtime/src/hostStorage.ts index 83678494eca8..f8e6e1e01eac 100644 --- a/packages/client-runtime/src/hostStorage.ts +++ b/packages/client-runtime/src/hostStorage.ts @@ -1,4 +1,4 @@ -import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import type { HostStorageResult } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; const BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] as const; @@ -11,7 +11,7 @@ function formatStorageBytes(bytes: number): string { /** A refreshing or failed reading must not present cached capacity as current. */ export function getHostStoragePresentation( - result: AsyncResult.AsyncResult, + result: AsyncResult.AsyncResult, ) { if (AsyncResult.isInitial(result) || result.waiting) { return { status: "loading" } as const; diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 18127076ff19..01175c782ba2 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1019,6 +1019,13 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-diagnostics", tag: WS_METHODS.serverGetProcessDiagnostics, }), + hostStorage: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:server:host-storage", + idleTtlMs: 0, + staleTimeMs: 5_000, + execute: (input: EnvironmentRpcInput) => + request(WS_METHODS.serverGetHostStorage, input).pipe(Effect.timeout("5 seconds")), + }), hostResources: createEnvironmentQueryAtomFamily(runtime, { label: "environment-data:server:host-resources", idleTtlMs: 0, diff --git a/packages/contracts/src/resourceTelemetry.test.ts b/packages/contracts/src/resourceTelemetry.test.ts index ffe71cd16b3a..a5f70d37308f 100644 --- a/packages/contracts/src/resourceTelemetry.test.ts +++ b/packages/contracts/src/resourceTelemetry.test.ts @@ -1,20 +1,13 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { HostResourcesSnapshot } from "./resourceTelemetry.ts"; +import { HostStorageResult } from "./resourceTelemetry.ts"; -const decode = Schema.decodeUnknownSync(HostResourcesSnapshot); -const resources = { - sampledAt: 0, - cpuUtilization: null, - cpuCount: 4, - availableMemoryBytes: 1024, - totalMemoryBytes: 2048, -}; +const decode = Schema.decodeUnknownSync(HostStorageResult); +const resources = { sampledAt: 0 }; -describe("HostResourcesSnapshot storage", () => { - it("accepts older servers without storage and unavailable readings", () => { - expect(decode(resources)).toEqual(resources); +describe("HostStorageResult storage", () => { + it("accepts unavailable readings", () => { expect(decode({ ...resources, storage: null }).storage).toBeNull(); }); diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index 79a07d72c747..c31f63ed9471 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -13,6 +13,12 @@ export const HostStorageSnapshot = Schema.Struct({ }).check(Schema.makeFilter((storage) => storage.availableBytes <= storage.totalBytes)); export type HostStorageSnapshot = typeof HostStorageSnapshot.Type; +export const HostStorageResult = Schema.Struct({ + sampledAt: NonNegativeInt, + storage: Schema.NullOr(HostStorageSnapshot), +}); +export type HostStorageResult = typeof HostStorageResult.Type; + /** Whole-host capacity, independent of T3's process diagnostics. */ export const HostResourcesSnapshot = Schema.Struct({ sampledAt: NonNegativeInt, @@ -20,8 +26,6 @@ export const HostResourcesSnapshot = Schema.Struct({ cpuCount: NonNegativeInt, availableMemoryBytes: NonNegativeInt, totalMemoryBytes: NonNegativeInt, - // Absent on older servers; null when the filesystem could not be sampled. - storage: Schema.optionalKey(Schema.NullOr(HostStorageSnapshot)), }); export type HostResourcesSnapshot = typeof HostResourcesSnapshot.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9dbcaa9f4164..eb5a698ed113 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -211,6 +211,7 @@ import { } from "./server.ts"; import { HostResourcesSnapshot, + HostStorageResult, ResourceTelemetryHistory, ResourceTelemetryHistoryInput, ResourceTelemetryRetryResult, @@ -326,6 +327,7 @@ export const WS_METHODS = { serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", serverGetHostResources: "server.getHostResources", + serverGetHostStorage: "server.getHostStorage", serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", serverRetryResourceTelemetry: "server.retryResourceTelemetry", @@ -547,6 +549,12 @@ const WsServerGetHostResourcesRpc = Rpc.make(WS_METHODS.serverGetHostResources, error: EnvironmentAuthorizationError, }); +const WsServerGetHostStorageRpc = Rpc.make(WS_METHODS.serverGetHostStorage, { + payload: Schema.Struct({}), + success: HostStorageResult, + error: EnvironmentAuthorizationError, +}); + const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, { payload: ServerProcessResourceHistoryInput, success: ServerProcessResourceHistoryResult, @@ -1207,6 +1215,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, WsServerGetHostResourcesRpc, + WsServerGetHostStorageRpc, WsServerGetProcessResourceHistoryRpc, WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, From 3e48edf1bfd951db5493843fc375abe279a4a39a Mon Sep 17 00:00:00 2001 From: Adam Blumoff <99273116+adamblumoff@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:54:30 -0500 Subject: [PATCH 4/4] fix(server): share pending disk capacity samples --- .../resourceTelemetry/HostResources.test.ts | 58 +++++++++++++++++++ .../src/resourceTelemetry/HostResources.ts | 31 +++++++--- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/apps/server/src/resourceTelemetry/HostResources.test.ts b/apps/server/src/resourceTelemetry/HostResources.test.ts index 5471c66f5aac..7d0ec7fcd480 100644 --- a/apps/server/src/resourceTelemetry/HostResources.test.ts +++ b/apps/server/src/resourceTelemetry/HostResources.test.ts @@ -22,6 +22,64 @@ const makeTest = Effect.fn(function* (statfs: typeof HostResources.HostStorageSt }); describe("HostResources storage", () => { + it.effect( + "shares an uncancellable filesystem call across concurrent reads and timed-out retries", + () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const pending = Promise.withResolvers<{ blocks: bigint; bsize: bigint; bavail: bigint }>(); + let reads = 0; + const service = yield* makeTest( + HostResources.makeHostStorageStatFs(() => { + reads++; + Deferred.doneUnsafe(started, Effect.void); + return pending.promise; + }), + ); + const clients = yield* Effect.all([service.readStorage, service.readStorage], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(clients)).map((reading) => reading.storage)).toEqual([ + null, + null, + ]); + const retry = yield* service.readStorage.pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(retry)).storage).toBeNull(); + expect(reads).toBe(1); + + pending.resolve({ blocks: 1000n, bsize: 4096n, bavail: 100n }); + yield* Effect.promise(() => pending.promise); + expect((yield* service.readStorage).storage).toEqual({ + totalBytes: 4096000, + availableBytes: 409600, + }); + expect(reads).toBe(2); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("releases the in-flight sample after a filesystem rejection", () => + Effect.gen(function* () { + let reads = 0; + const service = yield* makeTest( + HostResources.makeHostStorageStatFs(() => { + reads++; + return reads === 1 + ? Promise.reject(new Error("filesystem unavailable")) + : Promise.resolve({ blocks: 1000n, bsize: 4096n, bavail: 100n }); + }), + ); + expect((yield* service.readStorage).storage).toBeNull(); + expect((yield* service.readStorage).storage).toEqual({ + totalBytes: 4096000, + availableBytes: 409600, + }); + expect(reads).toBe(2); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("samples the configured data filesystem again on every explicit read", () => Effect.gen(function* () { const { stateDir } = yield* ServerConfig; diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts index 3ca80a92fdff..c06cfaaa71a7 100644 --- a/apps/server/src/resourceTelemetry/HostResources.ts +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -23,17 +23,30 @@ export class HostStorageError extends Schema.TaggedError()("Ho cause: Schema.Defect(), }) {} -export const HostStorageStatFs = Context.Reference< - ( - path: string, - ) => Effect.Effect, HostStorageError> ->("t3/resourceTelemetry/HostStorageStatFs", { - defaultValue: () => (path) => +type StorageStats = Pick; + +/** A timed-out caller must not start another uncancellable statfs on the same filesystem. */ +export function makeHostStorageStatFs( + read: (path: string) => Promise = (path) => NodeFSP.statfs(path, { bigint: true }), +) { + const pending = new Map>(); + return (path: string) => Effect.tryPromise({ - try: () => NodeFSP.statfs(path, { bigint: true }), + try: () => { + const current = pending.get(path); + if (current) return current; + const next = read(path).finally(() => pending.delete(path)); + pending.set(path, next); + return next; + }, catch: (cause) => new HostStorageError({ cause }), - }), -}); + }); +} + +export const HostStorageStatFs = Context.Reference>( + "t3/resourceTelemetry/HostStorageStatFs", + { defaultValue: makeHostStorageStatFs }, +); const decodeStorage = Schema.decodeUnknownEffect(HostStorageSnapshot);