diff --git a/core/core/api/access-http.js b/core/core/api/access-http.js index 2662cf9..aa7d7d7 100644 --- a/core/core/api/access-http.js +++ b/core/core/api/access-http.js @@ -366,12 +366,25 @@ function createAccessHttp({ return true; } - if (url.pathname === '/api/platform/runtime' && request.method === 'GET') { + if (url.pathname === '/api/platform/runtime' && ['GET', 'POST'].includes(request.method)) { const current = session(request); - requireNoQuery(url); access.requirePlatform(current, 'platform.installations.manage'); if (current.user.platformRole !== 'owner' || current.dspView) throw new AccessError('platform_forbidden', 403); - sendJson(response, 200, { ok: true, status: 'found', data: platformRuntime?.() + const validViewer = value => typeof value === 'string' && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(value); + let options; + if (request.method === 'POST') { + requireMutation(request, current, url); + const input = await readJson(request); exact(input, ['viewerId', 'action']); + if (!validViewer(input.viewerId) || input.action !== 'close') throw new AccessError('invalid_request', 400); + options = { closeViewer: `${current.user.id}:${input.viewerId}` }; + } else { + const viewer = url.searchParams.get('viewer'), refresh = url.searchParams.get('refreshStorage'); + if ([...url.searchParams.keys()].some(key => !['viewer', 'refreshStorage'].includes(key)) + || url.searchParams.getAll('viewer').length > 1 || url.searchParams.getAll('refreshStorage').length > 1 + || viewer !== null && !validViewer(viewer) || refresh !== null && (refresh !== '1' || !viewer)) throw new AccessError('invalid_request', 400); + options = { refreshStorage: refresh === '1', viewerKey: viewer ? `${current.user.id}:${viewer}` : null }; + } + sendJson(response, 200, { ok: true, status: 'found', data: platformRuntime?.(options) || { enabled: false, storageAvailableBytes: null, runtimes: [] }, error: null }); return true; } diff --git a/core/dashboard/frontend/src/pages/DspResources.tsx b/core/dashboard/frontend/src/pages/DspResources.tsx index 8976d67..6aeda59 100644 --- a/core/dashboard/frontend/src/pages/DspResources.tsx +++ b/core/dashboard/frontend/src/pages/DspResources.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import { useSession } from "@/lib/session"; import { request } from "@/lib/api"; import { ErrorNotice, Loading } from "@/components/shared"; @@ -43,14 +44,42 @@ function Metric({ label, value, children }: { label: string; value: string; chil ; } export function DspResources() { + const { session } = useSession(); + const [viewerId] = useState(() => crypto.randomUUID()); + const [visible, setVisible] = useState(() => !document.hidden); const [now, setNow] = useState(Date.now); - useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, []); - const runtime = useQuery({ queryKey: ["platform-runtime"], queryFn: () => request("/api/platform/runtime"), refetchInterval: 2000 }); + const scanOnOpen = useRef(true); + useEffect(() => { + if (!visible) return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [visible]); + useEffect(() => { + const leave = () => { void request("/api/platform/runtime", { method: "POST", keepalive: true, + headers: { "X-Dispatch-CSRF": session.csrfToken || "" }, body: JSON.stringify({ viewerId, action: "close" }), + }).catch(() => {}); }; + const visibility = () => { + setVisible(!document.hidden); + if (document.hidden) leave(); else scanOnOpen.current = true; + }; + document.addEventListener("visibilitychange", visibility); + window.addEventListener("pagehide", leave); + return () => { + document.removeEventListener("visibilitychange", visibility); + window.removeEventListener("pagehide", leave); + leave(); + }; + }, [viewerId, session.csrfToken]); + const runtime = useQuery({ queryKey: ["platform-runtime"], queryFn: ({ signal }) => { + const refresh = scanOnOpen.current; + scanOnOpen.current = false; + return request(`/api/platform/runtime?viewer=${viewerId}${refresh ? "&refreshStorage=1" : ""}`, { signal }); + }, enabled: visible, refetchInterval: 2000, refetchOnMount: "always", refetchIntervalInBackground: false }); const data = runtime.data; const stale = Boolean(runtime.error || (data && now - data.sampledAt > 10000)); return
-

DSP resources

CPU and RAM refresh every 2 seconds. Storage and backups refresh every minute.

+

DSP resources

CPU and RAM refresh every 2 seconds while this page is visible. Storage and backups are checked when you open it.

@@ -60,7 +89,7 @@ export function DspResources() {

Measured {age(data.sampledAt, now)} · Host storage available: {bytes(data.storageAvailableBytes)}{stale ? " · Showing last received measurements" : ""}

{data.runtimes.map(item => { const storage = item.storage, backup = storage?.backups; - const storageStale = storage?.status === "stale" || (storage?.sampledAt != null && now - storage.sampledAt > 120000); + const storageStale = storage?.status === "stale"; return

{item.name}

{item.status}
diff --git a/core/dashboard/tests/browser/diagnostics-resources.spec.cjs b/core/dashboard/tests/browser/diagnostics-resources.spec.cjs index f31006b..120fdf4 100644 --- a/core/dashboard/tests/browser/diagnostics-resources.spec.cjs +++ b/core/dashboard/tests/browser/diagnostics-resources.spec.cjs @@ -1,11 +1,11 @@ const {test,expect}=require('@playwright/test'); const {createPreview}=require('../../examples/independent-updates-preview'); -let app,view; +let app,view,opens=0,polls=0,closes=0; test.beforeAll(async()=>{ view={enabled:true,storageAvailableBytes:100*1024**3,runtimes:[{reference:'synthetic-dev',name:'Dev DSP',status:'connected',cpuPercent:12.5,memoryBytes:256*1024**2,tasks:12,activeWorkers:1, storage:{status:'ready',sampledAt:Date.now(),limited:true,usedBytes:2*1024**3,capacityBytes:10*1024**3,availableBytes:8*1024**3,runtimeBytes:20*1024**2,dataBytes:1024**3,pluginBytes:40*1024**2,logBytes:1024,localBackupBytes:1024**3, backups:{available:true,count:6,bytes:1024**3,manual:2,updates:3,plugins:1,lastAt:'2026-01-01T00:00:00Z'}}}]}; - app=await createPreview({automatic:false,platformRuntime:()=>({...view,sampledAt:Date.now()})}); + app=await createPreview({automatic:false,platformRuntime:(options={})=>{if(options.closeViewer){closes++;return {closed:true};}polls++;if(options.refreshStorage)opens++;return {...view,sampledAt:Date.now()};}}); }); test.afterAll(async()=>{await app?.close();}); test('owner sees live DSP resources, backup breakdown and stale storage on desktop and mobile',async({page},info)=>{ @@ -15,13 +15,27 @@ test('owner sees live DSP resources, backup breakdown and stale storage on deskt const card=page.getByRole('article',{name:'Dev DSP resources'}); await expect(card).toContainText('12.5%');await expect(card).toContainText('256.0 MiB');await expect(card).toContainText('2 manual · 3 update · 1 plugin rollback'); await page.screenshot({path:info.outputPath('resources-desktop.png'),fullPage:true}); + let initialOpens=opens;expect(initialOpens).toBeGreaterThan(0); view.runtimes[0].cpuPercent=87.3;view.runtimes[0].memoryBytes=512*1024**2; - await expect(card).toContainText('87.3%',{timeout:8000});await expect(card).toContainText('512.0 MiB'); + await expect(card).toContainText('87.3%',{timeout:8000});await expect(card).toContainText('512.0 MiB');expect(opens).toBe(initialOpens); + const beforeHide=closes; + await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,get:()=>true});document.dispatchEvent(new Event('visibilitychange'));}); + await expect.poll(()=>closes).toBeGreaterThan(beforeHide); + const paused=polls;await page.waitForTimeout(2500);expect(polls).toBe(paused); + await page.evaluate(()=>{delete document.hidden;document.dispatchEvent(new Event('visibilitychange'));}); + await expect.poll(()=>opens).toBe(initialOpens+1);initialOpens++; + view.runtimes[0].storage.sampledAt=Date.now()-24*60*60*1000; + await expect(card).toContainText('1440m ago',{timeout:8000});await expect(card).not.toContainText('Stale measurement'); view.runtimes[0].storage.status='stale';view.runtimes[0].cpuPercent=null;view.runtimes[0].memoryBytes=null; await expect(card).toContainText('Stale measurement',{timeout:8000});await expect(card).toContainText('Measuring / unavailable');await expect(card).toContainText('Unavailable'); await page.setViewportSize({width:390,height:844});await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); await page.screenshot({path:info.outputPath('resources-mobile.png'),fullPage:true}); - await page.route('**/api/platform/runtime',route=>route.fulfill({json:{ok:true,status:'found',data:{...view,sampledAt:Date.now()-20000},error:null}})); + const beforeClose=closes; + await page.goto(`${app.url}/#/platform`); + await expect.poll(()=>closes).toBeGreaterThan(beforeClose); + const stopped=polls;await page.waitForTimeout(2500);expect(polls).toBe(stopped); + await page.goto(`${app.url}/#/diagnostics`);await expect.poll(()=>opens).toBe(initialOpens+1); + await page.route('**/api/platform/runtime?**',route=>route.fulfill({json:{ok:true,status:'found',data:{...view,sampledAt:Date.now()-20000},error:null}})); await expect(page.getByRole('status').filter({hasText:'Live updates interrupted'})).toBeVisible({timeout:8000});expect(errors).toEqual([]); }); @@ -32,5 +46,11 @@ test('runtime endpoint rejects DSP members and the owner’s scoped DSP view',as const organization=app.access.platformOrganizations(app.owner.session)[0]; const scoped=app.access.beginDspView(app.owner.session,{controlRef:organization.controlRef}); expect((await request.get(endpoint,{headers:{Cookie:'dispatch_session='+app.owner.token,'X-Dispatch-DSP-View':scoped.dspView.viewRef}})).status()).toBe(403); - expect((await request.get(endpoint,{headers:{Cookie:'dispatch_session='+app.owner.token}})).status()).toBe(200); + const headers={Cookie:'dispatch_session='+app.owner.token}; + expect((await request.get(endpoint,{headers})).status()).toBe(200); + expect((await request.get(endpoint+'?refreshStorage=1',{headers})).status()).toBe(400); + const viewerId='aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + expect((await request.get(endpoint+'?viewer='+viewerId+'&refreshStorage=1&refreshStorage=1',{headers})).status()).toBe(400); + expect((await request.post(endpoint,{headers,data:{viewerId,action:'close'}})).status()).toBe(403); + expect((await request.post(endpoint,{headers:{...headers,'X-Dispatch-CSRF':app.owner.session.csrfToken},data:{viewerId,action:'close'}})).status()).toBe(200); }); diff --git a/core/host/capacity/README.md b/core/host/capacity/README.md index 36c4b46..3939caf 100644 --- a/core/host/capacity/README.md +++ b/core/host/capacity/README.md @@ -9,7 +9,14 @@ RAM, CPU and tasks include the DSP runtime plus isolated plugin and authenticati browser workers identified by Core's registries. Shared Core processes and shared dashboard assets are excluded; worker memory is not compared to the runtime limit. -Storage scans run asynchronously at most once per minute while requested. They +Storage scans run asynchronously when Diagnostics opens (`refreshStorage=1`). +Normal CPU/RAM polling only reads cached storage; elapsed time never starts a scan. +Concurrent opens share an in-flight scan. Reopening the page starts a fresh scan. +Visible pages renew owner-scoped viewer leases on their two-second polls. Leaving, +hiding or closing the page sends a CSRF-protected close request. Scans stop at the +next metadata checkpoint when the final viewer leaves; lost viewers expire after +seven seconds. CPU/RAM have no background timer, and storage never restarts from +ordinary polls. The browser stops its queries when hidden or unmounted. Scans read file metadata and completed backup manifests, skip symlinks, and have a shared entry/time budget. Managed volume usage comes from statfs; legacy layouts use allocated file bytes. Runtime code outside the volume is reported separately. diff --git a/core/host/capacity/monitor.js b/core/host/capacity/monitor.js index 4c35a8d..a4d9c0d 100644 --- a/core/host/capacity/monitor.js +++ b/core/host/capacity/monitor.js @@ -7,16 +7,24 @@ const sum=(values,key)=>values.some(value=>value[key]===null)?null:values.reduce const zero={memoryBytes:0,memoryLimitBytes:null,tasks:0,cpuPercent:0}; function createDirectoryMonitor({store,manager,paths,execution=null,clock=Date.now,cgroupRoot='/sys/fs/cgroup/system.slice', sampleResources=createResourceSampler(),readWorkers=workerGroups,storageSampler=createStorageSampler({paths}),disk=()=>fs.statfsSync(paths.dsps)}={}){ - let cached=null; - return ()=>{ - const now=clock();if(cached&&now-cached.sampledAt<2000)return cached; + let cached=null,storageAvailableBytes=null; + const viewers=new Map(); + function hasViewers(){ + for(const [key,expires] of viewers)if(expires<=clock())viewers.delete(key); + return viewers.size>0; + } + return ({refreshStorage=false,viewerKey=null,closeViewer=null}={})=>{ + if(closeViewer){viewers.delete(closeViewer);return {closed:true};} + if(!hasViewers()&&viewerKey){sampleResources.reset?.();cached=null;} + if(viewerKey)viewers.set(viewerKey,clock()+7000); + const now=clock();if(!refreshStorage&&cached&&now-cached.sampledAt<2000)return cached; const rows=store.db.prepare(`SELECT i.runtime_key,i.status installation_status,o.name FROM installations i JOIN organizations o ON o.id=i.organization_id WHERE i.backend='directory_service_v1' AND i.status<>'decommissioned' ORDER BY o.created_at,o.id`).all(); - const ids=rows.map(row=>row.runtime_key),workers=readWorkers(paths,ids),storage=storageSampler.read(ids); + const ids=rows.map(row=>row.runtime_key),workers=readWorkers(paths,ids),storage=storageSampler.read(ids,{refresh:refreshStorage,shouldContinue:hasViewers}); const groups=ids.flatMap(id=>[unitName(id),...(workers.groups.get(id)||[])]); const resources=sampleResources(groups.map(name=>path.join(cgroupRoot,name))); - let storageAvailableBytes=null;try{const value=disk();storageAvailableBytes=value.bavail*value.bsize;}catch{} - cached={enabled:true,sampledAt:now,refreshIntervalMs:2000,storageRefreshIntervalMs:60000,storageAvailableBytes, + if(refreshStorage){try{const value=disk();storageAvailableBytes=value.bavail*value.bsize;}catch{storageAvailableBytes=null;}} + cached={enabled:true,sampledAt:now,refreshIntervalMs:2000,storageRefreshMode:'on_open',storageAvailableBytes, resourceScope:'DSP runtime, isolated plugin jobs and browser workers. Shared Core services are excluded.', runtimes:rows.map(row=>{ const id=row.runtime_key,record=manager.journal.record(id),runtime=resources.get(path.join(cgroupRoot,unitName(id))); diff --git a/core/host/capacity/resources.js b/core/host/capacity/resources.js index 74508ea..7d34ee6 100644 --- a/core/host/capacity/resources.js +++ b/core/host/capacity/resources.js @@ -15,7 +15,7 @@ function readGroup(root){ // Each sampler is shared by all dashboard clients. CPU is a delta, not lifetime CPU time. function createResourceSampler({read=readGroup,monotonic=()=>performance.now()}={}){ const previous=new Map(); - return roots=>{ + const sample=roots=>{ const now=monotonic(),values=new Map(); for(const root of new Set(roots)){ const value=read(root),before=previous.get(root); @@ -28,6 +28,8 @@ function createResourceSampler({read=readGroup,monotonic=()=>performance.now()}= for(const root of previous.keys())if(!values.has(root))previous.delete(root); return values; }; + sample.reset=()=>previous.clear(); + return sample; } // Use Core's worker registry, never a tenant-supplied list of another DSP's jobs. function workerGroups(paths,ids){ diff --git a/core/host/capacity/storage.js b/core/host/capacity/storage.js index 8fcb419..e21410c 100644 --- a/core/host/capacity/storage.js +++ b/core/host/capacity/storage.js @@ -13,9 +13,11 @@ async function json(file){ }catch(error){if(error.code==='ENOENT')return null;throw error;}finally{await fd?.close();} } // Metadata only: never read tenant file contents, follow symlinks, or block the API on a recursive scan. +function checkpoint(budget){if(budget.active&&!budget.active())throw Error('scan_cancelled');} async function size(root,budget,logical=false){ let bytes=0;const seen=new Set(); async function walk(file){ + checkpoint(budget); if(++budget.entries>budget.maximum||Date.now()>budget.deadline)throw Error('scan_limit'); let stat;try{stat=await fsp.lstat(file);}catch(error){if(error.code==='ENOENT')return;throw error;} if(stat.isSymbolicLink())return; @@ -34,6 +36,7 @@ async function backupIndex(paths,ids,budget){ const manual=path.join(paths.local,'backups/manual'),updates=path.join(paths.local,'backups/updates/dsp'); for(const [kind,root,pattern,file] of [['manual',manual,/^mbk_[a-f0-9]{32}$/,'manifest.json'],['updates',updates,/^[a-f0-9]{32}$/,'snapshot.json']]){ try{for(const name of await names(root)){ + checkpoint(budget); if(!pattern.test(name))continue; if(kind==='manual'&&await json(path.join(root,name,'.erasing.json')))continue; const record=await json(path.join(root,name,file));if(!record)continue; @@ -49,7 +52,7 @@ async function backupIndex(paths,ids,budget){ if(!Array.isArray(v.roots)||!/^[a-f0-9]{64}$/.test(v.digest))throw Error('invalid_backup'); addBackup(result.get(v.dspId),kind,await size(path.join(root,name),budget,true),record.at); } - }}catch{for(const value of result.values())value.available=false;} + }}catch(error){if(error.message==='scan_cancelled')throw error;for(const value of result.values())value.available=false;} } return result; } @@ -63,8 +66,10 @@ async function measureDsp(paths,id,backups,budget,volumeCheck){ for(const name of areas)breakdown[name]=await size(path.join(root,name),budget); const revisions=path.join(root,'backups/plugin-revisions'); for(const plugin of await names(revisions)){ + checkpoint(budget); if(!/^[a-z][a-z0-9-]{0,63}$/.test(plugin))continue; for(const revision of await names(path.join(revisions,plugin))){ + checkpoint(budget); if(!/^[1-9]\d*$/.test(revision))continue; const record=await json(path.join(revisions,plugin,revision,'snapshot.json'));if(!record)continue; const value=record.value; @@ -78,22 +83,23 @@ async function measureDsp(paths,id,backups,budget,volumeCheck){ usedBytes,runtimeBytes,dataBytes:breakdown.data,pluginBytes:breakdown.plugins,logBytes:breakdown.logs,localBackupBytes:breakdown.backups, backups:{...backups,lastAt:backups.lastAt?new Date(backups.lastAt).toISOString():null}}; } -function createStorageSampler({paths,clock=Date.now,intervalMs=60000,volumeCheck=assertVolumeMounted,maximumEntries=200000}={}){ - let running=null,lastStart=null;const cache=new Map(); - async function refresh(ids){ - const budget={entries:0,maximum:maximumEntries,deadline:Date.now()+30000}; +function createStorageSampler({paths,clock=Date.now,volumeCheck=assertVolumeMounted,maximumEntries=200000}={}){ + let running=null;const cache=new Map(); + async function refresh(ids,active){ + const budget={entries:0,maximum:maximumEntries,deadline:Date.now()+30000,active}; + checkpoint(budget); const index=await backupIndex(paths,ids,budget); for(const id of ids){ - try{cache.set(id,{...await measureDsp(paths,id,index.get(id),budget,volumeCheck),sampledAt:clock(),status:'ready'});} - catch{const prior=cache.get(id);cache.set(id,{...prior,status:prior?.sampledAt?'stale':'unavailable'});} + try{checkpoint(budget);const value=await measureDsp(paths,id,index.get(id),budget,volumeCheck);checkpoint(budget);cache.set(id,{...value,sampledAt:clock(),status:'ready'});} + catch(error){if(error.message==='scan_cancelled')return;const prior=cache.get(id);cache.set(id,{...prior,status:prior?.sampledAt?'stale':'unavailable'});} } for(const id of cache.keys())if(!ids.includes(id))cache.delete(id); } - return {read(ids){ - if(!running&&(lastStart===null||clock()-lastStart>=intervalMs||ids.some(id=>!cache.has(id)))){ - lastStart=clock();running=refresh(ids).catch(()=>{for(const id of ids)cache.set(id,{...cache.get(id),status:cache.get(id)?.sampledAt?'stale':'unavailable'});}).finally(()=>{running=null;}); + return {read(ids,{refresh:requested=false,shouldContinue=()=>true}={}){ + if(requested&&!running){ + running=refresh(ids,shouldContinue).catch(error=>{if(error.message==='scan_cancelled')return;for(const id of ids)cache.set(id,{...cache.get(id),status:cache.get(id)?.sampledAt?'stale':'unavailable'});}).finally(()=>{running=null;}); } - return new Map(ids.map(id=>[id,{...(cache.get(id)||{status:'measuring',sampledAt:null}),refreshing:Boolean(running)}])); + return new Map(ids.map(id=>[id,{...(cache.get(id)||{status:running?'measuring':'unavailable',sampledAt:null}),refreshing:Boolean(running)}])); },settled:()=>running||Promise.resolve()}; } module.exports={createStorageSampler,size,backupIndex}; diff --git a/core/host/capacity/tests/resources.test.js b/core/host/capacity/tests/resources.test.js index 8b21550..3116c25 100644 --- a/core/host/capacity/tests/resources.test.js +++ b/core/host/capacity/tests/resources.test.js @@ -11,6 +11,7 @@ test('CPU uses monotonic deltas and resets on cgroup recreation, counter reset o const sample=createResourceSampler({read:()=>value,monotonic:()=>now}); assert.equal(sample(['group']).get('group').cpuPercent,null); now=2000;value.cpuUsage+=3000000;assert.equal(sample(['group']).get('group').cpuPercent,150); + sample.reset();now=3000;assert.equal(sample(['group']).get('group').cpuPercent,null); now=4000;value.identity='two';assert.equal(sample(['group']).get('group').cpuPercent,null); now=6000;value.cpuUsage=1;assert.equal(sample(['group']).get('group').cpuPercent,null); value=null;assert.equal(sample(['group']).get('group'),null); @@ -42,3 +43,14 @@ test('monitor aggregates runtime and isolated workers, caches samples, handles s assert.equal(JSON.stringify(first).includes(id),false);assert.equal(monitor(),first);assert.equal(calls,1); now+=2000;available=false;assert.equal(monitor().runtimes[0].memoryBytes,null); }); + +test('last viewer exit or heartbeat expiry cancels storage without further CPU reads',()=>{ + let now=1000,checks=0,active; + const monitor=createDirectoryMonitor({paths:{},clock:()=>now,store:{db:{prepare:()=>({all:()=>[]})}},manager:{}, + readWorkers:()=>({available:true,groups:new Map()}),sampleResources:()=>{checks++;return new Map();},disk:()=>({bavail:1,bsize:1}), + storageSampler:{read:(_ids,options)=>{if(options.refresh)active=options.shouldContinue;return new Map();}}}); + monitor({viewerKey:'owner:a',refreshStorage:true});assert.equal(active(),true); + monitor({viewerKey:'owner:b'});monitor({closeViewer:'owner:a'});assert.equal(active(),true);assert.equal(checks,1); + monitor({closeViewer:'owner:b'});assert.equal(active(),false);assert.equal(checks,1); + monitor({viewerKey:'owner:c',refreshStorage:true});assert.equal(active(),true);now+=7001;assert.equal(active(),false);assert.equal(checks,2); +}); diff --git a/core/host/capacity/tests/storage.test.js b/core/host/capacity/tests/storage.test.js index ca8024c..ff2eeb9 100644 --- a/core/host/capacity/tests/storage.test.js +++ b/core/host/capacity/tests/storage.test.js @@ -15,13 +15,22 @@ test('cached storage counts completed DSP backups, excludes neighbors and symlin write(path.join(dsp,'backups/plugin-revisions/paycom/1/snapshot.json'),{schemaVersion:1,pluginId:'paycom',revision:1,files:[{size:9}]}); write(path.join(root,'outside'),'x'.repeat(100000));fs.symlinkSync(path.join(root,'outside'),path.join(dsp,'data/link')); const sampler=createStorageSampler({paths,clock:()=>now,volumeCheck:()=>{if(fail)throw Error('mount_missing');return null;}}); - assert.equal(sampler.read([id,peer]).get(id).status,'measuring');await sampler.settled(); + assert.equal(sampler.read([id,peer],{refresh:true}).get(id).status,'measuring');await sampler.settled(); const first=sampler.read([id,peer]).get(id);assert.equal(first.status,'ready');assert.equal(first.backups.count,3);assert.equal(first.backups.manual,1);assert.equal(first.backups.updates,1);assert.equal(first.backups.plugins,1);assert.equal(sampler.read([id,peer]).get(peer).backups.count,0); assert.equal(first.dataBytes,fs.statSync(path.join(dsp,'data/example')).blocks*512); write(path.join(dsp,'data/new'),'x'.repeat(10000));assert.equal(sampler.read([id]).get(id).usedBytes,first.usedBytes); - now+=60000;fail=true;sampler.read([id]);await sampler.settled();const stale=sampler.read([id]).get(id);assert.equal(stale.status,'stale');assert.equal(stale.usedBytes,first.usedBytes);assert.equal(stale.sampledAt,1000); - now+=60000;fail=false;sampler.read([id]);await sampler.settled();assert.ok(sampler.read([id]).get(id).usedBytes>first.usedBytes); + now+=24*60*60*1000;sampler.read([id]);await sampler.settled();assert.equal(sampler.read([id]).get(id).sampledAt,1000); + fail=true;sampler.read([id],{refresh:true});await sampler.settled();const stale=sampler.read([id]).get(id);assert.equal(stale.status,'stale');assert.equal(stale.usedBytes,first.usedBytes);assert.equal(stale.sampledAt,1000); + now+=60000;fail=false;sampler.read([id],{refresh:true});await sampler.settled();assert.ok(sampler.read([id]).get(id).usedBytes>first.usedBytes); }); test('missing DSP storage and exhausted scan budgets remain unavailable rather than zero',async t=>{ - const {paths}=setup(t);const sampler=createStorageSampler({paths,maximumEntries:0,volumeCheck:()=>null});sampler.read([id]);await sampler.settled();const view=sampler.read([id]).get(id);assert.equal(view.status,'unavailable');assert.equal(view.usedBytes,undefined); + const {paths}=setup(t);const sampler=createStorageSampler({paths,maximumEntries:0,volumeCheck:()=>null});sampler.read([id],{refresh:true});await sampler.settled();const view=sampler.read([id]).get(id);assert.equal(view.status,'unavailable');assert.equal(view.usedBytes,undefined); +}); + +test('cancels storage when no viewers remain and does not restart on a poll',async t=>{ + const {paths}=setup(t);let active=true,visits=0; + const sampler=createStorageSampler({paths,volumeCheck:()=>{visits++;active=false;return null;}}); + sampler.read([id,peer],{refresh:true,shouldContinue:()=>active});await sampler.settled(); + assert.equal(visits,1);assert.equal(sampler.read([id]).get(id).sampledAt,null);assert.equal(sampler.read([peer]).get(peer).refreshing,false); + await sampler.settled();assert.equal(visits,1); });