From 5e6aee64cf07fee51cc574149ae82607c0940ab2 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 19 Oct 2026 13:17:59 +0200 Subject: [PATCH] i3blocks: leisure_shadow says "no data" when generated_at is missing A daily-limits cache without a numeric generated_at made the age arithmetic hit "null: unbound variable" under set -u: exit 1 and an empty block. The render now floors a numeric generated_at and the script emits the orange "SHADOW no data" fallback for anything else, still exiting 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N --- ...eisure-shadow-generated-at-2026-10-19.json | 36 +++++++++++++++++++ .../i3blocks/leisure_shadow.sh | 7 +++- .../tests/test_i3blocks_leisure_shadow.sh | 16 +++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/evidence/i3blocks-leisure-shadow-generated-at-2026-10-19.json diff --git a/docs/superpowers/evidence/i3blocks-leisure-shadow-generated-at-2026-10-19.json b/docs/superpowers/evidence/i3blocks-leisure-shadow-generated-at-2026-10-19.json new file mode 100644 index 00000000..354d5629 --- /dev/null +++ b/docs/superpowers/evidence/i3blocks-leisure-shadow-generated-at-2026-10-19.json @@ -0,0 +1,36 @@ +{ + "intent": "leisure_shadow.sh always exits 0 and prints the honest '๐Ÿ›‹ SHADOW โฑ? no data' fallback when the daily-limits cache has no usable generated_at, instead of dying with 'null: unbound variable' and an empty i3blocks block.", + "scope": [ + "linux_configuration/i3blocks/leisure_shadow.sh", + "linux_configuration/tests/test_i3blocks_leisure_shadow.sh", + "Non-goal: no change to the cache producer (daily-limits) or the stale threshold" + ], + "changes": [ + "jq renders generated_at floored when numeric, 'none' otherwise; the script emits the orange no-data fallback for any non-integer value before the age arithmetic", + "Test covers generated_at absent, null and a string (exit 0, no-data text, orange) and a fractional timestamp still rendering normally" + ], + "verification": [ + { + "command": "bash linux_configuration/tests/test_i3blocks_leisure_shadow.sh", + "result": "pass", + "evidence": "RED before the fix: 'FAIL: generated_at 'absent' still exits 0 (expected '0', actual '1')'; after: 'All leisure_shadow checks passed.'" + }, + { + "command": "shellcheck linux_configuration/i3blocks/leisure_shadow.sh linux_configuration/tests/test_i3blocks_leisure_shadow.sh", + "result": "pass", + "evidence": "no findings" + }, + { + "command": "DAILY_LIMITS_CACHE= bash linux_configuration/i3blocks/leisure_shadow.sh", + "result": "pass", + "evidence": "printed '๐Ÿ›‹ SHADOW โฑ? no data' / '๐Ÿ›‹ โฑ?' / '#FFB86C', rc=0; against the live cache: '๐Ÿ›‹ SHADOW 6h34 left ยท 0h26 used', rc=0" + } + ], + "risks": [ + "A producer that writes generated_at as a numeric string would now show 'no data' rather than a time; daily-limits writes a JSON number" + ], + "rollback": [ + "git revert the commit", + "Re-run test_i3blocks_leisure_shadow.sh and check the bar block renders" + ] +} diff --git a/linux_configuration/i3blocks/leisure_shadow.sh b/linux_configuration/i3blocks/leisure_shadow.sh index a7e673a6..124634c0 100755 --- a/linux_configuration/i3blocks/leisure_shadow.sh +++ b/linux_configuration/i3blocks/leisure_shadow.sh @@ -39,7 +39,7 @@ render_shadow() { def hm: . as $m | "\($m / 60 | floor)h\($m % 60 | tostring | if length < 2 then "0" + . else . end)"; (.leisure_shadow // {}) as $s - | .generated_at, + | (.generated_at | if type == "number" then floor else "none" end), (if $s.used_minutes == null or $s.budget_minutes == null then "๐Ÿ›‹ SHADOW โฑ? no data", "๐Ÿ›‹ โฑ?", $orange elif $s.would_block or ($s.left_minutes // 0) <= 0 then @@ -83,6 +83,11 @@ if [[ -n ${NOW_EPOCH:-} ]]; then else printf -v now '%(%s)T' -1 fi +# A cache without a numeric generated_at cannot be dated: say so, never crash. +if [[ ! $generated_at =~ ^-?[0-9]+$ ]]; then + emit '๐Ÿ›‹ SHADOW โฑ? no data' '๐Ÿ›‹ โฑ?' "$ORANGE" + exit 0 +fi age=$((now - generated_at)) if ((age > STALE_AFTER_SECONDS || age < -STALE_AFTER_SECONDS)); then emit "๐Ÿ›‹ SHADOW โฑ? stale $((age / 60))m" '๐Ÿ›‹ โฑ?' "$ORANGE" diff --git a/linux_configuration/tests/test_i3blocks_leisure_shadow.sh b/linux_configuration/tests/test_i3blocks_leisure_shadow.sh index f04b273f..590fc392 100755 --- a/linux_configuration/tests/test_i3blocks_leisure_shadow.sh +++ b/linux_configuration/tests/test_i3blocks_leisure_shadow.sh @@ -100,6 +100,22 @@ assert_eq '๐Ÿ›‹ SHADOW โฑ? no data' "$(line 1)" 'a cache without leisure_shadow rm -f "$CACHE" assert_eq '๐Ÿ›‹ SHADOW โฑ? no data' "$(line 1)" 'missing cache' +printf 'Checking a cache without a usable generated_at says no data, exit 0...\n' +for generated in '' '"generated_at":null,' '"generated_at":"soon",'; do + printf '{%s"leisure_shadow":{"budget_minutes":300,"used_minutes":60,"left_minutes":240,"would_block":false}}\n' \ + "$generated" >"$CACHE" + rm -f "$RENDER" + set +e + output=$(run_block 2>&1) + status=$? + set -e + assert_eq '0' "$status" "generated_at '${generated:-absent}' still exits 0" + assert_eq '๐Ÿ›‹ SHADOW โฑ? no data' "$(sed -n 1p <<<"$output")" "generated_at '${generated:-absent}'" + assert_eq '#FFB86C' "$(sed -n 3p <<<"$output")" "generated_at '${generated:-absent}' is orange" +done +write_cache 60 300 "$NOW.75" +assert_eq '๐Ÿ›‹ SHADOW 4h00 left ยท 1h00 used' "$(line 1)" 'a fractional generated_at is a timestamp' + printf 'Checking a stale or garbage cache...\n' write_cache 60 300 "$((NOW - 181))" assert_eq '๐Ÿ›‹ SHADOW โฑ? stale 3m' "$(line 1)" 'older than 180 s'