Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c984fbd49 | ||
|
|
a9a30406df |
@@ -168,7 +168,7 @@ pub(super) async fn read_disk_usage() -> Result<(u64, u64)> {
|
||||
/// Read disk usage via `df` for a given path.
|
||||
pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["--block-size=1", "--output=used,size", path])
|
||||
.args(["--block-size=1", "--output=used,size,avail", path])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run df")?;
|
||||
@@ -189,11 +189,22 @@ pub(super) async fn read_disk_usage_path(path: &str) -> Result<(u64, u64)> {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing used"))?
|
||||
.parse()
|
||||
.context("parse df used")?;
|
||||
let total: u64 = parts
|
||||
// Raw `size` includes the filesystem's root-reserved blocks (5% by default
|
||||
// on ext4 — 92 GiB of this node's 1.8 TiB), which nothing can allocate.
|
||||
// Reporting it as capacity told the dashboard there were 251 GiB free when
|
||||
// only 159 GiB were writable. Callers derive free as total - used, so total
|
||||
// must mean "what can actually be used".
|
||||
let _size: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing size"))?
|
||||
.parse()
|
||||
.context("parse df total")?;
|
||||
.context("parse df size")?;
|
||||
let avail: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing avail"))?
|
||||
.parse()
|
||||
.context("parse df avail")?;
|
||||
let total = used.saturating_add(avail);
|
||||
|
||||
Ok((used, total))
|
||||
}
|
||||
|
||||
@@ -4,9 +4,19 @@
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Parse df output into (used_bytes, total_bytes, used_percent).
|
||||
/// Expects output from `df --block-size=1 --output=used,size /` which has a header line
|
||||
/// followed by a data line with two whitespace-separated numbers.
|
||||
/// Parse df output into (used_bytes, usable_total_bytes, used_percent).
|
||||
/// Expects `df --block-size=1 --output=used,size,avail <path>`: a header line
|
||||
/// followed by used, size and avail.
|
||||
///
|
||||
/// `size` is deliberately NOT the denominator. ext4 reserves 5% of the
|
||||
/// filesystem for root — 92 GiB on archi-dev-box's 1.8 TiB disk — which `size`
|
||||
/// counts but no ordinary process can ever allocate. Dividing by `size`
|
||||
/// under-reports usage by about five points: on 2026-08-22 that disk was
|
||||
/// genuinely 90.8% full (159 GiB usable left) while this returned 86.2%, so the
|
||||
/// 90% auto-cleanup below had never once fired and ~72 GB of dangling images
|
||||
/// had accumulated. It also meant the dashboard advertised 251 GiB free when
|
||||
/// only 159 GiB could actually be written. used/(used+avail) is what `df`
|
||||
/// itself prints and what the operator can actually spend.
|
||||
fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
|
||||
let data_line = stdout
|
||||
.lines()
|
||||
@@ -18,11 +28,19 @@ fn parse_df_output(stdout: &str) -> Result<(u64, u64, f64)> {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing used"))?
|
||||
.parse()
|
||||
.context("parse df used")?;
|
||||
let total: u64 = parts
|
||||
// Parsed to keep the column contract explicit, then intentionally unused —
|
||||
// see the note above on why raw size is the wrong denominator.
|
||||
let _size: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing total"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing size"))?
|
||||
.parse()
|
||||
.context("parse df total")?;
|
||||
.context("parse df size")?;
|
||||
let avail: u64 = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing avail"))?
|
||||
.parse()
|
||||
.context("parse df avail")?;
|
||||
let total = used.saturating_add(avail);
|
||||
|
||||
let percent = if total > 0 {
|
||||
(used as f64 / total as f64) * 100.0
|
||||
@@ -44,7 +62,7 @@ pub async fn check_disk_usage() -> Result<(u64, u64, f64)> {
|
||||
"/"
|
||||
};
|
||||
let output = tokio::process::Command::new("df")
|
||||
.args(["--block-size=1", "--output=used,size", data_path])
|
||||
.args(["--block-size=1", "--output=used,size,avail", data_path])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run df")?;
|
||||
@@ -257,8 +275,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_normal() {
|
||||
// Simulates typical df --block-size=1 --output=used,size / output
|
||||
let output = " Used Size\n 500000000000 1000000000000\n";
|
||||
// df --block-size=1 --output=used,size,avail : used, size, avail
|
||||
let output = " Used Size Avail\n 500000000000 1000000000000 500000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 500_000_000_000);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
@@ -267,16 +285,35 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_high_usage() {
|
||||
let output = " Used Size\n 900000000000 1000000000000\n";
|
||||
let output = " Used Size Avail\n 900000000000 1000000000000 100000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 900_000_000_000);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
assert!((percent - 90.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// The bug this function existed to hide: reserved blocks are counted by
|
||||
/// `size` but are not available to anyone. Real numbers from archi-dev-box,
|
||||
/// 2026-08-22 — 1.8 TiB disk, ext4 5% reserve, genuinely 90.8% full. The old
|
||||
/// used/size math returned 86.2%, so the 90% auto-cleanup never triggered.
|
||||
#[test]
|
||||
fn reserved_blocks_are_not_counted_as_free() {
|
||||
let output = "Used Size Avail\n1681459122176 1951249276928 170581372928\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 1_681_459_122_176);
|
||||
// Total is what can actually be written, not the raw device size.
|
||||
assert_eq!(total, 1_852_040_495_104);
|
||||
assert!(
|
||||
total < 1_951_249_276_928,
|
||||
"raw size must not be the denominator"
|
||||
);
|
||||
assert!((percent - 90.8).abs() < 0.1, "got {percent}");
|
||||
assert!(percent >= 90.0, "must cross the auto-cleanup threshold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_almost_full() {
|
||||
let output = "Used Size\n999 1000\n";
|
||||
let output = "Used Size Avail\n999 1000 1\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 999);
|
||||
assert_eq!(total, 1000);
|
||||
@@ -285,7 +322,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_empty_disk() {
|
||||
let output = "Used Size\n0 1000000000000\n";
|
||||
let output = "Used Size Avail\n0 1000000000000 1000000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 0);
|
||||
assert_eq!(total, 1_000_000_000_000);
|
||||
@@ -295,7 +332,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_parse_df_output_zero_total() {
|
||||
// Edge case: total is 0 (should not happen but should not panic/divide-by-zero)
|
||||
let output = "Used Size\n0 0\n";
|
||||
let output = "Used Size Avail\n0 0 0\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 0);
|
||||
assert_eq!(total, 0);
|
||||
@@ -338,21 +375,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_extra_whitespace() {
|
||||
let output = " Used Size \n 123456 7890000 \n";
|
||||
let output = " Used Size Avail \n 123456 7890000 7766544 \n";
|
||||
let (used, total, _) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 123456);
|
||||
assert_eq!(total, 7890000);
|
||||
assert_eq!(total, 7_890_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_df_output_real_world_format() {
|
||||
// Closer to real df output with header padding
|
||||
let output = " Used Size\n 328000000000 1800000000000\n";
|
||||
// Real df output carries a reserved-block gap: size here is 1.8 TB but
|
||||
// only 1.382 TB is available, so usable total is used + avail.
|
||||
let output = " Used Size Avail\n 328000000000 1800000000000 1382000000000\n";
|
||||
let (used, total, percent) = parse_df_output(output).unwrap();
|
||||
assert_eq!(used, 328_000_000_000);
|
||||
assert_eq!(total, 1_800_000_000_000);
|
||||
// ~18.2%
|
||||
assert!(percent > 18.0 && percent < 19.0);
|
||||
assert_eq!(total, 1_710_000_000_000);
|
||||
// ~19.2% against usable space, not 18.2% against the raw device.
|
||||
assert!(percent > 19.0 && percent < 20.0, "got {percent}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
// Controllable doubles shared between the hoisted block and the mock
|
||||
// factories. Plain holders — each test writes to them before importing the
|
||||
// composable under a fresh module registry (the watcher keeps a module-level
|
||||
// `firedThisSession` session guard, so every case needs its own module).
|
||||
const state = vi.hoisted(() => ({
|
||||
packages: {} as Record<string, unknown>,
|
||||
goalStatus: 'in-progress',
|
||||
goalProgress: {} as Record<string, { completedSteps: string[] }>,
|
||||
toastAction: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
// Re-bound every time the useBitcoinSync factory is (re)evaluated; holds the
|
||||
// exact refs the freshly imported composable watches.
|
||||
syncRefs: null as null | { synced: { value: boolean }; loaded: { value: boolean } },
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useBitcoinSync', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const synced = ref(false)
|
||||
const loaded = ref(false)
|
||||
state.syncRefs = { synced, loaded }
|
||||
return {
|
||||
bitcoinSynced: synced,
|
||||
bitcoinSyncLoaded: loaded,
|
||||
acquireBitcoinSync: () => () => {},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/goals', () => ({
|
||||
useGoalStore: () => ({
|
||||
getGoalStatus: () => state.goalStatus,
|
||||
progress: state.goalProgress,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
get packages() {
|
||||
return state.packages
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({ action: state.toastAction }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: state.routerPush }),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Fresh module registry → fresh `firedThisSession`, then mount the composable
|
||||
* inside a real component so its watchers live in a proper effect scope.
|
||||
*/
|
||||
async function mountWatcher() {
|
||||
vi.resetModules()
|
||||
const { useIbdFinishWatcher } = await import('../useIbdFinishWatcher')
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
useIbdFinishWatcher()
|
||||
return () => null
|
||||
},
|
||||
})
|
||||
return mount(Host)
|
||||
}
|
||||
|
||||
/** Drive a real unsynced→synced transition through the mocked sync refs. */
|
||||
async function completeSync() {
|
||||
const refs = state.syncRefs!
|
||||
refs.loaded.value = true
|
||||
refs.synced.value = false // the watcher must observe unsynced at least once
|
||||
await nextTick()
|
||||
refs.synced.value = true
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('useIbdFinishWatcher', () => {
|
||||
beforeEach(() => {
|
||||
state.packages = {}
|
||||
state.goalStatus = 'in-progress'
|
||||
state.goalProgress = {}
|
||||
state.toastAction.mockClear()
|
||||
state.routerPush.mockClear()
|
||||
})
|
||||
|
||||
it('says to install LND next when Lightning is not installed yet (#143)', async () => {
|
||||
// Bitcoin synced mid-goal, but the goal's install-LND step is still
|
||||
// pending: the on-chain wallet lives in LND, so "fund your wallet" would
|
||||
// promise a flow that cannot work yet.
|
||||
state.packages = { 'bitcoin-knots': { state: 'running' } }
|
||||
const wrapper = await mountWatcher()
|
||||
await completeSync()
|
||||
|
||||
expect(state.toastAction).toHaveBeenCalledTimes(1)
|
||||
const [message, opts] = state.toastAction.mock.calls[0]
|
||||
expect(message).toBe(
|
||||
"Bitcoin is fully synced — next, install Lightning (LND) to get your node's on-chain wallet.",
|
||||
)
|
||||
expect(opts.label).toBe('Finish setup')
|
||||
opts.onClick()
|
||||
// "Finish setup" lands on the goal wizard, whose active step is the
|
||||
// pending install-LND one — the correct next action.
|
||||
expect(state.routerPush).toHaveBeenCalledWith('/dashboard/goals/open-a-shop')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('says to fund the wallet when LND is already installed', async () => {
|
||||
state.packages = { 'bitcoin-knots': { state: 'running' }, lnd: { state: 'running' } }
|
||||
const wrapper = await mountWatcher()
|
||||
await completeSync()
|
||||
|
||||
expect(state.toastAction).toHaveBeenCalledTimes(1)
|
||||
const [message, opts] = state.toastAction.mock.calls[0]
|
||||
expect(message).toBe(
|
||||
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
|
||||
)
|
||||
expect(opts.label).toBe('Finish setup')
|
||||
opts.onClick()
|
||||
expect(state.routerPush).toHaveBeenCalledWith('/dashboard/goals/open-a-shop')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('stays silent when no Lightning goal is in progress', async () => {
|
||||
state.goalStatus = 'not-started'
|
||||
const wrapper = await mountWatcher()
|
||||
await completeSync()
|
||||
|
||||
expect(state.toastAction).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('stays silent when the chain was already synced at page load', async () => {
|
||||
// A node that's already synced never shows unsynced this session, so the
|
||||
// toast must not fire (it only marks real IBD-completion transitions).
|
||||
const wrapper = await mountWatcher()
|
||||
const refs = state.syncRefs!
|
||||
refs.loaded.value = true
|
||||
refs.synced.value = true
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(state.toastAction).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { computed, watch, watchEffect, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
acquireBitcoinSync,
|
||||
@@ -20,6 +21,7 @@ let firedThisSession = false
|
||||
*/
|
||||
export function useIbdFinishWatcher() {
|
||||
const goalStore = useGoalStore()
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -68,8 +70,16 @@ export function useIbdFinishWatcher() {
|
||||
const goalId = pendingLightningGoalId.value
|
||||
if (!goalId) return
|
||||
firedThisSession = true
|
||||
// The on-chain wallet lives in LND, not Bitcoin Core — the address the
|
||||
// fund flow shows comes from `lnd.newaddress`. While the goal's
|
||||
// install-LND step is still pending, "fund your wallet" would point at
|
||||
// something that doesn't exist yet, so the toast names the actual next
|
||||
// step instead (issue #143).
|
||||
const lndInstalled = Object.keys(appStore.packages).includes('lnd')
|
||||
toast.action(
|
||||
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
|
||||
lndInstalled
|
||||
? 'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.'
|
||||
: "Bitcoin is fully synced — next, install Lightning (LND) to get your node's on-chain wallet.",
|
||||
{
|
||||
label: 'Finish setup',
|
||||
onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
|
||||
|
||||
Reference in New Issue
Block a user