diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md
index af091c6477..a8a2e2ebed 100644
--- a/docs/release-control/v6/internal/subsystems/monitoring.md
+++ b/docs/release-control/v6/internal/subsystems/monitoring.md
@@ -133,6 +133,43 @@ precede marker production. Legacy reports without the marker retain the
existing default policy; matched agent and server support is required.
This proves ingestion behaviour, not reporter installation or release delivery.
+### TrueNAS RPC operation budgets and poll isolation
+
+The configured client timeout (30 seconds by default) bounds each serialized
+RPC operation, including its session-lock wait, any inline authentication,
+method exchange or subscription, and the permitted read retry/backoff. They
+share one budget; initial transport negotiation before a method is separately
+bounded by the same timeout. A shorter caller deadline remains effective. A cancelled
+waiter neither dispatches a request nor alters the current owner's socket or
+transport status. RPC/stream readers receive the bounded context, and a
+timed-out socket is discarded before a subsequent operation authenticates a
+fresh session. Modern appliances never downgrade to REST on timeout, and an
+action timeout after dispatch retains its unknown outcome without replay.
+Keepalive still belongs to the session, not any completed operation's context.
+
+Optional live-telemetry timeouts do not prevent otherwise usable inventory;
+unobserved metrics remain unavailable. A required-method timeout returns a
+truthful failure, preserves the prior successful observation/cached identity,
+and lets the shared poll cycle continue to other connections. Socket deadline
+errors are classified as timeout, not credential or generic connection failures.
+The next normal poll can recover; completion-based failure backoff is unchanged.
+This is an operation budget, not a new total-snapshot deadline or parallel poller.
+The app-log idle window still yields a bounded tail; an earlier caller/operation
+deadline is an error rather than a successful empty log response.
+
+`TestJSONRPCConfiguredTimeout*`,
+`TestJSONRPCWaitingDeadlineDoesNotInterruptSessionOwner`,
+`TestJSONRPCPreCancelledCallDoesNotDispatch` and
+`TestJSONRPCSnapshotContinuesAfterTelemetryTimeout` in
+`internal/truenas/transport_test.go` cover silent handshake/authentication,
+read/retry budgets, all three stream readers, queued cancellation, caller
+deadlines, recovery and action no-replay.
+`TestTrueNASPollerUnresponsiveRPCDoesNotFreezeOtherConnections` and
+`TestClassifyTrueNASError` in `internal/monitoring/truenas_poller_test.go`
+cover actual TLS/RPC clients through two successful/failed/recovered poll cycles,
+cached identities, truthful health and unchanged backoff. These are controlled
+runtime proofs, not native SCALE acceptance or a diagnosis/resolution of #2382.
+
### TrueNAS persistent-session liveness and successful poll cadence
Authenticated JSON-RPC WebSocket sessions send transport-only PING controls
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 987a1e1836..6431645e11 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -6245,3 +6245,19 @@ state. The Recovery settings item and its capability gate are untouched.
`internal/api/onboarding_handlers.go` changed only the human-readable pairing
readiness messages so they name Settings > Pulse Mobile. This opens no new
storage or recovery path.
+
+### Live physical-disk row continuity
+
+Physical Disks uses the canonical keyed windowed renderer, which retains row
+owners across resource snapshots. Disk presentation must therefore stay
+reactive: health and its evidence, temperature, endurance, capacity, device,
+host and placement labels reflect the latest snapshot rather than the mount-time
+sample. Removed readings revert to their existing unknown/placeholders without
+retaining old warning text or colour. Metric summary identities, highlight
+matching and disclosure control IDs track the current canonical disk target.
+Unchanged resource IDs keep their mounted row, focus and expanded detail;
+attention filtering and the displayed health must agree through fault/recovery.
+This changes no collector, health thresholds, history policy or server identity.
+`frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx` exercises
+snapshot replacement, in-place reconciliation, missing evidence, target changes
+and fault/recovery using the real keyed renderer.
diff --git a/frontend-modern/browser-tests/disk-live-refresh.cjs b/frontend-modern/browser-tests/disk-live-refresh.cjs
new file mode 100644
index 0000000000..11f7d22373
--- /dev/null
+++ b/frontend-modern/browser-tests/disk-live-refresh.cjs
@@ -0,0 +1,177 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { createHash } = require('node:crypto');
+const { chromium, webkit } = require('playwright');
+
+(async () => {
+ const root = '/workspace/frontend-modern';
+ process.chdir(root);
+ const artifacts = path.join(root, 'node_modules/disk-live-refresh-proof');
+ fs.mkdirSync(artifacts, { recursive: true });
+ const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
+ const server = await createServer({
+ root,
+ configFile: path.join(root, 'vite.config.ts'),
+ cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-cache-')),
+ server: { host: '127.0.0.1', port: 5221, strictPort: true },
+ });
+ const observations = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const scenario of [
+ { engine: chromium, name: 'chromium', width: 1365, height: 900, dark: false },
+ { engine: webkit, name: 'webkit', width: 390, height: 844, dark: true },
+ ]) {
+ browser = await scenario.engine.launch(
+ scenario.name === 'chromium'
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const page = await browser.newPage({
+ viewport: { width: scenario.width, height: scenario.height },
+ isMobile: scenario.name === 'webkit',
+ hasTouch: scenario.name === 'webkit',
+ });
+ const errors = [],
+ requests = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ await page.route('**/*', async (route) => {
+ const url = new URL(route.request().url());
+ if (url.origin !== 'http://127.0.0.1:5221') return route.abort();
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ requests.push(url.pathname);
+ if (url.pathname === '/api/license/runtime-capabilities')
+ return route.fulfill({
+ json: {
+ capabilities: [],
+ limits: [],
+ max_history_days: 7,
+ hosted_mode: false,
+ runtime: { build: 'community', label: 'Pulse Community runtime' },
+ blocked_capabilities: [],
+ },
+ });
+ return route.fulfill({ json: { data: [], enabled: false } });
+ });
+ await page.goto('http://127.0.0.1:5221/browser-tests/disk-live-refresh.html');
+ await page.evaluate(
+ (dark) => document.documentElement.classList.toggle('dark', dark),
+ scenario.dark,
+ );
+ const fixture = page.getByTestId('disk-live-refresh-fixture');
+ const row = fixture.locator('[data-row-id="disk-one"]');
+ await row.getByText('Healthy', { exact: true }).waitFor();
+ await row.getByText('Archive SSD', { exact: true }).click();
+ const disclosure = row.getByRole('button', { name: 'Collapse Archive SSD', exact: true });
+ await disclosure.focus();
+ await row.evaluate((element) => {
+ element.dataset.proofOwner = 'retained';
+ });
+ const initialControls = await disclosure.getAttribute('aria-controls');
+ const snapshot = (phase) =>
+ page.locator(`button[data-update="${phase}"]`).evaluate((button) => button.click());
+ const checkContinuity = async () => {
+ assert.equal(await row.getAttribute('data-proof-owner'), 'retained');
+ assert.equal(
+ await row.locator('button').evaluate((button) => document.activeElement === button),
+ true,
+ );
+ assert.equal(await row.locator('button').getAttribute('aria-expanded'), 'true');
+ const controls = await row.locator('button').getAttribute('aria-controls');
+ assert.equal(await page.locator(`[id="${controls}"]`).count(), 1);
+ assert.equal(await fixture.locator('[data-inline-detail-for]').count(), 1);
+ const dimensions = await page.evaluate(() => ({
+ scroll: document.documentElement.scrollWidth,
+ width: innerWidth,
+ }));
+ assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
+ return dimensions;
+ };
+ const screenshot = (state) =>
+ fixture.screenshot({ path: path.join(artifacts, `${scenario.name}-${state}.png`) });
+
+ await snapshot('fault');
+ await row.getByText('Replace Now', { exact: true }).waitFor();
+ for (const text of ['Archive SSD (fault)', '4%', '63°C'])
+ assert.equal(await row.getByText(text, { exact: true }).count(), 1);
+ assert.equal(await row.getByText('Healthy', { exact: true }).count(), 0);
+ assert.equal(await row.getAttribute('data-summary-series-id'), 'agent-archive:sdz');
+ assert.notEqual(await row.locator('button').getAttribute('aria-controls'), initialControls);
+ await fixture.getByText('2', { exact: true }).waitFor();
+ const faultDimensions = await checkContinuity();
+ await screenshot('fault');
+
+ await snapshot('missing');
+ await row.getByText('Unknown', { exact: true }).waitFor();
+ for (const text of ['Replace Now', 'SMART failed.', '4%', '63°C'])
+ assert.equal(await row.getByText(text, { exact: true }).count(), 0);
+ for (const column of ['temp', 'life', 'size', 'role', 'parent'])
+ assert.equal(
+ (await row.locator(`td[data-storage-column="${column}"]`).innerText()).trim(),
+ '—',
+ );
+ await fixture
+ .getByText('Temperature is temporarily unavailable: No current reading', { exact: true })
+ .waitFor();
+ const missingDimensions = await checkContinuity();
+ await screenshot('missing');
+
+ await snapshot('healthy');
+ await row.getByText('Healthy', { exact: true }).waitFor();
+ assert.equal(await row.getByText('96%', { exact: true }).count(), 1);
+ assert.equal(await row.getByText('41°C', { exact: true }).count(), 1);
+ assert.equal(await row.getAttribute('data-summary-series-id'), 'agent-archive:sda');
+ assert.equal(await row.locator('button').getAttribute('aria-controls'), initialControls);
+ const recoveredDimensions = await checkContinuity();
+ await screenshot('recovered');
+
+ await snapshot('fault');
+ await row.getByText('Replace Now', { exact: true }).waitFor();
+ await page.locator('[data-filter="attention"]').evaluate((button) => button.click());
+ assert.equal(await row.getByText('Replace Now', { exact: true }).count(), 1);
+ await snapshot('healthy');
+ await fixture.getByText('No disks need attention', { exact: true }).waitFor();
+ await page.locator('[data-filter="all"]').evaluate((button) => button.click());
+ await row.getByText('Healthy', { exact: true }).waitFor();
+ assert.deepEqual(errors, []);
+ observations.push({
+ browser: scenario.name,
+ version: browser.version(),
+ viewport: { width: scenario.width, height: scenario.height },
+ dark: scenario.dark,
+ faultDimensions,
+ missingDimensions,
+ recoveredDimensions,
+ errors,
+ requests,
+ });
+ await browser.close();
+ browser = undefined;
+ }
+ const source = 'src/components/Storage/DiskList.tsx';
+ const result = {
+ result: 'passed',
+ playwright: require('playwright/package.json').version,
+ content_sha256: {
+ [`frontend-modern/${source}`]: createHash('sha256')
+ .update(fs.readFileSync(path.join(root, source)))
+ .digest('hex'),
+ },
+ observations,
+ };
+ fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
+ console.log(JSON.stringify(result));
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ for (const name of fs.readdirSync(artifacts)) {
+ const file = path.join(artifacts, name);
+ if (fs.statSync(file).isFile()) fs.chmodSync(file, 0o644);
+ }
+ }
+})().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/disk-live-refresh.html b/frontend-modern/browser-tests/disk-live-refresh.html
new file mode 100644
index 0000000000..e3fc664d80
--- /dev/null
+++ b/frontend-modern/browser-tests/disk-live-refresh.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Physical disk live refresh verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/disk-live-refresh.tsx b/frontend-modern/browser-tests/disk-live-refresh.tsx
new file mode 100644
index 0000000000..e7a7dc87dc
--- /dev/null
+++ b/frontend-modern/browser-tests/disk-live-refresh.tsx
@@ -0,0 +1,116 @@
+// Real DiskList/DiskDetail and keyed renderer, with synthetic collector snapshots.
+// This is browser behaviour evidence, not an installed SMART or appliance result.
+import { createSignal } from 'solid-js';
+import { render } from 'solid-js/web';
+import { DiskList } from '../src/components/Storage/DiskList';
+import type { StorageHealthFilter } from '../src/features/storageBackups/models';
+import type { Resource } from '../src/types/resource';
+import '../src/index.css';
+
+const disk = (phase: 'healthy' | 'fault' | 'missing'): Resource => ({
+ id: 'disk-one',
+ type: 'physical_disk',
+ name: 'Archive disk',
+ platformType: 'proxmox-pbs',
+ sourceType: 'agent',
+ status: 'online',
+ lastSeen: Date.now(),
+ metricsTarget: {
+ resourceType: 'disk',
+ resourceId: phase === 'healthy' ? 'agent-archive:sda' : 'agent-archive:sdz',
+ },
+ identity: { hostname: 'archive-host' },
+ canonicalIdentity: { hostname: 'archive-host' },
+ physicalDisk: {
+ model: phase === 'fault' ? 'Archive SSD (fault)' : 'Archive SSD',
+ devPath: phase === 'healthy' ? '/dev/sda' : '/dev/sdz',
+ serial: 'SYNTHETIC-ONE',
+ diskType: phase === 'missing' ? undefined : 'ssd',
+ health: phase === 'healthy' ? 'PASSED' : phase === 'fault' ? 'FAILED' : 'UNKNOWN',
+ wearout: phase === 'healthy' ? 96 : phase === 'fault' ? 4 : -1,
+ temperature: phase === 'healthy' ? 41 : phase === 'fault' ? 63 : 0,
+ sizeBytes: phase === 'missing' ? 0 : 2_000_000_000_000,
+ storageRole: phase === 'missing' ? undefined : 'cache_pool',
+ storageGroup: phase === 'missing' ? undefined : 'Archive Pool',
+ risk:
+ phase === 'fault'
+ ? {
+ level: 'critical',
+ reasons: [{ code: 'smart-failed', severity: 'critical', summary: 'SMART failed.' }],
+ }
+ : undefined,
+ smart:
+ phase === 'missing'
+ ? undefined
+ : { powerOnHours: 100, pendingSectors: phase === 'fault' ? 2 : 0 },
+ collection:
+ phase === 'missing'
+ ? {
+ temperature: { state: 'unavailable', source: 'fixture', reason: 'No current reading' },
+ }
+ : undefined,
+ },
+});
+
+const Fixture = () => {
+ const [disks, setDisks] = createSignal([disk('healthy')]);
+ const [selectedDiskId, setSelectedDiskId] = createSignal(null);
+ const [healthFilter, setHealthFilter] = createSignal('all');
+ return (
+
+ Physical disk live refresh verification
+
+ Synthetic snapshots in the production PBS disk table and detail.
+
+
+
+
+
+
+
+
+
+
+ );
+};
+render(() => , document.getElementById('root')!);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 45fb502a24..bc8f3ba8bc 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,49 +1,53 @@
{
"version": 1,
- "base_sha": "1a0cd7c9d5aa02de754535a293dc0cacd4ef6fa0",
- "verified_at": "2026-10-01T18:27:14.912695Z",
+ "base_sha": "33d6d729c1dc7c0913a970fd4c463a9f9575b7b1",
+ "verified_at": "2026-10-02T10:16:57.632722Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/features/docs/docMarkdown.ts"
+ "frontend-modern/src/components/Storage/DiskList.tsx"
],
"content_sha256": {
- "frontend-modern/src/features/docs/docMarkdown.ts": "572a3b4bc5bc1114adef5aa64bb0ea833c0dbe5865bbd57d3dc767fd1965aa55"
+ "frontend-modern/src/components/Storage/DiskList.tsx": "df63db162779ff0d2064475a9db1fd67ac748703f398a88d108ecdd154dae9d2"
},
"routes": [
- "/docs/PULSE_PRO",
- "/browser-tests/docs-fragment-navigation.html?scenario=plans"
+ "/browser-tests/disk-live-refresh.html"
],
"viewports": [
{
- "width": 1280,
+ "width": 1365,
"height": 900
},
{
"width": 390,
- "height": 900
+ "height": 844
}
],
"states": [
- "Production Docs page/router/styles with the current shipped eight-column plan comparison, not a replacement table fixture",
- "Every sanitized thead TH has trusted scope=col and all eight native columnheader roles; document role/scope/class remain stripped",
- "Desktop Chromium light and phone-emulated WebKit dark; table-local horizontal scrolling and outer-page containment; four final full-page/header screenshots inspected",
- "This establishes browser accessibility roles, not screen-reader speech or published availability"
+ "Production DiskList, DiskDetail, keyed renderer and styles with synthetic PBS collector snapshots, not installed appliance evidence",
+ "Healthy to failed disk updates health, warning text, endurance, temperature, model and canonical target without remounting the row or its expanded detail",
+ "Missing measurements remove obsolete values and critical styling; unavailable detail evidence remains visible; recovery restores current values",
+ "Disclosure keyboard focus and aria-controls survive same-ID updates; changed metric targets update summary/detail linkage",
+ "Attention filter includes the failed disk, removes the recovered disk and returns current healthy values in All",
+ "Desktop Chromium light and phone-emulated WebKit dark have no outer-page overflow or page errors; six fault/missing/recovered screenshots inspected; existing dense phone truncation is unchanged"
],
"interactions": [
- "Navigate to the real plans document and inspect table DOM and accessibility snapshot",
- "On phone, scroll the table horizontally without moving the outer page; bring its header into view"
+ "Expand the real disk row and focus its disclosure, apply fault/missing/recovery snapshots without navigating or reloading",
+ "Check current values, stable owner, focus, live disclosure target and selected detail after each snapshot",
+ "Toggle Attention/All across fault and recovery"
],
- "command": "pulse-worker-browser frontend-modern/browser-tests/docs-table-headers.cjs",
+ "command": "pulse-worker-browser frontend-modern/browser-tests/disk-live-refresh.cjs",
"browser_versions": {
"playwright": "1.56.1",
"chromium": "141.0.7390.37",
"webkit": "26.0"
},
"artifacts": [
- "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/chromium-after.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/chromium-headers.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/webkit-after.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/webkit-headers.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-yqww6hbj/browser/docs/after.json"
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-fault.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-missing.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-recovered.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-fault.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-missing.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-recovered.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/result.json"
]
}
diff --git a/frontend-modern/src/components/Storage/DiskList.tsx b/frontend-modern/src/components/Storage/DiskList.tsx
index 280ebce9cf..5715390316 100644
--- a/frontend-modern/src/components/Storage/DiskList.tsx
+++ b/frontend-modern/src/components/Storage/DiskList.tsx
@@ -320,15 +320,20 @@ export const DiskList: Component = (props) => {
{(disk) => {
- const data = model.getDiskData(disk);
- const status = getPhysicalDiskHealthStatus(data);
- const hostLabel = getPhysicalDiskHostLabel(data, disk);
- const healthSummary = getPhysicalDiskHealthSummary(status);
+ // Windowed rows retain their owner by resource ID across live
+ // snapshots. Derive the presentation reactively, not just when
+ // the row first mounts, so health and identity cannot go stale.
+ const data = createMemo(() => model.getDiskData(disk));
+ const status = createMemo(() => getPhysicalDiskHealthStatus(data()));
+ const hostLabel = createMemo(() => getPhysicalDiskHostLabel(data(), disk));
+ const healthSummary = createMemo(() => getPhysicalDiskHealthSummary(status()));
const isSelected = () => model.selectedDisk()?.id === disk.id;
- const summarySeriesId = resolvePhysicalDiskMetricResourceId(disk);
+ const summarySeriesId = createMemo(() => resolvePhysicalDiskMetricResourceId(disk));
const isSummaryHighlighted = () =>
- props.highlightedSummarySeriesId === summarySeriesId;
- const detailControlsId = buildSummaryDisclosureControlsId(summarySeriesId);
+ props.highlightedSummarySeriesId === summarySeriesId();
+ const detailControlsId = createMemo(() =>
+ buildSummaryDisclosureControlsId(summarySeriesId()),
+ );
const interactiveRowHandlers = createSummaryInteractiveRowPreviewHandlers({
onPreview: () => props.onHoverChange?.(disk.id),
onPreviewClear: () => props.onHoverChange?.(null),
@@ -338,7 +343,7 @@ export const DiskList: Component = (props) => {
<>
= (props) => {
model.toggleSelectedDisk(disk)}
onPreviewClear={() => props.onHoverChange?.(null)}
/>
- {data.model || 'Unknown Disk'}
+ {data().model || 'Unknown Disk'}
@@ -379,11 +384,11 @@ export const DiskList: Component = (props) => {
data-storage-column="device"
>
—}
>
-
- {data.devPath}
+
+ {data().devPath}
@@ -393,11 +398,11 @@ export const DiskList: Component = (props) => {
data-storage-column="host"
>
—}
>
-
- {hostLabel}
+
+ {hostLabel()}
@@ -407,14 +412,14 @@ export const DiskList: Component = (props) => {
data-storage-column="role"
>
—}
>
- {getPhysicalDiskRoleLabel(data)}
+ {getPhysicalDiskRoleLabel(data())}
@@ -424,14 +429,14 @@ export const DiskList: Component = (props) => {
data-storage-column="parent"
>
—}
>
- {getPhysicalDiskParentLabel(data)}
+ {getPhysicalDiskParentLabel(data())}
@@ -441,12 +446,15 @@ export const DiskList: Component = (props) => {
data-storage-column="health"
>
-
- {status.label}
+
+ {status().label}
-
-
- {healthSummary}
+
+
+ {healthSummary()}
@@ -457,13 +465,13 @@ export const DiskList: Component = (props) => {
data-storage-column="life"
>
—}
>
- {getPhysicalDiskLifeLabel(data)}
+ {getPhysicalDiskLifeLabel(data())}
@@ -473,17 +481,17 @@ export const DiskList: Component = (props) => {
data-storage-column="temp"
>
0}
+ when={data().temperature > 0}
fallback={—}
>
- {formatTemperature(data.temperature)}
+ {formatTemperature(data().temperature)}
@@ -493,7 +501,7 @@ export const DiskList: Component = (props) => {
data-storage-column="size"
>
0}
+ when={data().size > 0}
fallback={
= (props) => {
}
>
- {formatBytes(data.size)}
+ {formatBytes(data().size)}
-
+
diff --git a/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx
index 0956cb9176..c8b0ad9432 100644
--- a/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx
+++ b/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx
@@ -1,5 +1,6 @@
-import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
+import { createStore, reconcile } from 'solid-js/store';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import { DiskList } from '@/components/Storage/DiskList';
@@ -83,6 +84,159 @@ describe('DiskList', () => {
cleanup();
});
+ it('refreshes keyed disk rows without losing the expanded detail or keyboard focus', async () => {
+ const initial = buildDisk('sda', 'tower', { diskType: 'ssd', wearout: 96 });
+ const [disks, setDisks] = createSignal([initial]);
+ const [selectedDiskId, setSelectedDiskId] = createSignal('sda');
+ const view = render(() => (
+
+ ));
+ const row = view.container.querySelector('[data-row-id="sda"]')!;
+ const detail = screen.getByTestId('disk-detail');
+ const disclosure = within(row as HTMLElement).getByRole('button');
+ disclosure.focus();
+ const initialControls = disclosure.getAttribute('aria-controls');
+
+ setDisks([
+ {
+ ...buildDisk('sda', 'archive', {
+ model: 'Replacement SSD',
+ devPath: '/dev/sdz',
+ diskType: 'ssd',
+ wearout: 4,
+ sizeBytes: 4_000_000_000_000,
+ temperature: 63,
+ storageRole: 'cache_pool',
+ storageGroup: 'Archive Pool',
+ health: 'FAILED',
+ risk: {
+ level: 'critical',
+ reasons: [{ code: 'smart-failed', severity: 'critical', summary: 'SMART failed.' }],
+ },
+ }),
+ metricsTarget: { resourceType: 'disk', resourceId: 'disk:archive:sdz' },
+ },
+ ]);
+
+ await waitFor(() =>
+ expect(within(row as HTMLElement).getByText('Replace Now')).toBeInTheDocument(),
+ );
+ for (const text of [
+ 'Replacement SSD',
+ '/dev/sdz',
+ 'archive',
+ 'Cache Pool',
+ 'Archive Pool',
+ 'SMART failed.',
+ '4%',
+ '63°C',
+ ]) {
+ expect(within(row as HTMLElement).getByText(text)).toBeInTheDocument();
+ }
+ expect(within(row as HTMLElement).getByText('3.64 TB')).toBeInTheDocument();
+ expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
+ expect(screen.queryByText('96%')).not.toBeInTheDocument();
+ expect(view.container.querySelector('[data-row-id="sda"]')).toBe(row);
+ expect(screen.getByTestId('disk-detail')).toBe(detail);
+ expect(document.activeElement).toBe(disclosure);
+ expect(disclosure.getAttribute('aria-label')).toContain('Replacement SSD');
+ expect(row).toHaveAttribute('data-summary-series-id', 'disk:archive:sdz');
+ expect(disclosure.getAttribute('aria-controls')).not.toBe(initialControls);
+ expect(
+ view.container.querySelector('[data-inline-detail-for="disk:archive:sdz"]'),
+ ).not.toBeNull();
+ expect(document.getElementById(disclosure.getAttribute('aria-controls')!)).not.toBeNull();
+ });
+
+ it('removes obsolete readings and fault styling when a keyed snapshot stops reporting them', async () => {
+ const [disks, setDisks] = createSignal([
+ buildDisk('sda', 'tower', {
+ health: 'FAILED',
+ diskType: 'ssd',
+ wearout: 4,
+ temperature: 63,
+ risk: {
+ level: 'critical',
+ reasons: [{ code: 'smart-failed', severity: 'critical', summary: 'SMART failed.' }],
+ },
+ }),
+ ]);
+ const view = render(() => (
+ {}}
+ />
+ ));
+ const row = view.container.querySelector('[data-row-id="sda"]')!;
+ expect(within(row as HTMLElement).getByText('Replace Now')).toHaveClass('text-red-700');
+ setDisks([
+ buildDisk('sda', 'tower', {
+ health: 'UNKNOWN',
+ diskType: '',
+ wearout: -1,
+ temperature: 0,
+ sizeBytes: 0,
+ storageRole: '',
+ storageGroup: '',
+ model: '',
+ devPath: '',
+ }),
+ ]);
+ await waitFor(() =>
+ expect(within(row as HTMLElement).getByText('Unknown')).toBeInTheDocument(),
+ );
+ for (const text of ['Replace Now', 'SMART failed.', '4%', '63°C', 'Parity', 'Tower Array']) {
+ expect(within(row as HTMLElement).queryByText(text)).not.toBeInTheDocument();
+ }
+ expect(within(row as HTMLElement).getByText('Unknown')).not.toHaveClass('text-red-700');
+ expect(within(row as HTMLElement).getByText('sda')).toBeInTheDocument();
+ for (const column of ['temp', 'life', 'size', 'device', 'role', 'parent']) {
+ expect(row.querySelector(`td[data-storage-column="${column}"]`)).toHaveTextContent('—');
+ }
+ expect(view.container.querySelector('[data-row-id="sda"]')).toBe(row);
+ });
+
+ it('keeps the attention filter and row health consistent during live updates and recovery', async () => {
+ const [disks, setDisks] = createStore({ items: [buildDisk('sda', 'tower')] });
+ const [healthFilter, setHealthFilter] = createSignal<'all' | 'attention'>('all');
+ const view = render(() => (
+ {}}
+ />
+ ));
+ const row = view.container.querySelector('[data-row-id="sda"]');
+ setDisks(
+ 'items',
+ reconcile([buildDisk('sda', 'tower', { smart: { pendingSectors: 2 }, temperature: 52 })]),
+ );
+ await waitFor(() => expect(screen.getByText('Needs Attention')).toBeInTheDocument());
+ expect(view.container.querySelector('[data-row-id="sda"]')).toBe(row);
+ setHealthFilter('attention');
+ expect(screen.getByText('Needs Attention')).toBeInTheDocument();
+ setDisks('items', reconcile([buildDisk('sda', 'tower')]));
+ await waitFor(() => expect(screen.getByText('No disks need attention')).toBeInTheDocument());
+ setHealthFilter('all');
+ await waitFor(() => expect(screen.getByText('Healthy')).toBeInTheDocument());
+ expect(screen.queryByText('52°C')).not.toBeInTheDocument();
+ });
+
it('renders physical disks in a single-line operational grid', () => {
renderDiskList({
disks: [
diff --git a/internal/monitoring/truenas_poller.go b/internal/monitoring/truenas_poller.go
index 1f18061c19..51bf22ffc9 100644
--- a/internal/monitoring/truenas_poller.go
+++ b/internal/monitoring/truenas_poller.go
@@ -1540,8 +1540,8 @@ func classifyTrueNASError(err error, connectionID string) *internalerrors.Monito
}
}
// Transport-level errors: timeout takes precedence over generic connection failures.
- var urlErr *url.Error
- if (errors.As(err, &urlErr) && urlErr.Timeout()) || errors.Is(err, context.DeadlineExceeded) {
+ var netErr net.Error
+ if (errors.As(err, &netErr) && netErr.Timeout()) || errors.Is(err, context.DeadlineExceeded) {
errType = internalerrors.ErrorTypeTimeout
} else {
var netOpErr *net.OpError
diff --git a/internal/monitoring/truenas_poller_test.go b/internal/monitoring/truenas_poller_test.go
index bd99a72e19..9074c9cdd9 100644
--- a/internal/monitoring/truenas_poller_test.go
+++ b/internal/monitoring/truenas_poller_test.go
@@ -17,6 +17,7 @@ import (
"testing"
"time"
+ "github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/truenas"
@@ -1815,6 +1816,14 @@ func TestClassifyTrueNASError(t *testing.T) {
expectedType: "timeout",
expectedRetry: true,
},
+ {
+ name: "WebSocket socket deadline classifies as timeout",
+ err: &truenas.RPCTransportError{Method: "system.info", Phase: "read", Err: &net.OpError{
+ Op: "read", Net: "tcp", Err: os.ErrDeadlineExceeded,
+ }},
+ expectedType: "timeout",
+ expectedRetry: true,
+ },
{
name: "net.OpError classifies as connection",
err: &net.OpError{Op: "dial", Net: "tcp", Addr: nil, Err: fmt.Errorf("connection refused")},
@@ -1866,6 +1875,146 @@ func TestClassifyTrueNASError(t *testing.T) {
}
}
+func TestTrueNASPollerUnresponsiveRPCDoesNotFreezeOtherConnections(t *testing.T) {
+ var stalled atomic.Bool
+ release := make(chan struct{})
+ newServer := func(hostname string, mayStall bool) *httptest.Server {
+ upgrader := websocket.Upgrader{}
+ return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/current" {
+ t.Error("modern polling fell back to REST")
+ http.NotFound(w, r)
+ return
+ }
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ for {
+ var request struct {
+ ID int64 `json:"id"`
+ Method string `json:"method"`
+ Params []json.RawMessage `json:"params"`
+ }
+ if err := conn.ReadJSON(&request); err != nil {
+ return
+ }
+ var result any = []any{}
+ switch request.Method {
+ case "auth.login_ex":
+ result = map[string]any{"response_type": "SUCCESS"}
+ case "system.info":
+ if mayStall && stalled.Load() {
+ <-release
+ }
+ result = map[string]any{"hostname": hostname, "version": "TrueNAS-SCALE-25.10.7", "system_serial": hostname}
+ case "core.subscribe":
+ result = "fixture-realtime"
+ case "core.unsubscribe":
+ result = nil
+ }
+ if err := conn.WriteJSON(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": result}); err != nil {
+ return
+ }
+ if request.Method == "core.subscribe" {
+ var event string
+ if len(request.Params) != 1 || json.Unmarshal(request.Params[0], &event) != nil {
+ t.Error("subscription did not supply one event")
+ return
+ }
+ var fields any = map[string]any{"cpu": map[string]any{"usage": 12}}
+ if strings.HasPrefix(event, "app.stats:") {
+ fields = []any{}
+ }
+ if err := conn.WriteJSON(map[string]any{
+ "jsonrpc": "2.0", "method": "collection_update",
+ "params": map[string]any{"collection": event, "fields": fields},
+ }); err != nil {
+ return
+ }
+ }
+ }
+ }))
+ }
+ brokenServer := newServer("timeout-nas", true)
+ healthyServer := newServer("healthy-nas", false)
+ t.Cleanup(brokenServer.Close)
+ t.Cleanup(healthyServer.Close)
+ poller := NewTrueNASPoller(nil, 0, nil)
+ instances := []config.TrueNASInstance{
+ {ID: "timeout-connection", Host: brokenServer.URL, Enabled: true},
+ {ID: "healthy-connection", Host: healthyServer.URL, Enabled: true},
+ }
+ poller.providersByOrg["default"] = make(map[string]*truenas.Provider)
+ poller.configsByOrg["default"] = make(map[string]config.TrueNASInstance)
+ for i, instance := range instances {
+ server := []*httptest.Server{brokenServer, healthyServer}[i]
+ client, err := truenas.NewClient(truenas.ClientConfig{
+ Host: server.URL, APIKey: "fixture-key", Username: "fixture-user", Timeout: 200 * time.Millisecond,
+ InsecureSkipVerify: true, Fingerprint: fmt.Sprintf("%x", sha256.Sum256(server.Certificate().Raw)),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(client.Close)
+ poller.providersByOrg["default"][instance.ID] = truenas.NewLiveProviderForConnection(&truenas.APIFetcher{Client: client}, instance.ID)
+ poller.configsByOrg["default"][instance.ID] = instance
+ }
+ t.Cleanup(func() { close(release) })
+ due := func() {
+ for _, instance := range instances {
+ poller.ensureConnectionRuntimeStatusLocked("default", instance.ID).nextPollAt = time.Now().Add(-time.Second)
+ }
+ }
+ poller.pollAll(context.Background())
+ before := poller.ConnectionSummaries("default", instances)
+ for _, instance := range instances {
+ if before[instance.ID].Poll.LastSuccessAt == nil {
+ t.Fatal("initial successful poll was not established")
+ }
+ }
+ stalled.Store(true)
+ due()
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ done := make(chan struct{})
+ go func() { poller.pollAll(ctx); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Error("one unresponsive RPC froze the shared poll cycle")
+ cancel() // Safely terminate the pre-repair adverse control.
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("poll cycle did not stop after cancellation")
+ }
+ }
+ after := poller.ConnectionSummaries("default", instances)
+ broken, healthy := after[instances[0].ID], after[instances[1].ID]
+ if broken.Poll.LastError == nil || broken.Poll.LastError.Category != "timeout" || broken.Poll.ConsecutiveFailures != 1 || !broken.Poll.LastSuccessAt.Equal(*before[instances[0].ID].Poll.LastSuccessAt) {
+ t.Fatalf("failed connection lost truthful timeout or previous success: %+v", broken)
+ }
+ if healthy.Poll.LastError != nil || !healthy.Poll.LastSuccessAt.After(*before[instances[1].ID].Poll.LastSuccessAt) {
+ t.Fatalf("healthy connection did not continue polling: %+v", healthy)
+ }
+ if !hasTrueNASHostForOrg(poller, "default", "timeout-nas") || !hasTrueNASHostForOrg(poller, "default", "healthy-nas") {
+ t.Fatal("timeout discarded a cached host identity")
+ }
+ status := poller.statusByOrg["default"][instances[0].ID]
+ if !status.nextPollAt.Equal(status.lastAttemptAt.Add(defaultTrueNASPollInterval)) {
+ t.Fatal("timeout changed completion-based failure backoff")
+ }
+ stalled.Store(false)
+ due()
+ poller.pollAll(context.Background())
+ recovered := poller.ConnectionSummaries("default", instances)[instances[0].ID]
+ if recovered.Poll.LastError != nil || recovered.Poll.ConsecutiveFailures != 0 || !recovered.Poll.LastSuccessAt.After(*broken.Poll.LastSuccessAt) || recovered.Transport == nil || !recovered.Transport.Connected || recovered.Transport.Mode != truenas.TransportJSONRPC {
+ t.Fatalf("next poll did not recover the original connection over RPC: %+v", recovered)
+ }
+}
+
type trueNASMockServer struct {
server *httptest.Server
requests atomic.Int64
diff --git a/internal/truenas/client.go b/internal/truenas/client.go
index a6ff48f5a6..6ad3e11046 100644
--- a/internal/truenas/client.go
+++ b/internal/truenas/client.go
@@ -66,7 +66,7 @@ type Client struct {
baseURL string
rpcURL string
- rpcMu sync.Mutex
+ rpcMu rpcSessionMutex
rpc *trueNASRPCClient
mode TransportMode
closed bool
@@ -251,7 +251,7 @@ func (c *Client) GetSystemTelemetry(ctx context.Context) (*SystemInfo, error) {
var telemetry *SystemInfo
var temperatures map[string]float64
- err = c.withRPC(ctx, func(rpc *trueNASRPCClient) error {
+ err = c.withRPC(ctx, func(ctx context.Context, rpc *trueNASRPCClient) error {
temperatures, _ = rpc.getSystemTemperatures(ctx)
subscriptionName := fmt.Sprintf("reporting.realtime:{\"interval\":%d}", defaultRealtimeIntervalSeconds)
subscriptionID, err := rpc.subscribe(ctx, subscriptionName)
@@ -506,7 +506,7 @@ func (c *Client) GetSystemMetricHistory(ctx context.Context, duration time.Durat
}
var history *SystemMetricHistory
- err = c.withRPC(ctx, func(rpc *trueNASRPCClient) error {
+ err = c.withRPC(ctx, func(ctx context.Context, rpc *trueNASRPCClient) error {
var err error
history, err = rpc.getSystemMetricHistory(ctx, duration)
return err
@@ -1332,7 +1332,7 @@ func (c *Client) GetDiskTemperatureHistory(ctx context.Context, identifiers []st
}
var history map[string][]TimeSeriesPoint
- err := c.withRPC(ctx, func(rpc *trueNASRPCClient) error {
+ err := c.withRPC(ctx, func(ctx context.Context, rpc *trueNASRPCClient) error {
var err error
history, err = rpc.getDiskTemperatureHistory(ctx, identifiers, duration)
return err
@@ -1434,7 +1434,7 @@ func (c *Client) getDiskTemperaturesFromReporting(ctx context.Context, identifie
}
var temperatures map[string]int
- err := c.withRPC(ctx, func(rpc *trueNASRPCClient) error {
+ err := c.withRPC(ctx, func(ctx context.Context, rpc *trueNASRPCClient) error {
var err error
temperatures, err = rpc.getDiskTemperatures(ctx, identifiers)
return err
@@ -1449,7 +1449,7 @@ func (c *Client) getDiskTemperatureAggregates(ctx context.Context, identifiers [
}
var aggregates map[string]DiskTemperatureAggregate
- err := c.withRPC(ctx, func(rpc *trueNASRPCClient) error {
+ err := c.withRPC(ctx, func(ctx context.Context, rpc *trueNASRPCClient) error {
var err error
aggregates, err = rpc.getDiskTemperatureAggregates(ctx, identifiers, windowDays)
return err
@@ -1805,7 +1805,7 @@ func (c *Client) parseAppsWithStats(ctx context.Context, response []map[string]a
// failure.
func (c *Client) GetAppStats(ctx context.Context) (map[string]AppStats, error) {
var stats map[string]AppStats
- err := c.withRPC(ctx, func(rpc *trueNASRPCClient) error {
+ err := c.withRPC(ctx, func(ctx context.Context, rpc *trueNASRPCClient) error {
subscriptionName := fmt.Sprintf("app.stats:{\"interval\":%d}", defaultAppStatsIntervalSeconds)
subscriptionID, err := rpc.subscribe(ctx, subscriptionName)
if err != nil {
@@ -1861,7 +1861,7 @@ func (c *Client) GetAppLogs(ctx context.Context, appName, containerID string, ta
}
subscriptionName := fmt.Sprintf("app.container_log_follow:%s", string(subscriptionJSON))
var lines []AppLogLine
- err = c.withRPC(ctx, func(rpc *trueNASRPCClient) error {
+ err = c.withRPC(ctx, func(ctx context.Context, rpc *trueNASRPCClient) error {
subscriptionID, err := rpc.subscribe(ctx, subscriptionName)
if err != nil {
return err
@@ -2615,6 +2615,11 @@ func (c *trueNASRPCClient) call(ctx context.Context, method string, params any,
if c == nil || c.conn == nil {
return fmt.Errorf("truenas rpc connection is nil")
}
+ // A cancelled waiter must not dispatch an action or poison a healthy
+ // session by setting an already-expired socket deadline.
+ if err := ctx.Err(); err != nil {
+ return err
+ }
stopContext := c.armContext(ctx)
defer stopContext()
@@ -2759,6 +2764,15 @@ func (c *trueNASRPCClient) readAppLogEvents(ctx context.Context, tailLines int)
var message trueNASRPCResponse
if err := c.conn.ReadJSON(&message); err != nil {
if isTimeoutError(err) {
+ // The normal idle window completes a bounded tail. A caller or
+ // operation budget expiring first is a failure, not evidence that
+ // the log stream successfully returned no data.
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return nil, false, &RPCTransportError{Method: "app.container_log_follow", Phase: "read", Err: ctxErr}
+ }
+ if ctxDeadline, ok := ctx.Deadline(); ok && !time.Now().Before(ctxDeadline) {
+ return nil, false, &RPCTransportError{Method: "app.container_log_follow", Phase: "read", Err: context.DeadlineExceeded}
+ }
// Gorilla WebSocket documents a timed-out read as terminal for
// the connection. Preserve the collected log data, then make
// the caller discard this stream session instead of reusing it.
diff --git a/internal/truenas/transport.go b/internal/truenas/transport.go
index 68ef382ce5..16f1093e0f 100644
--- a/internal/truenas/transport.go
+++ b/internal/truenas/transport.go
@@ -8,6 +8,7 @@ import (
"net/http"
"strconv"
"strings"
+ "sync"
"time"
"github.com/gorilla/websocket"
@@ -15,6 +16,49 @@ import (
var errRPCStreamSessionConsumed = errors.New("truenas rpc stream session cannot be reused")
+// rpcSessionMutex serializes the sole WebSocket reader/writer, while allowing
+// a caller to abandon its wait without interrupting the current session owner.
+// Its zero value is usable, including by Client.Close and protocol fixtures.
+type rpcSessionMutex struct {
+ once sync.Once
+ token chan struct{}
+}
+
+func (m *rpcSessionMutex) LockContext(ctx context.Context) error {
+ m.once.Do(func() { m.token = make(chan struct{}, 1) })
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case m.token <- struct{}{}:
+ if err := ctx.Err(); err != nil {
+ m.Unlock()
+ return err
+ }
+ return nil
+ }
+}
+
+func (m *rpcSessionMutex) Lock() { _ = m.LockContext(context.Background()) }
+func (m *rpcSessionMutex) Unlock() { <-m.token }
+
+// rpcOperationContext applies the same configured timeout as HTTP requests to
+// a serialized RPC/negotiation operation: lock wait, inline authentication, exchange or
+// subscription, and its one permitted read retry share a single budget. A
+// shorter caller deadline is never extended. Keepalive has its own lifetime.
+func (c *Client) rpcOperationContext(ctx context.Context) (context.Context, context.CancelFunc) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ timeout := c.config.Timeout
+ if timeout <= 0 {
+ timeout = defaultHTTPTimeout
+ }
+ return context.WithTimeout(ctx, timeout)
+}
+
type discardRPCSessionError struct {
err error
}
@@ -170,7 +214,11 @@ func (c *Client) ensureTransport(ctx context.Context) (TransportMode, error) {
if c == nil {
return TransportUnknown, fmt.Errorf("truenas client is nil")
}
- c.rpcMu.Lock()
+ ctx, cancel := c.rpcOperationContext(ctx)
+ defer cancel()
+ if err := c.rpcMu.LockContext(ctx); err != nil {
+ return TransportUnknown, err
+ }
defer c.rpcMu.Unlock()
return c.ensureTransportLocked(ctx)
}
@@ -286,11 +334,15 @@ func (c *Client) callRPC(ctx context.Context, method string, params any, result
return c.callRPCWithRetry(ctx, method, params, result, true)
}
-func (c *Client) withRPC(ctx context.Context, operation func(*trueNASRPCClient) error) error {
+func (c *Client) withRPC(ctx context.Context, operation func(context.Context, *trueNASRPCClient) error) error {
if c == nil {
return fmt.Errorf("truenas client is nil")
}
- c.rpcMu.Lock()
+ ctx, cancel := c.rpcOperationContext(ctx)
+ defer cancel()
+ if err := c.rpcMu.LockContext(ctx); err != nil {
+ return err
+ }
defer c.rpcMu.Unlock()
mode, err := c.ensureTransportLocked(ctx)
@@ -300,7 +352,7 @@ func (c *Client) withRPC(ctx context.Context, operation func(*trueNASRPCClient)
if mode != TransportJSONRPC || c.rpc == nil {
return fmt.Errorf("truenas JSON-RPC operation is unavailable over negotiated transport %s", mode)
}
- if err := operation(c.rpc); err != nil {
+ if err := operation(ctx, c.rpc); err != nil {
if errors.Is(err, errRPCStreamSessionConsumed) {
c.closeRPCLocked()
c.reconnect = 0
@@ -337,7 +389,7 @@ func (c *Client) withRPC(ctx context.Context, operation func(*trueNASRPCClient)
status.LastConnectedAt = &now
status.LastError = ""
})
- if err := operation(c.rpc); err != nil {
+ if err := operation(ctx, c.rpc); err != nil {
var secondTransportErr *RPCTransportError
if errors.As(err, &secondTransportErr) {
c.closeRPCLocked()
@@ -360,7 +412,11 @@ func (c *Client) callRPCWithRetry(ctx context.Context, method string, params any
if c == nil {
return fmt.Errorf("truenas client is nil")
}
- c.rpcMu.Lock()
+ ctx, cancel := c.rpcOperationContext(ctx)
+ defer cancel()
+ if err := c.rpcMu.LockContext(ctx); err != nil {
+ return err
+ }
defer c.rpcMu.Unlock()
mode, err := c.ensureTransportLocked(ctx)
diff --git a/internal/truenas/transport_test.go b/internal/truenas/transport_test.go
index 34d16e2d25..00d284e872 100644
--- a/internal/truenas/transport_test.go
+++ b/internal/truenas/transport_test.go
@@ -1095,3 +1095,327 @@ func TestRPCSessionKeepaliveConcurrentCallsAndShutdown(t *testing.T) {
t.Fatal("keepalive did not exit after socket failure")
}
}
+
+// The production poll loop has a cancellation context, not a per-request
+// deadline. A silent peer must still be bounded by ClientConfig.Timeout. These
+// fixtures never infer an appliance's reason for failing to reply.
+func awaitConfiguredRPCBudget(t *testing.T, operation func(context.Context) error) error {
+ t.Helper()
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ done := make(chan error, 1)
+ go func() { done <- operation(ctx) }()
+ select {
+ case err := <-done:
+ return err
+ case <-time.After(time.Second):
+ t.Error("RPC operation ignored the configured timeout")
+ cancel() // Also lets the pre-repair adverse control terminate safely.
+ select {
+ case err := <-done:
+ return err
+ case <-time.After(5 * time.Second):
+ t.Fatal("cancelled RPC operation did not stop")
+ return nil
+ }
+ }
+}
+
+func assertRPCTimeout(t *testing.T, err error) {
+ t.Helper()
+ if err == nil || (!errors.Is(err, context.DeadlineExceeded) && !isTimeoutError(err)) {
+ t.Errorf("expected a timeout, got %v", err)
+ }
+}
+
+func TestJSONRPCConfiguredTimeoutBoundsHandshake(t *testing.T) {
+ release := make(chan struct{})
+ var restRequests atomic.Int32
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/current" {
+ restRequests.Add(1)
+ }
+ <-release // TCP/TLS succeeds, but no WebSocket handshake response follows.
+ }))
+ t.Cleanup(server.Close)
+ client := protocolFixtureClient(t, server.URL, ClientConfig{APIKey: "fixture-key", Username: "fixture-user", Timeout: 200 * time.Millisecond})
+ t.Cleanup(func() { close(release) })
+ err := awaitConfiguredRPCBudget(t, client.TestConnection)
+ assertRPCTimeout(t, err)
+ var handshake *RPCHandshakeError
+ if !errors.As(err, &handshake) || client.TransportStatus().Connected || restRequests.Load() != 0 {
+ t.Fatalf("handshake timeout lost its type/status or downgraded to REST: %v", err)
+ }
+}
+
+func TestJSONRPCConfiguredTimeoutBoundsAuthentication(t *testing.T) {
+ release := make(chan struct{})
+ var logins atomic.Int32
+ fixture := newProtocolFixture(t, func(_ int, request trueNASRPCRequest) protocolFixtureReply {
+ if request.Method == "auth.login_ex" {
+ logins.Add(1)
+ <-release
+ }
+ return protocolFixtureReply{result: map[string]any{"response_type": "SUCCESS"}}
+ }, nil)
+ client := protocolFixtureClient(t, fixture.server.URL, ClientConfig{APIKey: "fixture-key", Username: "fixture-user", Timeout: 200 * time.Millisecond})
+ t.Cleanup(func() { close(release) })
+ err := awaitConfiguredRPCBudget(t, client.TestConnection)
+ assertRPCTimeout(t, err)
+ if fixture.sessions.Load() != 1 || logins.Load() != 1 || fixture.restRequests.Load() != 0 || client.TransportStatus().Connected {
+ t.Fatalf("silent authentication retried, downgraded or appeared connected: %+v", client.TransportStatus())
+ }
+}
+
+func TestJSONRPCConfiguredTimeoutBoundsReadAndRecovers(t *testing.T) {
+ release := make(chan struct{})
+ var reads atomic.Int32
+ fixture := newProtocolFixture(t, func(_ int, request trueNASRPCRequest) protocolFixtureReply {
+ if request.Method == "auth.login_ex" {
+ return protocolFixtureReply{result: map[string]any{"response_type": "SUCCESS"}}
+ }
+ if request.Method == "system.info" && reads.Add(1) == 1 {
+ <-release
+ }
+ return protocolFixtureReply{result: map[string]any{"hostname": "timeout-fixture", "version": "TrueNAS-SCALE-25.10.7"}}
+ }, nil)
+ client := protocolFixtureClient(t, fixture.server.URL, ClientConfig{APIKey: "fixture-key", Username: "fixture-user", Timeout: 200 * time.Millisecond})
+ t.Cleanup(func() { close(release) })
+ err := awaitConfiguredRPCBudget(t, client.TestConnection)
+ assertRPCTimeout(t, err)
+ if fixture.sessions.Load() != 1 || reads.Load() != 1 || client.TransportStatus().Connected {
+ t.Fatal("expired operation gained a retry budget or retained its timed-out session")
+ }
+ if err := client.TestConnection(context.Background()); err != nil {
+ t.Fatalf("subsequent operation did not recover: %v", err)
+ }
+ if fixture.sessions.Load() != 2 || reads.Load() != 2 || fixture.restRequests.Load() != 0 || !client.TransportStatus().Connected {
+ t.Fatalf("subsequent operation did not negotiate one fresh RPC session: %+v", client.TransportStatus())
+ }
+}
+
+func TestJSONRPCConfiguredTimeoutIncludesReadRetryBackoff(t *testing.T) {
+ release := make(chan struct{})
+ var reads atomic.Int32
+ fixture := newProtocolFixture(t, func(_ int, request trueNASRPCRequest) protocolFixtureReply {
+ if request.Method == "auth.login_ex" {
+ return protocolFixtureReply{result: map[string]any{"response_type": "SUCCESS"}}
+ }
+ if reads.Add(1) == 1 {
+ // The 100 ms reconnect backoff cannot fit in the remaining part of
+ // a 200 ms operation. It must not create a new deadline on retry.
+ time.Sleep(150 * time.Millisecond)
+ return protocolFixtureReply{close: true}
+ }
+ <-release
+ return protocolFixtureReply{result: nil}
+ }, nil)
+ client := protocolFixtureClient(t, fixture.server.URL, ClientConfig{APIKey: "fixture-key", Username: "fixture-user", Timeout: 200 * time.Millisecond})
+ t.Cleanup(func() { close(release) })
+ err := awaitConfiguredRPCBudget(t, client.TestConnection)
+ assertRPCTimeout(t, err)
+ if fixture.sessions.Load() != 1 || reads.Load() != 1 || fixture.restRequests.Load() != 0 {
+ t.Fatal("read retry restarted an exhausted operation budget")
+ }
+}
+
+func TestJSONRPCConfiguredTimeoutDoesNotExtendCallerDeadline(t *testing.T) {
+ release := make(chan struct{})
+ fixture := newProtocolFixture(t, func(_ int, request trueNASRPCRequest) protocolFixtureReply {
+ if request.Method == "auth.login_ex" {
+ return protocolFixtureReply{result: map[string]any{"response_type": "SUCCESS"}}
+ }
+ <-release
+ return protocolFixtureReply{result: nil}
+ }, nil)
+ client := protocolFixtureClient(t, fixture.server.URL, ClientConfig{APIKey: "fixture-key", Username: "fixture-user", Timeout: 5 * time.Second})
+ t.Cleanup(func() { close(release) })
+ err := awaitConfiguredRPCBudget(t, func(parent context.Context) error {
+ ctx, cancel := context.WithTimeout(parent, 100*time.Millisecond)
+ defer cancel()
+ return client.TestConnection(ctx)
+ })
+ assertRPCTimeout(t, err)
+ if fixture.sessions.Load() != 1 || fixture.restRequests.Load() != 0 {
+ t.Fatal("caller deadline gained a retry or REST downgrade")
+ }
+}
+
+func TestJSONRPCConfiguredTimeoutDoesNotReplayAction(t *testing.T) {
+ release := make(chan struct{})
+ var actions atomic.Int32
+ fixture := newProtocolFixture(t, func(_ int, request trueNASRPCRequest) protocolFixtureReply {
+ if request.Method == "auth.login_ex" {
+ return protocolFixtureReply{result: map[string]any{"response_type": "SUCCESS"}}
+ }
+ if request.Method == "app.start" {
+ actions.Add(1)
+ <-release
+ }
+ return protocolFixtureReply{result: nil}
+ }, nil)
+ client := protocolFixtureClient(t, fixture.server.URL, ClientConfig{APIKey: "fixture-key", Username: "fixture-user", Timeout: 200 * time.Millisecond})
+ t.Cleanup(func() { close(release) })
+ err := awaitConfiguredRPCBudget(t, func(ctx context.Context) error { return client.StartApp(ctx, "fixture-app") })
+ assertRPCTimeout(t, err)
+ if !strings.Contains(err.Error(), "outcome is unknown") || actions.Load() != 1 || fixture.sessions.Load() != 1 || fixture.restRequests.Load() != 0 {
+ t.Fatalf("timed-out action was replayed or lost its unknown outcome: %v", err)
+ }
+}
+
+func TestJSONRPCConfiguredTimeoutBoundsSubscriptions(t *testing.T) {
+ for _, stream := range []string{"telemetry", "app-stats", "app-logs"} {
+ t.Run(stream, func(t *testing.T) {
+ fixture := newProtocolFixture(t, func(_ int, request trueNASRPCRequest) protocolFixtureReply {
+ switch request.Method {
+ case "auth.login_ex":
+ return protocolFixtureReply{result: map[string]any{"response_type": "SUCCESS"}}
+ case "core.subscribe":
+ return protocolFixtureReply{result: "fixture-subscription"} // No stream event ever arrives.
+ default:
+ return protocolFixtureReply{result: []any{}}
+ }
+ }, nil)
+ client := protocolFixtureClient(t, fixture.server.URL, ClientConfig{APIKey: "fixture-key", Username: "fixture-user", Timeout: 200 * time.Millisecond})
+ err := awaitConfiguredRPCBudget(t, func(ctx context.Context) error {
+ switch stream {
+ case "telemetry":
+ _, err := client.GetSystemTelemetry(ctx)
+ return err
+ case "app-stats":
+ _, err := client.GetAppStats(ctx)
+ return err
+ default:
+ _, err := client.GetAppLogs(ctx, "fixture-app", "fixture-container", 100)
+ return err
+ }
+ })
+ assertRPCTimeout(t, err)
+ if fixture.sessions.Load() != 1 || fixture.restRequests.Load() != 0 || client.TransportStatus().Connected {
+ t.Fatal("expired stream was retried, downgraded or left reusable")
+ }
+ })
+ }
+}
+
+func TestJSONRPCWaitingDeadlineDoesNotInterruptSessionOwner(t *testing.T) {
+ release := make(chan struct{})
+ started := make(chan struct{})
+ var reads atomic.Int32
+ fixture := newProtocolFixture(t, func(_ int, request trueNASRPCRequest) protocolFixtureReply {
+ if request.Method == "auth.login_ex" {
+ return protocolFixtureReply{result: map[string]any{"response_type": "SUCCESS"}}
+ }
+ if reads.Add(1) == 1 {
+ close(started)
+ <-release
+ }
+ return protocolFixtureReply{result: map[string]any{"hostname": "session-owner"}}
+ }, nil)
+ client := protocolFixtureClient(t, fixture.server.URL, ClientConfig{APIKey: "fixture-key", Username: "fixture-user", Timeout: 5 * time.Second})
+ var released atomic.Bool
+ unblock := func() {
+ if released.CompareAndSwap(false, true) {
+ close(release)
+ }
+ }
+ t.Cleanup(unblock)
+ owner := make(chan error, 1)
+ go func() { owner <- client.TestConnection(context.Background()) }()
+ select {
+ case <-started:
+ case <-time.After(2 * time.Second):
+ t.Fatal("session owner did not dispatch")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ waiter := make(chan error, 1)
+ go func() { waiter <- client.TestConnection(ctx) }()
+ select {
+ case err := <-waiter:
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Errorf("waiter error = %v, want caller deadline", err)
+ }
+ case <-time.After(time.Second):
+ t.Error("caller deadline could not abandon the session lock wait")
+ unblock() // Do not strand the predecessor's uncancellable mutex waiter.
+ <-waiter
+ }
+ if reads.Load() != 1 {
+ t.Error("cancelled waiter dispatched a request")
+ }
+ unblock()
+ if err := <-owner; err != nil {
+ t.Fatalf("waiter interrupted the session owner: %v", err)
+ }
+ if err := client.TestConnection(context.Background()); err != nil || fixture.sessions.Load() != 1 {
+ t.Fatalf("cancelled waiter poisoned the shared session: %v, sessions=%d", err, fixture.sessions.Load())
+ }
+}
+
+func TestJSONRPCPreCancelledCallDoesNotDispatch(t *testing.T) {
+ var actions atomic.Int32
+ fixture := newProtocolFixture(t, func(_ int, request trueNASRPCRequest) protocolFixtureReply {
+ if request.Method == "auth.login_ex" {
+ return protocolFixtureReply{result: map[string]any{"response_type": "SUCCESS"}}
+ }
+ if request.Method == "app.start" {
+ actions.Add(1)
+ }
+ return protocolFixtureReply{result: map[string]any{"hostname": "cancel-fixture"}}
+ }, nil)
+ client := protocolFixtureClient(t, fixture.server.URL, ClientConfig{APIKey: "fixture-key", Username: "fixture-user"})
+ if err := client.TestConnection(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := client.StartApp(ctx, "fixture-app"); !errors.Is(err, context.Canceled) {
+ t.Fatalf("pre-cancelled action error = %v", err)
+ }
+ if err := client.TestConnection(context.Background()); err != nil || fixture.sessions.Load() != 1 || actions.Load() != 0 {
+ t.Fatalf("pre-cancelled action dispatched or poisoned the session: %v", err)
+ }
+}
+
+func TestJSONRPCSnapshotContinuesAfterTelemetryTimeout(t *testing.T) {
+ fixture := newProtocolFixture(t, func(_ int, request trueNASRPCRequest) protocolFixtureReply {
+ switch request.Method {
+ case "auth.login_ex":
+ return protocolFixtureReply{result: map[string]any{"response_type": "SUCCESS"}}
+ case "system.info":
+ return protocolFixtureReply{result: map[string]any{"hostname": "inventory-timeout", "version": "TrueNAS-SCALE-25.10.7", "system_serial": "FIXTURE-1", "physmem": 1024}}
+ case "core.subscribe":
+ params, _ := request.Params.([]any)
+ event, _ := params[0].(string)
+ if strings.HasPrefix(event, "app.stats:") {
+ // Only live system telemetry is silent in this fixture. App
+ // inventory's separate best-effort subscription still replies.
+ return protocolFixtureReply{result: "empty-app-stats", notifications: []protocolFixtureNotification{{
+ method: "collection_update", params: map[string]any{"collection": event, "fields": []any{}},
+ }}}
+ }
+ return protocolFixtureReply{result: "silent-realtime"}
+ case "pool.query":
+ return protocolFixtureReply{result: []map[string]any{{"id": 1, "name": "tank", "status": "ONLINE"}}}
+ default:
+ return protocolFixtureReply{result: []any{}}
+ }
+ }, nil)
+ client := protocolFixtureClient(t, fixture.server.URL, ClientConfig{APIKey: "fixture-key", Username: "fixture-user", Timeout: 200 * time.Millisecond})
+ var snapshot *FixtureSnapshot
+ err := awaitConfiguredRPCBudget(t, func(ctx context.Context) error {
+ var err error
+ snapshot, err = client.FetchSnapshot(ctx)
+ return err
+ })
+ if err != nil || snapshot == nil || snapshot.System.Hostname != "inventory-timeout" || len(snapshot.Pools) != 1 {
+ t.Fatalf("optional telemetry timeout prevented usable inventory: %v, %+v", err, snapshot)
+ }
+ if snapshot.System.Telemetry == nil || snapshot.System.Telemetry.ErrorCategory != "timeout" || snapshot.System.Telemetry.CPU || snapshot.System.Telemetry.Memory {
+ t.Fatalf("silent telemetry appeared fresh or lost its timeout: %+v", snapshot.System.Telemetry)
+ }
+ if fixture.sessions.Load() != 2 || fixture.restRequests.Load() != 0 {
+ t.Fatalf("inventory did not recover on RPC after discarding the stream: sessions=%d rest=%d", fixture.sessions.Load(), fixture.restRequests.Load())
+ }
+}