Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/web
Steps to reproduce
Use the commit under Version or commit, with lockfile dependencies installed and the repository-pinned Playwright Chromium runtime available. Save the script below outside the checkout as repro.sh and run env -u NODE_ENV sh /path/to/repro.sh "$PWD" from the checkout's apps/web directory, passing the checkout root. The script sets a private HOME, TMPDIR and T3CODE_HOME under a mktemp directory; it reads PLAYWRIGHT_BROWSERS_PATH, defaulting to the macOS Playwright cache under the original home directory, so set that variable to the existing browser installation on other platforms.
- The script writes a producer probe to
apps/server/reproProducer.tsand runs it with Node. The probe feeds 11, then 23, generated native snapshot records through a test native-telemetry layer into the exported productionResourceTelemetryandProcessResourceMonitorservices, reads both history results for the 15m window, and schema-encodes them to JSON. - It installs a browser test at
apps/web/src/components/settings/reproProcessHistory.browser.test.tsxthat mounts the realDiagnosticsSettingsPanelat/settings/diagnosticsin Chromium with the realcreateServerEnvironmentAtomsquery atoms, query runtime, environment query hook and diagnostics read gate. A controlled RPC client returns the generated, schema-decoded service results. Layout containers, the selected environment context, the scope sentence, unrelated write actions and the OpenTelemetry export form are substituted; neither history query, history hook, statistic rendering, process table nor refresh control is mocked. - The test switches the controlled RPC to the later results, advances fake timers by 12 seconds, reads the Resource History Samples and CPU Time values and the Resource timeline's process sample count, then clicks the Resource History refresh arrow.
- The script exits 0 only when the producer output grows, the Resource timeline updates, Resource History stays stale and its own refresh restores the later values. It removes its three fixture files and the
mktempdirectory on exit and refuses to overwrite existing files.
This is a production-component browser test with a controlled native and transport boundary, not a running OS sidecar, packaged desktop app or full-app navigation run.
Reproduction script: repro.sh
#!/bin/sh
set -eu
repo=${1:?Usage: sh repro.sh /path/to/dependency-installed/t3code}
repo=$(cd "$repo" && pwd)
fixture=$(mktemp -d "${TMPDIR:-/tmp}/process-history-repro.XXXXXX")
browser_cache=${PLAYWRIGHT_BROWSERS_PATH:-${HOME}/Library/Caches/ms-playwright}
target="$repo/apps/web/src/components/settings/reproProcessHistory.browser.test.tsx"
producer="$repo/apps/server/reproProducer.ts"
data="$repo/apps/web/src/components/settings/reproHistoryData.json"
if [ -e "$target" ] || [ -e "$producer" ] || [ -e "$data" ]; then
echo "Refusing to replace existing fixture files" >&2
exit 2
fi
cleanup() {
rm -f "$target" "$producer" "$data"
rm -rf "$fixture"
}
trap cleanup EXIT HUP INT TERM
mkdir -p "$fixture/fake-home" "$fixture/tmp" "$fixture/t3home"
export HOME="$fixture/fake-home" TMPDIR="$fixture/tmp" T3CODE_HOME="$fixture/t3home"
export PLAYWRIGHT_BROWSERS_PATH="$browser_cache"
export PATH="$repo/node_modules/.bin:$PATH"
cat > "$fixture/panel.tsx" <<'BROWSER_FIXTURE'
import type { ReactNode } from "react";
import { RegistryContext } from "@effect/atom-react";
import { AuthDiagnosticsReadScope, EnvironmentId, WS_METHODS, ResourceTelemetryHistory, ResourceTelemetrySnapshot, ServerProcessResourceHistoryResult } from "@t3tools/contracts";
import { AVAILABLE_CONNECTION_STATE, PrimaryConnectionTarget, EnvironmentRegistry, EnvironmentSupervisor } from "@t3tools/client-runtime/connection";
import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";
import * as Persistence from "../../../../../packages/client-runtime/src/platform/persistence.ts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
import * as Schema from "effect/Schema";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";
import { render, act, fakeTimers } from "../../../test/render";
const fixture = vi.hoisted(() => ({ atoms: null as any, session: null as any }));
vi.mock("../../state/server", () => ({ serverEnvironment: new Proxy({}, {get: (_, key) => fixture.atoms[key]}) }));
vi.mock("../../state/environments", () => ({ usePrimaryEnvironment: () => null, useEnvironments: () => ({environments: []}) }));
vi.mock("../../state/session", () => ({
environmentSession: {sessionStateAtom: () => fixture.session},
useEnvironmentScope: () => true, readEnvironmentScope: () => true,
}));
vi.mock("../../editorPreferences", () => ({useOpenInPreferredEditor: () => vi.fn()}));
vi.mock("../../state/use-atom-command", () => ({useAtomCommand: () => vi.fn()}));
vi.mock("./SettingsScopeContext", () => ({
useSettingsScope: () => ({ environment: {environmentId: "repro", connection: {phase: "connected"}}, scope: {kind: "environment"} }),
useOptionalSettingsScope: () => null,
}));
vi.mock("./SettingsScopeSentence", () => ({SettingsScopeSentence: () => null}));
vi.mock("./TelemetryExportSettings", () => ({TelemetryExportSettings: () => null}));
vi.mock("./settingsLayout", () => ({
SettingsPageContainer: ({children}: {children: ReactNode}) => <main>{children}</main>,
SettingsSection: ({title, headerAction, children}: {title: string, headerAction?: ReactNode, children: ReactNode}) => <section><h2>{title}</h2>{headerAction}{children}</section>,
}));
import generated from "./reproHistoryData.json";
import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings";
it("shows process Resource History stale while its sibling timeline updates", async () => {
using _timers = fakeTimers();
const codec = Schema.toCodecJson(Schema.Struct({first: ServerProcessResourceHistoryResult, second: ServerProcessResourceHistoryResult, firstTelemetry: ResourceTelemetryHistory, secondTelemetry: ResourceTelemetryHistory, snapshot: ResourceTelemetrySnapshot}));
const generatedData = Schema.decodeUnknownSync(codec)(generated);
const {snapshot} = generatedData;
const readAt = snapshot.readAt;
let later = false;
const reads = {telemetry: 0, process: 0};
const requests: unknown[] = [];
const history = () => later ? generatedData.secondTelemetry : generatedData.firstTelemetry;
const processHistory = () => later ? generatedData.second : generatedData.first;
const client = {
[WS_METHODS.serverGetResourceTelemetryHistory]: (input: unknown) => Effect.sync(() => {requests.push({method: "telemetry", input}); reads.telemetry++; return history();}),
[WS_METHODS.serverGetProcessResourceHistory]: (input: {windowMs: number, bucketMs: number}) => Effect.sync(() => {requests.push({method: "process", input}); reads.process++; return processHistory();}),
[WS_METHODS.subscribeResourceTelemetry]: () => Stream.concat(Stream.make(snapshot), Stream.never),
[WS_METHODS.serverGetProcessDiagnostics]: () => Effect.succeed({readAt, serverPid: 101, processCount: 0, totalRssBytes: 0, totalCpuPercent: 0, processes: [], error: Option.none()}),
[WS_METHODS.serverGetTraceDiagnostics]: () => Effect.succeed({readAt, traceFilePath: "traces", scannedFilePaths: [], recordCount: 0, parseErrorCount: 0, firstSpanAt: Option.none(), lastSpanAt: Option.none(), failureCount: 0, interruptionCount: 0, slowSpanThresholdMs: 1000, slowSpanCount: 0, logLevelCounts: {}, topSpansByCount: [], slowestSpans: [], commonFailures: [], latestFailures: [], latestWarningAndErrorLogs: [], partialFailure: Option.none(), error: Option.none()}),
};
const target = new PrimaryConnectionTarget({environmentId: EnvironmentId.make("repro"), label: "Repro", httpBaseUrl: "https://repro.example.test", wsBaseUrl: "wss://repro.example.test"});
const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({target, state: await Effect.runPromise(SubscriptionRef.make({...AVAILABLE_CONNECTION_STATE, phase: "connected"})), session: await Effect.runPromise(SubscriptionRef.make(Option.some({client, initialConfig: Effect.succeed({}), ready: Effect.void, probe: Effect.void, inboundFrames: Effect.succeed(0), closed: Effect.never}))), prepared: await Effect.runPromise(SubscriptionRef.make(Option.none())), connect: Effect.void, disconnect: Effect.void, retryNow: Effect.void} as any);
const environments = EnvironmentRegistry.EnvironmentRegistry.of({run: (_id: unknown, effect: any) => Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), followStream: (_id: unknown, stream: any) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor)} as any);
const runtime = Atom.runtime(Layer.merge(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments), Layer.succeed(Persistence.EnvironmentCacheStore, {loadServerConfig: () => Effect.succeedNone} as any)));
fixture.atoms = createServerEnvironmentAtoms(runtime, {initialConfigValueAtom: () => Atom.make({} as any)});
fixture.session = Atom.make(AsyncResult.success({authenticated: true, scopes: [AuthDiagnosticsReadScope]}));
const registry = AtomRegistry.make();
using _registry = {[Symbol.dispose]: () => registry.dispose()};
await using screen = await render(<RegistryContext.Provider value={registry}><DiagnosticsSettingsPanel /></RegistryContext.Provider>, {router: "/settings/diagnostics"});
await expect.element(screen.getByRole("heading", {name: "Resource History", exact: true})).toBeInTheDocument();
const section = (name: string) => screen.getByRole("heading", {name, exact: true}).element().closest("section")!;
const numbers = () => {
const legacy = section("Resource History");
const sampleLabel = [...legacy.querySelectorAll("span")].find(el => el.textContent === "Samples")!;
return {processSamples: sampleLabel.parentElement!.nextElementSibling!.textContent, telemetrySamples: section("Resource timeline").querySelector("tbody tr")?.children[7]?.textContent, processCpuTime: [...legacy.querySelectorAll("span")].find(el => el.textContent === "CPU Time")?.parentElement?.nextElementSibling?.textContent};
};
await vi.waitFor(() => expect(numbers().processSamples).toBe("22"));
await vi.waitFor(() => expect(numbers().telemetrySamples).toBe("11"));
const initial = numbers();
console.log("INITIAL " + JSON.stringify({reads, ...initial}));
later = true;
await act(() => vi.advanceTimersByTimeAsync(12000));
await vi.waitFor(() => expect(reads.telemetry).toBeGreaterThan(1));
const after = numbers();
expect(after.processSamples).toBe("22");
expect(after.telemetrySamples).toBe("23");
expect(reads.process).toBe(1);
console.log("AFTER_12S " + JSON.stringify({reads, serverSamples: generatedData.second.retainedSampleCount, ...after}));
console.log("REQUESTS " + JSON.stringify(requests));
vi.useRealTimers();
const refresh = section("Resource History").querySelector<HTMLButtonElement>('button[aria-label="Refresh resource history"]')!;
await act(() => refresh.click());
await vi.waitFor(() => expect(numbers().processSamples).toBe("46"));
expect(reads.process).toBe(2);
console.log("MANUAL_REFRESH " + JSON.stringify({reads, ...numbers()}));
console.log("DEFECT PRESENT: real DiagnosticsSettingsPanel and real query atoms; telemetry control updates, process view needs manual refresh");
});
BROWSER_FIXTURE
cat > "$fixture/producer.ts" <<'PRODUCER_FIXTURE'
import assert from 'node:assert/strict';
import {writeFileSync} from 'node:fs';
import * as Schema from 'effect/Schema';
import {ServerProcessResourceHistoryResult, ResourceTelemetryHistory, ResourceTelemetrySnapshot} from '@t3tools/contracts';
import * as DateTime from 'effect/DateTime';
import * as Effect from 'effect/Effect';
import * as Layer from 'effect/Layer';
import * as Option from 'effect/Option';
import * as Native from './src/resourceTelemetry/NativeTelemetryClient.ts';
import * as Desktop from './src/resourceTelemetry/DesktopTelemetryReceiver.ts';
import * as Attribution from './src/resourceTelemetry/ResourceAttribution.ts';
import * as Telemetry from './src/resourceTelemetry/ResourceTelemetry.ts';
import * as Process from './src/diagnostics/ProcessResourceMonitor.ts';
let count = 11;
const now = Date.now();
const samples = () => Array.from({length: count}, (_, i) => ({version: 3 as const, type: 'snapshot' as const, sequence: i + 1, sampledAtUnixMs: now - (count - i) * 1000, collectionDurationMicros: 1, scannedProcessCount: 1, retainedProcessCount: 1, inaccessibleProcessCount: 0, processes: [{pid: process.pid, ppid: 1, startTimeMs: 100, runTimeMs: (i+1)*1000, name: 'server', command: 'server', status: 'Running', cpuPercent: 10, cpuTimeMs: i*1000, residentBytes: 1000, virtualBytes: 2000, ioReadBytes: 0, ioWriteBytes: 0, ioSemantics: 'storage' as const}]}));
const telemetryLayer = Telemetry.layer.pipe(Layer.provide(Layer.mergeAll(
Native.layerTest({readHistory: () => Effect.sync(samples), health: Effect.succeed({status: 'healthy', hello: Option.none(), lastSampleAt: Option.some(DateTime.makeUnsafe(now)), lastError: Option.none(), restartCount: 0, sampleIntervalMs: 1000})}),
Desktop.layerTest(), Attribution.layer,
)));
await Effect.runPromise(Effect.scoped(Effect.gen(function*() {
const telemetry = yield* Telemetry.ResourceTelemetry;
const monitor = yield* Process.make();
const input = {windowMs: 900000, bucketMs: 60000};
const first = yield* monitor.readHistory(input);
const firstTelemetry = yield* telemetry.readHistory({...input, bucketMs: 30000});
count = 23;
const second = yield* monitor.readHistory(input);
const control = yield* telemetry.readHistory({...input, bucketMs: 30000});
const snapshot = yield* telemetry.latest;
const codec = Schema.toCodecJson(Schema.Struct({first: ServerProcessResourceHistoryResult, second: ServerProcessResourceHistoryResult, firstTelemetry: ResourceTelemetryHistory, secondTelemetry: ResourceTelemetryHistory, snapshot: ResourceTelemetrySnapshot}));
if (process.argv[2]) writeFileSync(process.argv[2], JSON.stringify(Schema.encodeSync(codec)({first, second, firstTelemetry, secondTelemetry: control, snapshot})));
const describe = (h: typeof first) => ({retainedSampleCount: h.retainedSampleCount, processSamples: h.topProcesses[0]?.sampleCount, cpuSeconds: h.totalCpuSecondsApprox, bucketCount: h.buckets.length});
console.log('PRODUCER_FIRST ' + JSON.stringify(describe(first)));
console.log('PRODUCER_LATER ' + JSON.stringify(describe(second)));
console.log('PRODUCER_CONTROL ' + JSON.stringify({retainedSampleCount: control.retainedSampleCount, processSamples: control.topProcesses[0]?.sampleCount}));
assert.equal(first.topProcesses[0]?.sampleCount, 11);
assert.equal(second.topProcesses[0]?.sampleCount, 23);
assert.equal(second.retainedSampleCount, control.retainedSampleCount);
assert(second.totalCpuSecondsApprox > first.totalCpuSecondsApprox);
})).pipe(Effect.provide(telemetryLayer)));
PRODUCER_FIXTURE
cp "$fixture/panel.tsx" "$target"
cp "$fixture/producer.ts" "$producer"
printf 'REVISION '
git -C "$repo" rev-parse HEAD
(cd "$repo/apps/server" && node reproProducer.ts "$fixture/data.json")
cp "$fixture/data.json" "$data"
(cd "$repo/apps/web" && env -u NODE_ENV vp test run --project browser src/components/settings/reproProcessHistory.browser.test.tsx --silent=false --reporter=verbose)
echo 'REPRO PASSED: defect present, sibling control correct, manual refresh correct'Expected behavior
While Settings → Diagnostics stays open on the same history window, process Resource History should reflect newly retained samples, as the sibling Resource timeline does. The section's CPU Time tooltip says older samples leave as the selected window moves, and its Samples tooltip says the server samples while Diagnostics is open (DiagnosticsSettings.tsx). The process history RPC takes a relative window and bucket size with no fixed read time or snapshot identity, and the production ProcessResourceMonitor.readHistory maps the same changing ResourceTelemetry history to backend processes on each call (ProcessResourceMonitor.ts). The original feature, #2685, describes rolling windows over periodic sampling, and #19399 sets retention coverage to the time Diagnostics is open rather than freezing the history on entry.
Actual behavior
Summary. In Settings → Diagnostics, the process Resource History section (its Samples and CPU Time statistics and its process table) loads once and does not update while the panel stays open on the same history window, even as the server retains new samples. The sibling Resource timeline section in the same panel refreshes every 5 seconds and shows the new samples, and the Resource History section's own refresh arrow loads the current values immediately. #30520 and its fix #30528 added automatic refresh to the telemetry Resource timeline (resourceTelemetryHistory) only; the separate processResourceHistory query that backs Resource History has no refresh path. This is a sibling refresh path that fix did not cover, not a regression of #30528. Users watching backend process samples and CPU totals in Diagnostics see stale numbers until they refresh by hand.
Observed in Chromium. The production producer returned 22 retained records, 11 server-process samples and 10 CPU seconds from the first history, then 46 retained records, 23 process samples and 22 CPU seconds after more snapshots were retained. In the panel, Resource History showed 22 samples and 10s CPU time at first and still showed 22 and 10s after 12 seconds, while the Resource timeline table updated from 11 to 23 process samples. By then the telemetry history RPC had been called three times in total and the process history RPC once. Clicking the Resource History refresh arrow called the process history RPC a second time and immediately displayed 46 samples and 22s.
The production caller passes the 15m selection to the process query as {windowMs:900000,bucketMs:60000} (caller). The processResourceHistory query family is created with only a label and RPC tag (server.ts), while the sibling resourceTelemetryHistory sets refreshIntervalMs: 5_000, staleTimeMs: 5_000 and idleTtlMs: 0 (server.ts). The query runtime installs a periodic refresh only when refreshIntervalMs is given (runtime.ts). This configuration difference is the likely cause, but the report does not prescribe a fix.
The browser result is from the tested commit. On current main (under Version or commit) the processResourceHistory and resourceTelemetryHistory definitions are unchanged, and runtime.ts, DiagnosticsSettings.tsx, ProcessResourceMonitor.ts and ResourceTelemetry.ts have no commits since the tested commit; server.ts changed only in unrelated provider code. A runtime rerun on an earlier main (b68aedf) was blocked because the tested commit's dependency install lacks a workspace dependency link that main now needs, and no rerun was attempted on the current main, so persistence on main comes from source inspection only.
Related, not duplicates: #30520, #30528 and #30541 concern the telemetry Resource timeline only; #30541 was closed because #30528 already fixed that timeline. None of them describes Resource History as a deliberately frozen snapshot or promises to refresh it. Open #32081 moves history aggregation off the RPC event loop and does not touch this refresh path. Closed #23970 proposed refreshing the telemetry query after a monitor retry.
Evidence
- Expected source: the Resource History Samples and CPU Time tooltips at
apps/web/src/components/settings/DiagnosticsSettings.tsxlines 1006-1015, and the changing-history producer atapps/server/src/diagnostics/ProcessResourceMonitor.tslines 32-81. - Failure source: the observed Chromium output under Logs or stack traces, the
processResourceHistoryquery definition without a refresh interval atpackages/client-runtime/src/state/server.tslines 1388-1391, and the interval-only periodic refresh atpackages/client-runtime/src/state/runtime.tslines 821-826. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
Restoration check
With the script's fixture, after the 12-second timer advance and without clicking refresh, Resource History shows the later 46 samples and 22s CPU time and the process history RPC has been read more than once, while the Resource timeline still shows 23 process samples. As controls, the initial render still shows 22 samples and 10s, and the refresh arrow still loads current values. The script asserts the current stale values, so its AFTER_12S assertions need updating to the restored behavior for this check.
Impact
Minor bug or occasional failure
Version or commit
0.1.9 at d9805b2 (the relevant source is unchanged through main at ee90bdc, as described under Actual behavior)
Environment
macOS arm64, Node v26.12.1, repository-pinned Vite+ 1.4.0, Vitest 5.0.5 and Playwright 1.64.1, Chromium through the repo's browser test project; no OS-native sidecar, packaged desktop app, relay or mobile client
Logs or stack traces
The script's output, with the test runner's path lines removed:
PRODUCER_FIRST {"retainedSampleCount":22,"processSamples":11,"cpuSeconds":10,"bucketCount":15}
PRODUCER_LATER {"retainedSampleCount":46,"processSamples":23,"cpuSeconds":22,"bucketCount":15}
PRODUCER_CONTROL {"retainedSampleCount":46,"processSamples":23}
INITIAL {"reads":{"telemetry":1,"process":1},"processSamples":"22","telemetrySamples":"11","processCpuTime":"10s"}
AFTER_12S {"reads":{"telemetry":3,"process":1},"serverSamples":46,"processSamples":"22","telemetrySamples":"23","processCpuTime":"10s"}
REQUESTS [{"method":"process","input":{"windowMs":900000,"bucketMs":60000}},{"method":"telemetry","input":{"windowMs":900000,"bucketMs":30000}},{"method":"telemetry","input":{"windowMs":900000,"bucketMs":30000}},{"method":"telemetry","input":{"windowMs":900000,"bucketMs":30000}}]
MANUAL_REFRESH {"reads":{"telemetry":3,"process":2},"processSamples":"46","telemetrySamples":"23","processCpuTime":"22s"}
Test Files 1 passed (1)
Tests 1 passed (1)
REPRO PASSED: defect present, sibling control correct, manual refresh correctScreenshots, recordings, or supporting files
No response
Workaround
Click the refresh arrow in the Resource History section. The test's manual refresh step shows it loads the current samples and CPU time immediately; the user has to notice that the numbers are stale.
Activity