From ca92f630378d0a09cb6fe7836954f7ecb6c7c1ba Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:14:48 -0700 Subject: [PATCH 01/25] docs: add implementation plan for host-pressure-pane --- docs/plans/2026-08-25-host-pressure-pane.md | 941 ++++++++++++++++++++ 1 file changed, 941 insertions(+) create mode 100644 docs/plans/2026-08-25-host-pressure-pane.md diff --git a/docs/plans/2026-08-25-host-pressure-pane.md b/docs/plans/2026-08-25-host-pressure-pane.md new file mode 100644 index 000000000..3f34e4738 --- /dev/null +++ b/docs/plans/2026-08-25-host-pressure-pane.md @@ -0,0 +1,941 @@ +# Host Pressure Pane Implementation Plan + +> **For agentic workers:** Execute this plan task by task with a fresh +> implementer and a specification-plus-quality review after every task. Track +> progress with the checkbox steps below. + +**Goal:** Freshell gains a `host-stats` pane — an at-a-glance host load dashboard (CPU, memory, paging, PSI, disk I/O, network, limits, Freshell's own footprint, plus on-request heavy measurements) that costs the host nothing while no pane watches it. + +**Architecture:** One server-side collector (`server/host-stats/`) reads `/proc`+`/sys` directly on two cadence tiers (2s fast / 5s slow) ONLY while ≥1 WebSocket client is subscribed; heavier sections refresh strictly on explicit request (`hoststats.refresh`) with per-section time budgets, single-flight suppression, and previous-value retention on failure. Snapshots flow to subscribers over WS (`hoststats.snapshot`); the client renders tiles from a new `hostStatsSlice`, with an on-request group that shared-ramp desaturates (full color ≤30s → fully grey at 5min). The pane kind `host-stats` is gated by a new `hostStatsAvailable` feature flag (true on linux/wsl/darwin, false on win32). Full Rust-server parity: protocol discriminants in `crates/freshell-protocol`, collector in `crates/freshell-server/src/host_stats.rs`, flag in `build_platform_payload`. + +**Tech Stack:** TypeScript/React/Redux Toolkit client (Vitest + Testing Library), Node/Express+ws server (Vitest + raw `ws` client tests), Rust workspace (cargo test), frozen wire contract (`npm run contract:generate` + `npm run test:port`). + +## Global Constraints + +- **Test command env:** every vitest/npm `test` command in this plan MUST be prefixed with `env -u FRESHELL_BIND_HOST` — the orchestrator session exports `FRESHELL_BIND_HOST=0.0.0.0`, which fails `test/unit/vite-config.test.ts` (3 tests) by design of `getNetworkHost()`. Unprefixed runs show a false failure. +- **Repo-owned test paths only:** focused vitest via `npm run test:vitest -- run [--config config/vitest/vitest.server.config.ts]`; never raw `npx vitest`. Broad suites go through the coordinated runner (`npm test` etc.) at Stage 5's gate, not per task. +- **Server is NodeNext/ESM:** relative imports in `server/`/`shared/` must include `.js` extensions. +- **Client MUST import shared protocol with `import type`** (no zod runtime in the bundle) — `shared/ws-protocol.ts:1-8`. +- **Frozen wire contract:** any change to `shared/ws-protocol.ts` requires `npm run contract:generate` and committing the regenerated `port/contract/*` artifacts in the SAME commit; `npm run test:port` and `cargo test -p freshell-protocol --locked` must pass (the Rust inventory tests at `crates/freshell-protocol/tests/inventory.rs` hardcode type counts — update the counts with the discriminants). +- **No `WS_PROTOCOL_VERSION` bump** — additive messages follow the accept-and-strip precedent (`shared/ws-protocol.ts:376-381` comment). +- **No new runtime dependencies.** Node ≥22.5.0 (package.json engines). +- **Collector rules:** recurring paths are direct `/proc`+`/sys` reads only — NO subprocesses ever in recurring paths; NO subprocess at all on darwin except the single allowed `ps` call inside the on-request refresh. All collector timers `.unref?.()`. All per-section failures degrade that section to `{ available: false }` — never fail a whole snapshot or response. +- **Structured logging:** pino child `logger.child({ component: 'host-stats' })`, fields-first, stable `event:` snake_case keys, errors carry `{ err }` (convention: `server/index.ts:169`, `server/perf-logger.ts`). +- **A11y:** real ` + {ageText} + {refresh.error !== null ? ( +
{refresh.error}
+ ) : null} + + +
+ {renderManualTiles(manualAt === null ? null : manual)} +
+ + + + {/* One-shot completion announcement; the 1s-updating age label above is + deliberately not a live region. */} +
{announcement}
+ + ) +} + +/** Worst capped sub-limit as the Limits headline; all no-cap → em dash. */ +function limitsValue(limits: HostStatsLive['limits']): string { + const pcts: number[] = [] + if (limits.fdsUsed !== null && limits.fdsMax !== null && limits.fdsMax > 0) { + pcts.push((limits.fdsUsed / limits.fdsMax) * 100) + } + if (limits.pidsUsed !== null && limits.pidsMax !== null && limits.pidsMax > 0) { + pcts.push((limits.pidsUsed / limits.pidsMax) * 100) + } + if (limits.timeWait !== null && limits.ephemeralPorts !== null && limits.ephemeralPorts > 0) { + pcts.push((limits.timeWait / limits.ephemeralPorts) * 100) + } + return pcts.length > 0 ? formatPercent(Math.max(...pcts)) : EM_DASH +} + +/** + * On-request tiles. A null manual is the never-measured state (manualAt === + * null): every tile renders '—' placeholders. A degraded section + * (available:false inside a filled manual) renders '—' per value. + */ +function renderManualTiles(manual: HostStatsManual | null): ReactNode { + const topProcesses = manual?.topProcesses.available === true ? manual.topProcesses : null + const processHealth = manual?.processHealth.available === true ? manual.processHealth : null + const inotify = manual?.inotify.available === true ? manual.inotify : null + const disks = manual?.disks.available === true ? manual.disks : null + const thermals = manual?.thermals.available === true ? manual.thermals : null + + return ( + <> + ( +
+ {proc.name} + {formatPercent(proc.cpuPct)} + {formatBytes(proc.rssBytes)} + {proc.state} +
+ )) : null} + /> + + + + + ) : null} + /> + + ) : null} + /> + 0 + ? formatPercent(Math.max(...disks.list.map((disk) => disk.usedPct))) + : EM_DASH} + rows={disks ? disks.list.map((disk) => ( + + )) : null} + /> + 0 + ? `${Math.max(...thermals.zones.map((zone) => zone.celsius)).toFixed(1)}°C` + : EM_DASH} + rows={thermals ? ( + <> + {thermals.zones.map((zone) => ( + + ))} + + + ) : null} + /> + + ) +} diff --git a/src/components/panes/PaneContainer.tsx b/src/components/panes/PaneContainer.tsx index 259db55a9..f0c984442 100644 --- a/src/components/panes/PaneContainer.tsx +++ b/src/components/panes/PaneContainer.tsx @@ -9,6 +9,7 @@ import TerminalView from '../TerminalView' import BrowserPane from './BrowserPane' import FreshAgentView from '../fresh-agent/FreshAgentView' import ExtensionPane from './ExtensionPane' +import HostStatsPane from './HostStatsPane' import PanePicker, { type PanePickerType } from './PanePicker' import DirectoryPicker from './DirectoryPicker' import { getProviderLabel, isCodingCliProviderName } from '@/lib/coding-cli-utils' @@ -742,6 +743,8 @@ function PickerWrapper({ viewMode: 'source', wordWrap: true, } + case 'host-stats': + return { kind: 'host-stats' } default: throw new Error(`Unsupported pane type: ${String(type)}`) } @@ -885,6 +888,14 @@ function renderContent( ) } + if (content.kind === 'host-stats') { + return ( + + + + ) + } + if (content.kind === 'picker') { return ( > @@ -40,6 +40,10 @@ const nonShellOptions: PickerOption[] = [ { type: 'browser', label: 'Browser', icon: Globe, shortcut: 'B' }, ] +// Host pressure dashboard (plan-pane-types §3c): the server-derived flag +// already encodes platform support; the platform clause is belt-and-braces. +const hostStatsOption: PickerOption = { type: 'host-stats', label: 'Host Stats', icon: Gauge, shortcut: 'H' } + const EMPTY_AVAILABLE_CLIS: Record = {} const EMPTY_FEATURE_FLAGS: Record = {} const EMPTY_ENABLED_PROVIDERS: CodingCliProviderName[] = [] @@ -136,8 +140,16 @@ export default function PanePicker({ onSelect, onCancel, isOnlyPane, tabId, pane shortcut: ext.picker?.shortcut ?? '', })) - // Order: fresh-agent clients (before), CLIs, fresh-agent clients (after), Editor, Browser, Shell(s), Extensions - return [...freshAgentOptionsBeforeCli, ...cliOptions, ...freshAgentOptionsAfterCli, ...nonShellOptions, ...shellOptions, ...extensionOptions] + // Host Stats: gated on the server-advertised capability flag (which is + // false on win32) plus a direct platform clause; inserted before the + // non-shell options. First-match-wins shortcut dispatch accepts an 'H' + // collision with an H-named extension as cosmetic. + const hostStatsOptions = featureFlags.hostStatsAvailable === true && platform !== 'win32' + ? [hostStatsOption] + : [] + + // Order: fresh-agent clients (before), CLIs, fresh-agent clients (after), Host Stats, Editor, Browser, Shell(s), Extensions + return [...freshAgentOptionsBeforeCli, ...cliOptions, ...freshAgentOptionsAfterCli, ...hostStatsOptions, ...nonShellOptions, ...shellOptions, ...extensionOptions] }, [platform, availableClis, featureFlags, enabledProviders, disabledExtensions, freshClientsEnabled, extensionEntries]) const [focusedIndex, setFocusedIndex] = useState(null) diff --git a/src/lib/derivePaneTitle.ts b/src/lib/derivePaneTitle.ts index b5fc1da1d..01df9bfdb 100644 --- a/src/lib/derivePaneTitle.ts +++ b/src/lib/derivePaneTitle.ts @@ -39,6 +39,10 @@ export function derivePaneTitle(content: PaneContent, extensions?: ClientExtensi return content.extensionName } + if (content.kind === 'host-stats') { + return 'Host Stats' + } + // Terminal content — coding-agent (non-shell) terminals name by working directory if (isNonShellMode(content.mode)) { const segment = content.initialCwd ? basenameSegment(content.initialCwd) : null diff --git a/src/store/paneTreeValidation.ts b/src/store/paneTreeValidation.ts index 2fd647986..b3d37072e 100644 --- a/src/store/paneTreeValidation.ts +++ b/src/store/paneTreeValidation.ts @@ -60,6 +60,8 @@ function isPaneContentShape(content: unknown): boolean { && (content.viewMode === 'source' || content.viewMode === 'preview') case 'picker': return true + case 'host-stats': + return true case 'fresh-agent': { const sessionType = isFreshAgentSessionType(content.sessionType) ? content.sessionType : undefined const runtimeProvider = sessionType ? resolveFreshAgentRuntimeProvider(sessionType) : undefined diff --git a/src/store/paneTypes.ts b/src/store/paneTypes.ts index 529bf2461..85bd5a608 100644 --- a/src/store/paneTypes.ts +++ b/src/store/paneTypes.ts @@ -159,6 +159,15 @@ export type PickerPaneContent = { kind: 'picker' } +/** + * Host stats pane content — the host pressure dashboard (CPU/memory/PSI/IO). + * Stateless: every value lives in the connection-level hostStats slice, so a + * host-stats leaf carries no fields beyond its kind. + */ +export type HostStatsPaneContent = { + kind: 'host-stats' +} + /** SDK session statuses — richer than TerminalStatus to reflect Claude Code lifecycle */ export type SdkSessionStatus = 'creating' | 'starting' | 'connected' | 'running' | 'idle' | 'compacting' | 'exited' | 'create-failed' @@ -250,7 +259,7 @@ export type ExtensionPaneContent = { * Union type for all pane content types. */ export type PaneContent = TerminalPaneContent | BrowserPaneContent | EditorPaneContent - | PickerPaneContent | FreshAgentPaneContent | ExtensionPaneContent + | PickerPaneContent | FreshAgentPaneContent | ExtensionPaneContent | HostStatsPaneContent /** * Input type for creating terminal panes. @@ -279,7 +288,7 @@ export type FreshAgentPaneInput = Omit diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 2e3408b8b..e905650b7 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -287,6 +287,10 @@ function normalizePaneContent( if (input.kind === 'extension') { return input // Extension content passes through unchanged } + if (input.kind === 'host-stats') { + // Stateless pane kind: the bare kind is the whole persisted/runtime shape. + return { kind: 'host-stats' } + } // Editor/picker content passes through unchanged return input } diff --git a/test/unit/client/components/panes/HostStatsPane.test.tsx b/test/unit/client/components/panes/HostStatsPane.test.tsx new file mode 100644 index 000000000..c9e37bfd7 --- /dev/null +++ b/test/unit/client/components/panes/HostStatsPane.test.tsx @@ -0,0 +1,342 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, cleanup, fireEvent, act } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import panesReducer from '@/store/panesSlice' +import settingsReducer from '@/store/settingsSlice' +import connectionReducer from '@/store/connectionSlice' +import hostStatsReducer, { + failHostStatsRefresh, + hostStatsSnapshotReceived, + requestHostStatsRefresh, + resolveHostStatsRefresh, + _resetHostStatsThunkState, +} from '@/store/hostStatsSlice' +import type { HostStatsLive, HostStatsManual } from '@shared/ws-protocol' +import { derivePaneTitle } from '@/lib/derivePaneTitle' +import PaneIcon from '@/components/icons/PaneIcon' +import HostStatsPane from '@/components/panes/HostStatsPane' + +// Repo thunk pattern (hostStatsSlice.test.ts): the thunks reach the real +// '@/lib/host-stats-ws' module, which reaches the mocked ws-client. +const sendSpy = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => ({ send: sendSpy }), +})) + +function makeLive(): HostStatsLive { + return { + machine: { + cores: 8, memTotalBytes: 34_000_000_000, platform: 'linux', wsl: false, + kernel: '6.6', hostname: 'test', psi: true, cgroup: 'v2', + thermalCount: 1, batteryPresent: false, gpu: 'none', + }, + cpu: { available: true, usagePct: 10, stealPct: 0, perCorePct: [10, 20, 30, 40], freqMHz: 3400 }, + load: { available: true, load1: 0.5, load5: 0.6, load15: 0.7, cores: 8 }, + memory: { + available: true, source: 'host', totalBytes: 10_000, usedBytes: 1_000, availableBytes: 9_000, + cgroupLimitBytes: null, swapTotalBytes: 0, swapUsedBytes: 0, + }, + paging: { available: true, swapInKbps: 0, swapOutKbps: 0, majFaultsPerSec: 0, oomKillsDelta: 0, oomKillsTotal: 0 }, + psi: { available: true, cpuSome10: 0.1, memSome10: 0.2, memFull10: 0, ioSome10: 0.1, ioFull10: 0 }, + diskIo: { available: true, readBps: 0, writeBps: 0, utilPct: 1, weightedAwaitMs: 5 }, + network: { + available: true, rxBps: 0, txBps: 0, + rxErrorsTotal: 0, txErrorsTotal: 0, rxDroppedTotal: 0, txDroppedTotal: 0, + rxErrorsDelta: 0, txErrorsDelta: 0, rxDroppedDelta: 0, txDroppedDelta: 0, + }, + limits: { available: true, fdsUsed: 321, fdsMax: 0, pidsUsed: 100, pidsMax: 4_194_304, timeWait: 10, ephemeralPorts: 28_232 }, + freshell: { + available: true, source: 'node', ptysRunning: 1, ptysMax: 50, wsClients: 1, wsClientsMax: 50, + eventLoopLagP99Ms: 5, rssBytes: 1_000_000, uptimeSec: 60, + }, + } +} + +function makeManual(): HostStatsManual { + return { + topProcesses: { available: true, dwellMs: 300, list: [{ pid: 5, name: 'node', cpuPct: 12.3, rssBytes: 1e6, state: 'S' }] }, + processHealth: { available: true, zombies: 0, dState: 0, total: 900 }, + inotify: { available: true, instances: 3, watches: 420, maxUserWatches: 1_048_576, maxUserInstances: 128 }, + disks: { available: true, list: [{ mount: '/', totalBytes: 1e12, freeBytes: 5e11, usedPct: 50, inodesTotal: 1e8, inodesFree: 9e7 }] }, + thermals: { available: true, zones: [{ label: 'cpu', celsius: 51.5 }], battery: null }, + sectionErrors: {}, + } +} + +const createMockStore = () => + configureStore({ + reducer: { + panes: panesReducer, + settings: settingsReducer, + connection: connectionReducer, + hostStats: hostStatsReducer, + }, + }) + +type TestStore = ReturnType + +function renderHostStatsPane(store: TestStore = createMockStore()) { + return { + store, + ...render( + + + , + ), + } +} + +function seedLive(store: TestStore, live: HostStatsLive, at: number = 50_000) { + store.dispatch(hostStatsSnapshotReceived({ at, live, manualAt: null, manual: null })) +} + +function seedLiveAndManual(store: TestStore, live: HostStatsLive, manual: HostStatsManual, at: number) { + store.dispatch(hostStatsSnapshotReceived({ at, live, manualAt: at, manual })) +} + +const verdictStrip = () => screen.getByText((_content, el) => + el?.getAttribute('role') === 'status' && !el.classList.contains('sr-only')) +const onRequestGroup = () => + screen.getByText('ON REQUEST').closest('[data-host-stats-on-request]') as HTMLElement +const ageLabel = () => onRequestGroup().querySelector('[data-host-stats-age]') as HTMLElement +const tileValue = (tileId: string) => + document.querySelector(`[data-host-stats-tile="${tileId}"] [data-host-stats-value]`) as HTMLElement + +describe('HostStatsPane', () => { + beforeEach(() => { + sendSpy.mockClear() + }) + + afterEach(() => { + cleanup() + _resetHostStatsThunkState() + vi.useRealTimers() + }) + + describe('(a) mount subscription lifecycle', () => { + it('sends exactly one hoststats.subscribe on mount and one hoststats.unsubscribe on unmount', () => { + const { unmount } = renderHostStatsPane() + const sendsAfterMount = sendSpy.mock.calls.map(([frame]) => frame) + expect(sendsAfterMount).toEqual([{ type: 'hoststats.subscribe' }]) + + unmount() + const sendsAfterUnmount = sendSpy.mock.calls.map(([frame]) => frame) + expect(sendsAfterUnmount).toEqual([ + { type: 'hoststats.subscribe' }, + { type: 'hoststats.unsubscribe' }, + ]) + }) + + it('a second mounted pane does not re-subscribe (client-side mount refcount)', () => { + const store = createMockStore() + const first = render( + + + , + ) + const second = render( + + + , + ) + expect(sendSpy.mock.calls.map(([frame]) => frame)).toEqual([ + { type: 'hoststats.subscribe' }, + ]) + first.unmount() + expect(sendSpy.mock.calls.map(([frame]) => frame)).toHaveLength(1) + second.unmount() + expect(sendSpy.mock.calls.map(([frame]) => frame)).toEqual([ + { type: 'hoststats.subscribe' }, + { type: 'hoststats.unsubscribe' }, + ]) + }) + }) + + describe('(b) verdict strip + tile words from seeded live state', () => { + it('composes ELEVATED with offender names joined (BUSY tile at cpu 85%)', () => { + const store = createMockStore() + const live = makeLive() + live.cpu.usagePct = 85 + seedLive(store, live) + renderHostStatsPane(store) + + expect(verdictStrip()).toHaveTextContent('ELEVATED — CPU BUSY') + expect(verdictStrip().className).toContain('bg-warning/15') + const cpuTile = document.querySelector('[data-host-stats-tile="cpu"]') as HTMLElement + expect(cpuTile.querySelector('[data-host-stats-value]')).toHaveTextContent('85.0%') + // The tile pill carries the same display word the strip names. + expect(cpuTile).toHaveTextContent('BUSY') + }) + + it('composes the ok verdict with the deliberate "nothing needs attention" suffix', () => { + const store = createMockStore() + seedLive(store, makeLive()) + renderHostStatsPane(store) + + expect(verdictStrip()).toHaveTextContent('ALL GOOD — nothing needs attention') + expect(verdictStrip().className).toContain('bg-success/15') + }) + + it('composes TROUBLE with bad offenders first', () => { + const store = createMockStore() + const live = makeLive() + live.cpu.usagePct = 99 // maxed (bad) + live.memory.usedBytes = 9_000 // 90% of 10_000 → tight (warn) + seedLive(store, live) + renderHostStatsPane(store) + + expect(verdictStrip()).toHaveTextContent('TROUBLE — CPU MAXED · MEMORY TIGHT') + expect(verdictStrip().className).toContain('bg-destructive/10') + }) + + it('a *Max === 0 (no-cap convention) renders as —, never a zero', () => { + const store = createMockStore() + seedLive(store, makeLive()) // makeLive has fdsMax: 0 + renderHostStatsPane(store) + + const limitsTile = document.querySelector('[data-host-stats-tile="limits"]') as HTMLElement + expect(limitsTile).toHaveTextContent('fds') + expect(limitsTile.textContent).toContain('—') + // fdsUsed is 321 in the fixture; a rendered cap would show it — the — must not. + expect(limitsTile.textContent).not.toContain('321') + }) + }) + + describe('(c) manualAt === null → neutral on-request group', () => { + it('renders the on-request group at saturate(0) with an empty age label', () => { + renderHostStatsPane() + + expect(onRequestGroup().style.filter).toBe('saturate(0)') + expect(ageLabel()).toHaveTextContent('') + }) + + it('pre-first-snapshot frame: strip and tile values are neutral placeholders, never bright green ALL GOOD', () => { + renderHostStatsPane() + + // No live snapshot yet — the strip must NOT claim ALL GOOD (nit: zeros + // would lie); it renders a neutral grey '—' instead. + expect(verdictStrip()).toHaveTextContent('—') + expect(verdictStrip().textContent).not.toContain('ALL GOOD') + expect(verdictStrip().className).toContain('bg-muted') + expect(tileValue('cpu')).toHaveTextContent('—') + expect(tileValue('disks')).toHaveTextContent('—') + }) + }) + + describe('(d) desaturation ramp against server-now', () => { + it('fresh manual renders saturate(1); after 60s the group moves toward grey', () => { + vi.useFakeTimers({ now: 1_000_000 }) + const store = createMockStore() + seedLiveAndManual(store, makeLive(), makeManual(), 1_000_000) + renderHostStatsPane(store) + + expect(onRequestGroup().style.filter).toBe('saturate(1)') + expect(ageLabel()).toHaveTextContent('just now') + + act(() => { + vi.advanceTimersByTime(60_000) + }) + + // 60s old: 1 - (60_000-30_000)/270_000 = 0.888… (past the full-color floor, + // not yet grey) — recomputed by the pane-local 1s interval. + const match = onRequestGroup().style.filter.match(/^saturate\(([\d.]+)\)$/) + expect(match).not.toBeNull() + const sat = Number(match![1]) + expect(sat).toBeGreaterThan(0.8) + expect(sat).toBeLessThan(1) + expect(sat).toBeCloseTo(1 - 30_000 / 270_000, 5) + expect(ageLabel()).toHaveTextContent('updated 1m 0s ago') + }) + + it('a manual older than 5 minutes renders fully grey (saturate(0))', () => { + vi.useFakeTimers({ now: 1_000_000 }) + const store = createMockStore() + // A fresh snapshot (at=now) can carry an older manual (on-request + // measurements age independently of the live cadence). + store.dispatch(hostStatsSnapshotReceived({ + at: 1_000_000, + live: makeLive(), + manualAt: 1_000_000 - 301_000, + manual: makeManual(), + })) + renderHostStatsPane(store) + + expect(onRequestGroup().style.filter).toBe('saturate(0)') + expect(ageLabel()).toHaveTextContent('updated 5m 1s ago') + }) + }) + + describe('(e) refresh interaction', () => { + it('click sends hoststats.refresh with an hsr- requestId and shows the Collecting state', () => { + const store = createMockStore() + seedLiveAndManual(store, makeLive(), makeManual(), 42_000) + renderHostStatsPane(store) + + const button = screen.getByRole('button', { name: 'Refresh on-request measurements' }) + fireEvent.click(button) + + const refreshFrames = sendSpy.mock.calls + .map(([frame]) => frame as { type?: string; requestId?: string }) + .filter((frame) => frame.type === 'hoststats.refresh') + expect(refreshFrames).toHaveLength(1) + expect(refreshFrames[0].requestId).toMatch(/^hsr-\d+-[a-z0-9]+$/) + expect(button).toBeDisabled() + expect(button).toHaveTextContent('Collecting…') + }) + + it('failure shows role=alert and preserves the old manual values + age (no visual blanking)', () => { + const store = createMockStore() + seedLiveAndManual(store, makeLive(), makeManual(), 42_000) + renderHostStatsPane(store) + + fireEvent.click(screen.getByRole('button', { name: 'Refresh on-request measurements' })) + const requestId = store.getState().hostStats.refresh.requestId! + act(() => { + store.dispatch(failHostStatsRefresh({ requestId, error: 'server exploded' }) as any) + }) + + expect(screen.getByRole('alert')).toHaveTextContent('server exploded') + expect(screen.getByRole('button', { name: 'Refresh on-request measurements' })).toBeEnabled() + // Old values AND the original manualAt stay rendered (slice guarantee, visually pinned). + expect(document.querySelector('[data-host-stats-tile="top-processes"]')).toHaveTextContent('node') + expect(ageLabel().textContent).toMatch(/updated .*ago|just now/) + }) + + it('resolution announces "Measurements refreshed" once via a sr-only role=status, cleared on the next tick', () => { + vi.useFakeTimers({ now: 1_000_000 }) + const store = createMockStore() + seedLive(store, makeLive(), 1_000_000) + renderHostStatsPane(store) + + act(() => { + store.dispatch(requestHostStatsRefresh() as any) + }) + const requestId = store.getState().hostStats.refresh.requestId! + act(() => { + store.dispatch(resolveHostStatsRefresh({ requestId, at: 1_000_000, manual: makeManual() }) as any) + }) + + const announcer = () => screen.getAllByRole('status').find((el) => el.classList.contains('sr-only')) + expect(announcer()).toHaveTextContent('Measurements refreshed') + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(announcer()).toHaveTextContent('') + }) + }) + + describe('(f) title + icon helpers', () => { + it('derivePaneTitle returns Host Stats for host-stats content', () => { + expect(derivePaneTitle({ kind: 'host-stats' })).toBe('Host Stats') + }) + + it('PaneIcon renders the Gauge icon for host-stats content', () => { + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).not.toBeNull() + expect(svg!.getAttribute('class')).toContain('lucide-gauge') + }) + }) +}) diff --git a/test/unit/client/components/panes/PaneContainer.createContent.test.tsx b/test/unit/client/components/panes/PaneContainer.createContent.test.tsx index 8337a0122..3a844769a 100644 --- a/test/unit/client/components/panes/PaneContainer.createContent.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.createContent.test.tsx @@ -10,6 +10,7 @@ import connectionReducer from '@/store/connectionSlice' import terminalMetaReducer from '@/store/terminalMetaSlice' import turnCompletionReducer from '@/store/turnCompletionSlice' import extensionsReducer from '@/store/extensionsSlice' +import hostStatsReducer from '@/store/hostStatsSlice' import type { PanesState } from '@/store/panesSlice' import type { PaneNode } from '@/store/paneTypes' import type { ClientExtensionEntry } from '@shared/extension-types' @@ -62,6 +63,7 @@ vi.mock('lucide-react', () => ({ Code: ({ className }: { className?: string }) => , FileText: ({ className }: { className?: string }) => , LayoutGrid: ({ className }: { className?: string }) => , + Gauge: ({ className }: { className?: string }) => , Maximize2: ({ className }: { className?: string }) => , Minimize2: ({ className }: { className?: string }) => , Pencil: ({ className }: { className?: string }) => , @@ -137,6 +139,7 @@ function createStore( terminalMeta: terminalMetaReducer, turnCompletion: turnCompletionReducer, extensions: extensionsReducer, + hostStats: hostStatsReducer, }, preloadedState: { panes: { @@ -543,4 +546,48 @@ describe('createContentForType with ext: prefix', () => { expect(paneContent.kind).toBe('editor') }) }) + + it('creates host-stats content when the host stats option is selected', async () => { + const node = createPickerNode('pane-1') + const store = createStore( + { layouts: { 'tab-1': node }, activePane: { 'tab-1': 'pane-1' } }, + [], + {}, + { status: 'ready', platform: 'linux', featureFlags: { hostStatsAvailable: true } }, + ) + + render( + + + , + ) + + const hostStatsButton = document.querySelector('[aria-label="Host Stats"]') as HTMLElement + expect(hostStatsButton).not.toBeNull() + fireEvent.click(hostStatsButton) + fireEvent.transitionEnd(getPickerContainer()) + + await waitFor(() => { + const paneContent = (store.getState().panes.layouts['tab-1'] as Extract).content + expect(paneContent).toEqual({ kind: 'host-stats' }) + }) + }) + + it('does not offer host stats when the feature flag is off', () => { + const node = createPickerNode('pane-1') + const store = createStore( + { layouts: { 'tab-1': node }, activePane: { 'tab-1': 'pane-1' } }, + [], + {}, + { status: 'ready', platform: 'linux', featureFlags: {} }, + ) + + render( + + + , + ) + + expect(document.querySelector('[aria-label="Host Stats"]')).toBeNull() + }) }) diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index fc0c737a9..bfd0aa795 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -143,6 +143,9 @@ vi.mock('lucide-react', () => ({ LayoutGrid: ({ className }: { className?: string }) => ( ), + Gauge: ({ className }: { className?: string }) => ( + + ), Maximize2: ({ className }: { className?: string }) => ( ), diff --git a/test/unit/client/components/panes/PaneLayout.test.tsx b/test/unit/client/components/panes/PaneLayout.test.tsx index 872373c8d..a2bc776f6 100644 --- a/test/unit/client/components/panes/PaneLayout.test.tsx +++ b/test/unit/client/components/panes/PaneLayout.test.tsx @@ -60,6 +60,9 @@ vi.mock('lucide-react', () => ({ LayoutGrid: ({ className }: { className?: string }) => ( ), + Gauge: ({ className }: { className?: string }) => ( + + ), Eye: ({ className }: { className?: string }) => ( ), diff --git a/test/unit/client/components/panes/PanePicker.test.tsx b/test/unit/client/components/panes/PanePicker.test.tsx index df3e94cf2..4d48ce925 100644 --- a/test/unit/client/components/panes/PanePicker.test.tsx +++ b/test/unit/client/components/panes/PanePicker.test.tsx @@ -48,6 +48,9 @@ vi.mock('lucide-react', () => ({ LayoutGrid: ({ className }: { className?: string }) => ( ), + Gauge: ({ className }: { className?: string }) => ( + + ), })) function createStore(overrides?: { @@ -672,6 +675,47 @@ describe('PanePicker', () => { }) }) + // Host Stats option gating (mirrors 'platform-specific shell options'): + // gate = featureFlags.hostStatsAvailable === true && platform !== 'win32'. + describe('host stats pane option', () => { + it('hides Host Stats when the hostStatsAvailable feature flag is absent', () => { + renderPicker({ platform: 'linux' }) + expect(screen.queryByRole('button', { name: 'Host Stats' })).not.toBeInTheDocument() + }) + + it('hides Host Stats when hostStatsAvailable is false', () => { + renderPicker({ platform: 'linux', featureFlags: { hostStatsAvailable: false } }) + expect(screen.queryByRole('button', { name: 'Host Stats' })).not.toBeInTheDocument() + }) + + it('hides Host Stats on win32 even when the flag is true', () => { + renderPicker({ platform: 'win32', featureFlags: { hostStatsAvailable: true } }) + expect(screen.queryByRole('button', { name: 'Host Stats' })).not.toBeInTheDocument() + // Sanity: the platform-specific windows shells still render in this state. + expect(screen.getByText('PowerShell')).toBeInTheDocument() + }) + + it('shows Host Stats with an accessible button name and Gauge icon when the flag is true on linux', () => { + renderPicker({ platform: 'linux', featureFlags: { hostStatsAvailable: true } }) + expect(screen.getByRole('button', { name: 'Host Stats' })).toBeInTheDocument() + expect(screen.getByTestId('gauge-icon')).toBeInTheDocument() + }) + + it('calls onSelect with host-stats when Host Stats is clicked', () => { + const { onSelect } = renderPicker({ platform: 'linux', featureFlags: { hostStatsAvailable: true } }) + fireEvent.click(screen.getByRole('button', { name: 'Host Stats' })) + completeFadeAnimation() + expect(onSelect).toHaveBeenCalledWith('host-stats') + }) + + it('uses the H shortcut for Host Stats', () => { + const { onSelect } = renderPicker({ platform: 'linux', featureFlags: { hostStatsAvailable: true } }) + fireEvent.keyDown(getContainer(), { key: 'h' }) + completeFadeAnimation() + expect(onSelect).toHaveBeenCalledWith('host-stats') + }) + }) + describe('auto-focus on mount', () => { it('focuses the picker container on mount', () => { renderPicker() diff --git a/test/unit/client/store/panesPersistence.test.ts b/test/unit/client/store/panesPersistence.test.ts index 2eabd0695..538bcd8b2 100644 --- a/test/unit/client/store/panesPersistence.test.ts +++ b/test/unit/client/store/panesPersistence.test.ts @@ -28,6 +28,7 @@ import { resetPersistedLayoutCacheForTests, } from '../../../../src/store/persistMiddleware' import { PANES_SCHEMA_VERSION } from '../../../../src/store/persistedState' +import { isWellFormedPaneTree } from '../../../../src/store/paneTreeValidation' describe('Panes Persistence Integration', () => { beforeEach(() => { @@ -532,6 +533,59 @@ describe('Panes Persistence Integration', () => { expect(restored.crashTrace).toEqual({ exitCode: 1, resumedAtMs: 1_753_760_220_000 }) }) + it('round-trips a host-stats leaf: {kind:"host-stats"} normalizes bare and survives reload validation', () => { + const store1 = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + }, + middleware: (getDefault) => getDefault().concat(persistMiddleware as any), + }) + + store1.dispatch(addTab({ mode: 'shell' })) + const tabId = store1.getState().tabs.tabs[0].id + store1.dispatch(initLayout({ tabId, content: { kind: 'host-stats' } as any })) + + // normalize: the bare shape stays exactly {kind:'host-stats'} (no minted + // lifecycle fields), and the derived pane title is the fixed label. + const createdLayout = store1.getState().panes.layouts[tabId] as any + expect(createdLayout.type).toBe('leaf') + expect(createdLayout.content).toEqual({ kind: 'host-stats' }) + const createdPaneId = createdLayout.id + expect(store1.getState().panes.paneTitles[tabId][createdPaneId]).toBe('Host Stats') + + vi.runAllTimers() + + // The raw persisted bytes carry exactly the bare content. + const rawLayout = JSON.parse(localStorage.getItem('freshell.layout.v3')!) + expect(rawLayout.panes.layouts[tabId].content).toEqual({ kind: 'host-stats' }) + // Tree-validation round-trip: the persisted leaf must pass the reload gate + // (a missing isPaneContentShape case silently DROPS the pane on reload). + expect(isWellFormedPaneTree(rawLayout.panes.layouts[tabId])).toBe(true) + + const persistedTabs = loadPersistedTabs() + const persistedPanes = loadPersistedPanes() + + const store2 = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + }, + middleware: (getDefault) => getDefault().concat(persistMiddleware as any), + }) + if (persistedTabs?.tabs) { + store2.dispatch(hydrateTabs(persistedTabs.tabs)) + } + if (persistedPanes) { + store2.dispatch(hydratePanes(persistedPanes)) + } + + const restoredLayout = store2.getState().panes.layouts[tabId] as any + expect(restoredLayout).toBeDefined() + expect(restoredLayout.type).toBe('leaf') + expect(restoredLayout.content).toEqual({ kind: 'host-stats' }) + }) + it('flushes pending writes on visibility change', () => { const store = configureStore({ reducer: { From 9ba52b56f956e1379aeac6aa216cc690b95147d9 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:21:48 -0700 Subject: [PATCH 15/25] feat(host-stats): registry, REST, and MCP surface for host-stats panes Widen RegistryPaneKindSchema (+Node/Rust mirrors) with 'host-stats' so tabs.sync registry records carrying host-stats panes round-trip (resolves the known typecheck:client error at tab-registry-snapshot.ts:87), and expose hostStats: true on POST /api/tabs, POST /api/panes/:id/split, and MCP new-tab/split-pane as a cheap content kind (no terminal spawned), mirroring the browser/editor precedents in both servers. --- .../src/layout_store_content.rs | 1 + .../src/layout_store_tests.rs | 3 ++ crates/freshell-freshagent/src/pane_ops.rs | 11 ++++++- .../freshell-freshagent/src/pane_ops_tests.rs | 18 ++++++++++ .../freshell-freshagent/src/terminal_tabs.rs | 33 +++++++++++++++++++ crates/freshell-ws/src/tabs_persist_tests.rs | 1 + .../src/tabs_persist_validation.rs | 3 +- crates/freshell-ws/src/tabs_store_model.rs | 5 +-- server/agent-api/layout-store.ts | 12 +++++-- server/agent-api/router.ts | 23 ++++++++----- server/mcp/freshell-tool.ts | 5 +-- server/tabs-registry/types.ts | 1 + src/lib/tab-registry-open.ts | 7 ++++ src/lib/tab-registry-snapshot.ts | 2 ++ test/server/agent-panes-write.test.ts | 22 +++++++++++++ test/server/agent-tabs-write.test.ts | 26 +++++++++++++++ .../unit/client/lib/tab-registry-open.test.ts | 9 +++++ test/unit/server/mcp/freshell-tool.test.ts | 26 +++++++++++++++ test/unit/server/tabs-registry/types.test.ts | 25 ++++++++++++++ 19 files changed, 216 insertions(+), 17 deletions(-) diff --git a/crates/freshell-freshagent/src/layout_store_content.rs b/crates/freshell-freshagent/src/layout_store_content.rs index 63c8c3c35..6293ba52f 100644 --- a/crates/freshell-freshagent/src/layout_store_content.rs +++ b/crates/freshell-freshagent/src/layout_store_content.rs @@ -58,6 +58,7 @@ pub fn derive_pane_title(content: &Value) -> String { .filter(|name| !name.is_empty()) .unwrap_or("Extension") .to_string(), + "host-stats" => "Host Stats".to_string(), "terminal" => match obj.get("mode").and_then(Value::as_str) { Some("claude") => "Claude CLI".to_string(), Some("codex") => "Codex CLI".to_string(), diff --git a/crates/freshell-freshagent/src/layout_store_tests.rs b/crates/freshell-freshagent/src/layout_store_tests.rs index 14716acdd..7b8f48dd7 100644 --- a/crates/freshell-freshagent/src/layout_store_tests.rs +++ b/crates/freshell-freshagent/src/layout_store_tests.rs @@ -482,6 +482,9 @@ fn derive_pane_title_full_matrix() { ); assert_eq!(derive_pane_title(&json!({ "kind": "terminal" })), "Shell"); + // host-stats -> fixed title (stateless pane; plan Task 8 arm) + assert_eq!(derive_pane_title(&json!({ "kind": "host-stats" })), "Host Stats"); + // non-terminal unknown kinds and non-objects -> no title (Node: undefined) assert_eq!(derive_pane_title(&json!({ "kind": "picker" })), ""); assert_eq!(derive_pane_title(&json!(null)), ""); diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index c0e879a19..92a2f9a8d 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -188,7 +188,16 @@ pub(crate) async fn split_pane( Err(_) => return approx_json(Value::Null, "pane split requested; not applied"), }; - let new_content = if let Some(url) = body.get("browser").and_then(Value::as_str) { + let new_content = if body.get("hostStats").and_then(Value::as_bool).unwrap_or(false) { + // Stateless cheap content kind (router.ts `wantsHostStats` split branch). + let content = json!({ "kind": "host-stats" }); + state + .content_panes + .lock() + .expect("content_panes mutex") + .insert(new_pane_id.clone(), content.clone()); + content + } else if let Some(url) = body.get("browser").and_then(Value::as_str) { let content = json!({ "kind": "browser", "url": url, diff --git a/crates/freshell-freshagent/src/pane_ops_tests.rs b/crates/freshell-freshagent/src/pane_ops_tests.rs index f61881f8c..ce8eef4dd 100644 --- a/crates/freshell-freshagent/src/pane_ops_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tests.rs @@ -245,6 +245,24 @@ async fn split_browser_pane_registers_cheap_content_no_terminal() { assert_eq!(body["message"], json!("pane split (non-terminal)")); } +#[tokio::test] +async fn split_host_stats_pane_registers_cheap_content_no_terminal() { + let state = state_with_registry(); + let router = app(state.clone()); + let (_tab_id, pane_id, _terminal_id) = create_shell_tab(router.clone()).await; + + let (status, body) = post( + router, + &format!("/api/panes/{pane_id}/split"), + json!({ "hostStats": true }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body["data"]["terminalId"].is_null()); + assert_eq!(body["message"], json!("pane split (non-terminal)")); +} + /// kata ejh6: `POST /api/panes/:id/split` REFUSES a body carrying the legacy /// `resumeSessionId` field at the door-top — 400 with the frozen text, /// presence-based for EVERY JSON value type, and (finding 3) the layout must diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index 9a94bbe20..390b51886 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -200,6 +200,17 @@ async fn create_terminal_or_content_tab_with_delivery( .and_then(Value::as_str) .map(str::to_string); + // `hostStats: true` -> stateless host-stats pane (router.ts `wantsHostStats` + // branch before browser): no process, no terminal admission. + if body.get("hostStats").and_then(Value::as_bool).unwrap_or(false) { + return create_content_tab( + &state, + name, + json!({ "kind": "host-stats" }), + restore_key.as_deref(), + broadcast, + ); + } if let Some(url) = body.get("browser").and_then(Value::as_str) { // `devToolsOpen` flows into the frozen client verbatim via // `paneContent` (ui-commands.ts `tab.create` -> initLayout), so a @@ -3179,6 +3190,28 @@ mod tests { assert!(body["data"]["tabId"].as_str().is_some()); } + #[tokio::test] + async fn create_host_stats_tab_attaches_host_stats_pane_content_and_no_terminal() { + let state = state_with_registry(); + let mut rx = state.broadcast_tx.subscribe(); + let (status, body) = post( + app(state), + "/api/tabs", + json!({ "hostStats": true }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(body["data"]["tabId"].as_str().is_some()); + assert!(body["data"]["paneId"].as_str().is_some()); + assert!(body["data"].get("terminalId").is_none()); + + let frame = rx.recv().await.expect("ui.command frame broadcast"); + let msg: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(msg["command"], json!("tab.create")); + assert_eq!(msg["payload"]["paneContent"]["kind"], json!("host-stats")); + } + // ── GET /api/tabs ──────────────────────────────────────────────────────── #[tokio::test] diff --git a/crates/freshell-ws/src/tabs_persist_tests.rs b/crates/freshell-ws/src/tabs_persist_tests.rs index 15827a2d0..0bcbd12c8 100644 --- a/crates/freshell-ws/src/tabs_persist_tests.rs +++ b/crates/freshell-ws/src/tabs_persist_tests.rs @@ -1196,6 +1196,7 @@ fn every_supported_pane_kind_passes_semantic_generation_validation() { "sandbox": "workspace-write", "style": "sans" } }, { "paneId": "extension", "kind": "extension", "payload": { "extensionName": "demo", "props": {} } }, + { "paneId": "hoststats", "kind": "host-stats", "payload": {} }, { "paneId": "picker", "kind": "picker", "payload": {} } ]); put(dir.path(), "dev", "c1", 1, 1000, vec![record]); diff --git a/crates/freshell-ws/src/tabs_persist_validation.rs b/crates/freshell-ws/src/tabs_persist_validation.rs index 8c09c2bd1..0aad2d2ee 100644 --- a/crates/freshell-ws/src/tabs_persist_validation.rs +++ b/crates/freshell-ws/src/tabs_persist_validation.rs @@ -507,10 +507,11 @@ fn validate_pane( "fresh-agent" => validate_fresh_agent(path, payload, &payload_name), "extension" => validate_extension(path, payload, &payload_name), "picker" => Ok(()), + "host-stats" => Ok(()), _ => Err(invalid( path, &format!("{name}.kind"), - "one of terminal, browser, editor, fresh-agent, extension, or picker", + "one of terminal, browser, editor, fresh-agent, extension, picker, or host-stats", )), } } diff --git a/crates/freshell-ws/src/tabs_store_model.rs b/crates/freshell-ws/src/tabs_store_model.rs index 8615640a0..9f913b211 100644 --- a/crates/freshell-ws/src/tabs_store_model.rs +++ b/crates/freshell-ws/src/tabs_store_model.rs @@ -245,12 +245,13 @@ pub fn archive_timestamp(now_ms: i64) -> String { // ── Record validation (TabRegistryRecordSchema port, types.ts:57-83) ───────── -/// The seven legal pane kinds (`RegistryPaneKindSchema`, types.ts:7-15). -const PANE_KINDS: [&str; 7] = [ +/// The eight legal pane kinds (`RegistryPaneKindSchema`, types.ts:7-16). +const PANE_KINDS: [&str; 8] = [ "terminal", "browser", "editor", "picker", + "host-stats", "claude-chat", "fresh-agent", "extension", diff --git a/server/agent-api/layout-store.ts b/server/agent-api/layout-store.ts index 680c22a36..a3d2e011e 100644 --- a/server/agent-api/layout-store.ts +++ b/server/agent-api/layout-store.ts @@ -314,7 +314,10 @@ export class LayoutStore { return null } - private buildContent(opts: { terminalId?: string; browser?: string; editor?: string }) { + private buildContent(opts: { terminalId?: string; browser?: string; editor?: string; hostStats?: boolean }) { + if (opts.hostStats) { + return { kind: 'host-stats' } + } if (opts.browser) { return { kind: 'browser', url: opts.browser, devToolsOpen: false } } @@ -433,6 +436,7 @@ export class LayoutStore { terminalId, browser, editor, + hostStats, tabId, paneId, }: { @@ -440,13 +444,14 @@ export class LayoutStore { terminalId?: string browser?: string editor?: string + hostStats?: boolean tabId?: string paneId?: string }) { const snapshot = this.ensureSnapshot() const resolvedTabId = tabId ?? nanoid() const resolvedPaneId = paneId ?? nanoid() - const content = this.buildContent({ terminalId, browser, editor }) + const content = this.buildContent({ terminalId, browser, editor, hostStats }) snapshot.tabs.push({ id: resolvedTabId, title }) snapshot.layouts[resolvedTabId] = { type: 'leaf', @@ -465,6 +470,7 @@ export class LayoutStore { terminalId?: string browser?: string editor?: string + hostStats?: boolean newPaneId?: string }) { const snapshot = this.ensureSnapshot() @@ -475,7 +481,7 @@ export class LayoutStore { if (!leaves.find((leaf) => leaf.id === opts.paneId)) continue const newPaneId = opts.newPaneId ?? nanoid() - const newContent = this.buildContent({ terminalId: opts.terminalId, browser: opts.browser, editor: opts.editor }) + const newContent = this.buildContent({ terminalId: opts.terminalId, browser: opts.browser, editor: opts.editor, hostStats: opts.hostStats }) const splitNode = { type: 'split', id: nanoid(), diff --git a/server/agent-api/router.ts b/server/agent-api/router.ts index ce9fa4514..34c8d5bbb 100644 --- a/server/agent-api/router.ts +++ b/server/agent-api/router.ts @@ -689,7 +689,7 @@ export function createAgentApiRouter({ if (req.body?.resumeSessionId !== undefined) { return res.status(400).json(fail(INVALID_RAW_CODEX_RESUME_MESSAGE)) } - const { name, mode, shell, cwd, browser, editor, resumeSessionId, permissionMode, model, sandbox } = req.body || {} + const { name, mode, shell, cwd, browser, editor, hostStats, resumeSessionId, permissionMode, model, sandbox } = req.body || {} const requestedSessionRef = sanitizeSessionRef(req.body?.sessionRef) if (typeof req.body?.agent === 'string') { const handled = await createFreshAgentPane( @@ -703,6 +703,7 @@ export function createAgentApiRouter({ ).catch((err: any) => { res.status(agentRouteErrorStatus(err)).json(fail(err?.message || 'Failed to create fresh-agent tab')); return true }) if (handled) return } + const wantsHostStats = !!hostStats const wantsBrowser = !!browser const wantsEditor = !!editor let launch: ResolvedSpawnProviderSettings | undefined @@ -713,7 +714,9 @@ export function createAgentApiRouter({ let paneContent: any let terminalId: string | undefined - if (wantsBrowser) { + if (wantsHostStats) { + paneContent = { kind: 'host-stats' } + } else if (wantsBrowser) { paneContent = { kind: 'browser', url: browser, devToolsOpen: false } } else if (wantsEditor) { paneContent = { kind: 'editor', filePath: editor, language: null, readOnly: false, content: '', viewMode: 'source', wordWrap: true } @@ -733,7 +736,7 @@ export function createAgentApiRouter({ { cwd, resumeSessionId: requestedResumeSessionId, codexLaunchPlanner, assertTerminalCreateAccepted: assertTerminalAdmission }, ) assertTerminalAdmission() - const { tabId, paneId } = layoutStore.createTab({ title: name, browser, editor }) + const { tabId, paneId } = layoutStore.createTab({ title: name, browser, editor, hostStats }) createdTabId = tabId const sessionBindingReason = getCodexSessionBindingReason(effectiveMode, requestedResumeSessionId) assertTerminalAdmission() @@ -789,7 +792,7 @@ export function createAgentApiRouter({ return } - const { tabId, paneId } = layoutStore.createTab({ title: name, browser, editor }) + const { tabId, paneId } = layoutStore.createTab({ title: name, browser, editor, hostStats }) createdTabId = tabId layoutStore.attachPaneContent(tabId, paneId, paneContent) @@ -1284,9 +1287,10 @@ export function createAgentApiRouter({ if (handled) return } const direction = req.body?.direction || 'horizontal' + const wantsHostStats = !!req.body?.hostStats const wantsBrowser = !!req.body?.browser const wantsEditor = !!req.body?.editor - const splitMode = !wantsBrowser && !wantsEditor ? req.body?.mode || 'shell' : undefined + const splitMode = !wantsHostStats && !wantsBrowser && !wantsEditor ? req.body?.mode || 'shell' : undefined const requestedSessionRef = splitMode ? sanitizeSessionRef(req.body?.sessionRef) : undefined const acceptedSessionRef = splitMode ? acceptedSessionRefForMode(requestedSessionRef, splitMode) @@ -1298,7 +1302,7 @@ export function createAgentApiRouter({ req.body?.resumeSessionId, ) : undefined - if (!wantsBrowser && !wantsEditor) { + if (!wantsHostStats && !wantsBrowser && !wantsEditor) { assertTerminalAdmission() } @@ -1307,6 +1311,7 @@ export function createAgentApiRouter({ direction, browser: wantsBrowser ? req.body?.browser : undefined, editor: wantsEditor ? req.body?.editor : undefined, + hostStats: wantsHostStats ? true : undefined, }) if (!result?.tabId || !result?.newPaneId) { @@ -1319,7 +1324,9 @@ export function createAgentApiRouter({ let content: any let terminalId: string | undefined - if (wantsBrowser) { + if (wantsHostStats) { + content = { kind: 'host-stats' } + } else if (wantsBrowser) { content = { kind: 'browser', url: req.body.browser, devToolsOpen: false } } else if (wantsEditor) { content = { kind: 'editor', filePath: req.body.editor, language: null, readOnly: false, content: '', viewMode: 'source', wordWrap: true } @@ -1381,7 +1388,7 @@ export function createAgentApiRouter({ }, }) - const message = wantsBrowser || wantsEditor ? 'pane split (non-terminal)' : 'pane split' + const message = wantsHostStats || wantsBrowser || wantsEditor ? 'pane split (non-terminal)' : 'pane split' createdTerminalId = undefined res.json(ok({ paneId: newPaneId, terminalId }, message)) } catch (err: any) { diff --git a/server/mcp/freshell-tool.ts b/server/mcp/freshell-tool.ts index a88b20913..05ad6dcbb 100644 --- a/server/mcp/freshell-tool.ts +++ b/server/mcp/freshell-tool.ts @@ -302,7 +302,7 @@ async function handleDisplay(format: string, target?: string): Promise { // --------------------------------------------------------------------------- const ACTION_PARAMS: Record = { - 'new-tab': { required: [], optional: ['name', 'mode', 'shell', 'cwd', 'browser', 'editor', 'resume', 'resumeSessionId', 'sessionRef', 'prompt', 'agent', 'model', 'effort'] }, + 'new-tab': { required: [], optional: ['name', 'mode', 'shell', 'cwd', 'browser', 'editor', 'hostStats', 'resume', 'resumeSessionId', 'sessionRef', 'prompt', 'agent', 'model', 'effort'] }, 'list-tabs': { required: [], optional: [] }, 'select-tab': { required: ['target'], optional: [] }, 'kill-tab': { required: ['target'], optional: [] }, @@ -310,7 +310,7 @@ const ACTION_PARAMS: Record 'has-tab': { required: ['target'], optional: [] }, 'next-tab': { required: [], optional: [] }, 'prev-tab': { required: [], optional: [] }, - 'split-pane': { required: [], optional: ['target', 'direction', 'mode', 'shell', 'cwd', 'browser', 'editor', 'resume', 'sessionRef', 'agent', 'model', 'effort'] }, + 'split-pane': { required: [], optional: ['target', 'direction', 'mode', 'shell', 'cwd', 'browser', 'editor', 'hostStats', 'resume', 'sessionRef', 'agent', 'model', 'effort'] }, 'list-panes': { required: [], optional: ['target'] }, 'select-pane': { required: ['target'], optional: [] }, 'rename-pane': { required: ['name'], optional: ['target'] }, @@ -455,6 +455,7 @@ Pane commands: resize-pane Resize a pane. Params: target, x? (1-99), y? (1-99) swap-pane Swap two panes. Params: target, with (other pane ID) respawn-pane Restart a pane's terminal. Params: target, mode?, shell?, cwd?, resume?, sessionRef? + hostStats Pass hostStats: true on new-tab or split-pane to create a Host Stats pane (CPU/RAM/load metrics) instead of a terminal -- no process is spawned. Terminal I/O: send-keys Send input to a pane. Params: target, keys, literal?, sessionRef? diff --git a/server/tabs-registry/types.ts b/server/tabs-registry/types.ts index 122e8caa4..b4d79efe8 100644 --- a/server/tabs-registry/types.ts +++ b/server/tabs-registry/types.ts @@ -9,6 +9,7 @@ export const RegistryPaneKindSchema = z.enum([ 'browser', 'editor', 'picker', + 'host-stats', 'claude-chat', 'fresh-agent', 'extension', diff --git a/src/lib/tab-registry-open.ts b/src/lib/tab-registry-open.ts index 7836021b4..04f1a687f 100644 --- a/src/lib/tab-registry-open.ts +++ b/src/lib/tab-registry-open.ts @@ -6,6 +6,7 @@ import { nanoid } from 'nanoid' import { Bot, FileCode2, + Gauge, Globe, Square, TerminalSquare, @@ -177,6 +178,9 @@ export function sanitizePaneSnapshot( props: (payload.props as Record) || {}, } } + if (snapshot.kind === 'host-stats') { + return { kind: 'host-stats' } + } return { kind: 'picker' } } @@ -200,6 +204,7 @@ export function paneKindIcon(kind: RegistryPaneSnapshot['kind']): LucideIcon { if (kind === 'browser') return Globe if (kind === 'editor') return FileCode2 if (kind === 'fresh-agent') return Bot + if (kind === 'host-stats') return Gauge return Square } @@ -209,6 +214,7 @@ export function paneKindColorClass(kind: RegistryPaneSnapshot['kind']): string { if (kind === 'editor') return 'text-emerald-500' if (kind === 'fresh-agent' || kind === 'claude-chat') return 'text-amber-500' if (kind === 'extension') return 'text-purple-500' + if (kind === 'host-stats') return 'text-cyan-500' return 'text-muted-foreground' } @@ -218,6 +224,7 @@ export function paneKindLabel(kind: RegistryPaneSnapshot['kind']): string { if (kind === 'editor') return 'Editor' if (kind === 'fresh-agent' || kind === 'claude-chat') return 'Agent' if (kind === 'extension') return 'Extension' + if (kind === 'host-stats') return 'Host Stats' return kind } diff --git a/src/lib/tab-registry-snapshot.ts b/src/lib/tab-registry-snapshot.ts index 05b979a60..ad14f540a 100644 --- a/src/lib/tab-registry-snapshot.ts +++ b/src/lib/tab-registry-snapshot.ts @@ -67,6 +67,8 @@ function stripPanePayload(content: PaneContent, serverInstanceId: string): Recor extensionName: content.extensionName, props: content.props, } + case 'host-stats': + return {} case 'picker': default: return {} diff --git a/test/server/agent-panes-write.test.ts b/test/server/agent-panes-write.test.ts index b93060293..3eded54a2 100644 --- a/test/server/agent-panes-write.test.ts +++ b/test/server/agent-panes-write.test.ts @@ -28,6 +28,28 @@ it('splits a pane horizontally', async () => { expect(attachPaneContent).toHaveBeenCalled() }) +it('splits a pane into a host-stats pane without spawning a terminal', async () => { + const app = express() + app.use(express.json()) + const splitPane = vi.fn(() => ({ newPaneId: 'pane_new', tabId: 'tab_1' })) + const attachPaneContent = vi.fn() + const registryCreate = vi.fn(() => ({ terminalId: 'term_new' })) + app.use('/api', createAgentApiRouter({ + layoutStore: { splitPane, attachPaneContent }, + registry: { create: registryCreate }, + wsHandler: { broadcastUiCommand: () => {} }, + })) + + const res = await request(app).post('/api/panes/pane_1/split').send({ hostStats: true }) + expect(res.body.status).toBe('ok') + expect(res.body.message).toBe('pane split (non-terminal)') + expect(res.body.data.paneId).toBe('pane_new') + expect(res.body.data.terminalId).toBeUndefined() + expect(registryCreate).not.toHaveBeenCalled() + expect(splitPane).toHaveBeenCalledWith(expect.objectContaining({ hostStats: true })) + expect(attachPaneContent).toHaveBeenCalledWith('tab_1', 'pane_new', { kind: 'host-stats' }) +}) + it('rejects invalid Codex settings when splitting a pane before spawning', async () => { const app = express() app.use(express.json()) diff --git a/test/server/agent-tabs-write.test.ts b/test/server/agent-tabs-write.test.ts index 0b98dc082..baa40317a 100644 --- a/test/server/agent-tabs-write.test.ts +++ b/test/server/agent-tabs-write.test.ts @@ -54,6 +54,32 @@ describe('tab endpoints', () => { expect(layoutStore.attachPaneContent).toHaveBeenCalled() }) + it('creates host-stats tabs without spawning a terminal', async () => { + const app = express() + app.use(express.json()) + const registry = new FakeRegistry() + const createTab = vi.fn(() => ({ tabId: 'tab_1', paneId: 'pane_1' })) + const attachPaneContent = vi.fn() + const layoutStore = { + createTab, + attachPaneContent, + selectTab: () => ({}), + renameTab: () => ({}), + closeTab: () => ({}), + hasTab: () => true, + selectNextTab: () => ({ tabId: 'tab_1' }), + selectPrevTab: () => ({ tabId: 'tab_1' }), + } + app.use('/api', createAgentApiRouter({ layoutStore, registry, wsHandler: { broadcastUiCommand: () => {} } })) + const res = await request(app).post('/api/tabs').send({ name: 'stats', hostStats: true }) + + expect(res.body.status).toBe('ok') + expect(registry.create).not.toHaveBeenCalled() + expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ hostStats: true })) + expect(attachPaneContent).toHaveBeenCalledWith('tab_1', 'pane_1', { kind: 'host-stats' }) + expect(res.body.data.terminalId).toBeUndefined() + }) + it('allocates and passes an OpenCode control endpoint when creating an opencode tab', async () => { const app = express() app.use(express.json()) diff --git a/test/unit/client/lib/tab-registry-open.test.ts b/test/unit/client/lib/tab-registry-open.test.ts index 905ab67f5..66a9e6f9e 100644 --- a/test/unit/client/lib/tab-registry-open.test.ts +++ b/test/unit/client/lib/tab-registry-open.test.ts @@ -4,6 +4,7 @@ import { jumpToRecord, openPaneInNewTab, openRecordAsUnlinkedCopy, + sanitizePaneSnapshot, type TabsRegistryGroups, } from '@/lib/tab-registry-open' import type { RegistryTabRecord } from '@/store/tabRegistryTypes' @@ -34,6 +35,14 @@ function makeGroups(overrides: Partial = {}): TabsRegistryGr return { localOpen: [], sameDeviceOpen: [], remoteOpen: [], closed: [], ...overrides } } +describe('sanitizePaneSnapshot', () => { + it('returns a host-stats pane for a host-stats snapshot (no picker fallback)', () => { + const record = makeRecord() + const snapshot = { paneId: 'pane-hs', kind: 'host-stats', payload: {} } as never + expect(sanitizePaneSnapshot(record, snapshot)).toEqual({ kind: 'host-stats' }) + }) +}) + describe('findRecordByTabKey', () => { it('finds a record in any group', () => { const record = makeRecord() diff --git a/test/unit/server/mcp/freshell-tool.test.ts b/test/unit/server/mcp/freshell-tool.test.ts index dc4ebf108..91dc12629 100644 --- a/test/unit/server/mcp/freshell-tool.test.ts +++ b/test/unit/server/mcp/freshell-tool.test.ts @@ -170,6 +170,16 @@ describe('executeAction -- tab actions', () => { expect(result).toBeTruthy() }) + it('new-tab passes hostStats through to /api/tabs', async () => { + mockClient.post.mockResolvedValue({ id: 't1' }) + + await executeAction('new-tab', { hostStats: true }) + + expect(mockClient.post).toHaveBeenCalledWith('/api/tabs', expect.objectContaining({ + hostStats: true, + })) + }) + // Fresh-agent shorthand resume: when `mode` is absent and the pane is a // fresh agent (`agent` param), resume sugar previously dropped the resume // fields silently. Only opencode is synthesized -- it is the only provider @@ -326,6 +336,22 @@ describe('executeAction -- pane actions', () => { ) }) + it('split-pane passes hostStats through to the split route', async () => { + mockClient.get.mockImplementation((path: string) => { + if (path === '/api/tabs') return Promise.resolve({ tabs: [{ id: 't1', activePaneId: 'p1' }], activeTabId: 't1' }) + if (path.includes('/api/panes')) return Promise.resolve({ panes: [{ id: 'p1', index: 0, kind: 'terminal', terminalId: 'term-1' }] }) + return Promise.resolve({}) + }) + mockClient.post.mockResolvedValue({ ok: true }) + + await executeAction('split-pane', { target: 'p1', hostStats: true }) + + expect(mockClient.post).toHaveBeenCalledWith( + expect.stringContaining('/api/panes/p1/split'), + expect.objectContaining({ hostStats: true }), + ) + }) + it('split-pane passes explicit canonical Codex sessionRef', async () => { mockClient.get.mockImplementation((path: string) => { if (path === '/api/tabs') return Promise.resolve({ tabs: [{ id: 't1', activePaneId: 'p1' }], activeTabId: 't1' }) diff --git a/test/unit/server/tabs-registry/types.test.ts b/test/unit/server/tabs-registry/types.test.ts index 3af789d44..fedcb75de 100644 --- a/test/unit/server/tabs-registry/types.test.ts +++ b/test/unit/server/tabs-registry/types.test.ts @@ -27,6 +27,31 @@ describe('TabRegistryRecordSchema (server)', () => { expect(parsed.status).toBe('open') }) + it('accepts a tab record with a host-stats pane', () => { + const parsed = TabRegistryRecordSchema.parse({ + tabKey: 'device-1:tab-1', + tabId: 'tab-1', + serverInstanceId: 'srv-test', + deviceId: 'device-1', + deviceLabel: 'danlaptop', + tabName: 'stats', + status: 'open', + revision: 1, + createdAt: 1739491200000, + updatedAt: 1739577600000, + paneCount: 1, + titleSetByUser: false, + panes: [ + { + paneId: 'pane-1', + kind: 'host-stats', + payload: {}, + }, + ], + }) + expect(parsed.panes[0]?.kind).toBe('host-stats') + }) + it('rejects invalid status', () => { const result = TabRegistryRecordSchema.safeParse({ tabKey: 'device-1:tab-1', From 39d5959cdb6abd13653f2c36eff489a985627e18 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:21:48 -0700 Subject: [PATCH 16/25] =?UTF-8?q?feat(host-stats):=20Rust=20collector=20pa?= =?UTF-8?q?rity=20=E2=80=94=20platform=20readers,=20trait=20bridge,=20inte?= =?UTF-8?q?rest-gated=20cadence,=20targeted=20send,=20refresh=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/host_stats_readers.rs | 1135 ++++++++++ crates/freshell-platform/src/lib.rs | 1 + crates/freshell-server/src/host_stats.rs | 1873 +++++++++++++++++ crates/freshell-server/src/main.rs | 29 + .../tests/fixtures/host-stats/proc/diskstats | 5 + .../tests/fixtures/host-stats/proc/loadavg | 1 + .../tests/fixtures/host-stats/proc/meminfo | 49 + .../tests/fixtures/host-stats/proc/net/dev | 5 + .../tests/fixtures/host-stats/proc/net/tcp | 5 + .../tests/fixtures/host-stats/proc/net/tcp6 | 3 + .../fixtures/host-stats/proc/pressure/cpu | 1 + .../fixtures/host-stats/proc/pressure/io | 2 + .../fixtures/host-stats/proc/pressure/memory | 2 + .../fixtures/host-stats/proc/self/fdinfo/3 | 6 + .../fixtures/host-stats/proc/self/fdinfo/4 | 7 + .../fixtures/host-stats/proc/self/fdinfo/5 | 5 + .../fixtures/host-stats/proc/self/limits | 17 + .../tests/fixtures/host-stats/proc/stat | 24 + .../proc/sys/fs/inotify/max_user_instances | 1 + .../proc/sys/fs/inotify/max_user_watches | 1 + .../host-stats/proc/sys/kernel/threads-max | 1 + .../proc/sys/net/ipv4/ip_local_port_range | 1 + .../tests/fixtures/host-stats/proc/vmstat | 18 + .../fixtures/host-stats/procmini/101/stat | 1 + .../fixtures/host-stats/procmini/101/status | 12 + .../fixtures/host-stats/procmini/202/stat | 1 + .../fixtures/host-stats/procmini/202/status | 12 + .../fixtures/host-stats/procmini/303/stat | 1 + .../fixtures/host-stats/procmini/303/status | 12 + .../fixtures/host-stats/procmini/404/stat | 1 + .../fixtures/host-stats/procmini/404/status | 12 + .../fixtures/host-stats/procmini/505/stat | 1 + .../fixtures/host-stats/procmini/505/status | 9 + .../fixtures/host-stats/procmini/606/stat | 1 + .../fixtures/host-stats/procmini/606/status | 12 + .../fixtures/host-stats/procmini/707/stat | 1 + .../fixtures/host-stats/procmini/707/status | 12 + .../fixtures/host-stats/procmini/self/cgroup | 1 + .../sys/class/power_supply/BAT0/capacity | 1 + .../sys/class/power_supply/BAT0/status | 1 + .../sys/class/power_supply/BAT0/type | 1 + .../sys/class/thermal/thermal_zone0/temp | 1 + .../sys/class/thermal/thermal_zone0/type | 1 + .../system/cpu/cpu0/cpufreq/scaling_cur_freq | 1 + .../system/cpu/cpu1/cpufreq/scaling_cur_freq | 1 + .../freshell-rust.service/memory.current | 1 + .../freshell-rust.service/memory.max | 1 + .../freshell-rust.service/pids.current | 1 + .../app.slice/freshell-rust.service/pids.max | 1 + crates/freshell-ws/src/codex_association.rs | 1 + crates/freshell-ws/src/codex_proxy_route.rs | 1 + .../freshell-ws/src/host_stats_collector.rs | 86 + crates/freshell-ws/src/host_stats_interest.rs | 179 ++ crates/freshell-ws/src/lib.rs | 10 + .../freshell-ws/src/opencode_association.rs | 1 + crates/freshell-ws/src/terminal.rs | 633 ++++++ .../freshell-ws/tests/auto_resume_respawn.rs | 1 + .../tests/claude_session_rebind.rs | 1 + .../tests/codex_managed_launch_e2e.rs | 1 + .../tests/codex_session_ref_resume.rs | 1 + .../tests/codex_sidecar_reattach_e2e.rs | 1 + crates/freshell-ws/tests/common/mod.rs | 9 + .../freshell-ws/tests/cross_kind_liveness.rs | 1 + .../tests/diag01_lifecycle_events.rs | 1 + .../tests/freshagent_claude_attach.rs | 1 + .../tests/freshagent_claude_kill_interrupt.rs | 1 + .../tests/freshagent_session_lease.rs | 1 + crates/freshell-ws/tests/hello_timeout.rs | 1 + crates/freshell-ws/tests/keepalive.rs | 1 + crates/freshell-ws/tests/max_payload.rs | 1 + .../tests/opencode_switch_rebind.rs | 1 + crates/freshell-ws/tests/origin_policy.rs | 1 + crates/freshell-ws/tests/pane_reconcile.rs | 1 + .../tests/pane_reconcile_freshagent.rs | 1 + .../freshell-ws/tests/rest_claude_identity.rs | 1 + .../tests/rest_locator_identity.rs | 1 + .../freshell-ws/tests/rest_ws_shared_gate.rs | 1 + .../tests/restore_plan_queue_cap.rs | 1 + .../freshell-ws/tests/restore_spawn_gate.rs | 1 + crates/freshell-ws/tests/restore_storm.rs | 1 + .../tests/resume_validation_gate.rs | 2 + .../tests/safe08_restore_diagnostics.rs | 1 + crates/freshell-ws/tests/sessions_prefs.rs | 1 + .../freshell-ws/tests/term09_output_queue.rs | 1 + crates/freshell-ws/tests/ui_layout_sync.rs | 1 + 85 files changed, 4241 insertions(+) create mode 100644 crates/freshell-platform/src/host_stats_readers.rs create mode 100644 crates/freshell-server/src/host_stats.rs create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/diskstats create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/loadavg create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/meminfo create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/net/dev create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp6 create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/pressure/cpu create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/pressure/io create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/pressure/memory create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/3 create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/4 create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/5 create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/self/limits create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/stat create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_instances create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_watches create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/sys/kernel/threads-max create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/sys/net/ipv4/ip_local_port_range create mode 100644 crates/freshell-server/tests/fixtures/host-stats/proc/vmstat create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/101/stat create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/101/status create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/202/stat create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/202/status create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/303/stat create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/303/status create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/404/stat create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/404/status create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/505/stat create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/505/status create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/606/stat create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/606/status create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/707/stat create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/707/status create mode 100644 crates/freshell-server/tests/fixtures/host-stats/procmini/self/cgroup create mode 100644 crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/capacity create mode 100644 crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/status create mode 100644 crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/type create mode 100644 crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/temp create mode 100644 crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/type create mode 100644 crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq create mode 100644 crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu1/cpufreq/scaling_cur_freq create mode 100644 crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.current create mode 100644 crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.max create mode 100644 crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.current create mode 100644 crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.max create mode 100644 crates/freshell-ws/src/host_stats_collector.rs create mode 100644 crates/freshell-ws/src/host_stats_interest.rs diff --git a/crates/freshell-platform/src/host_stats_readers.rs b/crates/freshell-platform/src/host_stats_readers.rs new file mode 100644 index 000000000..2a258273d --- /dev/null +++ b/crates/freshell-platform/src/host_stats_readers.rs @@ -0,0 +1,1135 @@ +//! Host-stats `/proc` + `/sys` reader layer — the Rust port of +//! `server/host-stats/readers.ts` (plan: `docs/plans/2026-08-25-host-pressure-pane.md`, +//! Task 9 contract lines 874–933; reader semantics frozen by Task 2). +//! +//! Pure, path-injected, synchronous readers mirroring the Node layer one for +//! one: every reader NEVER panics on a read/parse failure — it returns `None` +//! instead. The ONLY async piece of the Node layer (`scanProcessTable`'s +//! two-sample dwell) is not here: this crate is deliberately tokio-free (see +//! `lib.rs`), so the dwell + deadline loop lives in `freshell-server`'s +//! concrete collector (`host_stats.rs`); the pure pieces it needs +//! ([`parse_proc_pid_stat`], [`parse_status_vm_rss_kb`], [`list_numeric_pids`], +//! [`read_pid_file_bounded`], [`compute_cpu_pct`]) are exported here. +//! +//! Platform notes: `/proc` readers are Linux-only — on darwin/Windows the +//! files do not exist and the readers return `None`; the caller (the +//! collector) then degrades the section to its zero-shape +//! (`available: false`). Unlike Node there is NO darwin `ps` subprocess path +//! (frozen Task 9 note: the Rust collector on darwin reports +//! `cpu.available:false`; `/proc`-dependent sections are zero-shaped). +//! +//! Known intentional divergence from `readers.ts`: Node's +//! `readNumberFile`/`parseCgroupLimit` lean on `Number('') === 0`, so an +//! EMPTY limit file reads as 0 there; here an unparsable payload is `None` +//! (degraded). Kernel `/proc`+`/sys` files are never empty when present, so +//! the divergence is unreachable on a real host. + +use std::collections::BTreeMap; +use std::path::Path; + +/// USER_HZ=100 is the documented ABI exposure of `/proc//stat` tick +/// fields on every Linux architecture this project targets, so ticks -> +/// seconds is a plain /100 (Task 2 documented assumption; computed cpuPct is +/// also clamped defensively). +pub const USER_HZ: u64 = 100; + +/// Cap on numeric `/proc` entries enumerated by [`list_numeric_pids`] +/// (mirrors Node's `PROC_SCAN_CAP`). +pub const PROC_SCAN_CAP: usize = 100_000; +/// Cap on [`read_self_fd_count`] (mirrors Node's `FD_COUNT_CAP`). +pub const FD_COUNT_CAP: u64 = 1_048_576; +/// Cap on [`read_pid_count`] (mirrors Node's `PID_COUNT_CAP`). +pub const PID_COUNT_CAP: u64 = 10_000_000; +/// Bounded scan cap for the inotify fd sweep (mirrors Node's +/// `INOTIFY_FD_SCAN_CAP`). +pub const INOTIFY_FD_SCAN_CAP: usize = 4096; +/// cgroup v1 reports "unlimited" as a huge sentinel (varies by kernel); +/// >= 2^60 is garbage. +pub const CGROUP_V1_GARBAGE_LIMIT: u64 = 1 << 60; +/// Bounded `/proc/` file read (mirrors Node's +/// `PROC_STAT_READ_MAX_BYTES`). +pub const PROC_PID_FILE_MAX_BYTES: usize = 4096; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Read a whole file as utf8 (lossy); `None` on any failure. +fn safe_read(file_path: &Path) -> Option { + std::fs::read_to_string(file_path).ok() +} + +/// Non-empty, right-trimmed lines of a text file's contents. +fn non_empty_lines(text: &str) -> impl Iterator { + text.lines().filter(|line| !line.trim().is_empty()) +} + +/// Parse a file whose entire payload is a single number (e.g. threads-max). +fn read_number_file(file_path: &Path) -> Option { + safe_read(file_path)?.trim().parse::().ok() +} + +/// List a directory's entry NAMES; `None` instead of throwing. +fn safe_read_dir(dir_path: &Path) -> Option> { + let rd = std::fs::read_dir(dir_path).ok()?; + Some( + rd.filter_map(|entry| entry.ok()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(), + ) +} + +/// Resolve THIS process's cgroup leaf from `/self/cgroup`. The +/// cgroup fs root has NO limit files by design, so callers must always +/// resolve the leaf and never read the fs root. +enum CgroupLeaf { + V1(String), + V2(String), +} + +fn resolve_cgroup_leaf(proc_root: &Path, v1_controller: &str) -> Option { + let text = safe_read(&proc_root.join("self").join("cgroup"))?; + let lines: Vec<&str> = non_empty_lines(&text).collect(); + // v2 unified hierarchy: a single "0::/path" line. + for line in &lines { + if let Some(rest) = line.strip_prefix("0::") { + let leaf = rest.trim_start_matches('/'); + if leaf.is_empty() { + // process sits at the cgroup2 root: no limit files there + return None; + } + return Some(CgroupLeaf::V2(leaf.to_string())); + } + } + // v1: "::/path" + for line in lines { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() != 3 { + continue; + } + if !parts[1].split(',').any(|c| c == v1_controller) { + continue; + } + let leaf = parts[2].trim_start_matches('/'); + if leaf.is_empty() { + return None; + } + return Some(CgroupLeaf::V1(leaf.to_string())); + } + None +} + +/// 'max' / unreadable / non-finite cgroup limit -> `None` (unlimited). +fn parse_cgroup_limit(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed == "max" || trimmed.is_empty() { + return None; + } + trimmed.parse::().ok() +} + +// --------------------------------------------------------------------------- +// CPU / load / memory +// --------------------------------------------------------------------------- + +/// One `cpuN` line's cumulative counters (jiffies). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CpuCoreTimes { + pub total: f64, + pub busy: f64, +} + +/// `/proc/stat` aggregated + per-core totals; steal jiffies (Node +/// `readCpuTimes`). +#[derive(Debug, Clone, PartialEq)] +pub struct CpuTimes { + pub total: f64, + pub busy: f64, + pub steal: f64, + pub per_core: Vec, +} + +fn parse_proc_stat_cpu_fields(fields: &[f64]) -> Option<(f64, f64, f64)> { + // user nice system idle iowait irq softirq steal [guest guest_nice] + if fields.len() < 8 || fields.iter().any(|f| !f.is_finite()) { + return None; + } + let total: f64 = fields.iter().sum(); + let busy = total - fields[3] - fields[4]; // idle + iowait + Some((total, busy, fields[7])) +} + +/// `/proc/stat` aggregated + per-core totals; steal jiffies. +pub fn read_cpu_times(proc_root: &Path) -> Option { + let text = safe_read(&proc_root.join("stat"))?; + let mut aggregate: Option<(f64, f64, f64)> = None; + let mut per_core: Vec = Vec::new(); + for line in non_empty_lines(&text) { + // /^cpu(\d*)\s+(.*)$/ + let Some(after_cpu) = line.strip_prefix("cpu") else { + continue; + }; + let Some(idx_end) = after_cpu.find(char::is_whitespace) else { + continue; + }; + let idx_str = &after_cpu[..idx_end]; + if !idx_str.is_empty() && !idx_str.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + let fields: Vec = after_cpu[idx_end..] + .split_whitespace() + .map(|tok| tok.parse::().unwrap_or(f64::NAN)) + .collect(); + let Some((total, busy, steal)) = parse_proc_stat_cpu_fields(&fields) else { + continue; + }; + if idx_str.is_empty() { + aggregate = Some((total, busy, steal)); + } else { + let idx: usize = idx_str.parse().ok()?; + if per_core.len() <= idx { + per_core.resize(idx + 1, CpuCoreTimes { total: 0.0, busy: 0.0 }); + } + per_core[idx] = CpuCoreTimes { total, busy }; + } + } + let (total, busy, steal) = aggregate?; + Some(CpuTimes { + total, + busy, + steal, + per_core, + }) +} + +/// `/proc/loadavg` (Node `readLoadavg`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LoadAvg { + pub load1: f64, + pub load5: f64, + pub load15: f64, +} + +/// `/proc/loadavg`. On darwin the file does not exist -> `None`. +pub fn read_loadavg(proc_root: &Path) -> Option { + let text = safe_read(&proc_root.join("loadavg"))?; + let fields: Vec = text + .trim() + .split_whitespace() + .map(|tok| tok.parse::().unwrap_or(f64::NAN)) + .collect(); + if fields.len() < 3 || fields[..3].iter().any(|f| !f.is_finite()) { + return None; + } + Some(LoadAvg { + load1: fields[0], + load5: fields[1], + load15: fields[2], + }) +} + +/// `/proc/meminfo` kB values (Node `readMeminfo`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MeminfoKb { + pub total_kb: u64, + pub avail_kb: u64, + pub swap_total_kb: u64, + pub swap_free_kb: u64, +} + +/// `/proc/meminfo`. Returns `None` when the file is absent or the two +/// mandatory keys (`MemTotal`/`MemAvailable`) are missing. +pub fn read_meminfo(proc_root: &Path) -> Option { + let text = safe_read(&proc_root.join("meminfo"))?; + let mut values: BTreeMap = BTreeMap::new(); + for line in non_empty_lines(&text) { + // /^([^:]+):\s+(\d+)/ + let Some((key, rest)) = line.split_once(':') else { + continue; + }; + let Some(value_tok) = rest.split_whitespace().next() else { + continue; + }; + if let Ok(value) = value_tok.parse::() { + values.insert(key.to_string(), value); + } + } + Some(MeminfoKb { + total_kb: *values.get("MemTotal")?, + avail_kb: *values.get("MemAvailable")?, + swap_total_kb: values.get("SwapTotal").copied().unwrap_or(0), + swap_free_kb: values.get("SwapFree").copied().unwrap_or(0), + }) +} + +/// This process's cgroup memory view (Node `readCgroupMemory`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CgroupMemory { + /// `None` = unlimited ('max' / v1 garbage sentinel / unreadable). + pub limit_bytes: Option, + pub current_bytes: u64, +} + +/// Resolves THIS process's cgroup leaf from `/self/cgroup` and +/// reads its memory files. v2: `0::/path` -> `/path/ +/// memory.current` + `memory.max` ('max' -> `None` limit). v1: `memory` +/// controller line -> `/memory/path/usage_in_bytes` + +/// `limit_in_bytes` (garbage limit >= 2^60 -> `None`). The cgroup fs root has +/// NO limit files by design, so the leaf is always resolved; the fs root is +/// never read. +/// +/// NOTE (frozen contract): parameter order here is (cgroup_root, proc_root) +/// — the opposite of [`read_pids_limit`]. Callers: read the signatures, do +/// not assume. +pub fn read_cgroup_memory(cgroup_root: &Path, proc_root: &Path) -> Option { + let leaf = resolve_cgroup_leaf(proc_root, "memory")?; + match leaf { + CgroupLeaf::V2(leaf) => { + let dir = cgroup_root.join(leaf); + let current_bytes = read_number_file(&dir.join("memory.current"))?; + let limit_bytes = safe_read(&dir.join("memory.max")) + .as_deref() + .and_then(parse_cgroup_limit); + Some(CgroupMemory { + limit_bytes, + current_bytes, + }) + } + CgroupLeaf::V1(leaf) => { + let dir = cgroup_root.join("memory").join(leaf); + let current_bytes = read_number_file(&dir.join("memory.usage_in_bytes"))?; + let raw = read_number_file(&dir.join("memory.limit_in_bytes")); + // v1 "unlimited" is a huge sentinel value (>= 2^60 depending on + // kernel) -> None + let limit_bytes = raw.filter(|v| *v < CGROUP_V1_GARBAGE_LIMIT); + Some(CgroupMemory { + limit_bytes, + current_bytes, + }) + } + } +} + +// --------------------------------------------------------------------------- +// Paging / PSI +// --------------------------------------------------------------------------- + +/// `/proc/vmstat` paging counters (Node `readVmstat`). `oom_kill` is `None` +/// when the kernel omits the `oom_kill` line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Vmstat { + pub pswpin: u64, + pub pswpout: u64, + pub pgmajfault: u64, + pub oom_kill: Option, +} + +/// `/proc/vmstat` paging counters. +pub fn read_vmstat(proc_root: &Path) -> Option { + let text = safe_read(&proc_root.join("vmstat"))?; + let mut values: BTreeMap = BTreeMap::new(); + for line in non_empty_lines(&text) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() == 2 { + if let Ok(value) = parts[1].parse::() { + values.insert(parts[0].to_string(), value); + } + } + } + Some(Vmstat { + pswpin: *values.get("pswpin")?, + pswpout: *values.get("pswpout")?, + pgmajfault: *values.get("pgmajfault")?, + oom_kill: values.get("oom_kill").copied(), + }) +} + +/// `/proc/pressure/{cpu,memory,io}` avg10 values (Node `readPsi`); `None` +/// per-file when unreadable, `None` overall when the PSI directory is missing +/// entirely. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PsiSnapshot { + pub cpu_some10: Option, + pub mem_some10: Option, + pub mem_full10: Option, + pub io_some10: Option, + pub io_full10: Option, +} + +/// `/proc/pressure/{cpu,memory,io}` avg10 values. +pub fn read_psi(proc_root: &Path) -> Option { + let pressure_dir = proc_root.join("pressure"); + let cpu = safe_read(&pressure_dir.join("cpu")); + let memory = safe_read(&pressure_dir.join("memory")); + let io = safe_read(&pressure_dir.join("io")); + if cpu.is_none() && memory.is_none() && io.is_none() { + return None; + } + Some(PsiSnapshot { + cpu_some10: cpu.as_deref().and_then(|t| parse_psi_avg10(t, "some")), + mem_some10: memory.as_deref().and_then(|t| parse_psi_avg10(t, "some")), + mem_full10: memory.as_deref().and_then(|t| parse_psi_avg10(t, "full")), + io_some10: io.as_deref().and_then(|t| parse_psi_avg10(t, "some")), + io_full10: io.as_deref().and_then(|t| parse_psi_avg10(t, "full")), + }) +} + +fn parse_psi_avg10(text: &str, line_kind: &str) -> Option { + // /^(some|full)\s+.*?\bavg10=([\d.]+)/ + for line in non_empty_lines(text) { + let mut tokens = line.split_whitespace(); + if tokens.next() != Some(line_kind) { + continue; + } + for token in tokens { + if let Some(rest) = token.strip_prefix("avg10=") { + if let Ok(value) = rest.parse::() { + return value.is_finite().then_some(value); + } + // Malformed avg10 on the matching line: Node's regex simply + // fails this line and the search continues. + break; + } + } + } + None +} + +// --------------------------------------------------------------------------- +// Disk / network +// --------------------------------------------------------------------------- + +/// One whole-device row of `/proc/diskstats` (Node `DiskCounters`). Field +/// mapping per the kernel iostats doc (1-indexed after the device name). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiskCounters { + pub reads_completed: u64, + pub read_ms: u64, + pub writes_completed: u64, + pub write_ms: u64, + pub read_sectors: u64, + pub written_sectors: u64, + pub time_doing_ios_ms: u64, +} + +/// Whole-device name filter for `/proc/diskstats`: partitions (`sda1`, +/// `nvme0n1p1`, `mmcblk0p1`), loop and ram devices are excluded; everything +/// else (whole disks, `dm-*`, `drbd`, ...) is kept — fail-open so an +/// unrecognized whole device is still shown. +pub fn is_whole_device(name: &str) -> bool { + // /^(?:loop|ram)\d+/ (prefix match) + for prefix in ["loop", "ram"] { + if let Some(rest) = name.strip_prefix(prefix) { + if rest.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return false; + } + } + } + // /^nvme\d+n\d+p\d+$/ + if let Some(rest) = name.strip_prefix("nvme") { + if let Some((bus, tail)) = rest.split_once('n') { + if let Some((inst, part)) = tail.split_once('p') { + if !bus.is_empty() + && bus.bytes().all(|b| b.is_ascii_digit()) + && !inst.is_empty() + && inst.bytes().all(|b| b.is_ascii_digit()) + && !part.is_empty() + && part.bytes().all(|b| b.is_ascii_digit()) + { + return false; + } + } + } + } + // /^mmcblk\d+p\d+$/ + if let Some(rest) = name.strip_prefix("mmcblk") { + if let Some((idx, part)) = rest.split_once('p') { + if !idx.is_empty() + && idx.bytes().all(|b| b.is_ascii_digit()) + && !part.is_empty() + && part.bytes().all(|b| b.is_ascii_digit()) + { + return false; + } + } + } + // /^(?:sd|vd|xvd|hd)[a-z]+\d+$/ + for prefix in ["sd", "vd", "xvd", "hd"] { + if let Some(rest) = name.strip_prefix(prefix) { + // Longest-first prefix order matters (vd before d-shadowing); + // "xvd" must be tried before "vd" would also match after 'x' is + // consumed — strip_prefix is anchored, so only exact prefixes fire. + let letters: usize = rest.chars().take_while(|c| c.is_ascii_lowercase()).count(); + if letters == 0 { + continue; + } + let (alpha, digits) = rest.split_at(letters); + if !alpha.is_empty() + && alpha.bytes().all(|b| b.is_ascii_lowercase()) + && !digits.is_empty() + && digits.bytes().all(|b| b.is_ascii_digit()) + { + return false; + } + } + } + true +} + +/// `/proc/diskstats` keyed by whole-device name. +pub fn read_disk_stats(proc_root: &Path) -> Option> { + let text = safe_read(&proc_root.join("diskstats"))?; + let mut devices = BTreeMap::new(); + for line in non_empty_lines(&text) { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 14 { + continue; + } + let name = cols[2]; + if !is_whole_device(name) { + continue; + } + // Node: `numbers.some((n) => !Number.isFinite(n))` skips the LINE, + // never the file. + let mut numbers: Vec = Vec::with_capacity(cols.len() - 3); + let mut unparsable = false; + for tok in &cols[3..] { + match tok.parse::() { + Ok(v) => numbers.push(v), + Err(_) => { + unparsable = true; + break; + } + } + } + if unparsable { + continue; + } + // doc field 1 = readsCompleted, 3 = readSectors, 4 = readMs, + // 5 = writesCompleted, 7 = writtenSectors, 8 = writeMs, + // 10 = timeDoingIosMs. + devices.insert( + name.to_string(), + DiskCounters { + reads_completed: numbers[0], + read_ms: numbers[3], + writes_completed: numbers[4], + write_ms: numbers[7], + read_sectors: numbers[2], + written_sectors: numbers[6], + time_doing_ios_ms: numbers[9], + }, + ); + } + Some(devices) +} + +/// `/proc/net/dev` summed across interfaces, EXCLUDING loopback (`lo`) +/// (Node `readNetDev`; virtual interfaces are kept). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NetDevTotals { + pub rx_bytes: u64, + pub tx_bytes: u64, + pub rx_err: u64, + pub tx_err: u64, + pub rx_drop: u64, + pub tx_drop: u64, +} + +/// `/proc/net/dev` interface totals. +pub fn read_net_dev(proc_root: &Path) -> Option { + let text = safe_read(&proc_root.join("net").join("dev"))?; + let mut totals = NetDevTotals { + rx_bytes: 0, + tx_bytes: 0, + rx_err: 0, + tx_err: 0, + rx_drop: 0, + tx_drop: 0, + }; + for line in non_empty_lines(&text) { + let Some(colon) = line.find(':') else { + continue; + }; + let name = line[..colon].trim(); + if name == "lo" { + continue; + } + let numbers: Vec = line[colon + 1..] + .split_whitespace() + .map(|tok| tok.parse::().unwrap_or(u64::MAX)) + .collect(); + if numbers.len() < 16 || numbers.iter().any(|n| *n == u64::MAX) { + continue; + } + totals.rx_bytes += numbers[0]; + totals.rx_err += numbers[2]; + totals.rx_drop += numbers[3]; + totals.tx_bytes += numbers[8]; + totals.tx_err += numbers[10]; + totals.tx_drop += numbers[11]; + } + Some(totals) +} + +/// TIME_WAIT (state `06`) count across `tcp` + `tcp6` (Node +/// `readTcpStateCounts`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TcpStateCounts { + pub time_wait: u64, +} + +/// TIME_WAIT connection count across `/proc/net/tcp` + `/proc/net/tcp6`. +pub fn read_tcp_state_counts(proc_root: &Path) -> Option { + let tcp = safe_read(&proc_root.join("net").join("tcp")); + let tcp6 = safe_read(&proc_root.join("net").join("tcp6")); + if tcp.is_none() && tcp6.is_none() { + return None; + } + let mut time_wait = 0u64; + for text in [tcp, tcp6].into_iter().flatten() { + for line in non_empty_lines(&text) { + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.len() < 4 { + continue; + } + // /^\d+:$/ + let Some(sl) = tokens[0].strip_suffix(':') else { + continue; + }; + if sl.is_empty() || !sl.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + if tokens[3] == "06" { + time_wait += 1; + } + } + } + Some(TcpStateCounts { time_wait }) +} + +/// `/proc/sys/net/ipv4/ip_local_port_range` (Node `readEphemeralPortRange`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PortRange { + pub start: u64, + pub end: u64, +} + +/// `/proc/sys/net/ipv4/ip_local_port_range`. +pub fn read_ephemeral_port_range(proc_root: &Path) -> Option { + let text = safe_read( + &proc_root + .join("sys") + .join("net") + .join("ipv4") + .join("ip_local_port_range"), + )?; + let fields: Vec = text + .trim() + .split_whitespace() + .map(|tok| tok.parse::().unwrap_or(u64::MAX)) + .collect(); + if fields.len() < 2 || fields[..2].iter().any(|f| *f == u64::MAX) { + return None; + } + Some(PortRange { + start: fields[0], + end: fields[1], + }) +} + +// --------------------------------------------------------------------------- +// Limits +// --------------------------------------------------------------------------- + +/// Count of entries in `/self/fd`, capped at [`FD_COUNT_CAP`] +/// (Node `readSelfFdCount`). +pub fn read_self_fd_count(proc_root: &Path) -> Option { + let entries = safe_read_dir(&proc_root.join("self").join("fd"))?; + Some((entries.len() as u64).min(FD_COUNT_CAP)) +} + +/// Count of numeric `` entries (processes), capped at +/// [`PID_COUNT_CAP`] (Node `readPidCount`). +pub fn read_pid_count(proc_root: &Path) -> Option { + let entries = safe_read_dir(proc_root)?; + let mut count = 0u64; + for entry in entries { + if !entry.is_empty() && entry.bytes().all(|b| b.is_ascii_digit()) { + count += 1; + } + } + Some(count.min(PID_COUNT_CAP)) +} + +/// The BINDING process cap: cgroup v2 leaf `pids.max` ('max' -> unlimited -> +/// fall back), else cgroup v1 `pids.max`, else +/// `/proc/sys/kernel/threads-max`. `/proc/sys/kernel/pid_max` is a PID-number +/// wrap boundary, NOT a creatable-process cap, and is deliberately never used +/// (validated R3M2). +/// +/// NOTE (frozen contract): parameter order here is (proc_root, cgroup_root) +/// — the opposite of [`read_cgroup_memory`]. Callers: read the signatures, +/// do not assume. +pub fn read_pids_limit(proc_root: &Path, cgroup_root: &Path) -> Option { + if let Some(leaf) = resolve_cgroup_leaf(proc_root, "pids") { + let dir = match &leaf { + CgroupLeaf::V2(leaf) => cgroup_root.join(leaf), + CgroupLeaf::V1(leaf) => cgroup_root.join("pids").join(leaf), + }; + if let Some(text) = safe_read(&dir.join("pids.max")) { + if let Some(limit) = parse_cgroup_limit(&text) { + if limit > 0 { + return Some(limit); + } + } + // 'max'/garbage: cgroup says unlimited -> the binding cap is the + // host limit below + } + } + read_number_file(&proc_root.join("sys").join("kernel").join("threads-max")) +} + +/// `Max open files` SOFT limit from `/proc/self/limits` ('unlimited' -> +/// `None`) (Node `readSelfLimitsFdsMax`). +pub fn read_self_limits_fds_max(proc_root: &Path) -> Option { + let text = safe_read(&proc_root.join("self").join("limits"))?; + for line in non_empty_lines(&text) { + // /^Max open files\s+(\S+)/ + let Some(rest) = line.strip_prefix("Max open files") else { + continue; + }; + if !rest.starts_with(char::is_whitespace) { + continue; + } + let Some(soft) = rest.split_whitespace().next() else { + return None; + }; + return soft.parse::().ok(); + } + None +} + +/// This process's inotify usage (Node `readSelfInotifyStats`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InotifyUsage { + pub instances: u64, + pub watches: u64, +} + +/// inotify usage of THIS process: bounded scan (cap +/// [`INOTIFY_FD_SCAN_CAP`] fds) of `/proc/self/fd` where the readlink target +/// starts with `anon_inode:inotify` counts instances; +/// `/proc/self/fdinfo/` lines starting with `inotify` count watches. +pub fn read_self_inotify_stats(proc_root: &Path) -> Option { + let fd_dir = proc_root.join("self").join("fd"); + let entries = safe_read_dir(&fd_dir)?; + let mut instances = 0u64; + let mut watches = 0u64; + for fd in entries.iter().take(INOTIFY_FD_SCAN_CAP) { + let Ok(target) = std::fs::read_link(fd_dir.join(fd)) else { + continue; // fd vanished mid-scan + }; + if !target.to_string_lossy().starts_with("anon_inode:inotify") { + continue; + } + instances += 1; + if let Some(fdinfo) = safe_read(&proc_root.join("self").join("fdinfo").join(fd)) { + for line in non_empty_lines(&fdinfo) { + if line.starts_with("inotify") { + watches += 1; + } + } + } + } + Some(InotifyUsage { instances, watches }) +} + +/// `/proc/sys/fs/inotify/max_user_{watches,instances}` (Node +/// `readInotifyLimits`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InotifyLimits { + pub max_user_watches: Option, + pub max_user_instances: Option, +} + +/// inotify sysctls; `None` when BOTH limit files are unreadable. +pub fn read_inotify_limits(proc_root: &Path) -> Option { + let base = proc_root.join("sys").join("fs").join("inotify"); + let max_user_watches = read_number_file(&base.join("max_user_watches")); + let max_user_instances = read_number_file(&base.join("max_user_instances")); + if max_user_watches.is_none() && max_user_instances.is_none() { + return None; + } + Some(InotifyLimits { + max_user_watches, + max_user_instances, + }) +} + +// --------------------------------------------------------------------------- +// Sysfs sensors / machine info +// --------------------------------------------------------------------------- + +/// Mean of `/sys/devices/system/cpu/cpuN/cpufreq/scaling_cur_freq` +/// (kHz -> MHz) (Node `readCpuFreqMHz`). +pub fn read_cpu_freq_mhz(sys_root: &Path) -> Option { + let cpu_dir = sys_root.join("devices").join("system").join("cpu"); + let entries = safe_read_dir(&cpu_dir)?; + let mut freqs: Vec = Vec::new(); + for entry in entries { + // /^cpu\d+$/ + let Some(rest) = entry.strip_prefix("cpu") else { + continue; + }; + if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + if let Some(khz) = + read_number_file(&cpu_dir.join(&entry).join("cpufreq").join("scaling_cur_freq")) + { + if khz > 0 { + freqs.push(khz as f64); + } + } + } + if freqs.is_empty() { + return None; + } + Some(freqs.iter().sum::() / freqs.len() as f64 / 1000.0) +} + +fn probe_psi_readable(proc_root: &Path) -> bool { + proc_root.join("pressure").is_dir() +} + +fn probe_cgroup_version(proc_root: &Path) -> &'static str { + let Some(text) = safe_read(&proc_root.join("self").join("cgroup")) else { + return "none"; + }; + if text.trim().is_empty() { + return "none"; + } + if non_empty_lines(&text).any(|line| line.starts_with("0::")) { + "v2" + } else { + "v1" + } +} + +fn list_thermal_zones(sys_root: &Path) -> Option> { + let entries = safe_read_dir(&sys_root.join("class").join("thermal"))?; + let mut zones: Vec<(u64, String)> = entries + .into_iter() + .filter_map(|entry| { + let rest = entry.strip_prefix("thermal_zone")?; + if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + Some((rest.parse::().ok()?, entry)) + }) + .collect(); + zones.sort_by_key(|(idx, _)| *idx); + Some(zones.into_iter().map(|(_, name)| name).collect()) +} + +fn list_battery_entries(sys_root: &Path) -> Option> { + let power_supply = sys_root.join("class").join("power_supply"); + let entries = safe_read_dir(&power_supply)?; + Some( + entries + .into_iter() + .filter(|entry| { + match safe_read(&power_supply.join(entry).join("type")) { + Some(kind) => kind.trim() == "Battery", + // No type file: fall back to the /^bat/i name heuristic + // (Node parity). + None => { + let lower = entry.to_ascii_lowercase(); + lower.starts_with("bat") + } + } + }) + .collect(), + ) +} + +/// Kernel release from the injected root; `None` when absent (there is no +/// `os.release()` fallback on this Rust path — the payload field is nullable +/// by contract). +fn read_kernel_release(proc_root: &Path) -> Option { + let release = safe_read(&proc_root.join("sys").join("kernel").join("osrelease"))?; + let release = release.trim(); + (!release.is_empty()).then(|| release.to_string()) +} + +/// Hostname from the injected root (`/proc/sys/kernel/hostname`); `None` +/// when absent (there is no `os.hostname()` fallback on this Rust path — the +/// payload field is nullable by contract). +fn read_hostname(proc_root: &Path) -> Option { + let hostname = safe_read(&proc_root.join("sys").join("kernel").join("hostname"))?; + let hostname = hostname.trim(); + (!hostname.is_empty()).then(|| hostname.to_string()) +} + +/// First battery under `/sys/class/power_supply` (capacity % + status +/// string) (Node `readBattery`). +#[derive(Debug, Clone, PartialEq)] +pub struct Battery { + pub pct: f64, + pub status: String, +} + +/// First battery under `/sys/class/power_supply`; `None` if none. +pub fn read_battery(sys_root: &Path) -> Option { + let batteries = list_battery_entries(sys_root)?; + let entry = batteries.first()?; + let dir = sys_root.join("class").join("power_supply").join(entry); + let pct = read_number_file(&dir.join("capacity"))?; + let status = safe_read(&dir.join("status")) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "Unknown".to_string()); + Some(Battery { + pct: (pct as f64).clamp(0.0, 100.0), + status, + }) +} + +/// One thermal zone (millidegree -> celsius, `type` as label). +#[derive(Debug, Clone, PartialEq)] +pub struct ThermalZone { + pub label: String, + pub celsius: f64, +} + +/// Thermal zones (max 16); `None` when the thermal class dir is missing +/// (Node `readThermals`). +pub fn read_thermals(sys_root: &Path) -> Option> { + let zones = list_thermal_zones(sys_root)?; + let base = sys_root.join("class").join("thermal"); + let mut results = Vec::new(); + for zone in zones.iter().take(16) { + let Some(milli) = read_number_file(&base.join(zone).join("temp")) else { + continue; + }; + let label = safe_read(&base.join(zone).join("type")) + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| zone.clone()); + results.push(ThermalZone { + label, + celsius: milli as f64 / 1000.0, + }); + } + Some(results) +} + +/// Machine identity + capability snapshot (Node `readMachineInfo`, cheap +/// probes only — dir listings, no scans). `cgroup` is the exact `'v1' | +/// 'v2' | 'none'` vocabulary of the Node payload. +#[derive(Debug, Clone, PartialEq)] +pub struct MachineInfo { + pub cores: u64, + pub mem_total_bytes: u64, + pub platform: String, + pub wsl: bool, + pub kernel: Option, + pub hostname: Option, + pub psi: bool, + pub cgroup: String, + pub thermal_count: u64, + pub battery_present: bool, + pub gpu: String, +} + +/// Machine identity + capability snapshot. There is no `os.cpus()`/ +/// `os.totalmem()`/`os.hostname()` equivalent on this Rust path: cores come +/// from [`std::thread::available_parallelism`], `mem_total_bytes` from the +/// injected meminfo (0 when absent), kernel/hostname from the injected +/// `/sys/kernel/{osrelease,hostname}` (`None` when absent — both +/// payload fields are nullable by contract). +pub fn read_machine_info(proc_root: &Path, sys_root: &Path) -> MachineInfo { + let release = read_kernel_release(proc_root); + let thermal_zones = list_thermal_zones(sys_root); + let batteries = list_battery_entries(sys_root); + let release_lower = release.as_deref().unwrap_or("").to_ascii_lowercase(); + MachineInfo { + cores: std::thread::available_parallelism() + .map(|n| n.get() as u64) + .unwrap_or(1), + mem_total_bytes: read_meminfo(proc_root) + .map(|m| m.total_kb.saturating_mul(1024)) + .unwrap_or(0), + platform: if cfg!(target_os = "windows") { + "win32".to_string() + } else if cfg!(target_os = "macos") { + "darwin".to_string() + } else { + "linux".to_string() + }, + // /microsoft|wsl/i + wsl: release_lower.contains("microsoft") || release_lower.contains("wsl"), + kernel: release, + hostname: read_hostname(proc_root), + psi: probe_psi_readable(proc_root), + cgroup: probe_cgroup_version(proc_root).to_string(), + thermal_count: thermal_zones.as_ref().map(|z| z.len() as u64).unwrap_or(0), + battery_present: batteries.as_ref().map(|b| !b.is_empty()).unwrap_or(false), + // GPU detection is out of scope by design (renders 'n/a' truthfully). + gpu: "none".to_string(), + } +} + +// --------------------------------------------------------------------------- +// Process-table scan pure pieces (the async dwell loop lives in +// freshell-server::host_stats — this crate is tokio-free) +// --------------------------------------------------------------------------- + +/// Parsed `/proc//stat`: comm (after the LAST ')'), state, utime+stime +/// busy jiffies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProcPidStat { + pub name: String, + pub state: String, + pub busy_jiffies: u64, +} + +/// `pid (comm) state ...` — comm may contain spaces AND parens, so fields +/// are counted after the LAST ')' (precedent: +/// `server/coding-cli/codex-child-registry.ts`, mirrored by +/// `freshell-server`'s `shutdown_forensics`). After the close paren, +/// zero-indexed fields: [0] state, [11] utime, [12] stime. +pub fn parse_proc_pid_stat(text: &str) -> Option { + let open = text.find('(')?; + let close = text.rfind(')')?; + if open > close { + return None; + } + let fields: Vec<&str> = text[close + 1..].split_whitespace().collect(); + if fields.len() < 13 { + return None; + } + let state = fields[0]; + if state.is_empty() { + return None; + } + let utime = fields[11].trim().parse::().ok()?; + let stime = fields[12].trim().parse::().ok()?; + Some(ProcPidStat { + name: text[open + 1..close].to_string(), + state: state.to_string(), + busy_jiffies: utime + stime, + }) +} + +/// `/proc//status` VmRSS in kB. Preferred over stat rss pages x 4096: +/// page size is NOT 4096 on every target (aarch64 16K/64K pages would +/// silently inflate RSS 16x). +pub fn parse_status_vm_rss_kb(text: &str) -> Option { + // /^VmRSS:\s+(\d+)\s*kB/m + for line in text.lines() { + let Some(rest) = line.strip_prefix("VmRSS:") else { + continue; + }; + let tokens: Vec<&str> = rest.split_whitespace().collect(); + if tokens.len() < 2 || tokens[1] != "kB" { + return None; + } + return tokens[0].parse::().ok(); + } + None +} + +/// Numeric `/proc` entries (pids), capped at [`PROC_SCAN_CAP`]; `None` when +/// the root is unreadable. +pub fn list_numeric_pids(proc_root: &Path) -> Option> { + let entries = safe_read_dir(proc_root)?; + let mut pids = Vec::new(); + for entry in entries { + if entry.is_empty() || !entry.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + if let Ok(pid) = entry.parse::() { + pids.push(pid); + if pids.len() >= PROC_SCAN_CAP { + break; + } + } + } + pids.sort_unstable(); + Some(pids) +} + +/// Bounded read of one `/proc/` file (mirrors Node's +/// `readTextFileBounded(path, 4096)`); `None` on any failure. +pub fn read_pid_file_bounded(proc_root: &Path, pid: u64, name: &str) -> Option { + use std::io::Read; + let file = std::fs::File::open(proc_root.join(pid.to_string()).join(name)).ok()?; + let mut buffer = Vec::with_capacity(PROC_PID_FILE_MAX_BYTES); + file.take(PROC_PID_FILE_MAX_BYTES as u64) + .read_to_end(&mut buffer) + .ok()?; + Some(String::from_utf8_lossy(&buffer).into_owned()) +} + +/// jiffies delta over `dwell_ms` -> cpu percent, clamped to +/// `[0, 100 * cores]` (Node `computeCpuPct`; USER_HZ=100). A non-positive +/// dwell returns 0 (never NaN/Infinity). +pub fn compute_cpu_pct(delta_jiffies: f64, dwell_ms: u64, cores: u64) -> f64 { + if !delta_jiffies.is_finite() || dwell_ms == 0 { + return 0.0; + } + let cores = cores.max(1); + let pct = (delta_jiffies / USER_HZ as f64 / (dwell_ms as f64 / 1000.0)) * 100.0; + pct.clamp(0.0, 100.0 * cores as f64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_stats_readers_is_whole_device_classifies_names() { + assert!(is_whole_device("sda")); + assert!(is_whole_device("nvme0n1")); + assert!(is_whole_device("mmcblk0")); + assert!(is_whole_device("dm-0")); + assert!(!is_whole_device("sda1")); + assert!(!is_whole_device("vda2")); + assert!(!is_whole_device("nvme0n1p1")); + assert!(!is_whole_device("mmcblk0p1")); + assert!(!is_whole_device("loop0")); + assert!(!is_whole_device("ram0")); + } + + #[test] + fn host_stats_readers_compute_cpu_pct_jiffy_math() { + // 30 jiffies over a 300ms dwell = 100% of one core (USER_HZ=100). + assert_eq!(compute_cpu_pct(30.0, 300, 4), 100.0); + assert_eq!(compute_cpu_pct(15.0, 300, 4), 50.0); + // Clamped to [0, 100 * cores]; non-positive dwell -> 0. + assert_eq!(compute_cpu_pct(1e12, 1, 4), 400.0); + assert_eq!(compute_cpu_pct(-5.0, 300, 4), 0.0); + assert_eq!(compute_cpu_pct(50.0, 0, 4), 0.0); + } + + #[test] + fn host_stats_readers_parse_proc_pid_stat_comm_with_parens() { + // The procmini fixture's pid 404 line: comm contains parens AND + // spaces — the split must happen after the LAST ')'. + let text = "404 (my (weird) proc) D 1 404 404 0 -1 4194304 200 0 5 0 999 111 0 0 20 0 2 0 8000 300000000 6000\n"; + let parsed = parse_proc_pid_stat(text).expect("valid stat line"); + assert_eq!(parsed.name, "my (weird) proc"); + assert_eq!(parsed.state, "D"); + assert_eq!(parsed.busy_jiffies, 999 + 111); + assert!(parse_proc_pid_stat("999 (broken").is_none()); + } + + #[test] + fn host_stats_readers_parse_status_vm_rss_kb() { + let text = "Name:\tsystemd\nVmRSS:\t 12345 kB\nThreads:\t1\n"; + assert_eq!(parse_status_vm_rss_kb(text), Some(12345)); + assert_eq!(parse_status_vm_rss_kb("Name:\tx\n"), None); + } +} diff --git a/crates/freshell-platform/src/lib.rs b/crates/freshell-platform/src/lib.rs index 528ae3cbc..cf8b19730 100644 --- a/crates/freshell-platform/src/lib.rs +++ b/crates/freshell-platform/src/lib.rs @@ -55,6 +55,7 @@ pub mod cli_launch; pub mod clock; pub mod detect; pub mod git_meta; +pub mod host_stats_readers; pub mod mcp_inject; pub mod opencode_plugin; pub mod path; diff --git a/crates/freshell-server/src/host_stats.rs b/crates/freshell-server/src/host_stats.rs new file mode 100644 index 000000000..4afd91949 --- /dev/null +++ b/crates/freshell-server/src/host_stats.rs @@ -0,0 +1,1873 @@ +//! HostStatsCollectorService — the Rust port of the subscriber-gated two-tier +//! host pressure collector `server/host-stats/service.ts` +//! (`docs/plans/2026-08-25-host-pressure-pane.md` Task 9 contract lines +//! 874–933). Implements [`freshell_ws::host_stats_collector::HostStatsCollector`] +//! over the pure path-injected readers in +//! [`freshell_platform::host_stats_readers`] (themselves the port of +//! `server/host-stats/readers.ts`); `freshell-ws` owns the trait + +//! interest registry + dispatch and never touches `/proc` or timers. +//! +//! Tiers: FAST (default `FRESHELL_HOST_STATS_FAST_MS` || 2000) reads +//! cpu/load/memory (cgroup-aware)/paging/psi + freshell internals; SLOW +//! (default `FRESHELL_HOST_STATS_SLOW_MS` || 5000) reads +//! diskstats/netdev/tcp/limits/cpufreq. Rates (cpu%, paging KB/s, disk/net +//! B/s) come from CUMULATIVE reader counters delta'd over dt; the previous +//! sample of each counter family lives in the shared cache. The first tick of +//! each family has no window, so it reports null-safe zeros (rates 0, +//! nullable windows null). +//! +//! `set_active(true)` runs ONE immediate fast tick (a fresh subscriber gets a +//! shaped snapshot at once); the slow tier only ticks on its own interval. +//! `set_active(false)` aborts ALL collection tasks (true zero cost). +//! `snapshot()` never blocks on I/O — ticks write caches, snapshots read +//! caches. +//! +//! `refresh()` (on-request manual data — process table, disks, inotify, +//! thermals/battery) is single-flight with a 1s post-completion cooldown +//! (connection-agnostic, R3M6). Section budgets are COOPERATIVE: every +//! section gets a shared absolute deadline (start + section_budget; the +//! process-table scan's per-pid deadline check exists for this) and an +//! overall_budget watchdog marks any still-running section failed. A failed +//! section keeps the full zero-shape + `available:false` + a sectionErrors +//! entry; other sections complete. +//! +//! Platform: `/proc` + `/sys` readers are Linux-only. Unlike Node there is NO +//! darwin fallback (`os.cpus()/os.loadavg()/os.totalmem()` scraped objects +//! and the `ps` subprocess are Node-only): on darwin/Windows the files simply +//! do not exist, so every `/proc`-dependent section degrades to its +//! zero-shape (`available:false`) and `cpu.available` is `false` (frozen +//! Task 9 note). +//! +//! Delivery (frozen contract): snapshots flow ONLY to subscribed connections +//! — the cadence iterates the interest registry's per-connection senders +//! (captured by `terminal.rs` at subscribe time); the shared `broadcast_tx` +//! fan-out bus is NEVER used (non-watchers get zero traffic). + +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use freshell_platform::host_stats_readers as readers; +use freshell_protocol::{ + HostStatsBattery, HostStatsCpu, HostStatsDisk, HostStatsDiskIo, HostStatsDisks, + HostStatsFreshell, HostStatsInotify, HostStatsLimits, HostStatsLive, HostStatsLoad, + HostStatsMachine, HostStatsManual, HostStatsMemory, HostStatsNetwork, HostStatsPaging, + HostStatsProcessHealth, HostStatsPsi, HostStatsSnapshot, HostStatsThermalZone, + HostStatsThermals, HostStatsTopProcess, HostStatsTopProcesses, +}; +use freshell_ws::host_stats_collector::{ + HostStatsCollector, HostStatsRefreshFuture, HostStatsRefreshOk, +}; +use freshell_ws::host_stats_interest::HostStatsInterestRegistry; + +const DEFAULT_FAST: Duration = Duration::from_millis(2000); +const DEFAULT_SLOW: Duration = Duration::from_millis(5000); +const DEFAULT_OVERALL_BUDGET: Duration = Duration::from_millis(4000); +/// No re-start stampede: refresh() rejects <1s after the previous refresh +/// COMPLETED (connection-agnostic, mirrors Node's REFRESH_MIN_INTERVAL_MS). +const DEFAULT_REFRESH_COOLDOWN: Duration = Duration::from_millis(1000); +/// Scheduler-drift sampler cadence while active (the Rust stand-in for +/// Node's `monitorEventLoopDelay` histogram; samples land in a per-fast-tick +/// window whose p99 becomes `eventLoopLagP99Ms`). +const DEFAULT_DRIFT_SAMPLE_INTERVAL: Duration = Duration::from_millis(100); +/// On-request process-table dwell (two `/proc` samples + dwell → per-process +/// cpuPct). Mirrors Node's PROC_SCAN_DWELL_MS. +const PROC_SCAN_DWELL: Duration = Duration::from_millis(300); +const TOP_PROCESS_COUNT: usize = 12; +const DISK_SECTOR_BYTES: u64 = 512; +/// `/proc/vmstat` pswpin/pswpout count PAGES; 4KB pages on every production +/// target (documented Node assumption, mirrored). +const VMSTAT_PAGE_KB: u64 = 4; + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn env_positive_ms(name: &str, fallback: Duration) -> Duration { + std::env::var(name) + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|v| *v > 0) + .map(Duration::from_millis) + .unwrap_or(fallback) +} + +fn clamp_pct(value: f64) -> f64 { + value.clamp(0.0, 100.0) +} + +/// Tunables + injected filesystem roots. `Default` is the production +/// contract; tests inject the committed fixture tree + fast cadences (no +/// tokio time control — real short cadences, deterministic count +/// assertions). +#[derive(Debug, Clone)] +pub struct HostStatsCollectorConfig { + /// Default `/proc` (the machine probe + every reader root). + pub proc_root: PathBuf, + /// Default `/sys` (cgroup root = `/fs/cgroup`, cpufreq, + /// thermal, power_supply). + pub sys_root: PathBuf, + pub fast: Duration, + pub slow: Duration, + /// Watchdog for a refresh section still running past the cooperative + /// per-section budget (the trait's `deadline` argument is that budget). + pub overall_budget: Duration, + pub refresh_cooldown: Duration, + pub drift_sample_interval: Duration, +} + +impl Default for HostStatsCollectorConfig { + fn default() -> Self { + Self { + proc_root: PathBuf::from("/proc"), + sys_root: PathBuf::from("/sys"), + fast: DEFAULT_FAST, + slow: DEFAULT_SLOW, + overall_budget: DEFAULT_OVERALL_BUDGET, + refresh_cooldown: DEFAULT_REFRESH_COOLDOWN, + drift_sample_interval: DEFAULT_DRIFT_SAMPLE_INTERVAL, + } + } +} + +impl HostStatsCollectorConfig { + /// Production wiring: defaults with the two cadence env overrides + /// (`FRESHELL_HOST_STATS_FAST_MS`/`_SLOW_MS`, positive ms only — Node + /// `envPositiveMs` parity). + pub fn from_env() -> Self { + let mut cfg = Self::default(); + cfg.fast = env_positive_ms("FRESHELL_HOST_STATS_FAST_MS", DEFAULT_FAST); + cfg.slow = env_positive_ms("FRESHELL_HOST_STATS_SLOW_MS", DEFAULT_SLOW); + cfg + } + + fn cgroup_root(&self) -> PathBuf { + self.sys_root.join("fs").join("cgroup") + } +} + +/// The in-flight wire one refresh clones to every waiter (single-flight). +type RefreshWire = Result; + +/// The mutable collection state, shared by the collector handle and its +/// spawned cadence tasks. All guards are std Mutexes: locks are never held +/// across an await (ticks are sync reader calls; refresh awaits only the +/// dwell sleep / watch channel). +struct Share { + live: Mutex, + manual: Mutex>, + prev_cpu: Mutex>, + prev_vmstat: Mutex>, + prev_disks: Mutex)>>, + prev_net: Mutex>, + /// Scheduler-drift samples (ms) since the previous fast tick; drained per + /// fast tick into `freshell.eventLoopLagP99Ms`. + lag_samples: Mutex>, + cadence: Mutex>, + /// Single-flight: while Some, a refresh is in flight and later callers + /// clone this receiver and await the SAME wire (Node returns the same + /// in-flight promise). + refresh_flight: Mutex>>>, + last_refresh_completed: Mutex>, +} + +struct CadenceHandles { + fast: tokio::task::JoinHandle<()>, + slow: tokio::task::JoinHandle<()>, + drift: tokio::task::JoinHandle<()>, +} + +/// Everything the cadence tasks + refresh path need, Arc-shared. +struct CollectorCtx { + cfg: HostStatsCollectorConfig, + registry: freshell_terminal::TerminalRegistry, + interest: HostStatsInterestRegistry, + boot_anchor: Instant, + machine: HostStatsMachine, + scan_runs: AtomicUsize, + share: Share, +} + +/// The concrete Task 9 collector. Construct + `Arc` +/// it in `main.rs` next to the terminal-registry construction; NO task spawns +/// here — the interest-transition callback (`set_active`) owns spawn/abort. +pub struct HostStatsCollectorService { + ctx: Arc, +} + +impl HostStatsCollectorService { + pub fn new( + cfg: HostStatsCollectorConfig, + registry: freshell_terminal::TerminalRegistry, + interest: HostStatsInterestRegistry, + boot_anchor: Instant, + ) -> Self { + let machine_info = readers::read_machine_info(&cfg.proc_root, &cfg.sys_root); + let machine = HostStatsMachine { + cores: machine_info.cores, + mem_total_bytes: machine_info.mem_total_bytes, + platform: machine_info.platform, + wsl: machine_info.wsl, + kernel: machine_info.kernel, + hostname: machine_info.hostname, + psi: machine_info.psi, + cgroup: machine_info.cgroup, + thermal_count: machine_info.thermal_count, + battery_present: machine_info.battery_present, + gpu: machine_info.gpu, + }; + Self { + ctx: Arc::new(CollectorCtx { + share: Share { + live: Mutex::new(zero_live(&machine)), + manual: Mutex::new(None), + prev_cpu: Mutex::new(None), + prev_vmstat: Mutex::new(None), + prev_disks: Mutex::new(None), + prev_net: Mutex::new(None), + lag_samples: Mutex::new(Vec::new()), + cadence: Mutex::new(None), + refresh_flight: Mutex::new(None), + last_refresh_completed: Mutex::new(None), + }, + cfg, + registry, + interest, + boot_anchor, + machine, + scan_runs: AtomicUsize::new(0), + }), + } + } + + /// Test-visible cadence state: true while the two-tier cadence + drift + /// sampler JoinHandles are owned (between `set_active(true)` and + /// `set_active(false)`). Only test code reads this (the binary crate's + /// non-test build has no other consumer, hence the allow). + #[allow(dead_code)] + pub fn is_running(&self) -> bool { + self.ctx.share.cadence.lock().unwrap().is_some() + } + + /// Test-support instrumentation: how many process-table scans the + /// refresh path has run (single-flight proof). + #[allow(dead_code)] + pub fn scan_run_count(&self) -> usize { + self.ctx.scan_runs.load(Ordering::SeqCst) + } +} + +// --------------------------------------------------------------------------- +// Cadence internals +// --------------------------------------------------------------------------- + +impl CollectorCtx { + /// The merge view `snapshot()` publishes (ticks write caches; snapshots + /// read caches — never blocks on fresh I/O). + fn snapshot_payload(&self) -> HostStatsSnapshot { + let live = self.share.live.lock().unwrap().clone(); + let manual = self.share.manual.lock().unwrap().clone(); + HostStatsSnapshot { + at: now_ms(), + live, + manual_at: manual.as_ref().map(|(at, _)| *at), + manual: manual.map(|(_, m)| m), + } + } + + /// Push the current snapshot to SUBSCRIBED connections only (the frozen + /// Task 9 delivery contract: the per-connection senders captured at + /// subscribe time; never `broadcast_tx`). + fn deliver_snapshot(&self) { + if !self.interest.any() { + return; + } + let msg = freshell_protocol::ServerMessage::HostStatsSnapshot(self.snapshot_payload()); + for sink in self.interest.senders() { + sink(msg.clone()); + } + } + + /// FAST tier (Node `tickFast`): cpu/load/memory/paging/psi + freshell + /// internals, then the snapshot fan-out (Node emits after fast ticks + /// only; the slow tier is pull-side). + fn tick_fast(&self) { + let at = now_ms(); + let cpu = self.read_cpu_section(at); + let load = self.read_load_section(); + let memory = self.read_memory_section(); + let paging = self.read_paging_section(at); + let psi = self.read_psi_section(); + let freshell = self.read_freshell_section(); + { + let mut live = self.share.live.lock().unwrap(); + live.cpu = cpu; + live.load = load; + live.memory = memory; + live.paging = paging; + live.psi = psi; + live.freshell = freshell; + } + self.deliver_snapshot(); + } + + /// SLOW tier (Node `tickSlow`): cpufreq merges into the cached cpu + /// section; diskstats/netdev/limits are delta'd here. + fn tick_slow(&self) { + let at = now_ms(); + let freq_m_hz = readers::read_cpu_freq_mhz(&self.cfg.sys_root); + let disk_io = self.read_disk_io_section(at); + let network = self.read_network_section(at); + let limits = self.read_limits_section(); + let mut live = self.share.live.lock().unwrap(); + live.cpu.freq_m_hz = freq_m_hz; + live.disk_io = disk_io; + live.network = network; + live.limits = limits; + } + + // ----------------------------------------------------------------- + // Fast-tier sections + // ----------------------------------------------------------------- + + fn read_cpu_section(&self, at: u64) -> HostStatsCpu { + let Some(sample) = readers::read_cpu_times(&self.cfg.proc_root) else { + return zero_cpu(); + }; + let prev = self + .share + .prev_cpu + .lock() + .unwrap() + .replace((at, sample.clone())); + let freq_m_hz = self.share.live.lock().unwrap().cpu.freq_m_hz; + let Some((prev_at, prev_v)) = prev else { + // First tick: no window — null-safe zero rates. + return HostStatsCpu { + available: true, + usage_pct: 0.0, + steal_pct: Some(0.0), + per_core_pct: sample.per_core.iter().map(|_| 0.0).collect(), + freq_m_hz, + }; + }; + if at <= prev_at || sample.total <= prev_v.total { + return HostStatsCpu { + available: true, + usage_pct: 0.0, + steal_pct: Some(0.0), + per_core_pct: sample.per_core.iter().map(|_| 0.0).collect(), + freq_m_hz, + }; + } + let d_total = sample.total - prev_v.total; + HostStatsCpu { + available: true, + usage_pct: clamp_pct((sample.busy - prev_v.busy) / d_total * 100.0), + steal_pct: Some(clamp_pct((sample.steal - prev_v.steal) / d_total * 100.0)), + per_core_pct: sample + .per_core + .iter() + .enumerate() + .map(|(i, core)| { + let Some(before) = prev_v.per_core.get(i) else { + return 0.0; + }; + let d_core_total = core.total - before.total; + if d_core_total <= 0.0 { + 0.0 + } else { + clamp_pct((core.busy - before.busy) / d_core_total * 100.0) + } + }) + .collect(), + freq_m_hz, + } + } + + fn read_load_section(&self) -> HostStatsLoad { + let cores = self.machine.cores; + let Some(load) = readers::read_loadavg(&self.cfg.proc_root) else { + return zero_load(cores); + }; + HostStatsLoad { + available: true, + load1: load.load1, + load5: load.load5, + load15: load.load15, + cores, + } + } + + /// Memory precedence (contract point 2): a FINITE cgroup leaf limit wins + /// outright (source 'cgroup'; total/used/available/limit all from the + /// leaf). Unlimited or absent → host meminfo (source 'host'); a cgroup + /// current is NEVER mixed with a host total. Swap stays host-scoped + /// context either way (no cgroup swap accounting is collected). + fn read_memory_section(&self) -> HostStatsMemory { + let cgroup = readers::read_cgroup_memory(&self.cfg.cgroup_root(), &self.cfg.proc_root); + let meminfo = readers::read_meminfo(&self.cfg.proc_root); + let swap_total_bytes = meminfo.map(|m| m.swap_total_kb * 1024); + let swap_used_bytes = + meminfo.map(|m| (m.swap_total_kb - m.swap_free_kb) * 1024); + if let Some(cg) = cgroup { + if let Some(limit) = cg.limit_bytes { + return HostStatsMemory { + available: true, + source: "cgroup".to_string(), + total_bytes: limit, + used_bytes: cg.current_bytes, + available_bytes: limit.saturating_sub(cg.current_bytes), + cgroup_limit_bytes: Some(limit), + swap_total_bytes, + swap_used_bytes, + }; + } + } + if let Some(mem) = meminfo { + let total_bytes = mem.total_kb * 1024; + let available_bytes = mem.avail_kb * 1024; + return HostStatsMemory { + available: true, + source: "host".to_string(), + total_bytes, + used_bytes: total_bytes.saturating_sub(available_bytes), + available_bytes, + cgroup_limit_bytes: None, + swap_total_bytes, + swap_used_bytes, + }; + } + zero_memory() + } + + fn read_paging_section(&self, at: u64) -> HostStatsPaging { + let Some(vm) = readers::read_vmstat(&self.cfg.proc_root) else { + return zero_paging(); + }; + let prev = self + .share + .prev_vmstat + .lock() + .unwrap() + .replace((at, vm)); + let oom_kills_total = vm.oom_kill.unwrap_or(0); + let Some((prev_at, prev_v)) = prev else { + return HostStatsPaging { + available: true, + swap_in_kbps: 0.0, + swap_out_kbps: 0.0, + maj_faults_per_sec: 0.0, + oom_kills_delta: 0, + oom_kills_total, + }; + }; + if at <= prev_at { + return HostStatsPaging { + available: true, + swap_in_kbps: 0.0, + swap_out_kbps: 0.0, + maj_faults_per_sec: 0.0, + oom_kills_delta: 0, + oom_kills_total, + }; + } + let dt_sec = (at - prev_at) as f64 / 1000.0; + HostStatsPaging { + available: true, + swap_in_kbps: (vm.pswpin.saturating_sub(prev_v.pswpin) * VMSTAT_PAGE_KB) as f64 / dt_sec, + swap_out_kbps: (vm.pswpout.saturating_sub(prev_v.pswpout) * VMSTAT_PAGE_KB) as f64 + / dt_sec, + maj_faults_per_sec: vm.pgmajfault.saturating_sub(prev_v.pgmajfault) as f64 / dt_sec, + oom_kills_delta: match (vm.oom_kill, prev_v.oom_kill) { + (Some(cur), Some(before)) => cur.saturating_sub(before), + _ => 0, + }, + oom_kills_total, + } + } + + fn read_psi_section(&self) -> HostStatsPsi { + let Some(psi) = readers::read_psi(&self.cfg.proc_root) else { + return zero_psi(); + }; + HostStatsPsi { + available: true, + cpu_some10: psi.cpu_some10, + mem_some10: psi.mem_some10, + mem_full10: psi.mem_full10, + io_some10: psi.io_some10, + io_full10: psi.io_full10, + } + } + + fn read_freshell_section(&self) -> HostStatsFreshell { + HostStatsFreshell { + available: true, + source: "rust".to_string(), + // The diag.rs access pattern: the live inventory length. + ptys_running: self.registry.inventory().len() as u64, + ptys_max: 0, + ws_clients: self.registry.connection_count() as u64, + ws_clients_max: 0, + event_loop_lag_p99_ms: self.drain_lag_p99_ms(), + rss_bytes: read_self_rss_bytes(), + uptime_sec: self.boot_anchor.elapsed().as_secs_f64(), + } + } + + /// p99 scheduler drift (ms) collected since the previous fast tick; + /// None when unmeasurable (Node histogram parity: drain + reset per fast + /// tick). + fn drain_lag_p99_ms(&self) -> Option { + let mut guard = self.share.lag_samples.lock().unwrap(); + if guard.is_empty() { + return None; + } + let mut samples = std::mem::take(&mut *guard); + drop(guard); + samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + // nearest-rank p99 + let rank = ((0.99 * samples.len() as f64).ceil() as usize).clamp(1, samples.len()); + let value = samples[rank - 1]; + (value.is_finite() && value >= 0.0).then_some(value) + } + + // ----------------------------------------------------------------- + // Slow-tier sections + // ----------------------------------------------------------------- + + fn read_disk_io_section(&self, at: u64) -> HostStatsDiskIo { + let Some(devs) = readers::read_disk_stats(&self.cfg.proc_root) else { + return zero_disk_io(); + }; + let prev = self + .share + .prev_disks + .lock() + .unwrap() + .replace((at, devs.clone())); + let Some((prev_at, prev_v)) = prev else { + return HostStatsDiskIo { + available: true, + read_bps: 0.0, + write_bps: 0.0, + util_pct: None, + weighted_await_ms: None, + }; + }; + if at <= prev_at { + return HostStatsDiskIo { + available: true, + read_bps: 0.0, + write_bps: 0.0, + util_pct: None, + weighted_await_ms: None, + }; + } + let dt_ms = (at - prev_at) as f64; + let dt_sec = dt_ms / 1000.0; + let mut read_bytes = 0u64; + let mut write_bytes = 0u64; + let mut util_pct: Option = None; + let mut weighted_await_ms: Option = None; + for (name, cur) in &devs { + let Some(before) = prev_v.get(name) else { + continue; + }; + read_bytes += cur.read_sectors.saturating_sub(before.read_sectors) * DISK_SECTOR_BYTES; + write_bytes += + cur.written_sectors.saturating_sub(before.written_sectors) * DISK_SECTOR_BYTES; + // Multi-device rule (plan thresholds): worst device wins; util + // can never exceed 100. + let util = clamp_pct( + (cur.time_doing_ios_ms.saturating_sub(before.time_doing_ios_ms)) as f64 / dt_ms + * 100.0, + ); + if util_pct.is_none_or(|best| util > best) { + util_pct = Some(util); + let ios = cur.reads_completed.saturating_sub(before.reads_completed) + + cur.writes_completed.saturating_sub(before.writes_completed); + let io_ms = + cur.read_ms.saturating_sub(before.read_ms) + cur.write_ms.saturating_sub(before.write_ms); + weighted_await_ms = if ios > 0 { Some(io_ms as f64 / ios as f64) } else { None }; + } + } + HostStatsDiskIo { + available: true, + read_bps: read_bytes as f64 / dt_sec, + write_bps: write_bytes as f64 / dt_sec, + util_pct, + weighted_await_ms, + } + } + + fn read_network_section(&self, at: u64) -> HostStatsNetwork { + let Some(net) = readers::read_net_dev(&self.cfg.proc_root) else { + return zero_network(); + }; + let prev = self.share.prev_net.lock().unwrap().replace((at, net)); + let totals = |rx_bps: f64, tx_bps: f64, deltas: (u64, u64, u64, u64)| HostStatsNetwork { + available: true, + rx_bps, + tx_bps, + rx_errors_total: net.rx_err, + tx_errors_total: net.tx_err, + rx_dropped_total: net.rx_drop, + tx_dropped_total: net.tx_drop, + rx_errors_delta: deltas.0, + tx_errors_delta: deltas.1, + rx_dropped_delta: deltas.2, + tx_dropped_delta: deltas.3, + }; + let Some((prev_at, prev_v)) = prev else { + return totals(0.0, 0.0, (0, 0, 0, 0)); + }; + if at <= prev_at { + return totals(0.0, 0.0, (0, 0, 0, 0)); + } + let dt_sec = (at - prev_at) as f64 / 1000.0; + totals( + net.rx_bytes.saturating_sub(prev_v.rx_bytes) as f64 / dt_sec, + net.tx_bytes.saturating_sub(prev_v.tx_bytes) as f64 / dt_sec, + ( + net.rx_err.saturating_sub(prev_v.rx_err), + net.tx_err.saturating_sub(prev_v.tx_err), + net.rx_drop.saturating_sub(prev_v.rx_drop), + net.tx_drop.saturating_sub(prev_v.tx_drop), + ), + ) + } + + fn read_limits_section(&self) -> HostStatsLimits { + let proc_root = &self.cfg.proc_root; + let fds_used = readers::read_self_fd_count(proc_root); + let fds_max = readers::read_self_limits_fds_max(proc_root); + let pids_used = readers::read_pid_count(proc_root); + let pids_max = readers::read_pids_limit(proc_root, &self.cfg.cgroup_root()); + let time_wait = readers::read_tcp_state_counts(proc_root).map(|t| t.time_wait); + let ephemeral_ports = + readers::read_ephemeral_port_range(proc_root).map(|r| r.end - r.start + 1); + if fds_used.is_none() + && fds_max.is_none() + && pids_used.is_none() + && pids_max.is_none() + && time_wait.is_none() + && ephemeral_ports.is_none() + { + return zero_limits(); + } + HostStatsLimits { + available: true, + fds_used, + fds_max, + pids_used, + pids_max, + time_wait, + ephemeral_ports, + } + } +} + +/// `/proc/self/statm` resident pages × page size (Node +/// `process.memoryUsage().rss`). A REAL self-read independent of the injected +/// proc root (same as Node's). +#[cfg(unix)] +fn read_self_rss_bytes() -> Option { + let text = std::fs::read_to_string("/proc/self/statm").ok()?; + let resident_pages: u64 = text.split_whitespace().nth(1)?.parse().ok()?; + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + if page_size <= 0 { + return None; + } + Some(resident_pages.saturating_mul(page_size as u64)) +} + +/// Non-unix: no RSS source on this Rust path (nullable by contract). +#[cfg(not(unix))] +fn read_self_rss_bytes() -> Option { + None +} + +impl HostStatsCollector for HostStatsCollectorService { + fn snapshot(&self) -> HostStatsSnapshot { + self.ctx.snapshot_payload() + } + + fn refresh(&self, deadline: Duration) -> HostStatsRefreshFuture<'_> { + let ctx = Arc::clone(&self.ctx); + Box::pin(async move { + // Connection-AGNOSTIC post-completion cooldown (Node + // REFRESH_MIN_INTERVAL_MS — separate from terminal.rs's + // per-connection floor). + { + let last = ctx.share.last_refresh_completed.lock().unwrap(); + if let Some(t) = *last { + if t.elapsed() < ctx.cfg.refresh_cooldown { + return Err("rate_limited".to_string()); + } + } + } + enum Flight { + Lead(tokio::sync::watch::Sender>), + Join(tokio::sync::watch::Receiver>), + } + let flight = { + let mut flight = ctx.share.refresh_flight.lock().unwrap(); + if let Some(rx) = flight.clone() { + Flight::Join(rx) + } else { + let (tx, rx) = tokio::sync::watch::channel(None); + *flight = Some(rx); + Flight::Lead(tx) + } + }; + match flight { + Flight::Lead(tx) => { + let result = run_refresh(&ctx, deadline).await; + let _ = tx.send(Some(result.clone())); + *ctx.share.refresh_flight.lock().unwrap() = None; + *ctx.share.last_refresh_completed.lock().unwrap() = Some(Instant::now()); + result + } + Flight::Join(mut rx) => loop { + if let Some(wire) = rx.borrow().clone() { + return wire; + } + if rx.changed().await.is_err() { + return Err("refresh leader vanished".to_string()); + } + }, + } + }) + } + + fn set_active(&self, active: bool) { + let mut cadence = self.ctx.share.cadence.lock().unwrap(); + if active { + if cadence.is_some() { + return; // idempotent + } + // ONE immediate fast tick (Node start() parity: a fresh + // subscriber gets a shaped snapshot at once). Sync reader calls; + // holding the cadence lock across them is safe (disjoint mutexes). + self.ctx.tick_fast(); + let fast_ctx = Arc::clone(&self.ctx); + let fast = tokio::spawn(async move { + let mut ticker = tokio::time::interval(fast_ctx.cfg.fast); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // setInterval never fires at t=0; the inline tick already ran. + ticker.tick().await; + loop { + ticker.tick().await; + fast_ctx.tick_fast(); + } + }); + let slow_ctx = Arc::clone(&self.ctx); + let slow = tokio::spawn(async move { + let mut ticker = tokio::time::interval(slow_ctx.cfg.slow); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + ticker.tick().await; + loop { + ticker.tick().await; + slow_ctx.tick_slow(); + } + }); + let drift_ctx = Arc::clone(&self.ctx); + let drift = tokio::spawn(async move { + let interval = drift_ctx.cfg.drift_sample_interval; + let mut last = Instant::now(); + loop { + tokio::time::sleep(interval).await; + let now = Instant::now(); + let drift_ms = + now.duration_since(last).as_secs_f64() * 1000.0 - interval.as_secs_f64() * 1000.0; + last = now; + if drift_ms.is_finite() && drift_ms > 0.0 { + drift_ctx.share.lag_samples.lock().unwrap().push(drift_ms); + } + } + }); + *cadence = Some(CadenceHandles { fast, slow, drift }); + } else if let Some(handles) = cadence.take() { + handles.fast.abort(); + handles.slow.abort(); + handles.drift.abort(); + } + } +} + +// --------------------------------------------------------------------------- +// On-request refresh (manual sections) +// --------------------------------------------------------------------------- + +/// `fs.statfs` on a mount; `free_bytes` is the unprivileged view (`bavail`). +/// Node `statfsInfo` parity; unix-only on this Rust path. +#[cfg(unix)] +fn statfs_info(mount: &str) -> Option<(u64, u64, f64, Option, Option)> { + let c_path = std::ffi::CString::new(mount).ok()?; + let mut stats: libc::statfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::statfs(c_path.as_ptr(), &mut stats) } != 0 { + return None; + } + let bsize = stats.f_bsize as u64; + let blocks = stats.f_blocks as u64; + let bavail = stats.f_bavail as u64; + let files = stats.f_files as u64; + let ffree = stats.f_ffree as u64; + let total_bytes = bsize * blocks; + let free_bytes = bsize * bavail; + let used_pct = if blocks > 0 { + (1.0 - bavail as f64 / blocks as f64) * 100.0 + } else { + 0.0 + }; + // inodes from files/ffree; some filesystems report 0/0 -> None + let inodes_total = (files > 0).then_some(files); + let inodes_free = (files > 0).then_some(ffree); + Some((total_bytes, free_bytes, used_pct, inodes_total, inodes_free)) +} + +#[cfg(not(unix))] +fn statfs_info(_mount: &str) -> Option<(u64, u64, f64, Option, Option)> { + None +} + +/// How the scan arm resolves (drives BOTH `topProcesses` and +/// `processHealth`, mirroring the Node sections' shared scan promise). +enum ScanOutcome { + Completed(Option), + /// Cooperative per-pid deadline tripped (Node DeadlineExceeded). + SectionDeadline, + /// Overall watchdog preempted a still-running scan. + Watchdog, +} + +/// One refresh run: sections race under a shared absolute cooperative +/// deadline (`started + deadline`, the trait argument — Node +/// `sectionBudgetMs`) and a per-section overall watchdog (`started + +/// overall_budget`, Node `overallBudgetMs`). Never fails for data reasons. +async fn run_refresh(ctx: &Arc, deadline: Duration) -> RefreshWire { + let started = Instant::now(); + let section_deadline = started + deadline; + let overall_deadline = tokio::time::Instant::from_std(started + ctx.cfg.overall_budget); + + let scan_ctx = Arc::clone(ctx); + let scan_fut = async move { + scan_ctx.scan_runs.fetch_add(1, Ordering::SeqCst); + match tokio::time::timeout_at( + overall_deadline, + scan_process_table(&scan_ctx.cfg.proc_root, PROC_SCAN_DWELL, section_deadline), + ) + .await + { + Ok(Ok(scan)) => ScanOutcome::Completed(scan), + Ok(Err(ScanError::DeadlineExceeded)) => ScanOutcome::SectionDeadline, + Err(_elapsed) => ScanOutcome::Watchdog, + } + }; + let inotify_ctx = Arc::clone(ctx); + let inotify_fut = async move { + let usage = readers::read_self_inotify_stats(&inotify_ctx.cfg.proc_root); + let limits = readers::read_inotify_limits(&inotify_ctx.cfg.proc_root); + (usage, limits) + }; + let disks_fut = async { + // Node: darwin mounts ['/'], else ['/', '/dev/shm']. + let mounts: &[&str] = if cfg!(target_os = "macos") { + &["/"] + } else if cfg!(target_os = "windows") { + &[] + } else { + &["/", "/dev/shm"] + }; + let mut list = Vec::new(); + for mount in mounts { + if let Some((total_bytes, free_bytes, used_pct, inodes_total, inodes_free)) = + statfs_info(mount) + { + list.push(HostStatsDisk { + mount: mount.to_string(), + total_bytes, + free_bytes, + used_pct, + inodes_total, + inodes_free, + }); + } + } + list + }; + let thermals_ctx = Arc::clone(ctx); + let thermals_fut = async move { + let zones = readers::read_thermals(&thermals_ctx.cfg.sys_root); + let battery = readers::read_battery(&thermals_ctx.cfg.sys_root); + (zones, battery) + }; + + let (scan_out, (inotify_usage, inotify_limits), disk_list, (zones, battery)) = + tokio::join!(scan_fut, inotify_fut, disks_fut, thermals_fut); + + let mut manual = zero_manual(); + let mut section_errors = HashMap::new(); + + match scan_out { + ScanOutcome::Completed(Some(scan)) => { + manual.top_processes = HostStatsTopProcesses { + available: true, + dwell_ms: PROC_SCAN_DWELL.as_millis() as u64, + list: scan + .top + .into_iter() + .map(|p| HostStatsTopProcess { + pid: p.pid, + name: p.name, + cpu_pct: p.cpu_pct, + rss_bytes: p.rss_bytes, + state: p.state, + }) + .collect(), + }; + manual.process_health = HostStatsProcessHealth { + available: true, + zombies: scan.zombies, + d_state: scan.d_state, + total: scan.total, + }; + } + ScanOutcome::Completed(None) => { + // Missing proc root: degraded WITHOUT an error entry (Node parity: + // `if (!table) return zeroManualSection(key)`). + } + ScanOutcome::SectionDeadline => { + section_errors.insert( + "topProcesses".to_string(), + ScanError::DeadlineExceeded.message().to_string(), + ); + section_errors.insert( + "processHealth".to_string(), + ScanError::DeadlineExceeded.message().to_string(), + ); + } + ScanOutcome::Watchdog => { + // Node's watchdog message. + let msg = "host-stats refresh overall budget exceeded".to_string(); + section_errors.insert("topProcesses".to_string(), msg.clone()); + section_errors.insert("processHealth".to_string(), msg); + } + } + + if inotify_usage.is_some() || inotify_limits.is_some() { + manual.inotify = HostStatsInotify { + available: true, + instances: inotify_usage.map(|u| u.instances), + watches: inotify_usage.map(|u| u.watches), + max_user_watches: inotify_limits.and_then(|l| l.max_user_watches), + max_user_instances: inotify_limits.and_then(|l| l.max_user_instances), + }; + } + + if !disk_list.is_empty() { + manual.disks = HostStatsDisks { + available: true, + list: disk_list, + }; + } + + if let Some(zones) = zones { + manual.thermals = HostStatsThermals { + available: true, + zones: zones + .into_iter() + .map(|z| HostStatsThermalZone { + label: z.label, + celsius: z.celsius, + }) + .collect(), + battery: battery.map(|b| HostStatsBattery { + pct: b.pct, + status: b.status, + }), + }; + } + + manual.section_errors = section_errors; + let at = now_ms(); + *ctx.share.manual.lock().unwrap() = Some((at, manual.clone())); + // Merged snapshot: live may be one tick stale, manual/manualAt are fresh + // (contract point 9) — and subscribers see it (Node emitSnapshot). + ctx.deliver_snapshot(); + Ok(HostStatsRefreshOk { at, manual }) +} + +// --------------------------------------------------------------------------- +// On-request process-table scan (the ONLY async reader family; the dwell is +// why the pure `/proc/` parsers live in freshell-platform but this loop +// lives here — freshell-platform is deliberately tokio-free) +// --------------------------------------------------------------------------- + +/// A scanned process row (mirrors Node's `ProcessSample`). +#[derive(Debug, Clone, PartialEq)] +pub struct ProcessSampleR { + pub pid: u64, + pub name: String, + pub cpu_pct: f64, + pub rss_bytes: u64, + pub state: String, +} + +/// The scan outcome (mirrors Node's `ProcessTableScan`). +#[derive(Debug, Clone, PartialEq)] +pub struct ProcessTableScan { + pub top: Vec, + pub zombies: u64, + pub d_state: u64, + pub total: u64, +} + +/// The scan's only sanctioned failure (Node `DeadlineExceeded`): the shared +/// absolute section budget was exhausted mid-scan. All other failures +/// (missing root, vanished pid, truncated stat) degrade to `Ok(None)` / +/// per-pid skips. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScanError { + DeadlineExceeded, +} + +impl ScanError { + /// The exact Node `DeadlineExceeded` section-error message + /// (`sectionErrors[key]` payload parity). + pub fn message(&self) -> &'static str { + "host-stats section deadline exceeded" + } +} + +/// On-request process table scan: enumerate numeric `` dirs (cap +/// 100k), sample utime+stime (A), dwell, sample again (B) + status VmRSS; +/// cpuPct from the jiffy delta. `deadline` is an ABSOLUTE monotonic budget +/// (the section's cooperative deadline), checked BEFORE each pid's unit of +/// work; on expiry this returns `Err(ScanError::DeadlineExceeded)`. +async fn scan_process_table( + proc_root: &std::path::Path, + dwell: Duration, + deadline: Instant, +) -> Result, ScanError> { + let Some(pids) = readers::list_numeric_pids(proc_root) else { + return Ok(None); + }; + // total = numeric /proc entries discovered (enumeration truth), + // independent of per-pid parse health. + let total = pids.len() as u64; + let mut sample_a: HashMap = HashMap::new(); + let mut zombies = 0u64; + let mut d_state = 0u64; + for pid in &pids { + if Instant::now() > deadline { + return Err(ScanError::DeadlineExceeded); + } + // truncated/vanished -> process skipped, never thrown + let Some(text) = readers::read_pid_file_bounded(proc_root, *pid, "stat") else { + continue; + }; + let Some(parsed) = readers::parse_proc_pid_stat(&text) else { + continue; + }; + if parsed.state == "Z" { + zombies += 1; + } + if parsed.state == "D" { + d_state += 1; + } + sample_a.insert(*pid, parsed); + } + + tokio::time::sleep(dwell).await; + + let cores = std::thread::available_parallelism() + .map(|n| n.get() as u64) + .unwrap_or(1); + let mut top: Vec = Vec::new(); + for (pid, before) in &sample_a { + if Instant::now() > deadline { + return Err(ScanError::DeadlineExceeded); + } + let Some(stat_text) = readers::read_pid_file_bounded(proc_root, *pid, "stat") else { + continue; + }; + let Some(after) = readers::parse_proc_pid_stat(&stat_text) else { + continue; + }; + let rss_kb = readers::read_pid_file_bounded(proc_root, *pid, "status") + .and_then(|text| readers::parse_status_vm_rss_kb(&text)); + top.push(ProcessSampleR { + pid: *pid, + name: after.name, + cpu_pct: readers::compute_cpu_pct( + after.busy_jiffies as f64 - before.busy_jiffies as f64, + dwell.as_millis() as u64, + cores, + ), + rss_bytes: rss_kb.unwrap_or(0) * 1024, + state: after.state, + }); + } + top.sort_by(|a, b| { + b.cpu_pct + .partial_cmp(&a.cpu_pct) + .unwrap_or(std::cmp::Ordering::Equal) + }); + top.truncate(TOP_PROCESS_COUNT); + Ok(Some(ProcessTableScan { + top, + zombies, + d_state, + total, + })) +} + +// --------------------------------------------------------------------------- +// Zero shapes (mirror of the Node LIVE_SECTION_ZERO / zeroManualSection tree; +// every degraded section reports `available:false` with the SAME otherwise- +// zero payload, so the client renders the em-dash family) +// --------------------------------------------------------------------------- + +fn zero_cpu() -> HostStatsCpu { + HostStatsCpu { + available: false, + usage_pct: 0.0, + steal_pct: None, + per_core_pct: Vec::new(), + freq_m_hz: None, + } +} + +fn zero_load(cores: u64) -> HostStatsLoad { + HostStatsLoad { + available: false, + load1: 0.0, + load5: 0.0, + load15: 0.0, + cores, + } +} + +fn zero_memory() -> HostStatsMemory { + HostStatsMemory { + available: false, + source: "host".to_string(), + total_bytes: 0, + used_bytes: 0, + available_bytes: 0, + cgroup_limit_bytes: None, + swap_total_bytes: None, + swap_used_bytes: None, + } +} + +fn zero_paging() -> HostStatsPaging { + HostStatsPaging { + available: false, + swap_in_kbps: 0.0, + swap_out_kbps: 0.0, + maj_faults_per_sec: 0.0, + oom_kills_delta: 0, + oom_kills_total: 0, + } +} + +fn zero_psi() -> HostStatsPsi { + HostStatsPsi { + available: false, + cpu_some10: None, + mem_some10: None, + mem_full10: None, + io_some10: None, + io_full10: None, + } +} + +fn zero_disk_io() -> HostStatsDiskIo { + HostStatsDiskIo { + available: false, + read_bps: 0.0, + write_bps: 0.0, + util_pct: None, + weighted_await_ms: None, + } +} + +fn zero_network() -> HostStatsNetwork { + HostStatsNetwork { + available: false, + rx_bps: 0.0, + tx_bps: 0.0, + rx_errors_total: 0, + tx_errors_total: 0, + rx_dropped_total: 0, + tx_dropped_total: 0, + rx_errors_delta: 0, + tx_errors_delta: 0, + rx_dropped_delta: 0, + tx_dropped_delta: 0, + } +} + +fn zero_limits() -> HostStatsLimits { + HostStatsLimits { + available: false, + fds_used: None, + fds_max: None, + pids_used: None, + pids_max: None, + time_wait: None, + ephemeral_ports: None, + } +} + +fn zero_freshell() -> HostStatsFreshell { + HostStatsFreshell { + available: false, + source: "rust".to_string(), + ptys_running: 0, + // LB9 (frozen): freshell-ws has NO connection cap and the Rust spawn + // gate is a concurrency gate, not a PTY-count cap — both maxes are 0 + // (client renders '—'). + ptys_max: 0, + ws_clients: 0, + ws_clients_max: 0, + event_loop_lag_p99_ms: None, + rss_bytes: None, + uptime_sec: 0.0, + } +} + +fn zero_live(machine: &HostStatsMachine) -> HostStatsLive { + HostStatsLive { + machine: machine.clone(), + cpu: zero_cpu(), + load: zero_load(machine.cores), + memory: zero_memory(), + paging: zero_paging(), + psi: zero_psi(), + disk_io: zero_disk_io(), + network: zero_network(), + limits: zero_limits(), + freshell: zero_freshell(), + } +} + +fn zero_manual() -> HostStatsManual { + HostStatsManual { + top_processes: HostStatsTopProcesses { + available: false, + dwell_ms: 0, + list: Vec::new(), + }, + process_health: HostStatsProcessHealth { + available: false, + zombies: 0, + d_state: 0, + total: 0, + }, + inotify: HostStatsInotify { + available: false, + instances: None, + watches: None, + max_user_watches: None, + max_user_instances: None, + }, + disks: HostStatsDisks { + available: false, + list: Vec::new(), + }, + thermals: HostStatsThermals { + available: false, + zones: Vec::new(), + battery: None, + }, + section_errors: HashMap::new(), + } +} + +// =========================================================================== +// Task 9 behavioral tests. These call the REAL production surface (they were +// authored RED-first against the compiling skeleton — runtime assertion +// failures/`unimplemented!()` panics, never compile errors). Fixture bytes are +// the intentional duplication of `test/fixtures/host-stats/` (plan step 5: +// ports drift independently). +// =========================================================================== +#[cfg(test)] +mod tests { + use super::*; + use freshell_platform::host_stats_readers as readers; + use freshell_protocol::{HostStatsBattery, ServerMessage}; + use freshell_terminal::FrameSink; + use std::path::{Path, PathBuf}; + use std::sync::Mutex as StdMutex; + + fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/host-stats") + } + fn proc_fixture() -> PathBuf { + fixtures().join("proc") + } + fn procmini_fixture() -> PathBuf { + fixtures().join("procmini") + } + fn sys_fixture() -> PathBuf { + fixtures().join("sys") + } + fn cgroup_fixture() -> PathBuf { + sys_fixture().join("fs").join("cgroup") + } + fn missing() -> PathBuf { + fixtures().join("never-existed") + } + + fn test_config(proc_root: PathBuf, sys_root: PathBuf) -> HostStatsCollectorConfig { + HostStatsCollectorConfig { + proc_root, + sys_root, + fast: Duration::from_millis(25), + slow: Duration::from_millis(50), + ..Default::default() + } + } + + fn test_collector( + proc_root: PathBuf, + sys_root: PathBuf, + interest: &HostStatsInterestRegistry, + ) -> HostStatsCollectorService { + HostStatsCollectorService::new( + test_config(proc_root, sys_root), + freshell_terminal::TerminalRegistry::new(), + interest.clone(), + Instant::now(), + ) + } + + async fn wait_until(timeout: Duration, mut predicate: impl FnMut() -> bool) -> bool { + let start = Instant::now(); + loop { + if predicate() { + return true; + } + if start.elapsed() >= timeout { + return false; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + /// Copy a fixture tree into a fresh tmpdir (the process-scan overlay then + /// adds a truncated-stat pid). Mirrors the Node suite's beforeAll tmp + /// overlays (symlinks/empty dirs cannot be committed to git). + fn copy_tree(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_tree(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } + } + + fn scan_proc_overlay(tmp: &Path) -> PathBuf { + let scan = tmp.join("scan-proc"); + copy_tree(&procmini_fixture(), &scan); + let broken = scan.join("999"); + std::fs::create_dir_all(&broken).unwrap(); + std::fs::write(broken.join("stat"), "999 (broken").unwrap(); + std::fs::write(broken.join("status"), "Name:\tbroken\nVmRSS:\t 1234 kB\n").unwrap(); + scan + } + + // ----------------------------------------------------------------- + // Fixture readers (Task 2 semantics pinned against the duplicated + // fixture bytes — Node suite: test/unit/server/host-stats/readers.test.ts) + // ----------------------------------------------------------------- + + #[test] + fn host_stats_fixture_cpu_times_parse_exact() { + let times = readers::read_cpu_times(&proc_fixture()).expect("fixture stat parses"); + assert_eq!(times.total, 174236.0); + assert_eq!(times.busy, 7885.0); + assert_eq!(times.steal, 777.0); // steal>0 is a fixture requirement + assert_eq!(times.per_core.len(), 16); + assert_eq!( + times.per_core[0], + readers::CpuCoreTimes { + total: 10645.0, + busy: 495.0 + } + ); + assert!(readers::read_cpu_times(&missing()).is_none()); + } + + #[test] + fn host_stats_fixture_load_meminfo_vmstat_psi_parse_exact() { + let load = readers::read_loadavg(&proc_fixture()).expect("loadavg"); + assert_eq!( + load, + readers::LoadAvg { + load1: 0.5, + load5: 1.0, + load15: 1.2 + } + ); + let mem = readers::read_meminfo(&proc_fixture()).expect("meminfo"); + assert_eq!( + mem, + readers::MeminfoKb { + total_kb: 67108864, + avail_kb: 33554432, + swap_total_kb: 8388608, + swap_free_kb: 7340032, + } + ); + let vm = readers::read_vmstat(&proc_fixture()).expect("vmstat"); + assert_eq!(vm.pswpin, 1234); + assert_eq!(vm.pswpout, 5678); + assert_eq!(vm.pgmajfault, 890); + assert_eq!(vm.oom_kill, Some(3)); + let psi = readers::read_psi(&proc_fixture()).expect("psi"); + assert_eq!(psi.cpu_some10, Some(1.23)); + assert_eq!(psi.mem_some10, Some(0.5)); + assert_eq!(psi.mem_full10, Some(0.3)); + assert_eq!(psi.io_some10, Some(2.5)); + assert_eq!(psi.io_full10, Some(1.0)); + // procmini has no pressure/ dir -> PSI absent (not per-file nulls). + assert!(readers::read_psi(&procmini_fixture()).is_none()); + } + + #[test] + fn host_stats_fixture_cgroup_memory_leaf_resolution() { + // Committed v2 leaf: memory.max = 'max' (freshell itself runs in an + // unlimited cgroup) -> limit None. + let leaf = readers::read_cgroup_memory(&cgroup_fixture(), &procmini_fixture()) + .expect("v2 leaf resolves"); + assert_eq!(leaf.limit_bytes, None); + assert_eq!(leaf.current_bytes, 17000000000); + // The cgroup fs root has NO limit files by design: a cgroup root that + // lacks the leaf tree must NOT fall back to reading the fs root. + let empty = tempfile::tempdir().unwrap(); + assert!(readers::read_cgroup_memory(empty.path(), &procmini_fixture()).is_none()); + // self/cgroup absent -> None (never a panic). + assert!(readers::read_cgroup_memory(&cgroup_fixture(), &missing()).is_none()); + } + + #[test] + fn host_stats_fixture_pids_limit_cgroup_then_threads_max() { + // v2 leaf pids.max wins outright. + assert_eq!( + readers::read_pids_limit(&procmini_fixture(), &cgroup_fixture()), + Some(10854) + ); + // No self/cgroup (full proc fixture) -> threads-max fallback. + assert_eq!( + readers::read_pids_limit(&proc_fixture(), &cgroup_fixture()), + Some(123456) + ); + // pid_max is a wrap boundary, NEVER the cap. + let tmp = tempfile::tempdir().unwrap(); + let pid_max_only = tmp.path().join("pid-max-only").join("proc"); + std::fs::create_dir_all(pid_max_only.join("sys/kernel")).unwrap(); + std::fs::write(pid_max_only.join("sys/kernel/pid_max"), "4194304\n").unwrap(); + assert_eq!(readers::read_pids_limit(&pid_max_only, &cgroup_fixture()), None); + } + + #[test] + fn host_stats_fixture_disk_net_tcp_limits_parse_exact() { + let disks = readers::read_disk_stats(&proc_fixture()).expect("diskstats"); + // Whole devices only: partitions and loop devices are filtered out. + assert!(disks.contains_key("sda")); + assert!(disks.contains_key("nvme0n1")); + assert!(!disks.contains_key("sda1")); + assert!(!disks.contains_key("nvme0n1p1")); + assert!(!disks.contains_key("loop0")); + let sda = disks.get("sda").unwrap(); + assert_eq!( + *sda, + readers::DiskCounters { + reads_completed: 5000, + read_ms: 6000, + writes_completed: 2000, + write_ms: 3000, + read_sectors: 400000, + written_sectors: 200000, + time_doing_ios_ms: 4000, + } + ); + let net = readers::read_net_dev(&proc_fixture()).expect("net/dev"); + assert_eq!( + net, + readers::NetDevTotals { + rx_bytes: 7000000, + tx_bytes: 11000000, + rx_err: 9, + tx_err: 16, + rx_drop: 4, + tx_drop: 6, + } + ); + let tcp = readers::read_tcp_state_counts(&proc_fixture()).expect("tcp counts"); + assert_eq!(tcp.time_wait, 3); + let ports = readers::read_ephemeral_port_range(&proc_fixture()).expect("port range"); + assert_eq!((ports.start, ports.end), (32768, 60999)); + assert_eq!(readers::read_self_limits_fds_max(&proc_fixture()), Some(1024)); + let inotify = readers::read_inotify_limits(&proc_fixture()).expect("inotify limits"); + assert_eq!(inotify.max_user_watches, Some(1048576)); + assert_eq!(inotify.max_user_instances, Some(128)); + assert_eq!(readers::read_pid_count(&procmini_fixture()), Some(7)); + } + + #[test] + fn host_stats_fixture_sysfs_sensors_parse_exact() { + assert_eq!(readers::read_cpu_freq_mhz(&sys_fixture()), Some(3100.0)); + let zones = readers::read_thermals(&sys_fixture()).expect("thermal zones"); + assert_eq!(zones.len(), 1); + assert_eq!(zones[0].label, "x86_pkg_temp"); + assert_eq!(zones[0].celsius, 51.5); + let battery = readers::read_battery(&sys_fixture()).expect("battery"); + assert_eq!(battery.pct, 87.0); + assert_eq!(battery.status, "Discharging"); + assert!(readers::read_thermals(&missing()).is_none()); + assert!(readers::read_battery(&missing()).is_none()); + } + + #[test] + fn host_stats_fixture_machine_info_probes() { + let info = readers::read_machine_info(&procmini_fixture(), &sys_fixture()); + assert_eq!(info.cgroup, "v2"); + assert!(!info.psi); // procmini has no pressure/ dir + assert_eq!(info.thermal_count, 1); + assert!(info.battery_present); + assert_eq!(info.gpu, "none"); + assert!(info.cores >= 1); + // Full proc fixture: psi readable, no self/cgroup -> 'none'. + let full = readers::read_machine_info(&proc_fixture(), &sys_fixture()); + assert!(full.psi); + assert_eq!(full.cgroup, "none"); + } + + #[cfg(unix)] + #[test] + fn host_stats_fixture_inotify_self_stats_readlink_counting() { + // fd readlink fixtures are REAL symlinks built in tmpdir (git cannot + // commit dangling symlinks) — the Node suite's exact overlay. + let tmp = tempfile::tempdir().unwrap(); + let fd_proc = tmp.path().join("fd-proc"); + std::fs::create_dir_all(fd_proc.join("self/fd")).unwrap(); + std::fs::create_dir_all(fd_proc.join("self/fdinfo")).unwrap(); + for fd in [3, 4, 5] { + std::os::unix::fs::symlink( + "anon_inode:inotify", + fd_proc.join("self/fd").join(fd.to_string()), + ) + .unwrap(); + std::fs::copy( + proc_fixture().join("self/fdinfo").join(fd.to_string()), + fd_proc.join("self/fdinfo").join(fd.to_string()), + ) + .unwrap(); + } + std::os::unix::fs::symlink("socket:[12345]", fd_proc.join("self/fd/6")).unwrap(); + std::os::unix::fs::symlink("pipe:[67890]", fd_proc.join("self/fd/7")).unwrap(); + std::os::unix::fs::symlink("/dev/null", fd_proc.join("self/fd/8")).unwrap(); + assert_eq!(readers::read_self_fd_count(&fd_proc), Some(6)); + let usage = readers::read_self_inotify_stats(&fd_proc).expect("inotify usage"); + assert_eq!(usage.instances, 3); + assert_eq!(usage.watches, 6); // fdinfo 3/4/5 carry 2/3/1 inotify lines + } + + // ----------------------------------------------------------------- + // Process-table scan (the collector-owned two-sample + dwell loop over + // the platform's pure pieces) + // ----------------------------------------------------------------- + + #[tokio::test] + async fn host_stats_scan_fixture_table_counts_and_names() { + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let scan = scan_process_table(&scan_root, Duration::from_millis(50), Instant::now() + Duration::from_secs(10)) + .await + .expect("fixture scan resolves") + .expect("no deadline"); + // 8 numeric entries enumerated (7 committed + truncated 999). + assert_eq!(scan.total, 8); + assert_eq!(scan.zombies, 1); + assert_eq!(scan.d_state, 1); + // truncated-stat pid 999 is skipped, never fatal. + assert_eq!(scan.top.len(), 7); + assert!(scan.top.iter().all(|p| p.pid != 999)); + let by_pid: HashMap = + scan.top.iter().map(|p| (p.pid, p)).collect(); + // comm-with-parens splits after the LAST ')'. + assert_eq!(by_pid[&404].name, "my (weird) proc"); + assert_eq!(by_pid[&404].state, "D"); + assert_eq!(by_pid[&505].state, "Z"); + // rssBytes from status VmRSS kB -> bytes, NOT stat rss pages. + assert_eq!(by_pid[&101].rss_bytes, 12345 * 1024); + // static fixture: sample A == sample B -> zero cpu deltas. + assert!(scan.top.iter().all(|p| p.cpu_pct == 0.0)); + } + + #[tokio::test] + async fn host_stats_scan_deadline_exceeded_is_an_error_never_a_panic() { + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let result = + scan_process_table(&scan_root, Duration::ZERO, Instant::now() - Duration::from_secs(1)) + .await; + assert!(matches!(result, Err(ScanError::DeadlineExceeded))); + // Missing proc root -> None (degraded), never an error. + let missing_result = + scan_process_table(&missing(), Duration::ZERO, Instant::now() + Duration::from_secs(10)) + .await; + assert!(matches!(missing_result, Ok(None))); + } + + // ----------------------------------------------------------------- + // Lifecycle (parity test 1): set_active spawns/aborts the cadence + // ----------------------------------------------------------------- + + #[tokio::test] + async fn host_stats_set_active_spawn_abort_lifecycle() { + let interest = HostStatsInterestRegistry::default(); + let collector = test_collector(proc_fixture(), sys_fixture(), &interest); + assert!(!collector.is_running(), "zero-cost idle before first interest"); + collector.set_active(true); + assert!(collector.is_running(), "0->1 interest spawns the cadence"); + collector.set_active(true); + assert!(collector.is_running(), "idempotent re-activate is harmless"); + collector.set_active(false); + assert!(!collector.is_running(), "1->0 interest aborts the cadence"); + collector.set_active(false); + assert!(!collector.is_running(), "idempotent deactivate is harmless"); + // Restart resumes ticking. + collector.set_active(true); + assert!(collector.is_running()); + collector.set_active(false); + } + + #[test] + fn host_stats_snapshot_zero_shape_before_first_tick() { + let interest = HostStatsInterestRegistry::default(); + let collector = test_collector(proc_fixture(), sys_fixture(), &interest); + let snap = collector.snapshot(); + assert!(snap.at > 0); + assert!(snap.manual_at.is_none()); + assert!(snap.manual.is_none()); + // machine filled from cheap probes, every section unavailable. + assert_eq!(snap.live.machine.thermal_count, 1); + assert!(!snap.live.cpu.available); + assert!(!snap.live.load.available); + assert!(!snap.live.memory.available); + assert!(!snap.live.paging.available); + assert!(!snap.live.psi.available); + assert!(!snap.live.disk_io.available); + assert!(!snap.live.network.available); + assert!(!snap.live.limits.available); + assert!(!snap.live.freshell.available); + // LB9 frozen: no caps exist on the Rust side — 0 renders '—'. + assert_eq!(snap.live.freshell.ws_clients_max, 0); + assert_eq!(snap.live.freshell.ptys_max, 0); + } + + #[tokio::test] + async fn host_stats_set_active_runs_one_immediate_fast_tick() { + // A fresh subscriber gets a SHAPED snapshot at once (Node start() + // parity): after set_active(true) returns, the live cache holds the + // first tick's null-safe zeros — no wall-clock wait needed. + let interest = HostStatsInterestRegistry::default(); + let collector = test_collector(proc_fixture(), sys_fixture(), &interest); + collector.set_active(true); + let live = &collector.snapshot().live; + assert!(live.cpu.available, "first fast tick ran inline"); + assert_eq!(live.cpu.usage_pct, 0.0, "first tick has no delta window"); + assert_eq!(live.cpu.per_core_pct.len(), 16); + assert!(live.load.available); + assert_eq!(live.load.load1, 0.5); + // Memory precedence: no cgroup for the full proc fixture -> host. + assert!(live.memory.available); + assert_eq!(live.memory.source, "host"); + assert_eq!(live.memory.total_bytes, 67108864 * 1024); + assert!(live.paging.available); + assert_eq!(live.paging.oom_kills_total, 3); + assert!(live.psi.available); + assert_eq!(live.psi.cpu_some10, Some(1.23)); + // freshell internals on the first fast tick. + assert!(live.freshell.available); + assert_eq!(live.freshell.source, "rust"); + assert_eq!(live.freshell.ws_clients_max, 0); + assert_eq!(live.freshell.ptys_max, 0); + assert!(live.freshell.uptime_sec >= 0.0); + // Slow-tier sections are STILL zero: the slow tier only ticks on its + // own interval (Node parity). + assert!(!live.disk_io.available); + assert!(!live.limits.available); + collector.set_active(false); + } + + #[tokio::test] + async fn host_stats_cadence_delivers_to_subscribed_conns_only() { + // Frozen delivery contract: snapshots flow ONLY to subscribed + // connections via their per-connection senders — never broadcast_tx. + let interest = HostStatsInterestRegistry::default(); + let delivered = Arc::new(StdMutex::new(Vec::::new())); + let not_watching = Arc::new(StdMutex::new(Vec::::new())); + let watcher_sink: FrameSink = { + let delivered = Arc::clone(&delivered); + Arc::new(move |msg| delivered.lock().unwrap().push(msg)) + }; + let bystander_sink: FrameSink = { + let not_watching = Arc::clone(¬_watching); + Arc::new(move |msg| not_watching.lock().unwrap().push(msg)) + }; + let collector = test_collector(proc_fixture(), sys_fixture(), &interest); + assert_eq!( + interest.set(1, Some(watcher_sink)), + freshell_ws::host_stats_interest::InterestTransition::BecameActive + ); + collector.set_active(true); + let got = wait_until(Duration::from_millis(500), || { + !delivered.lock().unwrap().is_empty() + }) + .await; + collector.set_active(false); + assert!(got, "a subscribed connection receives cadence snapshots"); + { + let frames = delivered.lock().unwrap(); + let first = serde_json::to_value(&frames[0]).unwrap(); + assert_eq!(first["type"], "hoststats.snapshot"); + assert_eq!(first["live"]["freshell"]["source"], "rust"); + assert_eq!(first["live"]["freshell"]["wsClientsMax"], 0); + assert_eq!(first["live"]["freshell"]["ptysMax"], 0); + assert_eq!(first["live"]["memory"]["source"], "host"); + } + // A connection that never subscribed is never touched. (The sink is + // kept alive so the assertion above isn't vacuous.) + let _ = bystander_sink; + assert!( + not_watching.lock().unwrap().is_empty(), + "non-watchers get zero traffic" + ); + // After ->0 interest (abort), no further snapshots arrive. + let count_at_stop = delivered.lock().unwrap().len(); + tokio::time::sleep(Duration::from_millis(120)).await; + assert_eq!(delivered.lock().unwrap().len(), count_at_stop); + } + + // ----------------------------------------------------------------- + // refresh(): single-flight, post-completion cooldown, cooperative budget + // ----------------------------------------------------------------- + + #[tokio::test] + async fn host_stats_refresh_is_single_flight() { + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let interest = HostStatsInterestRegistry::default(); + let collector = test_collector(scan_root, sys_fixture(), &interest); + let (one, two) = + tokio::join!(collector.refresh(Duration::from_millis(2000)), collector.refresh(Duration::from_millis(2000))); + let one = one.expect("leader refresh succeeds"); + let two = two.expect("joiner refresh succeeds"); + assert_eq!(collector.scan_run_count(), 1, "one scan serves both callers"); + assert_eq!(one, two, "the joiner gets the leader's exact result"); + // The fixture scan powered both process sections. + assert!(one.manual.top_processes.available); + assert_eq!(one.manual.top_processes.list.len(), 7); + assert!(one.manual.process_health.available); + assert_eq!(one.manual.process_health.zombies, 1); + assert_eq!(one.manual.process_health.d_state, 1); + assert_eq!(one.manual.process_health.total, 8); + // thermals from the injected sys root. + assert!(one.manual.thermals.available); + assert_eq!(one.manual.thermals.zones[0].label, "x86_pkg_temp"); + assert_eq!( + one.manual.thermals.battery, + Some(HostStatsBattery { + pct: 87.0, + status: "Discharging".to_string() + }) + ); + // Empty success: no section failed (procmini has no inotify sysctls, + // so that section is zero WITHOUT an error entry — Node parity). + assert!(one.manual.section_errors.is_empty()); + assert!(!one.manual.inotify.available); + // The merged snapshot now carries the manual cache. + let snap = collector.snapshot(); + assert_eq!(snap.manual_at, Some(one.at)); + assert_eq!(snap.manual, Some(one.manual)); + } + + #[tokio::test] + async fn host_stats_refresh_post_completion_cooldown_rate_limited() { + // Parity test 2: the connection-AGNOSTIC 1s post-completion cooldown + // (Instant-controlled; test shortens the cooldown and proves the floor + // + the allow-again sides with short real sleeps). + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let interest = HostStatsInterestRegistry::default(); + let mut cfg = test_config(scan_root, sys_fixture()); + cfg.refresh_cooldown = Duration::from_millis(150); + let collector = HostStatsCollectorService::new( + cfg, + freshell_terminal::TerminalRegistry::new(), + interest, + Instant::now(), + ); + assert!(collector.refresh(Duration::from_millis(2000)).await.is_ok()); + let limited = collector.refresh(Duration::from_millis(2000)).await; + assert_eq!(limited, Err("rate_limited".to_string())); + // Single-flight is NOT the cooldown: the first completed already. + assert_eq!(collector.scan_run_count(), 1); + tokio::time::sleep(Duration::from_millis(250)).await; + let again = collector.refresh(Duration::from_millis(2000)).await; + assert!(again.is_ok(), "the floor lifts after the cooldown window"); + assert_eq!(collector.scan_run_count(), 2); + } + + #[tokio::test] + async fn host_stats_refresh_section_budget_degrades_scan_sections_only() { + // Cooperative budget: an already-exhausted shared absolute deadline + // marks ONLY the scan sections failed (zero-shape + sectionErrors); + // the file-reading sections still complete. + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let interest = HostStatsInterestRegistry::default(); + let collector = test_collector(scan_root, sys_fixture(), &interest); + let result = collector + .refresh(Duration::ZERO) + .await + .expect("budget exhaustion degrades sections, never rejects"); + assert!(!result.manual.top_processes.available); + assert!(!result.manual.process_health.available); + assert_eq!( + result.manual.section_errors.get("topProcesses").map(String::as_str), + Some("host-stats section deadline exceeded") + ); + assert_eq!( + result + .manual + .section_errors + .get("processHealth") + .map(String::as_str), + Some("host-stats section deadline exceeded") + ); + // Non-scan sections complete under the same refresh. + assert!(result.manual.disks.available); + assert!(!result.manual.disks.list.is_empty()); + assert!(result.manual.thermals.available); + assert!(!result.manual.section_errors.contains_key("disks")); + } +} diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 31e77db3d..60caf7716 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -27,6 +27,7 @@ mod existence; mod existence_by_id; mod extensions; mod files; +mod host_stats; mod identity_sink; mod instance_id; mod legacy_local_seed; @@ -387,6 +388,27 @@ async fn main() -> ExitCode { // Cloned (cheap Arc) into the files REST surface too, whose `candidate-dirs` // sources the running terminals' cwds for the DirectoryPicker. let registry = freshell_terminal::TerminalRegistry::new(); + // HOST-PRESSURE PANE (Task 9, docs/plans/2026-08-25-host-pressure-pane.md): + // the Rust host-stats collector — freshell-platform readers over + // freshell-ws's trait bridge. Constructed here (not at the ~1311 + // subagent-cadence spawn the plan cites, which sits inside the + // session-index block BELOW `ws_state`): the concrete instance must be + // Arc'd and injected INTO `WsState::host_stats` when that literal builds. + // NO cadence spawns here — `terminal.rs`'s `hoststats.subscribe` + // 0->1 edge calls the collector's `set_active(true)`, which owns + // spawn/abort internally (zero-cost idle). The interest registry clone + // shared into WsState is the SAME instance the collector's cadence + // delivers snapshots through (subscribed connections only — never + // `broadcast_tx`). `boot_anchor` backs `freshell.uptimeSec`. + let host_stats_interest = + freshell_ws::host_stats_interest::HostStatsInterestRegistry::default(); + let host_stats_collector: std::sync::Arc = + std::sync::Arc::new(host_stats::HostStatsCollectorService::new( + host_stats::HostStatsCollectorConfig::from_env(), + registry.clone(), + host_stats_interest.clone(), + std::time::Instant::now(), + )); // Slice 1 (docs/plans/2026-07-18-agent-api-mcp-parity-spec.md \u00a79 Risk 1): the // Agent-API's terminal-mode `POST /api/tabs` shares THIS SAME registry -- // never a second one -- so an Agent-API-created shell terminal is a first-class @@ -1047,6 +1069,12 @@ async fn main() -> ExitCode { layout: layout_store.clone(), screenshots: screenshots.clone(), subagent_interest: subagent_interest.clone(), + // Task 9: the SAME interest registry the collector's cadence delivers + // through + the injected concrete collector. + host_stats: freshell_ws::host_stats_collector::WsHostStatsState { + interest: host_stats_interest.clone(), + collector: Some(host_stats_collector.clone()), + }, terminals_revision: Arc::clone(&terminals_revision), sessions_revision: Arc::clone(&sessions_revision), cli_commands: Arc::clone(&cli_commands), @@ -3092,6 +3120,7 @@ mod sessions_sweep_tests { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/diskstats b/crates/freshell-server/tests/fixtures/host-stats/proc/diskstats new file mode 100644 index 000000000..e0866839a --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/diskstats @@ -0,0 +1,5 @@ + 8 0 sda 5000 100 400000 6000 2000 50 200000 3000 0 4000 9000 + 8 1 sda1 4000 80 300000 5000 1500 40 150000 2500 0 3000 7500 + 7 0 loop0 100 0 800 10 0 0 0 0 0 10 10 + 259 0 nvme0n1 9000 200 700000 8000 3000 60 300000 4000 0 5000 12000 + 259 1 nvme0n1p1 8000 150 600000 7000 2500 55 250000 3500 0 4500 10500 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/loadavg b/crates/freshell-server/tests/fixtures/host-stats/proc/loadavg new file mode 100644 index 000000000..ecb2936f0 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/loadavg @@ -0,0 +1 @@ +0.50 1.00 1.20 2/1234 5678 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/meminfo b/crates/freshell-server/tests/fixtures/host-stats/proc/meminfo new file mode 100644 index 000000000..48d7877ca --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/meminfo @@ -0,0 +1,49 @@ +MemTotal: 67108864 kB +MemFree: 8388608 kB +MemAvailable: 33554432 kB +Buffers: 524288 kB +Cached: 4194304 kB +SwapCached: 0 kB +Active: 20971520 kB +Inactive: 8388608 kB +Active(anon): 16777216 kB +Inactive(anon): 4194304 kB +Active(file): 4194304 kB +Inactive(file): 4194304 kB +Unevictable: 0 kB +Mlocked: 0 kB +SwapTotal: 8388608 kB +SwapFree: 7340032 kB +Dirty: 100 kB +Writeback: 0 kB +AnonPages: 20970000 kB +Mapped: 500000 kB +Shmem: 150000 kB +Slab: 800000 kB +SReclaimable: 600000 kB +SUnreclaim: 200000 kB +KernelStack: 30000 kB +PageTables: 60000 kB +NFS_Unstable: 0 kB +Bounce: 0 kB +WritebackTmp: 0 kB +CommitLimit: 41943040 kB +Committed_AS: 30000000 kB +VmallocTotal: 34359738367 kB +VmallocUsed: 50000 kB +VmallocChunk: 0 kB +Percpu: 20000 kB +HardwareCorrupted: 0 kB +AnonHugePages: 0 kB +ShmemHugePages: 0 kB +ShmemPmdMapped: 0 kB +FileHugePages: 0 kB +FilePmdMapped: 0 kB +HugePages_Total: 0 +HugePages_Free: 0 +HugePages_Rsvd: 0 +HugePages_Surp: 0 +Hugepagesize: 2048 kB +Hugetlb: 0 kB +DirectMap4k: 1000000 kB +DirectMap2M: 66000000 kB diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/net/dev b/crates/freshell-server/tests/fixtures/host-stats/proc/net/dev new file mode 100644 index 000000000..89f4f112b --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/net/dev @@ -0,0 +1,5 @@ +Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 1000000 10000 0 0 0 0 0 0 1000000 10000 0 0 0 0 0 0 + eth0: 5000000 50000 7 3 0 0 0 0 8000000 80000 11 4 0 0 0 0 +docker0: 2000000 20000 2 1 0 0 0 0 3000000 30000 5 2 0 0 0 0 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp b/crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp new file mode 100644 index 000000000..642fc6731 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp @@ -0,0 +1,5 @@ + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 22334 1 0000000000000000 100 0 0 10 0 + 1: 0100007F:9C40 0200000A:0050 06 00000000:00000000 00:00000000 00000000 1000 0 22335 1 0000000000000000 20 4 30 10 -1 + 2: 0A00000A:C350 0100000A:01BB 01 00000000:00000000 02:000A9A78 00000000 1000 0 22336 2 0000000000000000 20 4 31 10 -1 + 3: 0100007F:8AE0 0200000A:1F91 06 00000000:00000000 00:00000000 00000000 1000 0 22337 1 0000000000000000 20 4 30 10 -1 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp6 b/crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp6 new file mode 100644 index 000000000..e67cd19ab --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp6 @@ -0,0 +1,3 @@ + sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 00000000000000000000000001000000:1F91 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 33001 1 0000000000000000 100 0 0 10 0 + 1: 00000000000000000000000001000000:9C41 0000000000000000000000000200000A:0050 06 00000000:00000000 00:00000000 00000000 1000 0 33002 1 0000000000000000 20 4 30 10 -1 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/cpu b/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/cpu new file mode 100644 index 000000000..50be24887 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/cpu @@ -0,0 +1 @@ +some avg10=1.23 avg60=2.34 avg300=3.45 total=987654321 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/io b/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/io new file mode 100644 index 000000000..dd9f0a14b --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/io @@ -0,0 +1,2 @@ +some avg10=2.50 avg60=1.00 avg300=0.50 total=654321 +full avg10=1.00 avg60=0.40 avg300=0.20 total=600000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/memory b/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/memory new file mode 100644 index 000000000..8593cd563 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/memory @@ -0,0 +1,2 @@ +some avg10=0.50 avg60=0.20 avg300=0.10 total=123456 +full avg10=0.30 avg60=0.10 avg300=0.05 total=100000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/3 b/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/3 new file mode 100644 index 000000000..4fe3ccb64 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/3 @@ -0,0 +1,6 @@ +pos: 0 +flags: 02004000 +mnt_id: 15 +ino: 1234 +inotify wd:1 ino:600001 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0106a000743b0200 +inotify wd:2 ino:600002 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0206a000743b0200 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/4 b/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/4 new file mode 100644 index 000000000..4191793c2 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/4 @@ -0,0 +1,7 @@ +pos: 0 +flags: 02004000 +mnt_id: 15 +ino: 2234 +inotify wd:1 ino:610001 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0306a000743b0200 +inotify wd:2 ino:610002 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0406a000743b0200 +inotify wd:3 ino:610003 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0506a000743b0200 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/5 b/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/5 new file mode 100644 index 000000000..bc73d2c98 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/5 @@ -0,0 +1,5 @@ +pos: 0 +flags: 02004000 +mnt_id: 15 +ino: 3234 +inotify wd:1 ino:620001 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0606a000743b0200 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/self/limits b/crates/freshell-server/tests/fixtures/host-stats/proc/self/limits new file mode 100644 index 000000000..7d4f86556 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/self/limits @@ -0,0 +1,17 @@ +Limit Soft Limit Hard Limit Units +Max cpu time unlimited unlimited seconds +Max file size unlimited unlimited bytes +Max data size unlimited unlimited bytes +Max stack size 8388608 unlimited bytes +Max core file size 0 unlimited bytes +Max resident set unlimited unlimited bytes +Max processes 257913 257913 processes +Max open files 1024 1048576 files +Max locked memory 1090519040 1090519040 bytes +Max address space unlimited unlimited bytes +Max file locks unlimited unlimited locks +Max pending signals 257913 257913 signals +Max msgqueue size 819200 819200 bytes +Max nice priority 0 0 +Max realtime priority 0 0 +Max realtime timeout unlimited unlimited us diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/stat b/crates/freshell-server/tests/fixtures/host-stats/proc/stat new file mode 100644 index 000000000..a1c60bc76 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/stat @@ -0,0 +1,24 @@ +cpu 4705 356 1622 164331 2020 80 345 777 0 0 +cpu0 300 10 120 10000 150 5 20 40 0 0 +cpu1 200 5 100 9001 100 2 10 21 0 0 +cpu2 200 5 100 9002 100 2 10 22 0 0 +cpu3 200 5 100 9003 100 2 10 23 0 0 +cpu4 200 5 100 9004 100 2 10 24 0 0 +cpu5 200 5 100 9005 100 2 10 25 0 0 +cpu6 200 5 100 9006 100 2 10 26 0 0 +cpu7 200 5 100 9007 100 2 10 27 0 0 +cpu8 200 5 100 9008 100 2 10 28 0 0 +cpu9 200 5 100 9009 100 2 10 29 0 0 +cpu10 200 5 100 9010 100 2 10 30 0 0 +cpu11 200 5 100 9011 100 2 10 31 0 0 +cpu12 200 5 100 9012 100 2 10 32 0 0 +cpu13 200 5 100 9013 100 2 10 33 0 0 +cpu14 200 5 100 9014 100 2 10 34 0 0 +cpu15 100 0 50 9000 10 0 5 15 0 0 +intr 1234567 8 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +ctxt 7654321 +btime 1690000000 +processes 12345 +procs_running 2 +procs_blocked 0 +softirq 123456 100 50000 200 60000 300 1000 5000 60000 2000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_instances b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_instances new file mode 100644 index 000000000..a949a93df --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_instances @@ -0,0 +1 @@ +128 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_watches b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_watches new file mode 100644 index 000000000..6820bf177 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_watches @@ -0,0 +1 @@ +1048576 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/sys/kernel/threads-max b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/kernel/threads-max new file mode 100644 index 000000000..9f358a4ad --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/kernel/threads-max @@ -0,0 +1 @@ +123456 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/sys/net/ipv4/ip_local_port_range b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/net/ipv4/ip_local_port_range new file mode 100644 index 000000000..10d6ed9d7 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/net/ipv4/ip_local_port_range @@ -0,0 +1 @@ +32768 60999 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/vmstat b/crates/freshell-server/tests/fixtures/host-stats/proc/vmstat new file mode 100644 index 000000000..99751486d --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/vmstat @@ -0,0 +1,18 @@ +nr_free_pages 2000000 +nr_zone_inactive_anon 100000 +nr_zone_active_anon 200000 +nr_inactive_anon 100000 +nr_active_anon 200000 +nr_inactive_file 150000 +nr_active_file 250000 +nr_unevictable 0 +nr_slab_reclaimable 150000 +nr_slab_unreclaimable 50000 +pswpin 1234 +pswpout 5678 +pgmajfault 890 +pgpgin 100000 +pgpgout 200000 +oom_kill 3 +nr_dirty 25 +nr_writeback 0 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/101/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/101/stat new file mode 100644 index 000000000..4392649cb --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/101/stat @@ -0,0 +1 @@ +101 (systemd) S 1 101 101 0 -1 4194304 1000 0 50 0 120 30 0 0 20 0 1 0 5000 200000000 1500 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/101/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/101/status new file mode 100644 index 000000000..c0913c7d8 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/101/status @@ -0,0 +1,12 @@ +Name: systemd +Umask: 0022 +State: S (sleeping) +Tgid: 101 +Pid: 101 +PPid: 1 +Uid: 0 0 0 0 +Gid: 0 0 0 0 +VmPeak: 200000 kB +VmSize: 195312 kB +VmRSS: 12345 kB +Threads: 1 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/202/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/202/stat new file mode 100644 index 000000000..f4d82bf79 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/202/stat @@ -0,0 +1 @@ +202 (node) S 1 202 202 0 -1 4194304 20000 0 100 0 800 200 0 0 20 0 8 0 6000 1500000000 50000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/202/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/202/status new file mode 100644 index 000000000..e5e1daf7b --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/202/status @@ -0,0 +1,12 @@ +Name: node +Umask: 0022 +State: S (sleeping) +Tgid: 202 +Pid: 202 +PPid: 1 +Uid: 1000 1000 1000 1000 +Gid: 1000 1000 1000 1000 +VmPeak: 1500000 kB +VmSize: 1464843 kB +VmRSS: 654321 kB +Threads: 8 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/303/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/303/stat new file mode 100644 index 000000000..c0749889a --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/303/stat @@ -0,0 +1 @@ +303 (postgres) S 1 303 303 0 -1 4194304 30000 0 200 0 400 100 0 0 20 0 4 0 7000 300000000 30000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/303/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/303/status new file mode 100644 index 000000000..7b678376e --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/303/status @@ -0,0 +1,12 @@ +Name: postgres +Umask: 0022 +State: S (sleeping) +Tgid: 303 +Pid: 303 +PPid: 1 +Uid: 999 999 999 999 +Gid: 999 999 999 999 +VmPeak: 350000 kB +VmSize: 292968 kB +VmRSS: 88888 kB +Threads: 4 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/404/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/404/stat new file mode 100644 index 000000000..1153d22bd --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/404/stat @@ -0,0 +1 @@ +404 (my (weird) proc) D 1 404 404 0 -1 4194304 200 0 5 0 999 111 0 0 20 0 2 0 8000 300000000 6000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/404/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/404/status new file mode 100644 index 000000000..dc934d325 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/404/status @@ -0,0 +1,12 @@ +Name: my (weird) proc +Umask: 0022 +State: D (disk sleep) +Tgid: 404 +Pid: 404 +PPid: 1 +Uid: 1000 1000 1000 1000 +Gid: 1000 1000 1000 1000 +VmPeak: 100000 kB +VmSize: 97656 kB +VmRSS: 4321 kB +Threads: 2 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/505/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/505/stat new file mode 100644 index 000000000..9b240a326 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/505/stat @@ -0,0 +1 @@ +505 (zomb) Z 1 505 505 0 -1 4194304 0 0 0 0 10 5 0 0 20 0 1 0 9000 0 0 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/505/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/505/status new file mode 100644 index 000000000..77aaf01a9 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/505/status @@ -0,0 +1,9 @@ +Name: zomb +Umask: 0022 +State: Z (zombie) +Tgid: 505 +Pid: 505 +PPid: 1 +Uid: 1000 1000 1000 1000 +Gid: 1000 1000 1000 1000 +Threads: 1 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/606/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/606/stat new file mode 100644 index 000000000..7e539090d --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/606/stat @@ -0,0 +1 @@ +606 (nginx) R 1 606 606 0 -1 4194304 40000 0 300 0 2000 500 0 0 20 0 4 0 10000 100000000 12000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/606/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/606/status new file mode 100644 index 000000000..aff39a979 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/606/status @@ -0,0 +1,12 @@ +Name: nginx +Umask: 0022 +State: R (running) +Tgid: 606 +Pid: 606 +PPid: 1 +Uid: 0 0 0 0 +Gid: 0 0 0 0 +VmPeak: 150000 kB +VmSize: 146484 kB +VmRSS: 23456 kB +Threads: 4 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/707/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/707/stat new file mode 100644 index 000000000..7fe337910 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/707/stat @@ -0,0 +1 @@ +707 (bash) S 1 707 707 0 -1 4194304 500 0 20 0 60 20 0 0 20 0 1 0 11000 80000000 2000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/707/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/707/status new file mode 100644 index 000000000..993f15f3f --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/707/status @@ -0,0 +1,12 @@ +Name: bash +Umask: 0022 +State: S (sleeping) +Tgid: 707 +Pid: 707 +PPid: 1 +Uid: 1000 1000 1000 1000 +Gid: 1000 1000 1000 1000 +VmPeak: 100000 kB +VmSize: 78125 kB +VmRSS: 3456 kB +Threads: 1 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/self/cgroup b/crates/freshell-server/tests/fixtures/host-stats/procmini/self/cgroup new file mode 100644 index 000000000..41d81f742 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/self/cgroup @@ -0,0 +1 @@ +0::/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/capacity b/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/capacity new file mode 100644 index 000000000..84df3526d --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/capacity @@ -0,0 +1 @@ +87 diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/status b/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/status new file mode 100644 index 000000000..4674475b6 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/status @@ -0,0 +1 @@ +Discharging diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/type b/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/type new file mode 100644 index 000000000..6784dd35c --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/type @@ -0,0 +1 @@ +Battery diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/temp b/crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/temp new file mode 100644 index 000000000..304cba046 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/temp @@ -0,0 +1 @@ +51500 diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/type b/crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/type new file mode 100644 index 000000000..0a11ba228 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/type @@ -0,0 +1 @@ +x86_pkg_temp diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq b/crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq new file mode 100644 index 000000000..98be78b86 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq @@ -0,0 +1 @@ +3400000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu1/cpufreq/scaling_cur_freq b/crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu1/cpufreq/scaling_cur_freq new file mode 100644 index 000000000..c754f1a46 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu1/cpufreq/scaling_cur_freq @@ -0,0 +1 @@ +2800000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.current b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.current new file mode 100644 index 000000000..fcd6d3c41 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.current @@ -0,0 +1 @@ +17000000000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.max b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.max new file mode 100644 index 000000000..355295a05 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.max @@ -0,0 +1 @@ +max diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.current b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.current new file mode 100644 index 000000000..d81cc0710 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.current @@ -0,0 +1 @@ +42 diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.max b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.max new file mode 100644 index 000000000..fff795a14 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.max @@ -0,0 +1 @@ +10854 diff --git a/crates/freshell-ws/src/codex_association.rs b/crates/freshell-ws/src/codex_association.rs index 72d1bcf97..af443ceb8 100644 --- a/crates/freshell-ws/src/codex_association.rs +++ b/crates/freshell-ws/src/codex_association.rs @@ -308,6 +308,7 @@ mod tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: StdArc::new(Vec::new()), diff --git a/crates/freshell-ws/src/codex_proxy_route.rs b/crates/freshell-ws/src/codex_proxy_route.rs index 3fb5edf76..b2fab257b 100644 --- a/crates/freshell-ws/src/codex_proxy_route.rs +++ b/crates/freshell-ws/src/codex_proxy_route.rs @@ -257,6 +257,7 @@ mod tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: StdArc::new(Vec::new()), diff --git a/crates/freshell-ws/src/host_stats_collector.rs b/crates/freshell-ws/src/host_stats_collector.rs new file mode 100644 index 000000000..3c60c90da --- /dev/null +++ b/crates/freshell-ws/src/host_stats_collector.rs @@ -0,0 +1,86 @@ +//! The host-stats collector TRAIT bridge for the freshell-ws crate +//! (`docs/plans/2026-08-25-host-pressure-pane.md` Task 9). +//! +//! Dependency direction is frozen: freshell-ws canNOT depend on +//! freshell-server. The concrete collector (cadences, `/proc` reads, refresh +//! budgets) lives in freshell-server (`host_stats.rs`); this crate owns ZERO +//! `/proc` knowledge and ZERO timers — it knows only the trait, so +//! `terminal.rs`'s `hoststats.*` dispatch can drive it and `main.rs` can +//! inject the concrete `Arc` into [`WsState`]. +//! +//! Lifecycle contract (mirrors Node's `HostStatsService` start/stop): +//! `terminal.rs` calls [`HostStatsCollector::set_active`] ONLY on the +//! interest registry's cardinality edges — `true` on 0->1 (the collector +//! spawns its two-tier cadence + drift sampler internally), `false` on ->0 +//! (the collector aborts the JoinHandles — true zero-cost idle). The interest +//! registry itself never holds a JoinHandle. + +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use freshell_protocol::{HostStatsManual, HostStatsSnapshot}; + +use crate::host_stats_interest::HostStatsInterestRegistry; + +/// The data returned by a successful [`HostStatsCollector::refresh`] — the +/// payload halves of `hoststats.refresh.response { ok:true, at, manual }` +/// (the response's `requestId` echo is the dispatcher's job). +#[derive(Debug, Clone, PartialEq)] +pub struct HostStatsRefreshOk { + pub at: u64, + pub manual: HostStatsManual, +} + +/// The boxed future [`HostStatsCollector::refresh`] returns (object-safe +/// async without an async-trait dependency; borrows the collector so the +/// boxed Arc in `WsState` can be driven in place). +pub type HostStatsRefreshFuture<'a> = + Pin> + Send + 'a>>; + +/// The host-stats collector contract. The concrete implementation is +/// freshell-server's `HostStatsCollectorService`; freshell-ws tests install +/// fakes. +pub trait HostStatsCollector: Send + Sync { + /// Cache read only — NEVER waits on I/O newer than the last tick (mirrors + /// `HostStatsService.getSnapshot`; ticks write caches, snapshots read + /// them). Fresh subscribers get this frame immediately on subscribe. + fn snapshot(&self) -> HostStatsSnapshot; + + /// On-request manual data (process table, disks, inotify, + /// thermals/battery). Single-flight with a connection-agnostic 1s + /// post-completion cooldown (`Err("rate_limited")`); NEVER fails for data + /// reasons — a failed section degrades to its zero-shape while the others + /// complete. `deadline` is the cooperative per-section budget (the shared + /// absolute deadline is `start + deadline`; Node `sectionBudgetMs`). + fn refresh(&self, deadline: Duration) -> HostStatsRefreshFuture<'_>; + + /// Interest-transition callback: `true` (0->1 interested) spawns the + /// cadence internally (one immediate fast tick so a fresh subscriber gets + /// a shaped snapshot at once), `false` (->0) aborts it. Idempotent. + fn set_active(&self, active: bool); +} + +/// The `WsState.host_stats` sub-struct (Task 9's `WsState` literal sweep: the +/// sweep exceeded the ~6-site threshold, so BOTH new fields are wrapped here +/// and every legacy `WsState { ... }` literal gains exactly one +/// `host_stats: Default::default()` arm). This is the type the plan's crate +/// architecture bullet names "`HostStatsShare`": the share BETWEEN the ws +/// crate (interest bookkeeping + dispatch) and the injected concrete +/// collector. +/// +/// `collector` is `None` ONLY in unit tests that never exercise host-stats +/// (like `WsState.activity`); on a real boot `freshell-server`'s `main.rs` +/// always wires the concrete collector. A `hoststats.refresh` with no +/// collector answers `{ ok:false, error:"host stats unavailable" }` (Node +/// parity when `this.hostStats` is unset); subscribe with no collector +/// records interest and sends no snapshot (Node `sendHostStatsSnapshot`'s +/// early return). +#[derive(Clone, Default)] +pub struct WsHostStatsState { + /// Per-connection subscribe bookkeeping + the cadence delivery fan-out. + pub interest: HostStatsInterestRegistry, + /// The injected concrete collector (freshell-server); `None` in + /// host-stats-free unit tests. + pub collector: Option>, +} diff --git a/crates/freshell-ws/src/host_stats_interest.rs b/crates/freshell-ws/src/host_stats_interest.rs new file mode 100644 index 000000000..c5bd0dbea --- /dev/null +++ b/crates/freshell-ws/src/host_stats_interest.rs @@ -0,0 +1,179 @@ +//! Per-connection `hoststats.subscribe` interest registry (host-pressure pane, +//! `docs/plans/2026-08-25-host-pressure-pane.md` Task 9). +//! +//! Shape precedent: [`crate::subagent_interest::SubagentInterestRegistry`] — +//! a cheaply-cloneable `Arc` handle; interest set/remove/any/count ONLY, with +//! NO cadence JoinHandle ownership (the concrete collector in freshell-server +//! owns spawn/abort through its `set_active` callback; `terminal.rs` calls it +//! on the transitions this registry reports). +//! +//! Task 9 delivery targeting difference from the subagent registry: each +//! entry ALSO stores the connection's outbound [`FrameSink`] (the +//! per-connection sender `terminal.rs` already owns for its socket write +//! loop), captured at subscribe time. This is the plan's frozen delivery +//! contract: host-stats snapshots flow ONLY to subscribed connections via +//! their per-conn channels — NEVER via the shared `broadcast_tx` bus +//! (non-watchers get zero traffic). [`HostStatsInterestRegistry::senders`] is +//! the cadence task's read surface for that fan-out. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use freshell_terminal::FrameSink; + +/// How a [`HostStatsInterestRegistry::set`]/[`HostStatsInterestRegistry::remove`] +/// mutated the interested-connection cardinality. `terminal.rs` maps +/// `BecameActive` -> `collector.set_active(true)` (0->1 spawns the cadence) +/// and `BecameIdle` -> `collector.set_active(false)` (1->0 aborts it). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InterestTransition { + /// Cardinality unchanged (idempotent re-subscribe / unknown-id removal). + Unchanged, + /// 0 -> 1: the first interested connection arrived. + BecameActive, + /// -> 0: the last interested connection left. + BecameIdle, +} + +/// The shared interior: interested connection-id -> outbound sender map under +/// a lock, plus a lock-free mirror of its cardinality for cheap gate reads. +/// The two are always updated under the same lock acquisition, so `count` +/// can never drift from `subs.len()`. +#[derive(Default)] +struct Inner { + subs: Mutex>, + count: Arc, +} + +/// A cheaply-cloneable handle to the per-connection host-stats interest map. +/// All clones share the one underlying map (like `SubagentInterestRegistry`). +#[derive(Clone, Default)] +pub struct HostStatsInterestRegistry { + inner: Arc, +} + +impl HostStatsInterestRegistry { + /// Declare (`Some(sink)`) or retract (`None`) this connection's + /// host-stats interest. Re-subscribing overwrites the connection's LATEST + /// sender (idempotent in cardinality, fresh sink). Reports the + /// cardinality transition so the caller can drive the collector's + /// `set_active` exactly on 0->1 / ->0 edges. + pub fn set(&self, conn_id: u64, sink: Option) -> InterestTransition { + let mut guard = self.inner.subs.lock().unwrap(); + // `insert`/`remove` on the map give exact cardinality under the lock; + // the count mirror is stored under the same lock acquisition, so it + // can never drift from the map. + let old_count = guard.len(); + match sink { + Some(sink) => { + guard.insert(conn_id, sink); + } + None => { + guard.remove(&conn_id); + } + } + let new_count = guard.len(); + self.inner.count.store(new_count, Ordering::SeqCst); + if new_count == old_count { + InterestTransition::Unchanged + } else if old_count == 0 && new_count == 1 { + InterestTransition::BecameActive + } else if new_count == 0 { + InterestTransition::BecameIdle + } else { + // 1->2, 2->1, ...: still active, no edge. + InterestTransition::Unchanged + } + } + + /// Clear a connection's entry entirely (socket teardown + the + /// `hoststats.unsubscribe` arm). Unknown ids are a no-op + /// (`InterestTransition::Unchanged`). + pub fn remove(&self, conn_id: u64) -> InterestTransition { + self.set(conn_id, None) + } + + /// True iff at least one connected client is currently interested. + pub fn any(&self) -> bool { + self.count() > 0 + } + + /// The lock-free cardinality mirror (e.g. teardown-edge checks). + pub fn count(&self) -> usize { + self.inner.count.load(Ordering::SeqCst) + } + + /// Snapshot of the live per-connection outbound senders, in + /// insertion-irrelevant order — the cadence task's delivery fan-out + /// (Task 9 frozen contract: subscribed connections ONLY, never + /// `broadcast_tx`). A conn whose socket is mid-teardown is removed + /// BEFORE its sink can go stale because `terminal.rs`'s teardown block + /// calls [`HostStatsInterestRegistry::remove`] under the same connection + /// lifecycle. + pub fn senders(&self) -> Vec { + let guard = self.inner.subs.lock().unwrap(); + guard.values().cloned().collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn noop_sink() -> FrameSink { + Arc::new(|_| {}) + } + + #[test] + fn host_stats_interest_set_any_count_remove_semantics() { + let r = HostStatsInterestRegistry::default(); + assert!(!r.any()); + assert_eq!(r.count(), 0); + + r.set(7, Some(noop_sink())); + assert!(r.any()); + assert_eq!(r.count(), 1); + // Idempotent re-subscribe: cardinality must NOT double-count (the + // sink is overwritten, the set gains nothing). + r.set(7, Some(noop_sink())); + assert_eq!(r.count(), 1); + + r.set(9, Some(noop_sink())); + assert_eq!(r.count(), 2); + + r.remove(7); + assert!(r.any(), "other connection still interested"); + assert_eq!(r.count(), 1); + r.remove(42); // unknown id is a no-op + assert_eq!(r.count(), 1); + r.remove(9); + assert!(!r.any()); + assert_eq!(r.count(), 0); + } + + #[test] + fn host_stats_interest_reports_0_to_1_and_1_to_0_transitions() { + let r = HostStatsInterestRegistry::default(); + // First arrival is the ->active edge; repeats are unchanged. + assert_eq!(r.set(1, Some(noop_sink())), InterestTransition::BecameActive); + assert_eq!(r.set(1, Some(noop_sink())), InterestTransition::Unchanged); + assert_eq!(r.set(2, Some(noop_sink())), InterestTransition::Unchanged); + // Removing one of two stays active; removing the last is ->idle. + assert_eq!(r.remove(1), InterestTransition::Unchanged); + assert_eq!(r.remove(2), InterestTransition::BecameIdle); + // Teardown on an unknown id never fires an edge. + assert_eq!(r.remove(2), InterestTransition::Unchanged); + } + + #[test] + fn host_stats_interest_senders_snapshots_live_sinks_only() { + let r = HostStatsInterestRegistry::default(); + assert!(r.senders().is_empty()); + r.set(1, Some(noop_sink())); + r.set(2, Some(noop_sink())); + assert_eq!(r.senders().len(), 2); + r.remove(1); + assert_eq!(r.senders().len(), 1); + } +} diff --git a/crates/freshell-ws/src/lib.rs b/crates/freshell-ws/src/lib.rs index 573a9f91f..c592e4831 100644 --- a/crates/freshell-ws/src/lib.rs +++ b/crates/freshell-ws/src/lib.rs @@ -33,6 +33,8 @@ pub mod create_dedupe; pub(crate) mod create_gate; pub mod create_limit; pub mod existence; +pub mod host_stats_collector; +pub mod host_stats_interest; pub mod identity; pub mod invariants; pub mod opencode_association; @@ -233,6 +235,13 @@ pub struct WsState { /// amplifier subagent rescan cadence (`freshell-server`, Task 9) runs while /// `any()` is true. See [`crate::subagent_interest`]. pub subagent_interest: crate::subagent_interest::SubagentInterestRegistry, + /// HOST-PRESSURE PANE (Task 9, `docs/plans/2026-08-25-host-pressure-pane.md`): + /// per-connection `hoststats.subscribe` interest + the injected concrete + /// collector (`Arc`, freshell-server). Bundled as + /// ONE sub-struct so the ~35 `WsState { ... }` literals across crates + /// gain exactly one `host_stats: Default::default()` arm each (the plan's + /// >~6-site sweep rule). See [`crate::host_stats_collector`]. + pub host_stats: crate::host_stats_collector::WsHostStatsState, /// The handler-scoped monotonic `terminals.changed` revision counter /// (`ws-handler.ts:566` `terminalsRevision`). SHARED with the REST /// `/api/terminals` PATCH/DELETE broadcasts (`terminals::TerminalsState`), @@ -895,6 +904,7 @@ mod tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/src/opencode_association.rs b/crates/freshell-ws/src/opencode_association.rs index 8c28cfc2d..f417d0be0 100644 --- a/crates/freshell-ws/src/opencode_association.rs +++ b/crates/freshell-ws/src/opencode_association.rs @@ -432,6 +432,7 @@ mod tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: StdArc::new(Vec::new()), diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 849843b28..ec248b54f 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -81,6 +81,14 @@ mod terminal_launch_prep_tests; /// The write half of a split axum WebSocket. pub(crate) type WsSink = SplitSink; +/// Task 9: per-connection `hoststats.refresh` floor (legacy parity: +/// `ws-handler.ts` `HOST_STATS_REFRESH_MIN_INTERVAL_MS`, default 1000). +const HOST_STATS_REFRESH_FLOOR: std::time::Duration = std::time::Duration::from_millis(1000); +/// Task 9: the cooperative per-section budget handed to the collector on +/// `hoststats.refresh` (legacy parity: `HostStatsService.sectionBudgetMs`, +/// default 2000). +const HOST_STATS_REFRESH_DEADLINE: std::time::Duration = std::time::Duration::from_millis(2000); + /// Serialize + send one server→client message. Returns `false` if the socket is /// closed/errored (the caller then tears the connection down). pub(crate) async fn send(ws_tx: &mut WsSink, msg: &ServerMessage) -> bool { @@ -330,6 +338,12 @@ async fn run_loop( (state.term09.catastrophic_stall_ms / 4).max(10), )); + // Task 9 (host-pressure pane): THIS connection's last `hoststats.refresh` + // stamp — the per-connection 1s floor (legacy parity: + // `ClientState.hostStatsLastRefreshAt`, `ws-handler.ts:3330-3336`). Fresh + // on every (re)connect, exactly like `create_limiter` above. + let mut host_stats_last_refresh_at: Option = None; + // Whether the broadcast bus is still open (guards the select branch so a closed // bus can never busy-loop). The bus outlives every connection in practice. let mut bus_open = true; @@ -402,6 +416,7 @@ async fn run_loop( pane_reconcile_fresh_agent_v1, &mut create_limiter, &create_cancel_rx, + &mut host_stats_last_refresh_at, ) .await { @@ -587,6 +602,17 @@ async fn run_loop( // it (amplifier watch reduction): the demand-driven subagent rescan cadence // stops when the last interested connection leaves. state.subagent_interest.remove(conn_id); + // Task 9 (host-pressure pane): this connection's `hoststats.subscribe` + // interest is gone with it; when the LAST watcher leaves, the collector's + // cadence JoinHandles are aborted (zero-cost idle) via the trait callback + // (`ws-handler.ts:1297-1298` teardown parity). + if state.host_stats.interest.remove(conn_id) + == crate::host_stats_interest::InterestTransition::BecameIdle + { + if let Some(collector) = &state.host_stats.collector { + collector.set_active(false); + } + } // Multi-client layout store: this connection's mirrored layout snapshot is // gone with it (its pane/tab ids are client-local and unreachable now); // the primary falls back to the most recently synced remaining client. @@ -629,6 +655,8 @@ async fn handle_client_text( pane_reconcile_fresh_agent_v1: bool, create_limiter: &mut crate::create_limit::CreateRateLimiter, create_cancel_rx: &tokio::sync::watch::Receiver, + // Task 9: per-connection hoststats.refresh floor stamp (see run_loop). + host_stats_last_refresh_at: &mut Option, ) -> bool { // Accept-and-strip: unknown/unparseable frames are ignored (matches the // runtime's tolerance; the handshake already gated auth). @@ -1223,6 +1251,117 @@ async fn handle_client_text( .set(conn_id, prefs.include_subagents); true } + // Task 9 (host-pressure pane) — `hoststats.subscribe`. Idempotent; the + // 0->1 interest edge starts the collector cadence; the CURRENT cached + // snapshot goes back to THIS connection immediately (Node + // `setHostStatsSubscribed` + `sendHostStatsSnapshot`, ws-handler.ts + // :3309-3325 — including the idempotent re-send). No collector (unit + // tests): interest is recorded, no snapshot is sent (Node early-return + // when `this.hostStats` is unset). + ClientMessage::HostStatsSubscribe => { + let transition = state + .host_stats + .interest + .set(conn_id, Some(std::sync::Arc::clone(conn_sink))); + if transition == crate::host_stats_interest::InterestTransition::BecameActive { + if let Some(collector) = &state.host_stats.collector { + collector.set_active(true); + } + } + if let Some(collector) = &state.host_stats.collector { + return send( + ws_tx, + &ServerMessage::HostStatsSnapshot(collector.snapshot()), + ) + .await; + } + true + } + // `hoststats.unsubscribe` — the 1->0 edge stops the cadence (zero-cost + // idle). No reply frame (Node parity). + ClientMessage::HostStatsUnsubscribe => { + let transition = state.host_stats.interest.remove(conn_id); + if transition == crate::host_stats_interest::InterestTransition::BecameIdle { + if let Some(collector) = &state.host_stats.collector { + collector.set_active(false); + } + } + true + } + // `hoststats.refresh` — on-request manual data. No collector: explicit + // refusal (Node's 'host stats unavailable'). Per-connection 1s floor: + // a repeat <1s after THIS connection's last stamped refresh rejects + // with `rate_limited` WITHOUT invoking the collector (legacy parity: + // `ws-handler.ts:3330-3336`); the stamp is consumed only past the + // floor, BEFORE invoking (a failed invoke still holds the slot). + ClientMessage::HostStatsRefresh(request) => { + let Some(collector) = &state.host_stats.collector else { + return send( + ws_tx, + &ServerMessage::HostStatsRefreshResponse( + freshell_protocol::HostStatsRefreshResponse { + request_id: request.request_id.clone(), + ok: false, + at: None, + manual: None, + error: Some("host stats unavailable".to_string()), + }, + ), + ) + .await; + }; + let now = std::time::Instant::now(); + if let Some(last) = *host_stats_last_refresh_at { + if now.duration_since(last) < HOST_STATS_REFRESH_FLOOR { + return send( + ws_tx, + &ServerMessage::HostStatsRefreshResponse( + freshell_protocol::HostStatsRefreshResponse { + request_id: request.request_id.clone(), + ok: false, + at: None, + manual: None, + error: Some("rate_limited".to_string()), + }, + ), + ) + .await; + } + } + *host_stats_last_refresh_at = Some(now); + match collector.refresh(HOST_STATS_REFRESH_DEADLINE).await { + Ok(ok) => { + send( + ws_tx, + &ServerMessage::HostStatsRefreshResponse( + freshell_protocol::HostStatsRefreshResponse { + request_id: request.request_id.clone(), + ok: true, + at: Some(ok.at), + manual: Some(ok.manual), + error: None, + }, + ), + ) + .await + } + Err(error) => { + send( + ws_tx, + &ServerMessage::HostStatsRefreshResponse( + freshell_protocol::HostStatsRefreshResponse { + request_id: request.request_id.clone(), + ok: false, + at: None, + manual: None, + error: Some(error), + }, + ), + ) + .await + } + } + } // Application-level liveness ping (legacy parity: `ws-handler.ts:1832-1835` // -- `if (m.type === 'ping') { this.send(ws, { type: 'pong', timestamp: // nowIso() }); return }`). Byte-identical reply shape: exactly @@ -6030,6 +6169,7 @@ mod terminals_changed_tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), @@ -6268,6 +6408,7 @@ mod terminal_meta_created_tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: std::sync::Arc::new(Vec::new()), @@ -6860,6 +7001,7 @@ mod pane_reconcile_gate_tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), @@ -6889,6 +7031,7 @@ mod pane_reconcile_gate_tests { let conn_sink: FrameSink = std::sync::Arc::new(|_| {}); let mut create_limiter = crate::create_limit::CreateRateLimiter::new(8, 60_000); let (_cancel_tx, create_cancel_rx) = tokio::sync::watch::channel(false); + let mut host_stats_last_refresh_at = None; let keep_open = handle_client_text( r#"{"type":"pane.reconcile.request","reconcileId":"r1","panes":[{"paneKey":"tab-1:pane-1","kind":"terminal","mode":"shell","createRequestId":"cr-1"}]}"#, @@ -6901,6 +7044,7 @@ mod pane_reconcile_gate_tests { false, &mut create_limiter, &create_cancel_rx, + &mut host_stats_last_refresh_at, ) .await; assert!( @@ -6923,6 +7067,7 @@ mod pane_reconcile_gate_tests { false, &mut create_limiter, &create_cancel_rx, + &mut host_stats_last_refresh_at, ) .await; assert!(pong_ok); @@ -6939,3 +7084,491 @@ mod pane_reconcile_gate_tests { assert_eq!(pong["type"], "pong"); } } + +/// Task 9 (host-pressure pane): the `hoststats.subscribe` / `.unsubscribe` / +/// `.refresh` dispatch arms. A REAL loopback websocket pair (same scaffold as +/// `pane_reconcile_gate_tests`) drives `handle_client_text`'s real +/// serialization + send path; the collector is a fake implementing the +/// freshell-server-owned trait (dependency direction is frozen — freshell-ws +/// can never import the concrete collector). +#[cfg(test)] +mod host_stats_dispatch_tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex as StdMutex; + use std::time::Duration; + + use freshell_protocol::{ + HostStatsCpu, HostStatsDiskIo, HostStatsFreshell, HostStatsInotify, HostStatsLimits, + HostStatsLive, HostStatsLoad, HostStatsMachine, HostStatsManual, HostStatsMemory, + HostStatsNetwork, HostStatsPaging, HostStatsProcessHealth, HostStatsPsi, + HostStatsSnapshot, HostStatsThermals, HostStatsTopProcesses, + }; + + use crate::host_stats_collector::{ + HostStatsCollector, HostStatsRefreshFuture, HostStatsRefreshOk, WsHostStatsState, + }; + use crate::host_stats_interest::HostStatsInterestRegistry; + + fn canned_snapshot() -> HostStatsSnapshot { + HostStatsSnapshot { + at: 111, + live: HostStatsLive { + machine: HostStatsMachine { + cores: 4, + mem_total_bytes: 1024, + platform: "linux".to_string(), + wsl: false, + kernel: None, + hostname: None, + psi: false, + cgroup: "none".to_string(), + thermal_count: 0, + battery_present: false, + gpu: "none".to_string(), + }, + cpu: HostStatsCpu { + available: true, + usage_pct: 0.0, + steal_pct: None, + per_core_pct: vec![0.0; 4], + freq_m_hz: None, + }, + load: HostStatsLoad { + available: true, + load1: 0.0, + load5: 0.0, + load15: 0.0, + cores: 4, + }, + memory: HostStatsMemory { + available: false, + source: "host".to_string(), + total_bytes: 0, + used_bytes: 0, + available_bytes: 0, + cgroup_limit_bytes: None, + swap_total_bytes: None, + swap_used_bytes: None, + }, + paging: HostStatsPaging { + available: false, + swap_in_kbps: 0.0, + swap_out_kbps: 0.0, + maj_faults_per_sec: 0.0, + oom_kills_delta: 0, + oom_kills_total: 0, + }, + psi: HostStatsPsi { + available: false, + cpu_some10: None, + mem_some10: None, + mem_full10: None, + io_some10: None, + io_full10: None, + }, + disk_io: HostStatsDiskIo { + available: false, + read_bps: 0.0, + write_bps: 0.0, + util_pct: None, + weighted_await_ms: None, + }, + network: HostStatsNetwork { + available: false, + rx_bps: 0.0, + tx_bps: 0.0, + rx_errors_total: 0, + tx_errors_total: 0, + rx_dropped_total: 0, + tx_dropped_total: 0, + rx_errors_delta: 0, + tx_errors_delta: 0, + rx_dropped_delta: 0, + tx_dropped_delta: 0, + }, + limits: HostStatsLimits { + available: false, + fds_used: None, + fds_max: None, + pids_used: None, + pids_max: None, + time_wait: None, + ephemeral_ports: None, + }, + freshell: HostStatsFreshell { + available: true, + source: "rust".to_string(), + ptys_running: 0, + ptys_max: 0, + ws_clients: 0, + ws_clients_max: 0, + event_loop_lag_p99_ms: None, + rss_bytes: None, + uptime_sec: 0.0, + }, + }, + manual_at: Some(222), + manual: Some(HostStatsManual { + top_processes: HostStatsTopProcesses { + available: false, + dwell_ms: 0, + list: Vec::new(), + }, + process_health: HostStatsProcessHealth { + available: false, + zombies: 0, + d_state: 0, + total: 0, + }, + inotify: HostStatsInotify { + available: false, + instances: None, + watches: None, + max_user_watches: None, + max_user_instances: None, + }, + disks: freshell_protocol::HostStatsDisks { + available: false, + list: Vec::new(), + }, + thermals: HostStatsThermals { + available: false, + zones: Vec::new(), + battery: None, + }, + section_errors: Default::default(), + }), + } + } + + struct FakeCollector { + refresh_calls: Arc, + set_active_calls: Arc>>, + } + + impl HostStatsCollector for FakeCollector { + fn snapshot(&self) -> HostStatsSnapshot { + canned_snapshot() + } + fn refresh(&self, _deadline: Duration) -> HostStatsRefreshFuture<'_> { + self.refresh_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + Ok(HostStatsRefreshOk { + at: 333, + manual: canned_snapshot().manual.unwrap(), + }) + }) + } + fn set_active(&self, active: bool) { + self.set_active_calls.lock().unwrap().push(active); + } + } + + /// Same REAL loopback pair scaffold as `pane_reconcile_gate_tests`: the + /// upgrade handler parks forever; the listener task dies with the test + /// runtime. + type TestClient = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >; + + async fn loopback_sink_and_client() -> (WsSink, TestClient) { + let (sink_tx, sink_rx) = tokio::sync::oneshot::channel::(); + let sink_tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(sink_tx))); + let router = axum::Router::new().route( + "/ws", + axum::routing::any(move |upgrade: axum::extract::ws::WebSocketUpgrade| { + let sink_tx = std::sync::Arc::clone(&sink_tx); + async move { + upgrade.on_upgrade(move |socket| async move { + let (sink, _read) = socket.split(); + if let Some(tx) = sink_tx.lock().await.take() { + let _ = tx.send(sink); + } + std::future::pending::<()>().await; + }) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral loopback port"); + let addr = listener.local_addr().expect("loopback local addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + let (client, _resp) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .expect("ws connect to scratch server"); + let sink = sink_rx + .await + .expect("upgrade handler delivered the write half"); + (sink, client) + } + + async fn next_text_frame(client: &mut TestClient) -> serde_json::Value { + let msg = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("frame within timeout") + .expect("stream not ended") + .expect("no ws error"); + match msg { + tokio_tungstenite::tungstenite::Message::Text(text) => { + serde_json::from_str(&text).expect("json frame") + } + other => panic!("expected a text frame, got {other:?}"), + } + } + + fn state_with_host_stats(host_stats: WsHostStatsState) -> WsState { + let auth_token = Arc::new("s3cr3t-token-abcdef".to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(16).0); + WsState { + pane_ledger: std::sync::Arc::new(crate::pane_ledger::PaneLedger::disabled()), + layout: Default::default(), + identity: crate::identity::TerminalIdentityRegistry::new(), + terminal_meta: Default::default(), + auth_token: Arc::clone(&auth_token), + server_instance_id: Arc::new("srv-1111".to_string()), + boot_id: Arc::new("boot-2222".to_string()), + settings: Arc::new(crate::test_settings()), + handshake_settings: Arc::new(tokio::sync::RwLock::new(crate::test_settings())), + broadcast_tx: Arc::clone(&broadcast_tx), + auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, + auto_resume_cancels: Default::default(), + fresh_codex: freshell_freshagent::FreshCodexState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + serde_json::json!({ "freshAgent": { "enabled": false } }), + ), + fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_opencode: freshell_freshagent::FreshOpencodeState::new( + freshell_freshagent::FreshAgentState::new(auth_token, Arc::clone(&broadcast_tx)), + ), + registry: freshell_terminal::TerminalRegistry::new(), + shutdown: Arc::new(tokio::sync::Notify::new()), + tabs: crate::tabs::TabsRegistry::new(), + screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), + subagent_interest: Default::default(), + host_stats, + terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + cli_commands: Arc::new(Vec::new()), + ping_interval_ms: 30_000, + hello_timeout_ms: 5_000, + allowed_origins: Arc::new(crate::origin::default_allowed_origins()), + ws_max_payload_bytes: 16 * 1024 * 1024, + term09: crate::backpressure::Term09Config::default(), + create_protect: crate::create_limit::CreateProtectConfig::default(), + spawn_gate: std::sync::Arc::new(crate::spawn_gate::SpawnGate::new(4, 64)), + shutdown_started: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + create_dedupe: std::sync::Arc::new(crate::create_dedupe::CreateDedupe::default()), + config_fallback: None, + opencode_locator: None, + codex_locator: None, + activity: None, + session_existence: std::sync::Arc::new(crate::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: crate::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), + } + } + + #[tokio::test] + async fn host_stats_subscribe_snapshot_and_set_active_edges() { + let (mut ws_tx, mut client) = loopback_sink_and_client().await; + let interest = HostStatsInterestRegistry::default(); + let fake = Arc::new(FakeCollector { + refresh_calls: Arc::new(AtomicUsize::new(0)), + set_active_calls: Arc::new(StdMutex::new(Vec::new())), + }); + let state = state_with_host_stats(WsHostStatsState { + interest: interest.clone(), + collector: Some(fake.clone()), + }); + let conn_sink: FrameSink = std::sync::Arc::new(|_| {}); + let mut create_limiter = crate::create_limit::CreateRateLimiter::new(8, 60_000); + let (_cancel_tx, create_cancel_rx) = tokio::sync::watch::channel(false); + let mut host_stats_last_refresh_at = None; + + // subscribe: 0->1 edge drives set_active(true) ONCE and the current + // snapshot is sent immediately Node `sendHostStatsSnapshot` parity, + // including idempotent re-subscribe (no double edge, re-sent frame). + for round in 0..2 { + let ok = handle_client_text( + r#"{"type":"hoststats.subscribe"}"#, + &mut ws_tx, + &state, + 1, + &conn_sink, + false, + false, + false, + &mut create_limiter, + &create_cancel_rx, + &mut host_stats_last_refresh_at, + ) + .await; + assert!(ok); + let frame = next_text_frame(&mut client).await; + assert_eq!(frame["type"], "hoststats.snapshot", "round {round}"); + assert_eq!(frame["at"], 111); + assert_eq!(frame["manualAt"], 222); + assert_eq!( + fake.set_active_calls.lock().unwrap().clone(), + vec![true], + "re-subscribe must not double-fire the 0->1 edge (round {round})" + ); + } + assert!(state.host_stats.interest.any()); + assert_eq!(state.host_stats.interest.count(), 1); + + // unsubscribe: 1->0 edge drives set_active(false) ONCE; no reply + // frame (Node parity) — proven by the followed ping answering pong. + let ok = handle_client_text( + r#"{"type":"hoststats.unsubscribe"}"#, + &mut ws_tx, + &state, + 1, + &conn_sink, + false, + false, + false, + &mut create_limiter, + &create_cancel_rx, + &mut host_stats_last_refresh_at, + ) + .await; + assert!(ok); + assert!(!state.host_stats.interest.any()); + assert_eq!( + fake.set_active_calls.lock().unwrap().clone(), + vec![true, false] + ); + let pong_ok = handle_client_text( + r#"{"type":"ping"}"#, + &mut ws_tx, + &state, + 1, + &conn_sink, + false, + false, + false, + &mut create_limiter, + &create_cancel_rx, + &mut host_stats_last_refresh_at, + ) + .await; + assert!(pong_ok); + let pong = next_text_frame(&mut client).await; + assert_eq!(pong["type"], "pong", "unsubscribe itself sends no frame"); + } + + #[tokio::test] + async fn host_stats_refresh_per_connection_floor_rate_limits_without_invoking_collector() { + let (mut ws_tx, mut client) = loopback_sink_and_client().await; + let interest = HostStatsInterestRegistry::default(); + let fake = Arc::new(FakeCollector { + refresh_calls: Arc::new(AtomicUsize::new(0)), + set_active_calls: Arc::new(StdMutex::new(Vec::new())), + }); + let state = state_with_host_stats(WsHostStatsState { + interest: interest.clone(), + collector: Some(fake.clone()), + }); + let conn_sink: FrameSink = std::sync::Arc::new(|_| {}); + let mut create_limiter = crate::create_limit::CreateRateLimiter::new(8, 60_000); + let (_cancel_tx, create_cancel_rx) = tokio::sync::watch::channel(false); + let mut host_stats_last_refresh_at = None; + + // First refresh passes the floor and invokes the collector. + let ok = handle_client_text( + r#"{"type":"hoststats.refresh","requestId":"r1"}"#, + &mut ws_tx, + &state, + 1, + &conn_sink, + false, + false, + false, + &mut create_limiter, + &create_cancel_rx, + &mut host_stats_last_refresh_at, + ) + .await; + assert!(ok); + let first = next_text_frame(&mut client).await; + assert_eq!(first["type"], "hoststats.refresh.response"); + assert_eq!(first["requestId"], "r1"); + assert_eq!(first["ok"], true); + assert_eq!(first["at"], 333); + assert!(first["manual"].is_object()); + assert_eq!(fake.refresh_calls.load(Ordering::SeqCst), 1); + + // Second refresh <1s later is rejected by the PER-CONNECTION floor + // WITHOUT invoking the collector (the service single-flight/cooldown + // is downstream and never reached). + let ok = handle_client_text( + r#"{"type":"hoststats.refresh","requestId":"r2"}"#, + &mut ws_tx, + &state, + 1, + &conn_sink, + false, + false, + false, + &mut create_limiter, + &create_cancel_rx, + &mut host_stats_last_refresh_at, + ) + .await; + assert!(ok); + let second = next_text_frame(&mut client).await; + assert_eq!(second["type"], "hoststats.refresh.response"); + assert_eq!(second["requestId"], "r2"); + assert_eq!(second["ok"], false); + assert_eq!(second["error"], "rate_limited"); + // zod `.optional()` discipline: at/manual are ABSENT on the reject, + // never explicit null. + assert!(second.get("at").is_none()); + assert!(second.get("manual").is_none()); + assert_eq!( + fake.refresh_calls.load(Ordering::SeqCst), + 1, + "the rate-limited repeat never reaches the collector" + ); + } + + #[tokio::test] + async fn host_stats_refresh_without_collector_reports_unavailable() { + let (mut ws_tx, mut client) = loopback_sink_and_client().await; + let state = state_with_host_stats(WsHostStatsState::default()); + let conn_sink: FrameSink = std::sync::Arc::new(|_| {}); + let mut create_limiter = crate::create_limit::CreateRateLimiter::new(8, 60_000); + let (_cancel_tx, create_cancel_rx) = tokio::sync::watch::channel(false); + let mut host_stats_last_refresh_at = None; + + let ok = handle_client_text( + r#"{"type":"hoststats.refresh","requestId":"r9"}"#, + &mut ws_tx, + &state, + 1, + &conn_sink, + false, + false, + false, + &mut create_limiter, + &create_cancel_rx, + &mut host_stats_last_refresh_at, + ) + .await; + assert!(ok); + let frame = next_text_frame(&mut client).await; + assert_eq!(frame["type"], "hoststats.refresh.response"); + assert_eq!(frame["requestId"], "r9"); + assert_eq!(frame["ok"], false); + assert_eq!(frame["error"], "host stats unavailable"); + // A rejected refresh must NOT claim the floor slot (Node stamps only + // after passing the floor, with a live service). + assert!(host_stats_last_refresh_at.is_none()); + } +} diff --git a/crates/freshell-ws/tests/auto_resume_respawn.rs b/crates/freshell-ws/tests/auto_resume_respawn.rs index 6aed49dcb..aaf7bec4f 100644 --- a/crates/freshell-ws/tests/auto_resume_respawn.rs +++ b/crates/freshell-ws/tests/auto_resume_respawn.rs @@ -395,6 +395,7 @@ fn respawn_state_with_probe( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![common::sleeper_cli_spec("amplifier")]), diff --git a/crates/freshell-ws/tests/claude_session_rebind.rs b/crates/freshell-ws/tests/claude_session_rebind.rs index 8274045f7..77850b4fc 100644 --- a/crates/freshell-ws/tests/claude_session_rebind.rs +++ b/crates/freshell-ws/tests/claude_session_rebind.rs @@ -169,6 +169,7 @@ async fn spawn_server_returning_state( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), diff --git a/crates/freshell-ws/tests/codex_managed_launch_e2e.rs b/crates/freshell-ws/tests/codex_managed_launch_e2e.rs index c13f4c11b..958080725 100644 --- a/crates/freshell-ws/tests/codex_managed_launch_e2e.rs +++ b/crates/freshell-ws/tests/codex_managed_launch_e2e.rs @@ -151,6 +151,7 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![codex_cli_spec()]), diff --git a/crates/freshell-ws/tests/codex_session_ref_resume.rs b/crates/freshell-ws/tests/codex_session_ref_resume.rs index 8d1fd7bf7..fa17c9fe7 100644 --- a/crates/freshell-ws/tests/codex_session_ref_resume.rs +++ b/crates/freshell-ws/tests/codex_session_ref_resume.rs @@ -141,6 +141,7 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![codex_cli_spec()]), diff --git a/crates/freshell-ws/tests/codex_sidecar_reattach_e2e.rs b/crates/freshell-ws/tests/codex_sidecar_reattach_e2e.rs index 4a207e166..512ba5466 100644 --- a/crates/freshell-ws/tests/codex_sidecar_reattach_e2e.rs +++ b/crates/freshell-ws/tests/codex_sidecar_reattach_e2e.rs @@ -295,6 +295,7 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![codex_cli_spec()]), diff --git a/crates/freshell-ws/tests/common/mod.rs b/crates/freshell-ws/tests/common/mod.rs index ad282c923..d7ecfab43 100644 --- a/crates/freshell-ws/tests/common/mod.rs +++ b/crates/freshell-ws/tests/common/mod.rs @@ -172,6 +172,7 @@ pub async fn spawn_server_with_specs_and_shared_settings( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -251,6 +252,7 @@ pub async fn spawn_server_with_specs( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -332,6 +334,7 @@ pub async fn spawn_server_with_specs_and_auto_resume_rx( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -417,6 +420,7 @@ pub async fn spawn_server_with_specs_and_auto_resume_hub( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -499,6 +503,7 @@ pub async fn spawn_server_with_specs_and_state( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -589,6 +594,7 @@ pub async fn spawn_server_with_ledger( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -675,6 +681,7 @@ pub async fn spawn_server_with_specs_and_activity( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -760,6 +767,7 @@ pub async fn spawn_server_with_specs_activity_and_codex_locator( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -867,6 +875,7 @@ pub async fn spawn_server_with_create_protect_probes( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/cross_kind_liveness.rs b/crates/freshell-ws/tests/cross_kind_liveness.rs index 42f21200e..1e46e4deb 100644 --- a/crates/freshell-ws/tests/cross_kind_liveness.rs +++ b/crates/freshell-ws/tests/cross_kind_liveness.rs @@ -271,6 +271,7 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![sleeper_cli_spec("claude")]), diff --git a/crates/freshell-ws/tests/diag01_lifecycle_events.rs b/crates/freshell-ws/tests/diag01_lifecycle_events.rs index 7cb7b33a8..5c2f688e2 100644 --- a/crates/freshell-ws/tests/diag01_lifecycle_events.rs +++ b/crates/freshell-ws/tests/diag01_lifecycle_events.rs @@ -240,6 +240,7 @@ async fn spawn_server(ping_interval_ms: u64) -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/freshagent_claude_attach.rs b/crates/freshell-ws/tests/freshagent_claude_attach.rs index c21047215..83f63d6a2 100644 --- a/crates/freshell-ws/tests/freshagent_claude_attach.rs +++ b/crates/freshell-ws/tests/freshagent_claude_attach.rs @@ -202,6 +202,7 @@ async fn spawn_server() -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs index 30c988f71..14dbded61 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs @@ -199,6 +199,7 @@ async fn spawn_server() -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/freshagent_session_lease.rs b/crates/freshell-ws/tests/freshagent_session_lease.rs index 25fe78147..1a43d8e26 100644 --- a/crates/freshell-ws/tests/freshagent_session_lease.rs +++ b/crates/freshell-ws/tests/freshagent_session_lease.rs @@ -306,6 +306,7 @@ async fn spawn_server() -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/hello_timeout.rs b/crates/freshell-ws/tests/hello_timeout.rs index 36fd28ce1..d0bbf319d 100644 --- a/crates/freshell-ws/tests/hello_timeout.rs +++ b/crates/freshell-ws/tests/hello_timeout.rs @@ -84,6 +84,7 @@ async fn spawn_server(hello_timeout_ms: u64) -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/keepalive.rs b/crates/freshell-ws/tests/keepalive.rs index 8b4dc31f2..e03f2e796 100644 --- a/crates/freshell-ws/tests/keepalive.rs +++ b/crates/freshell-ws/tests/keepalive.rs @@ -85,6 +85,7 @@ async fn spawn_server( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/max_payload.rs b/crates/freshell-ws/tests/max_payload.rs index 4c2424d94..78a416ec5 100644 --- a/crates/freshell-ws/tests/max_payload.rs +++ b/crates/freshell-ws/tests/max_payload.rs @@ -85,6 +85,7 @@ async fn spawn_server(ws_max_payload_bytes: usize) -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/opencode_switch_rebind.rs b/crates/freshell-ws/tests/opencode_switch_rebind.rs index a17a20bef..39703c638 100644 --- a/crates/freshell-ws/tests/opencode_switch_rebind.rs +++ b/crates/freshell-ws/tests/opencode_switch_rebind.rs @@ -240,6 +240,7 @@ async fn spawn_server_returning_state( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), diff --git a/crates/freshell-ws/tests/origin_policy.rs b/crates/freshell-ws/tests/origin_policy.rs index cfd9daed6..895aa0507 100644 --- a/crates/freshell-ws/tests/origin_policy.rs +++ b/crates/freshell-ws/tests/origin_policy.rs @@ -75,6 +75,7 @@ async fn spawn_server(allowed_origins: Vec) -> (String, String) { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/pane_reconcile.rs b/crates/freshell-ws/tests/pane_reconcile.rs index 3d255daf1..0f5db1eff 100644 --- a/crates/freshell-ws/tests/pane_reconcile.rs +++ b/crates/freshell-ws/tests/pane_reconcile.rs @@ -157,6 +157,7 @@ async fn spawn_server_with_probe( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs index c235cc6d8..7336a405f 100644 --- a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs +++ b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs @@ -227,6 +227,7 @@ async fn spawn_server_with_probe(probe: Arc) -> Server { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/rest_claude_identity.rs b/crates/freshell-ws/tests/rest_claude_identity.rs index a6ec5fa09..9fa0f6f26 100644 --- a/crates/freshell-ws/tests/rest_claude_identity.rs +++ b/crates/freshell-ws/tests/rest_claude_identity.rs @@ -94,6 +94,7 @@ async fn spawn_merged_server() -> Harness { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::clone(&cli_commands), diff --git a/crates/freshell-ws/tests/rest_locator_identity.rs b/crates/freshell-ws/tests/rest_locator_identity.rs index 503f8244e..09062efbd 100644 --- a/crates/freshell-ws/tests/rest_locator_identity.rs +++ b/crates/freshell-ws/tests/rest_locator_identity.rs @@ -118,6 +118,7 @@ async fn spawn_merged_server( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::clone(&cli_commands), diff --git a/crates/freshell-ws/tests/rest_ws_shared_gate.rs b/crates/freshell-ws/tests/rest_ws_shared_gate.rs index 02d88907e..675544ffe 100644 --- a/crates/freshell-ws/tests/rest_ws_shared_gate.rs +++ b/crates/freshell-ws/tests/rest_ws_shared_gate.rs @@ -154,6 +154,7 @@ async fn spawn_combined_server( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/restore_plan_queue_cap.rs b/crates/freshell-ws/tests/restore_plan_queue_cap.rs index d32f047ad..1c008b700 100644 --- a/crates/freshell-ws/tests/restore_plan_queue_cap.rs +++ b/crates/freshell-ws/tests/restore_plan_queue_cap.rs @@ -128,6 +128,7 @@ async fn spawn_server( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![ diff --git a/crates/freshell-ws/tests/restore_spawn_gate.rs b/crates/freshell-ws/tests/restore_spawn_gate.rs index d07b29c40..5d1b8a678 100644 --- a/crates/freshell-ws/tests/restore_spawn_gate.rs +++ b/crates/freshell-ws/tests/restore_spawn_gate.rs @@ -124,6 +124,7 @@ async fn spawn_server( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![ diff --git a/crates/freshell-ws/tests/restore_storm.rs b/crates/freshell-ws/tests/restore_storm.rs index 8972011b0..f038419d0 100644 --- a/crates/freshell-ws/tests/restore_storm.rs +++ b/crates/freshell-ws/tests/restore_storm.rs @@ -134,6 +134,7 @@ async fn spawn_server( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![ diff --git a/crates/freshell-ws/tests/resume_validation_gate.rs b/crates/freshell-ws/tests/resume_validation_gate.rs index 700a58575..e3c22efaa 100644 --- a/crates/freshell-ws/tests/resume_validation_gate.rs +++ b/crates/freshell-ws/tests/resume_validation_gate.rs @@ -167,6 +167,7 @@ async fn spawn_server_with_probe( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![ @@ -362,6 +363,7 @@ async fn spawn_managed_codex_server_with_probe( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![codex_cli_spec()]), diff --git a/crates/freshell-ws/tests/safe08_restore_diagnostics.rs b/crates/freshell-ws/tests/safe08_restore_diagnostics.rs index 2f9060047..57e181f16 100644 --- a/crates/freshell-ws/tests/safe08_restore_diagnostics.rs +++ b/crates/freshell-ws/tests/safe08_restore_diagnostics.rs @@ -171,6 +171,7 @@ async fn spawn_server() -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/sessions_prefs.rs b/crates/freshell-ws/tests/sessions_prefs.rs index 02eec51d0..7b53fbf7e 100644 --- a/crates/freshell-ws/tests/sessions_prefs.rs +++ b/crates/freshell-ws/tests/sessions_prefs.rs @@ -89,6 +89,7 @@ async fn spawn_server() -> ( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: interest.clone(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/term09_output_queue.rs b/crates/freshell-ws/tests/term09_output_queue.rs index f58dd4fa4..127cbb59d 100644 --- a/crates/freshell-ws/tests/term09_output_queue.rs +++ b/crates/freshell-ws/tests/term09_output_queue.rs @@ -78,6 +78,7 @@ async fn spawn_server(term09: Term09Config) -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/ui_layout_sync.rs b/crates/freshell-ws/tests/ui_layout_sync.rs index 5c9cb10ca..970a09537 100644 --- a/crates/freshell-ws/tests/ui_layout_sync.rs +++ b/crates/freshell-ws/tests/ui_layout_sync.rs @@ -104,6 +104,7 @@ async fn spawn_server() -> (String, String, LayoutStore) { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), From f83133cb172d715847cdaa8eb838951ac70020f2 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:21:48 -0700 Subject: [PATCH 17/25] fix(host-stats): collector-owned refresh run (leader-teardown parity) + per-section watchdog coverage --- crates/freshell-server/src/host_stats.rs | 382 +++++++++++++++++------ 1 file changed, 295 insertions(+), 87 deletions(-) diff --git a/crates/freshell-server/src/host_stats.rs b/crates/freshell-server/src/host_stats.rs index 4afd91949..fd58e2e24 100644 --- a/crates/freshell-server/src/host_stats.rs +++ b/crates/freshell-server/src/host_stats.rs @@ -171,7 +171,9 @@ struct Share { cadence: Mutex>, /// Single-flight: while Some, a refresh is in flight and later callers /// clone this receiver and await the SAME wire (Node returns the same - /// in-flight promise). + /// in-flight promise). The run itself is the COLLECTOR's own spawned + /// task — never the requesting caller's future — so a caller teardown + /// cancels nothing for anyone else (Node service-owned pendingRefresh). refresh_flight: Mutex>>>, last_refresh_completed: Mutex>, } @@ -713,36 +715,46 @@ impl HostStatsCollector for HostStatsCollectorService { } } } - enum Flight { - Lead(tokio::sync::watch::Sender>), - Join(tokio::sync::watch::Receiver>), - } - let flight = { + let mut rx = { let mut flight = ctx.share.refresh_flight.lock().unwrap(); if let Some(rx) = flight.clone() { - Flight::Join(rx) + rx } else { let (tx, rx) = tokio::sync::watch::channel(None); - *flight = Some(rx); - Flight::Lead(tx) + *flight = Some(rx.clone()); + // The COLLECTOR owns the run (Node parity: the service owns + // pendingRefresh independent of any requesting socket): the + // refresh runs as the collector's own spawned task, and + // every caller — the leader included — merely awaits a + // receiver. A leader connection tearing down mid-flight + // cancels NOTHING: the run still completes, the completion + // stamps land unconditionally, and every waiter gets the + // wire. + // + // Stamp the cooldown + free the flight slot BEFORE waking + // the waiters: a waiter whose next move is an immediate + // re-refresh must see the cooldown and never re-run. + let run_ctx = Arc::clone(&ctx); + tokio::spawn(async move { + let result = run_refresh(&run_ctx, deadline).await; + *run_ctx.share.last_refresh_completed.lock().unwrap() = + Some(Instant::now()); + *run_ctx.share.refresh_flight.lock().unwrap() = None; + let _ = tx.send(Some(result)); + }); + rx } }; - match flight { - Flight::Lead(tx) => { - let result = run_refresh(&ctx, deadline).await; - let _ = tx.send(Some(result.clone())); - *ctx.share.refresh_flight.lock().unwrap() = None; - *ctx.share.last_refresh_completed.lock().unwrap() = Some(Instant::now()); - result + loop { + if let Some(wire) = rx.borrow().clone() { + return wire; + } + if rx.changed().await.is_err() { + // Only reachable if the collector's own run task vanished + // without completing (runtime teardown/panic — run_refresh + // never fails for data reasons). + return Err("refresh run vanished".to_string()); } - Flight::Join(mut rx) => loop { - if let Some(wire) = rx.borrow().clone() { - return wire; - } - if rx.changed().await.is_err() { - return Err("refresh leader vanished".to_string()); - } - }, } }) } @@ -848,10 +860,50 @@ enum ScanOutcome { Watchdog, } +/// Node's overall-watchdog section-error payload (the `DeadlineExceeded` +/// message in `service.ts` runRefresh's watchdog promise). +const REFRESH_WATCHDOG_MSG: &str = "host-stats refresh overall budget exceeded"; + +/// The overall-watchdog verdict for a non-scan refresh section arm (Node +/// `Promise.race([section.run(), watchdog])` settling with the watchdog): +/// the section keeps its zero-shape and gains the watchdog sectionErrors +/// entry. The entry check and a mid-flight timeout are the same race. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SectionWatchdogFired; + +/// Race one refresh section arm against the overall watchdog. Sync-reader +/// arms never yield, so a plain `timeout_at` wrapper's immediately-ready +/// inner future would win the race even against an ALREADY-exhausted +/// deadline (tokio observes an expired timer on a driver turn, which an +/// instant section beats). Check the clock at arm ENTRY: a section whose +/// turn comes after the watchdog fired (its first poll was delayed past the +/// budget, e.g. by an earlier arm's sync work on the same executor task) +/// degrades WITHOUT running its reads — exactly how a section's race +/// settles in Node when the watchdog promise has already rejected. The +/// `timeout_at` wrapper still covers a section that runs past the budget. +async fn race_section_watchdog( + overall_deadline: tokio::time::Instant, + work: impl std::future::Future, +) -> Result { + match tokio::time::timeout_at(overall_deadline, async { + if tokio::time::Instant::now() >= overall_deadline { + None + } else { + Some(work.await) + } + }) + .await + { + Ok(Some(value)) => Ok(value), + Ok(None) | Err(_) => Err(SectionWatchdogFired), + } +} + /// One refresh run: sections race under a shared absolute cooperative /// deadline (`started + deadline`, the trait argument — Node -/// `sectionBudgetMs`) and a per-section overall watchdog (`started + -/// overall_budget`, Node `overallBudgetMs`). Never fails for data reasons. +/// `sectionBudgetMs`) and EVERY section arm races the overall watchdog +/// (`started + overall_budget`, Node `overallBudgetMs`). Never fails for +/// data reasons. async fn run_refresh(ctx: &Arc, deadline: Duration) -> RefreshWire { let started = Instant::now(); let section_deadline = started + deadline; @@ -873,44 +925,53 @@ async fn run_refresh(ctx: &Arc, deadline: Duration) -> RefreshWire }; let inotify_ctx = Arc::clone(ctx); let inotify_fut = async move { - let usage = readers::read_self_inotify_stats(&inotify_ctx.cfg.proc_root); - let limits = readers::read_inotify_limits(&inotify_ctx.cfg.proc_root); - (usage, limits) - }; - let disks_fut = async { - // Node: darwin mounts ['/'], else ['/', '/dev/shm']. - let mounts: &[&str] = if cfg!(target_os = "macos") { - &["/"] - } else if cfg!(target_os = "windows") { - &[] - } else { - &["/", "/dev/shm"] + let work = async move { + let usage = readers::read_self_inotify_stats(&inotify_ctx.cfg.proc_root); + let limits = readers::read_inotify_limits(&inotify_ctx.cfg.proc_root); + (usage, limits) }; - let mut list = Vec::new(); - for mount in mounts { - if let Some((total_bytes, free_bytes, used_pct, inodes_total, inodes_free)) = - statfs_info(mount) - { - list.push(HostStatsDisk { - mount: mount.to_string(), - total_bytes, - free_bytes, - used_pct, - inodes_total, - inodes_free, - }); + race_section_watchdog(overall_deadline, work).await + }; + let disks_fut = async move { + let work = async move { + // Node: darwin mounts ['/'], else ['/', '/dev/shm']. + let mounts: &[&str] = if cfg!(target_os = "macos") { + &["/"] + } else if cfg!(target_os = "windows") { + &[] + } else { + &["/", "/dev/shm"] + }; + let mut list = Vec::new(); + for mount in mounts { + if let Some((total_bytes, free_bytes, used_pct, inodes_total, inodes_free)) = + statfs_info(mount) + { + list.push(HostStatsDisk { + mount: mount.to_string(), + total_bytes, + free_bytes, + used_pct, + inodes_total, + inodes_free, + }); + } } - } - list + list + }; + race_section_watchdog(overall_deadline, work).await }; let thermals_ctx = Arc::clone(ctx); let thermals_fut = async move { - let zones = readers::read_thermals(&thermals_ctx.cfg.sys_root); - let battery = readers::read_battery(&thermals_ctx.cfg.sys_root); - (zones, battery) + let work = async move { + let zones = readers::read_thermals(&thermals_ctx.cfg.sys_root); + let battery = readers::read_battery(&thermals_ctx.cfg.sys_root); + (zones, battery) + }; + race_section_watchdog(overall_deadline, work).await }; - let (scan_out, (inotify_usage, inotify_limits), disk_list, (zones, battery)) = + let (scan_out, inotify_out, disks_out, thermals_out) = tokio::join!(scan_fut, inotify_fut, disks_fut, thermals_fut); let mut manual = zero_manual(); @@ -955,45 +1016,68 @@ async fn run_refresh(ctx: &Arc, deadline: Duration) -> RefreshWire ); } ScanOutcome::Watchdog => { - // Node's watchdog message. - let msg = "host-stats refresh overall budget exceeded".to_string(); + let msg = REFRESH_WATCHDOG_MSG.to_string(); section_errors.insert("topProcesses".to_string(), msg.clone()); section_errors.insert("processHealth".to_string(), msg); } } - if inotify_usage.is_some() || inotify_limits.is_some() { - manual.inotify = HostStatsInotify { - available: true, - instances: inotify_usage.map(|u| u.instances), - watches: inotify_usage.map(|u| u.watches), - max_user_watches: inotify_limits.and_then(|l| l.max_user_watches), - max_user_instances: inotify_limits.and_then(|l| l.max_user_instances), - }; + // A watchdog-losing non-scan section keeps the zero-shape already in + // place (zero_manual) and adds ONLY the sectionErrors entry — the same + // degradation Node's race produces for that key. + match inotify_out { + Ok((usage, limits)) => { + if usage.is_some() || limits.is_some() { + manual.inotify = HostStatsInotify { + available: true, + instances: usage.map(|u| u.instances), + watches: usage.map(|u| u.watches), + max_user_watches: limits.and_then(|l| l.max_user_watches), + max_user_instances: limits.and_then(|l| l.max_user_instances), + }; + } + } + Err(SectionWatchdogFired) => { + section_errors.insert("inotify".to_string(), REFRESH_WATCHDOG_MSG.to_string()); + } } - if !disk_list.is_empty() { - manual.disks = HostStatsDisks { - available: true, - list: disk_list, - }; + match disks_out { + Ok(disk_list) => { + if !disk_list.is_empty() { + manual.disks = HostStatsDisks { + available: true, + list: disk_list, + }; + } + } + Err(SectionWatchdogFired) => { + section_errors.insert("disks".to_string(), REFRESH_WATCHDOG_MSG.to_string()); + } } - if let Some(zones) = zones { - manual.thermals = HostStatsThermals { - available: true, - zones: zones - .into_iter() - .map(|z| HostStatsThermalZone { - label: z.label, - celsius: z.celsius, - }) - .collect(), - battery: battery.map(|b| HostStatsBattery { - pct: b.pct, - status: b.status, - }), - }; + match thermals_out { + Ok((zones, battery)) => { + if let Some(zones) = zones { + manual.thermals = HostStatsThermals { + available: true, + zones: zones + .into_iter() + .map(|z| HostStatsThermalZone { + label: z.label, + celsius: z.celsius, + }) + .collect(), + battery: battery.map(|b| HostStatsBattery { + pct: b.pct, + status: b.status, + }), + }; + } + } + Err(SectionWatchdogFired) => { + section_errors.insert("thermals".to_string(), REFRESH_WATCHDOG_MSG.to_string()); + } } manual.section_errors = section_errors; @@ -1870,4 +1954,128 @@ mod tests { assert!(result.manual.thermals.available); assert!(!result.manual.section_errors.contains_key("disks")); } + + #[tokio::test] + async fn host_stats_refresh_leader_teardown_joiner_and_cache_survive() { + // Parity regression (service.ts:321-331): the in-flight refresh run is + // owned by the COLLECTOR (Node's service-owned pendingRefresh), never + // by the requesting caller's future. If the "leader" caller is torn + // down mid-flight (its connection dies), the run still completes: + // the next caller joins the SAME collector-owned run and receives its + // result, and the manual cache is updated unconditionally. + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let interest = HostStatsInterestRegistry::default(); + let collector = Arc::new(test_collector(scan_root, sys_fixture(), &interest)); + // The leader drives refresh() from its own task, then tears down + // mid-flight (the abort drops the future mid-dwell). + let leader = { + let leader_collector = Arc::clone(&collector); + tokio::spawn(async move { leader_collector.refresh(Duration::from_millis(2000)).await }) + }; + let in_flight = + wait_until(Duration::from_secs(2), || collector.scan_run_count() == 1).await; + assert!(in_flight, "the leader's run started (scan in flight)"); + leader.abort(); + let outcome = leader.await; + let cancelled = matches!(&outcome, Err(e) if e.is_cancelled()); + assert!( + cancelled, + "the leader task was aborted mid-flight: {outcome:?}" + ); + // The NEXT caller joins the collector-owned run (never a "refresh + // leader vanished" error, never a poisoned flight slot). + let joined = collector + .refresh(Duration::from_millis(2000)) + .await + .expect("the collector-owned run completes for every caller"); + assert_eq!( + collector.scan_run_count(), + 1, + "no re-run: the surviving run serves the joiner" + ); + assert!(joined.manual.top_processes.available); + assert_eq!(joined.manual.top_processes.list.len(), 7); + assert!(joined.manual.process_health.available); + assert_eq!(joined.manual.process_health.zombies, 1); + assert_eq!(joined.manual.process_health.d_state, 1); + assert_eq!(joined.manual.process_health.total, 8); + assert!(joined.manual.disks.available); + assert!(joined.manual.thermals.available); + assert!(joined.manual.section_errors.is_empty()); + // The manual cache was written by the collector at completion. + let snap = collector.snapshot(); + assert_eq!(snap.manual_at, Some(joined.at)); + assert_eq!(snap.manual, Some(joined.manual)); + } + + #[tokio::test] + async fn host_stats_refresh_overall_watchdog_covers_every_section() { + // Parity regression (service.ts:744): EVERY section arm races the + // overall watchdog — not only the process-scan arm. An overall budget + // that is already exhausted must degrade EVERY section to its full + // zero-shape (available:false + the watchdog sectionErrors entry) + // while the refresh still resolves Ok and the manual cache updates. + // (Healthy-path completion under the same wrapper is pinned by the + // single-flight + cooperative-budget tests above.) + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + // Give the overlay readable inotify sysctls so an UNGUARDED inotify + // arm would complete as available:true (pre-fix discrimination). + let inotify_dir = scan_root.join("sys").join("fs").join("inotify"); + std::fs::create_dir_all(&inotify_dir).unwrap(); + std::fs::write(inotify_dir.join("max_user_watches"), "1048576\n").unwrap(); + std::fs::write(inotify_dir.join("max_user_instances"), "128\n").unwrap(); + let interest = HostStatsInterestRegistry::default(); + let mut cfg = test_config(scan_root, sys_fixture()); + // The watchdog fires at the first per-section preemption point. + cfg.overall_budget = Duration::ZERO; + let collector = HostStatsCollectorService::new( + cfg, + freshell_terminal::TerminalRegistry::new(), + interest, + Instant::now(), + ); + // A HEALTHY cooperative budget: only the overall-watchdog path is + // under test here (the per-pid cooperative deadline never trips). + let result = collector + .refresh(Duration::from_millis(2000)) + .await + .expect("watchdog preemption degrades sections, never rejects"); + let manual = &result.manual; + assert!(!manual.top_processes.available); + assert!(!manual.process_health.available); + assert!( + !manual.inotify.available, + "the watchdog must preempt the inotify arm" + ); + assert!( + !manual.disks.available, + "the watchdog must preempt the disks arm" + ); + assert!( + !manual.thermals.available, + "the watchdog must preempt the thermals arm" + ); + for key in [ + "topProcesses", + "processHealth", + "inotify", + "disks", + "thermals", + ] { + assert_eq!( + manual.section_errors.get(key).map(String::as_str), + Some("host-stats refresh overall budget exceeded"), + "section {key} carries the watchdog error" + ); + } + assert_eq!(manual.section_errors.len(), 5); + assert_eq!(collector.scan_run_count(), 1); + // The refresh still resolved and the manual cache holds the degraded + // shape (Node: manualCache is written after Promise.all, errors or not). + let snap = collector.snapshot(); + assert_eq!(snap.manual_at, Some(result.at)); + assert_eq!(snap.manual, Some(result.manual.clone())); + } } From a9ff7a2f7a879786387f8c4394017880235ffd57 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:21:48 -0700 Subject: [PATCH 18/25] fix(host-stats): drop-guard frees refresh flight slot on run panic (Node .finally parity) --- crates/freshell-server/src/host_stats.rs | 139 +++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/crates/freshell-server/src/host_stats.rs b/crates/freshell-server/src/host_stats.rs index fd58e2e24..ace34fce2 100644 --- a/crates/freshell-server/src/host_stats.rs +++ b/crates/freshell-server/src/host_stats.rs @@ -45,6 +45,8 @@ use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; +#[cfg(test)] +use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -192,6 +194,13 @@ struct CollectorCtx { boot_anchor: Instant, machine: HostStatsMachine, scan_runs: AtomicUsize, + /// Test-only fault-injection seam (never in production builds): a run + /// that consumes a `true` here dies mid-scan, unwinding the + /// collector's spawned run task — the "run vanished without + /// completing" fault the flight-slot guard cleans up after. One-shot, + /// so a recovery refresh runs healthily. + #[cfg(test)] + test_run_panic: AtomicBool, share: Share, } @@ -243,6 +252,8 @@ impl HostStatsCollectorService { boot_anchor, machine, scan_runs: AtomicUsize::new(0), + #[cfg(test)] + test_run_panic: AtomicBool::new(false), }), } } @@ -736,10 +747,16 @@ impl HostStatsCollector for HostStatsCollectorService { // re-refresh must see the cooldown and never re-run. let run_ctx = Arc::clone(&ctx); tokio::spawn(async move { + // Declared first so a panic anywhere below unwinds + // through this guard (its Drop frees the flight + // slot); locals drop before the moved-in `tx`, so + // waiters only wake AFTER the slot is free again. + let mut guard = RefreshFlightGuard::new(&run_ctx.share); let result = run_refresh(&run_ctx, deadline).await; *run_ctx.share.last_refresh_completed.lock().unwrap() = Some(Instant::now()); *run_ctx.share.refresh_flight.lock().unwrap() = None; + guard.disarm(); let _ = tx.send(Some(result)); }); rx @@ -818,6 +835,44 @@ impl HostStatsCollector for HostStatsCollectorService { // On-request refresh (manual sections) // --------------------------------------------------------------------------- +/// Panic-safety for the collector-owned refresh run (Node parity: `service.ts` +/// wraps `runRefresh()` in `.finally(() => { pendingRefresh = null; +/// lastRefreshCompletedAt = nowFn() })`, which runs even when the run +/// THROWS). Constructed as the first statement of the spawned run — the +/// earliest point after the flight slot is occupied. If the run dies without +/// completing (a panic unwinds the spawned task), Drop frees the flight slot +/// and stamps the cooldown, so every later refresh() starts a FRESH run +/// instead of joining a dead channel forever. The manual cache is NEVER +/// touched here — a run that did not complete has no data to cache. A normal +/// completion stamps + clears explicitly (in the stamp-then-clear-then-send +/// order waiters rely on) and then disarms the guard. +struct RefreshFlightGuard<'a> { + share: &'a Share, + armed: bool, +} + +impl<'a> RefreshFlightGuard<'a> { + fn new(share: &'a Share) -> Self { + Self { share, armed: true } + } + + /// The run completed and already performed the finalize itself. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for RefreshFlightGuard<'_> { + fn drop(&mut self) { + if self.armed { + // The run died mid-flight (panic-unwind): Node .finally parity — + // free the slot AND stamp the cooldown. Never the manual cache. + *self.share.last_refresh_completed.lock().unwrap() = Some(Instant::now()); + *self.share.refresh_flight.lock().unwrap() = None; + } + } +} + /// `fs.statfs` on a mount; `free_bytes` is the unprivileged view (`bavail`). /// Node `statfsInfo` parity; unix-only on this Rust path. #[cfg(unix)] @@ -912,6 +967,12 @@ async fn run_refresh(ctx: &Arc, deadline: Duration) -> RefreshWire let scan_ctx = Arc::clone(ctx); let scan_fut = async move { scan_ctx.scan_runs.fetch_add(1, Ordering::SeqCst); + #[cfg(test)] + if scan_ctx.test_run_panic.swap(false, Ordering::SeqCst) { + // Test-injected run death: the run task unwinds from here — + // no completion stamp, no slot clear, no cache write, no send. + panic!("test-injected refresh run death"); + } match tokio::time::timeout_at( overall_deadline, scan_process_table(&scan_ctx.cfg.proc_root, PROC_SCAN_DWELL, section_deadline), @@ -2078,4 +2139,82 @@ mod tests { assert_eq!(snap.manual_at, Some(result.at)); assert_eq!(snap.manual, Some(result.manual.clone())); } + + #[tokio::test] + async fn host_stats_refresh_dead_run_frees_flight_slot_and_never_caches() { + // Parity regression (service.ts:326-329 — Node wraps runRefresh() in + // `.finally(() => { pendingRefresh = null; lastRefreshCompletedAt = + // nowFn() })`, which runs even when the run THROWS): a refresh run + // that dies without completing (a panic unwinds the collector's + // spawned run task) must not brick the path. Without the drop-guard + // the flight slot stays occupied forever and every later refresh() + // joins the dead channel ("refresh run vanished"); with it, the next + // refresh() starts a FRESH run. The dead run NEVER writes the manual + // cache (Node's manualCache is only written by a completed run) but + // DOES stamp the cooldown (Node's .finally stamps even on a throw). + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let interest = HostStatsInterestRegistry::default(); + let mut cfg = test_config(scan_root, sys_fixture()); + // The cooldown stamp has its own dedicated test; here it must not + // gate the recovery refresh. + cfg.refresh_cooldown = Duration::ZERO; + let collector = HostStatsCollectorService::new( + cfg, + freshell_terminal::TerminalRegistry::new(), + interest, + Instant::now(), + ); + // Arm the one-shot seam: the FIRST refresh run dies mid-scan. + collector.ctx.test_run_panic.store(true, Ordering::SeqCst); + let dead = collector.refresh(Duration::from_millis(2000)).await; + assert_eq!( + dead, + Err("refresh run vanished".to_string()), + "a caller attached to the dead run gets the vanished-run error" + ); + assert_eq!( + collector.scan_run_count(), + 1, + "the dead run started (and died in flight)" + ); + // Node's .finally runs even on a throw: the flight slot is freed and + // the cooldown stamped... + assert!( + collector.ctx.share.refresh_flight.lock().unwrap().is_none(), + "a dead run frees the flight slot (Node .finally clears pendingRefresh)" + ); + assert!( + collector + .ctx + .share + .last_refresh_completed + .lock() + .unwrap() + .is_some(), + "a dead run still stamps the cooldown (Node .finally stamps lastRefreshCompletedAt)" + ); + // ...but the manual cache is NEVER updated by a failed run. + let snap = collector.snapshot(); + assert!( + snap.manual_at.is_none() && snap.manual.is_none(), + "the dead run never wrote the manual cache" + ); + // The next refresh() recovers: a FRESH run serves it. + let recovered = collector + .refresh(Duration::from_millis(2000)) + .await + .expect("a dead run must not brick the refresh path"); + assert_eq!( + collector.scan_run_count(), + 2, + "a FRESH run served the recovery refresh" + ); + assert!(recovered.manual.top_processes.available); + assert_eq!(recovered.manual.top_processes.list.len(), 7); + assert!(recovered.manual.section_errors.is_empty()); + let snap = collector.snapshot(); + assert_eq!(snap.manual_at, Some(recovered.at)); + assert_eq!(snap.manual, Some(recovered.manual)); + } } From b0c2b676b4b2cd762df9f45ba7c8f78e4a37e37f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:21:48 -0700 Subject: [PATCH 19/25] test(host-stats): promote pane spec to MATRIX_SPECS (Node + Rust legs green) --- test/e2e-browser/playwright.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/e2e-browser/playwright.config.ts b/test/e2e-browser/playwright.config.ts index afb4a8142..4c0f68a14 100644 --- a/test/e2e-browser/playwright.config.ts +++ b/test/e2e-browser/playwright.config.ts @@ -167,6 +167,13 @@ export const MATRIX_SPECS = [ // shared code, so legacy is a true regression control proving they didn't // regress Node behavior. See title-sync-convergence.spec.ts. /title-sync-convergence\.spec\.ts$/, + // HOST-STATS (host-pressure-pane plan, Task 10) — Host Stats pane smoke: + // picker create, verdict strip/CPU tile, refresh interaction (Collecting + // state + age label), Disks fallback em-dash contract, tab-switch liveness, + // reload restore. Assertions are backend-agnostic (the Rust lane renders + // zero-shape values identically), so legacy is a true parity control. See + // test/e2e-browser/specs/host-stats-pane.spec.ts. + /host-stats-pane\.spec\.ts$/, ] // CONTINUITY TRIO: rust-only specs kept out of every match-all project From e766a0a0dfeabdaff7885b64245fb7ef33a432ab Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:21:48 -0700 Subject: [PATCH 20/25] docs(host-stats): README bullet, docs/index.html picker tile, AGENTS.md pane-kind list --- AGENTS.md | 2 +- README.md | 1 + docs/index.html | 7 +++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 60e90ebb2..8158b3a18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -234,7 +234,7 @@ live in [docs/development/gcloud-robot.md](docs/development/gcloud-robot.md). **Configuration Persistence:** User config stored at `~/.freshell/config.json`. Atomic writes with temp file + rename. Settings changes POST to `/api/settings` and broadcast via WebSocket. -**Pane System:** Tabs contain pane layouts (tree structure of splits). Each pane owns its terminal lifecycle via `createRequestId` and `terminalId`. When splitting panes, each new pane gets its own `createRequestId`, ensuring independent backend terminals. Pane content types: `terminal` (with mode, shell, status) and `browser` (with URL, devtools state). +**Pane System:** Tabs contain pane layouts (tree structure of splits). Each pane owns its terminal lifecycle via `createRequestId` and `terminalId`. When splitting panes, each new pane gets its own `createRequestId`, ensuring independent backend terminals. Pane content types: `terminal` (with mode, shell, status), `browser` (with URL, devtools state), `editor` (file path), and `host-stats` (host pressure dashboard; no per-pane payload). **Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; green/needs-attention and the idle sound flow through `recordTurnComplete` and `useTurnCompletionNotifications`. Turn-complete (green/sound) is server-authoritative everywhere: terminal CLIs via `terminal.turn.complete`, and fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) via a discrete `freshAgent.turn.complete` edge emitted only on a positive completion — freshclaude/kilroy on the SDK `result` with `subtype === 'success'`, freshopencode on the success-only `emitStatus(idle)` path, and freshcodex on `turn/completed` only when `params.turn.status === 'completed'` (the notification also fires on interrupt). The client folds it in via `applyFreshAgentCompletion` using the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart). The waiting-for-approval edge is ALSO server-authoritative: the Claude/kilroy `SdkBridge` emits a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition (only Claude/kilroy raise approvals/questions), and the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all green/sound edges are now server-emitted. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, emitting `sdk.status:'exited'` to clear BLUE (no chime — a crash is not a positive completion). `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). diff --git a/README.md b/README.md index 6b62da4d6..3a19ed128 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ - **Extension system** — Add new pane types, CLI integrations, and server-side services via manifest-based extensions. Enable and disable from the Extensions management page. - **Self-configuring workspace** — Just ask Claude or Codex to open a browser in a pane, or create a tab with four subagents. Built-in tmux-like API and skill makes it simple. - **Live pane headers** — See your active directory, git branch, and context usage in every pane title bar, updating live as you work. Fresh-agent panes carry their context meter in their status strip instead of the header. +- **Host pressure dashboard pane** — CPU, memory, pressure, and I/O at a glance with near-zero overhead (metrics stream only while you're watching). Linux, WSL, and macOS only — not shown on Windows. - **Activity notifications** — Configurable attention indicators (highlight, pulse, darken) on tabs and pane headers when a coding CLI finishes its turn, with click or type dismiss modes - **AI-powered session titles** — Right-click any session and generate a Gemini-powered title based on conversation content - **Progressive sidebar search** — Two-phase search with instant local results followed by deep server-side content search diff --git a/docs/index.html b/docs/index.html index 94cac5018..3947faed2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -752,6 +752,13 @@ Shell S + +
+
From c8c7bf8c576019ec6efd0750fde16b4facca154f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:21:48 -0700 Subject: [PATCH 21/25] fix(host-stats): satisfy strict workspace clippy (-D warnings) for the host-stats delta --- crates/freshell-platform/src/host_stats_readers.rs | 10 +++------- crates/freshell-protocol/src/server_messages.rs | 2 +- crates/freshell-server/src/host_stats.rs | 2 +- crates/freshell-ws/src/terminal.rs | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/crates/freshell-platform/src/host_stats_readers.rs b/crates/freshell-platform/src/host_stats_readers.rs index 2a258273d..df1e75821 100644 --- a/crates/freshell-platform/src/host_stats_readers.rs +++ b/crates/freshell-platform/src/host_stats_readers.rs @@ -214,7 +214,6 @@ pub struct LoadAvg { pub fn read_loadavg(proc_root: &Path) -> Option { let text = safe_read(&proc_root.join("loadavg"))?; let fields: Vec = text - .trim() .split_whitespace() .map(|tok| tok.parse::().unwrap_or(f64::NAN)) .collect(); @@ -559,7 +558,7 @@ pub fn read_net_dev(proc_root: &Path) -> Option { .split_whitespace() .map(|tok| tok.parse::().unwrap_or(u64::MAX)) .collect(); - if numbers.len() < 16 || numbers.iter().any(|n| *n == u64::MAX) { + if numbers.len() < 16 || numbers.contains(&u64::MAX) { continue; } totals.rx_bytes += numbers[0]; @@ -625,11 +624,10 @@ pub fn read_ephemeral_port_range(proc_root: &Path) -> Option { .join("ip_local_port_range"), )?; let fields: Vec = text - .trim() .split_whitespace() .map(|tok| tok.parse::().unwrap_or(u64::MAX)) .collect(); - if fields.len() < 2 || fields[..2].iter().any(|f| *f == u64::MAX) { + if fields.len() < 2 || fields[..2].contains(&u64::MAX) { return None; } Some(PortRange { @@ -702,9 +700,7 @@ pub fn read_self_limits_fds_max(proc_root: &Path) -> Option { if !rest.starts_with(char::is_whitespace) { continue; } - let Some(soft) = rest.split_whitespace().next() else { - return None; - }; + let soft = rest.split_whitespace().next()?; return soft.parse::().ok(); } None diff --git a/crates/freshell-protocol/src/server_messages.rs b/crates/freshell-protocol/src/server_messages.rs index a259074d2..c70cb6634 100644 --- a/crates/freshell-protocol/src/server_messages.rs +++ b/crates/freshell-protocol/src/server_messages.rs @@ -79,7 +79,7 @@ pub enum ServerMessage { #[serde(rename = "hoststats.refresh.response")] HostStatsRefreshResponse(HostStatsRefreshResponse), #[serde(rename = "hoststats.snapshot")] - HostStatsSnapshot(HostStatsSnapshot), + HostStatsSnapshot(Box), #[serde(rename = "opencode.activity.list.response")] OpencodeActivityListResponse(OpencodeActivityListResponse), #[serde(rename = "opencode.activity.updated")] diff --git a/crates/freshell-server/src/host_stats.rs b/crates/freshell-server/src/host_stats.rs index ace34fce2..6bacf0011 100644 --- a/crates/freshell-server/src/host_stats.rs +++ b/crates/freshell-server/src/host_stats.rs @@ -300,7 +300,7 @@ impl CollectorCtx { if !self.interest.any() { return; } - let msg = freshell_protocol::ServerMessage::HostStatsSnapshot(self.snapshot_payload()); + let msg = freshell_protocol::ServerMessage::HostStatsSnapshot(Box::new(self.snapshot_payload())); for sink in self.interest.senders() { sink(msg.clone()); } diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index ec248b54f..8cf23953a 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -1271,7 +1271,7 @@ async fn handle_client_text( if let Some(collector) = &state.host_stats.collector { return send( ws_tx, - &ServerMessage::HostStatsSnapshot(collector.snapshot()), + &ServerMessage::HostStatsSnapshot(Box::new(collector.snapshot())), ) .await; } From 23c1be9e76190ecd8de3187614f17b01d8dccbf7 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:21:49 -0700 Subject: [PATCH 22/25] test(host-stats): add Gauge to remaining exhaustive lucide mocks (full-suite-only reach) --- test/integration/client/editor-pane.test.tsx | 3 +++ test/unit/client/components/component-edge-cases.test.tsx | 1 + 2 files changed, 4 insertions(+) diff --git a/test/integration/client/editor-pane.test.tsx b/test/integration/client/editor-pane.test.tsx index f23bde354..103c48462 100644 --- a/test/integration/client/editor-pane.test.tsx +++ b/test/integration/client/editor-pane.test.tsx @@ -58,6 +58,9 @@ vi.mock('lucide-react', () => ({ SplitSquareVertical: ({ className }: { className?: string }) => ( ), + Gauge: ({ className }: { className?: string }) => ( + + ), Globe: ({ className }: { className?: string }) => ( ), diff --git a/test/unit/client/components/component-edge-cases.test.tsx b/test/unit/client/components/component-edge-cases.test.tsx index 049f937ec..7c7ef3ee2 100644 --- a/test/unit/client/components/component-edge-cases.test.tsx +++ b/test/unit/client/components/component-edge-cases.test.tsx @@ -93,6 +93,7 @@ vi.mock('lucide-react', () => ({ Bot: ({ className }: { className?: string }) => , Square: ({ className }: { className?: string }) => , LayoutGrid: ({ className }: { className?: string }) => , + Gauge: ({ className }: { className?: string }) => , Globe: ({ className }: { className?: string }) => , FileText: ({ className }: { className?: string }) => , Search: ({ className }: { className?: string }) => , From 9d99916c1d127857a2856ec119375ef84be28888 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:21:49 -0700 Subject: [PATCH 23/25] fix(host-stats): strict-clippy round 2 (from_env init, StatfsInfo alias) --- crates/freshell-server/src/host_stats.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/freshell-server/src/host_stats.rs b/crates/freshell-server/src/host_stats.rs index 6bacf0011..b19096ce3 100644 --- a/crates/freshell-server/src/host_stats.rs +++ b/crates/freshell-server/src/host_stats.rs @@ -142,10 +142,11 @@ impl HostStatsCollectorConfig { /// (`FRESHELL_HOST_STATS_FAST_MS`/`_SLOW_MS`, positive ms only — Node /// `envPositiveMs` parity). pub fn from_env() -> Self { - let mut cfg = Self::default(); - cfg.fast = env_positive_ms("FRESHELL_HOST_STATS_FAST_MS", DEFAULT_FAST); - cfg.slow = env_positive_ms("FRESHELL_HOST_STATS_SLOW_MS", DEFAULT_SLOW); - cfg + Self { + fast: env_positive_ms("FRESHELL_HOST_STATS_FAST_MS", DEFAULT_FAST), + slow: env_positive_ms("FRESHELL_HOST_STATS_SLOW_MS", DEFAULT_SLOW), + ..Default::default() + } } fn cgroup_root(&self) -> PathBuf { @@ -873,10 +874,14 @@ impl Drop for RefreshFlightGuard<'_> { } } -/// `fs.statfs` on a mount; `free_bytes` is the unprivileged view (`bavail`). -/// Node `statfsInfo` parity; unix-only on this Rust path. +/// `(total_bytes, free_bytes, used_pct, inodes_total, inodes_free)`. +/// `free_bytes` is the unprivileged view (`bavail`); inodes are None when the +/// filesystem reports 0 total (some report 0/0 by design). +type StatfsInfo = (u64, u64, f64, Option, Option); + +/// `fs.statfs` on a mount. Node `statfsInfo` parity; unix-only on this Rust path. #[cfg(unix)] -fn statfs_info(mount: &str) -> Option<(u64, u64, f64, Option, Option)> { +fn statfs_info(mount: &str) -> Option { let c_path = std::ffi::CString::new(mount).ok()?; let mut stats: libc::statfs = unsafe { std::mem::zeroed() }; if unsafe { libc::statfs(c_path.as_ptr(), &mut stats) } != 0 { @@ -901,7 +906,7 @@ fn statfs_info(mount: &str) -> Option<(u64, u64, f64, Option, Option)> } #[cfg(not(unix))] -fn statfs_info(_mount: &str) -> Option<(u64, u64, f64, Option, Option)> { +fn statfs_info(_mount: &str) -> Option { None } From 5e506911d240ea0f8dcfd69a096b2b6408980829 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:21:49 -0700 Subject: [PATCH 24/25] test(codex): close fork/exec /proc cmdline race in sidecar identity test (workspace-load flake) --- .../freshell-codex/src/sidecar_store_tests.rs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/freshell-codex/src/sidecar_store_tests.rs b/crates/freshell-codex/src/sidecar_store_tests.rs index ed6fe7ac2..0dfacba4a 100644 --- a/crates/freshell-codex/src/sidecar_store_tests.rs +++ b/crates/freshell-codex/src/sidecar_store_tests.rs @@ -195,12 +195,31 @@ fn spawn_own_sleep_child() -> ChildGuard { } /// A record carrying the spawned child's REAL `/proc` evidence. +/// +/// Race note: between fork() and exec(), `/proc//cmdline` transiently +/// holds the PARENT's argv (possibly a truncated prefix) — reading in that +/// window captures wrong bytes and the verify re-read a millisecond later +/// diverges (observed as a load-only `Mismatch` flake in `cargo test +/// --workspace`). Poll until cmdline demonstrably reflects the exec'ed child. #[cfg(target_os = "linux")] fn record_for_child(pid: u32) -> CodexSidecarRecord { + let cmdline = { + let mut attempts = 0; + loop { + if let Some(args) = proc_cmdline(pid as i32) { + if args == ["sleep", "300"] { + break args; + } + } + attempts += 1; + assert!(attempts <= 1000, "child cmdline never reflected exec within 1000ms"); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + }; CodexSidecarRecord { pid, starttime: proc_starttime(pid as i32).expect("live child has a starttime"), - cmdline: proc_cmdline(pid as i32).expect("live child has a cmdline"), + cmdline, ..sample_record("codex-sidecar-88888888-8888-4888-8888-888888888888") } } From 8bd74c50ab451cf8aafe76276b6e3dde7cdd8f57 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:25:58 -0700 Subject: [PATCH 25/25] style: cargo fmt on the host-stats delta (CI rustfmt gate) --- .../freshell-codex/src/sidecar_store_tests.rs | 5 +- .../src/layout_store_tests.rs | 5 +- crates/freshell-freshagent/src/pane_ops.rs | 6 +- .../freshell-freshagent/src/terminal_tabs.rs | 15 ++- .../src/host_stats_readers.rs | 17 ++- .../tests/hoststats_shape.rs | 9 +- crates/freshell-server/src/host_stats.rs | 106 ++++++++++++------ crates/freshell-server/src/main.rs | 15 +-- crates/freshell-ws/src/host_stats_interest.rs | 5 +- crates/freshell-ws/src/terminal.rs | 4 +- 10 files changed, 123 insertions(+), 64 deletions(-) diff --git a/crates/freshell-codex/src/sidecar_store_tests.rs b/crates/freshell-codex/src/sidecar_store_tests.rs index 0dfacba4a..0ee60f0bc 100644 --- a/crates/freshell-codex/src/sidecar_store_tests.rs +++ b/crates/freshell-codex/src/sidecar_store_tests.rs @@ -212,7 +212,10 @@ fn record_for_child(pid: u32) -> CodexSidecarRecord { } } attempts += 1; - assert!(attempts <= 1000, "child cmdline never reflected exec within 1000ms"); + assert!( + attempts <= 1000, + "child cmdline never reflected exec within 1000ms" + ); std::thread::sleep(std::time::Duration::from_millis(1)); } }; diff --git a/crates/freshell-freshagent/src/layout_store_tests.rs b/crates/freshell-freshagent/src/layout_store_tests.rs index 7b8f48dd7..439edd7a7 100644 --- a/crates/freshell-freshagent/src/layout_store_tests.rs +++ b/crates/freshell-freshagent/src/layout_store_tests.rs @@ -483,7 +483,10 @@ fn derive_pane_title_full_matrix() { assert_eq!(derive_pane_title(&json!({ "kind": "terminal" })), "Shell"); // host-stats -> fixed title (stateless pane; plan Task 8 arm) - assert_eq!(derive_pane_title(&json!({ "kind": "host-stats" })), "Host Stats"); + assert_eq!( + derive_pane_title(&json!({ "kind": "host-stats" })), + "Host Stats" + ); // non-terminal unknown kinds and non-objects -> no title (Node: undefined) assert_eq!(derive_pane_title(&json!({ "kind": "picker" })), ""); diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index 92a2f9a8d..d9ce12274 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -188,7 +188,11 @@ pub(crate) async fn split_pane( Err(_) => return approx_json(Value::Null, "pane split requested; not applied"), }; - let new_content = if body.get("hostStats").and_then(Value::as_bool).unwrap_or(false) { + let new_content = if body + .get("hostStats") + .and_then(Value::as_bool) + .unwrap_or(false) + { // Stateless cheap content kind (router.ts `wantsHostStats` split branch). let content = json!({ "kind": "host-stats" }); state diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index 390b51886..9fdd5f34e 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -202,7 +202,11 @@ async fn create_terminal_or_content_tab_with_delivery( // `hostStats: true` -> stateless host-stats pane (router.ts `wantsHostStats` // branch before browser): no process, no terminal admission. - if body.get("hostStats").and_then(Value::as_bool).unwrap_or(false) { + if body + .get("hostStats") + .and_then(Value::as_bool) + .unwrap_or(false) + { return create_content_tab( &state, name, @@ -3194,13 +3198,8 @@ mod tests { async fn create_host_stats_tab_attaches_host_stats_pane_content_and_no_terminal() { let state = state_with_registry(); let mut rx = state.broadcast_tx.subscribe(); - let (status, body) = post( - app(state), - "/api/tabs", - json!({ "hostStats": true }), - true, - ) - .await; + let (status, body) = + post(app(state), "/api/tabs", json!({ "hostStats": true }), true).await; assert_eq!(status, StatusCode::OK); assert!(body["data"]["tabId"].as_str().is_some()); assert!(body["data"]["paneId"].as_str().is_some()); diff --git a/crates/freshell-platform/src/host_stats_readers.rs b/crates/freshell-platform/src/host_stats_readers.rs index df1e75821..37a42d60a 100644 --- a/crates/freshell-platform/src/host_stats_readers.rs +++ b/crates/freshell-platform/src/host_stats_readers.rs @@ -188,7 +188,13 @@ pub fn read_cpu_times(proc_root: &Path) -> Option { } else { let idx: usize = idx_str.parse().ok()?; if per_core.len() <= idx { - per_core.resize(idx + 1, CpuCoreTimes { total: 0.0, busy: 0.0 }); + per_core.resize( + idx + 1, + CpuCoreTimes { + total: 0.0, + busy: 0.0, + }, + ); } per_core[idx] = CpuCoreTimes { total, busy }; } @@ -781,9 +787,12 @@ pub fn read_cpu_freq_mhz(sys_root: &Path) -> Option { if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit()) { continue; } - if let Some(khz) = - read_number_file(&cpu_dir.join(&entry).join("cpufreq").join("scaling_cur_freq")) - { + if let Some(khz) = read_number_file( + &cpu_dir + .join(&entry) + .join("cpufreq") + .join("scaling_cur_freq"), + ) { if khz > 0 { freqs.push(khz as f64); } diff --git a/crates/freshell-protocol/tests/hoststats_shape.rs b/crates/freshell-protocol/tests/hoststats_shape.rs index 7e23321c3..1a304eaff 100644 --- a/crates/freshell-protocol/tests/hoststats_shape.rs +++ b/crates/freshell-protocol/tests/hoststats_shape.rs @@ -11,8 +11,8 @@ use freshell_protocol::server_messages::{ HostStatsCpu, HostStatsDisk, HostStatsDiskIo, HostStatsDisks, HostStatsFreshell, HostStatsInotify, HostStatsLimits, HostStatsLive, HostStatsLoad, HostStatsMachine, HostStatsManual, HostStatsMemory, HostStatsNetwork, HostStatsPaging, HostStatsProcessHealth, - HostStatsPsi, HostStatsRefreshResponse, HostStatsSnapshot, HostStatsThermals, - HostStatsThermalZone, HostStatsTopProcess, HostStatsTopProcesses, + HostStatsPsi, HostStatsRefreshResponse, HostStatsSnapshot, HostStatsThermalZone, + HostStatsThermals, HostStatsTopProcess, HostStatsTopProcesses, }; fn sample_live() -> HostStatsLive { @@ -258,7 +258,10 @@ fn nullable_fields_serialize_null_and_optional_fields_are_absent() { assert_eq!(obj.len(), 2, "bare response carries requestId+ok only"); assert_eq!(v["requestId"], "r1"); assert!(!obj.contains_key("at"), "at must be absent, not null"); - assert!(!obj.contains_key("manual"), "manual must be absent, not null"); + assert!( + !obj.contains_key("manual"), + "manual must be absent, not null" + ); assert!(!obj.contains_key("error"), "error must be absent, not null"); // Snapshot with no manual refresh yet: `.nullable()` fields must be diff --git a/crates/freshell-server/src/host_stats.rs b/crates/freshell-server/src/host_stats.rs index b19096ce3..85e8c2ccc 100644 --- a/crates/freshell-server/src/host_stats.rs +++ b/crates/freshell-server/src/host_stats.rs @@ -301,7 +301,8 @@ impl CollectorCtx { if !self.interest.any() { return; } - let msg = freshell_protocol::ServerMessage::HostStatsSnapshot(Box::new(self.snapshot_payload())); + let msg = + freshell_protocol::ServerMessage::HostStatsSnapshot(Box::new(self.snapshot_payload())); for sink in self.interest.senders() { sink(msg.clone()); } @@ -427,8 +428,7 @@ impl CollectorCtx { let cgroup = readers::read_cgroup_memory(&self.cfg.cgroup_root(), &self.cfg.proc_root); let meminfo = readers::read_meminfo(&self.cfg.proc_root); let swap_total_bytes = meminfo.map(|m| m.swap_total_kb * 1024); - let swap_used_bytes = - meminfo.map(|m| (m.swap_total_kb - m.swap_free_kb) * 1024); + let swap_used_bytes = meminfo.map(|m| (m.swap_total_kb - m.swap_free_kb) * 1024); if let Some(cg) = cgroup { if let Some(limit) = cg.limit_bytes { return HostStatsMemory { @@ -464,12 +464,7 @@ impl CollectorCtx { let Some(vm) = readers::read_vmstat(&self.cfg.proc_root) else { return zero_paging(); }; - let prev = self - .share - .prev_vmstat - .lock() - .unwrap() - .replace((at, vm)); + let prev = self.share.prev_vmstat.lock().unwrap().replace((at, vm)); let oom_kills_total = vm.oom_kill.unwrap_or(0); let Some((prev_at, prev_v)) = prev else { return HostStatsPaging { @@ -494,7 +489,8 @@ impl CollectorCtx { let dt_sec = (at - prev_at) as f64 / 1000.0; HostStatsPaging { available: true, - swap_in_kbps: (vm.pswpin.saturating_sub(prev_v.pswpin) * VMSTAT_PAGE_KB) as f64 / dt_sec, + swap_in_kbps: (vm.pswpin.saturating_sub(prev_v.pswpin) * VMSTAT_PAGE_KB) as f64 + / dt_sec, swap_out_kbps: (vm.pswpout.saturating_sub(prev_v.pswpout) * VMSTAT_PAGE_KB) as f64 / dt_sec, maj_faults_per_sec: vm.pgmajfault.saturating_sub(prev_v.pgmajfault) as f64 / dt_sec, @@ -600,16 +596,22 @@ impl CollectorCtx { // Multi-device rule (plan thresholds): worst device wins; util // can never exceed 100. let util = clamp_pct( - (cur.time_doing_ios_ms.saturating_sub(before.time_doing_ios_ms)) as f64 / dt_ms + (cur.time_doing_ios_ms + .saturating_sub(before.time_doing_ios_ms)) as f64 + / dt_ms * 100.0, ); if util_pct.is_none_or(|best| util > best) { util_pct = Some(util); let ios = cur.reads_completed.saturating_sub(before.reads_completed) + cur.writes_completed.saturating_sub(before.writes_completed); - let io_ms = - cur.read_ms.saturating_sub(before.read_ms) + cur.write_ms.saturating_sub(before.write_ms); - weighted_await_ms = if ios > 0 { Some(io_ms as f64 / ios as f64) } else { None }; + let io_ms = cur.read_ms.saturating_sub(before.read_ms) + + cur.write_ms.saturating_sub(before.write_ms); + weighted_await_ms = if ios > 0 { + Some(io_ms as f64 / ios as f64) + } else { + None + }; } } HostStatsDiskIo { @@ -815,8 +817,8 @@ impl HostStatsCollector for HostStatsCollectorService { loop { tokio::time::sleep(interval).await; let now = Instant::now(); - let drift_ms = - now.duration_since(last).as_secs_f64() * 1000.0 - interval.as_secs_f64() * 1000.0; + let drift_ms = now.duration_since(last).as_secs_f64() * 1000.0 + - interval.as_secs_f64() * 1000.0; last = now; if drift_ms.is_finite() && drift_ms > 0.0 { drift_ctx.share.lag_samples.lock().unwrap().push(drift_ms); @@ -1537,7 +1539,11 @@ mod tests { let broken = scan.join("999"); std::fs::create_dir_all(&broken).unwrap(); std::fs::write(broken.join("stat"), "999 (broken").unwrap(); - std::fs::write(broken.join("status"), "Name:\tbroken\nVmRSS:\t 1234 kB\n").unwrap(); + std::fs::write( + broken.join("status"), + "Name:\tbroken\nVmRSS:\t 1234 kB\n", + ) + .unwrap(); scan } @@ -1632,7 +1638,10 @@ mod tests { let pid_max_only = tmp.path().join("pid-max-only").join("proc"); std::fs::create_dir_all(pid_max_only.join("sys/kernel")).unwrap(); std::fs::write(pid_max_only.join("sys/kernel/pid_max"), "4194304\n").unwrap(); - assert_eq!(readers::read_pids_limit(&pid_max_only, &cgroup_fixture()), None); + assert_eq!( + readers::read_pids_limit(&pid_max_only, &cgroup_fixture()), + None + ); } #[test] @@ -1673,7 +1682,10 @@ mod tests { assert_eq!(tcp.time_wait, 3); let ports = readers::read_ephemeral_port_range(&proc_fixture()).expect("port range"); assert_eq!((ports.start, ports.end), (32768, 60999)); - assert_eq!(readers::read_self_limits_fds_max(&proc_fixture()), Some(1024)); + assert_eq!( + readers::read_self_limits_fds_max(&proc_fixture()), + Some(1024) + ); let inotify = readers::read_inotify_limits(&proc_fixture()).expect("inotify limits"); assert_eq!(inotify.max_user_watches, Some(1048576)); assert_eq!(inotify.max_user_instances, Some(128)); @@ -1748,10 +1760,14 @@ mod tests { async fn host_stats_scan_fixture_table_counts_and_names() { let tmp = tempfile::tempdir().unwrap(); let scan_root = scan_proc_overlay(tmp.path()); - let scan = scan_process_table(&scan_root, Duration::from_millis(50), Instant::now() + Duration::from_secs(10)) - .await - .expect("fixture scan resolves") - .expect("no deadline"); + let scan = scan_process_table( + &scan_root, + Duration::from_millis(50), + Instant::now() + Duration::from_secs(10), + ) + .await + .expect("fixture scan resolves") + .expect("no deadline"); // 8 numeric entries enumerated (7 committed + truncated 999). assert_eq!(scan.total, 8); assert_eq!(scan.zombies, 1); @@ -1759,8 +1775,7 @@ mod tests { // truncated-stat pid 999 is skipped, never fatal. assert_eq!(scan.top.len(), 7); assert!(scan.top.iter().all(|p| p.pid != 999)); - let by_pid: HashMap = - scan.top.iter().map(|p| (p.pid, p)).collect(); + let by_pid: HashMap = scan.top.iter().map(|p| (p.pid, p)).collect(); // comm-with-parens splits after the LAST ')'. assert_eq!(by_pid[&404].name, "my (weird) proc"); assert_eq!(by_pid[&404].state, "D"); @@ -1775,14 +1790,20 @@ mod tests { async fn host_stats_scan_deadline_exceeded_is_an_error_never_a_panic() { let tmp = tempfile::tempdir().unwrap(); let scan_root = scan_proc_overlay(tmp.path()); - let result = - scan_process_table(&scan_root, Duration::ZERO, Instant::now() - Duration::from_secs(1)) - .await; + let result = scan_process_table( + &scan_root, + Duration::ZERO, + Instant::now() - Duration::from_secs(1), + ) + .await; assert!(matches!(result, Err(ScanError::DeadlineExceeded))); // Missing proc root -> None (degraded), never an error. - let missing_result = - scan_process_table(&missing(), Duration::ZERO, Instant::now() + Duration::from_secs(10)) - .await; + let missing_result = scan_process_table( + &missing(), + Duration::ZERO, + Instant::now() + Duration::from_secs(10), + ) + .await; assert!(matches!(missing_result, Ok(None))); } @@ -1794,7 +1815,10 @@ mod tests { async fn host_stats_set_active_spawn_abort_lifecycle() { let interest = HostStatsInterestRegistry::default(); let collector = test_collector(proc_fixture(), sys_fixture(), &interest); - assert!(!collector.is_running(), "zero-cost idle before first interest"); + assert!( + !collector.is_running(), + "zero-cost idle before first interest" + ); collector.set_active(true); assert!(collector.is_running(), "0->1 interest spawns the cadence"); collector.set_active(true); @@ -1927,11 +1951,17 @@ mod tests { let scan_root = scan_proc_overlay(tmp.path()); let interest = HostStatsInterestRegistry::default(); let collector = test_collector(scan_root, sys_fixture(), &interest); - let (one, two) = - tokio::join!(collector.refresh(Duration::from_millis(2000)), collector.refresh(Duration::from_millis(2000))); + let (one, two) = tokio::join!( + collector.refresh(Duration::from_millis(2000)), + collector.refresh(Duration::from_millis(2000)) + ); let one = one.expect("leader refresh succeeds"); let two = two.expect("joiner refresh succeeds"); - assert_eq!(collector.scan_run_count(), 1, "one scan serves both callers"); + assert_eq!( + collector.scan_run_count(), + 1, + "one scan serves both callers" + ); assert_eq!(one, two, "the joiner gets the leader's exact result"); // The fixture scan powered both process sections. assert!(one.manual.top_processes.available); @@ -2003,7 +2033,11 @@ mod tests { assert!(!result.manual.top_processes.available); assert!(!result.manual.process_health.available); assert_eq!( - result.manual.section_errors.get("topProcesses").map(String::as_str), + result + .manual + .section_errors + .get("topProcesses") + .map(String::as_str), Some("host-stats section deadline exceeded") ); assert_eq!( diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 60caf7716..43f1f6d62 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -402,13 +402,14 @@ async fn main() -> ExitCode { // `broadcast_tx`). `boot_anchor` backs `freshell.uptimeSec`. let host_stats_interest = freshell_ws::host_stats_interest::HostStatsInterestRegistry::default(); - let host_stats_collector: std::sync::Arc = - std::sync::Arc::new(host_stats::HostStatsCollectorService::new( - host_stats::HostStatsCollectorConfig::from_env(), - registry.clone(), - host_stats_interest.clone(), - std::time::Instant::now(), - )); + let host_stats_collector: std::sync::Arc< + dyn freshell_ws::host_stats_collector::HostStatsCollector, + > = std::sync::Arc::new(host_stats::HostStatsCollectorService::new( + host_stats::HostStatsCollectorConfig::from_env(), + registry.clone(), + host_stats_interest.clone(), + std::time::Instant::now(), + )); // Slice 1 (docs/plans/2026-07-18-agent-api-mcp-parity-spec.md \u00a79 Risk 1): the // Agent-API's terminal-mode `POST /api/tabs` shares THIS SAME registry -- // never a second one -- so an Agent-API-created shell terminal is a first-class diff --git a/crates/freshell-ws/src/host_stats_interest.rs b/crates/freshell-ws/src/host_stats_interest.rs index c5bd0dbea..1431a7a35 100644 --- a/crates/freshell-ws/src/host_stats_interest.rs +++ b/crates/freshell-ws/src/host_stats_interest.rs @@ -156,7 +156,10 @@ mod tests { fn host_stats_interest_reports_0_to_1_and_1_to_0_transitions() { let r = HostStatsInterestRegistry::default(); // First arrival is the ->active edge; repeats are unchanged. - assert_eq!(r.set(1, Some(noop_sink())), InterestTransition::BecameActive); + assert_eq!( + r.set(1, Some(noop_sink())), + InterestTransition::BecameActive + ); assert_eq!(r.set(1, Some(noop_sink())), InterestTransition::Unchanged); assert_eq!(r.set(2, Some(noop_sink())), InterestTransition::Unchanged); // Removing one of two stays active; removing the last is ->idle. diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 8cf23953a..2bdaaea2b 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -7101,8 +7101,8 @@ mod host_stats_dispatch_tests { use freshell_protocol::{ HostStatsCpu, HostStatsDiskIo, HostStatsFreshell, HostStatsInotify, HostStatsLimits, HostStatsLive, HostStatsLoad, HostStatsMachine, HostStatsManual, HostStatsMemory, - HostStatsNetwork, HostStatsPaging, HostStatsProcessHealth, HostStatsPsi, - HostStatsSnapshot, HostStatsThermals, HostStatsTopProcesses, + HostStatsNetwork, HostStatsPaging, HostStatsProcessHealth, HostStatsPsi, HostStatsSnapshot, + HostStatsThermals, HostStatsTopProcesses, }; use crate::host_stats_collector::{