From 8e53b04e4ba2053bcdb51f844bc89a17bb81ffce Mon Sep 17 00:00:00 2001 From: boburning Date: Sun, 30 Aug 2026 17:40:53 -0400 Subject: [PATCH 1/2] feat(network): consume metadata connectivity events --- build/dependencies.lock | 8 +- build/legacy-manifest.json | 21 +++- docs/ARCHITECTURE.md | 13 +- src/bloomLibrary/bloom_library_jobs.c | 61 ++++++---- src/bloomLibrary/bloom_library_jobs.h | 4 + src/bloomLibrary/main.c | 26 +++- static/build/.tmp_update/bin/bloom-dev-deploy | 2 + .../.tmp_update/bin/bloom-network-metadata | 94 +++++++++++++++ static/build/.tmp_update/bin/bloom-wifi | 6 +- test/shell/dev_deploy.bats | 1 + test/shell/network_metadata.bats | 112 ++++++++++++++++++ test/shell/wifi_backend.bats | 24 +++- test/test_bloom_library_jobs.cpp | 39 ++++++ tools/dev-deploy-manifest.py | 1 + tools/legacy_manifest.py | 1 + 15 files changed, 375 insertions(+), 38 deletions(-) create mode 100755 static/build/.tmp_update/bin/bloom-network-metadata create mode 100644 test/shell/network_metadata.bats diff --git a/build/dependencies.lock b/build/dependencies.lock index 4b079836f..98a577d0b 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -207,10 +207,10 @@ legacy_channels = ["development"] [legacy_inventory] manifest = "build/legacy-manifest.json" schema = 1 -component_count = 332 -file_count = 2131 -byte_count = 314694727 -source_build_components = 228 +component_count = 333 +file_count = 2132 +byte_count = 314698725 +source_build_components = 229 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index fc168de0d..d316c6b79 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -488,8 +488,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-dev-deploy", "file_count": 1, - "byte_count": 32634, - "tree_sha256": "14aa71a938a0545d2e747efc3e7eecea17973404034098b488c9fd613c86ac18", + "byte_count": 32728, + "tree_sha256": "fc439fab7129c7c67ad7c4a3cc7e910f27bebc08097ffe0eb7f33c4b9d7ec0bd", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -613,6 +613,19 @@ "build_recipe": "Makefile", "resolution": "source-build" }, + { + "id": "runtime-tmp-update-bin-bloom-network-metadata", + "kind": "runtime", + "path": "static/build/.tmp_update/bin/bloom-network-metadata", + "file_count": 1, + "byte_count": 3711, + "tree_sha256": "4fabe59df76cb5a9a3d9efa39e0cd5a8570b0dd84b94eaca880da5cb60a8dfdc", + "source": "https://github.com/boburning/BloomOS", + "source_revision": "release-commit", + "license": "GPL-3.0-only", + "build_recipe": "Makefile", + "resolution": "source-build" + }, { "id": "runtime-tmp-update-bin-bloom-network-services", "kind": "runtime", @@ -982,8 +995,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-wifi", "file_count": 1, - "byte_count": 10100, - "tree_sha256": "fbb865dbb7c93af5a1ad4b4a36a4098450d076db3bc9f2699bf7df28fa1adf94", + "byte_count": 10293, + "tree_sha256": "11378a22655246e12060c3c87ce2c7a7ef0ee105dddd8a1c70d52dabf46e4adc", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0175f9c5d..ee787a00d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -969,7 +969,18 @@ DHCP recovery or fresh station start, `associated` only after both carrier and global IPv4 are present, and `offline` after radio shutdown. Preserving an already-healthy station is an idempotent associated publication. Notification failure is deliberately secondary: it cannot restart or roll back a healthy -station. Individual client consumption remains the next integration step. +station. + +`bloom-network-metadata` is the first fixed consumer. It validates the complete +schema-1 metadata event, asks `bloom-library` to apply only that associated or +offline observation to its durable job scheduler, and acknowledges only the +same exact generation after a valid scheduler result. The scheduler moves only +due network-required work between `waiting-network` and its runnable state; +local, future-deadline, leased, completed, failed, and cancelled work is not +claimed or run by this boundary. Invalid event or scheduler output, a busy or +failed database, and acknowledgement failure leave the generation pending for +a later idempotent reconciliation. Delivery remains secondary to Wi-Fi health. +Update and sync consumption remain separate follow-up integration steps. Bounded `request enable` and `request disable` operations persist only `device.wifi_enabled` through the active `bloom-settings` authority before diff --git a/src/bloomLibrary/bloom_library_jobs.c b/src/bloomLibrary/bloom_library_jobs.c index ad77f369e..d10b451ec 100644 --- a/src/bloomLibrary/bloom_library_jobs.c +++ b/src/bloomLibrary/bloom_library_jobs.c @@ -521,6 +521,41 @@ int bloom_library_job_recover_expired(sqlite3 *database, const char *owner, cons return finish(database, result); } +static int network_observe_locked(sqlite3 *database, sqlite3_int64 now, + int network_available, int *changed) +{ + const char *sql = network_available + ? "UPDATE metadata_jobs SET state='queued',updated_at=?1 WHERE " + "requires_network=1 AND state='waiting-network' AND " + "next_attempt_at<=?1" + : "UPDATE metadata_jobs SET state='waiting-network',updated_at=?1 " + "WHERE requires_network=1 AND state IN('queued','retry-wait') AND " + "next_attempt_at<=?1"; + sqlite3_stmt *statement = NULL; + int result = sqlite3_prepare_v2(database, sql, -1, &statement, NULL); + if (result == SQLITE_OK) + result = sqlite3_bind_int64(statement, 1, now); + if (result == SQLITE_OK) + result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK : sqlite3_errcode(database); + if (result == SQLITE_OK && changed != NULL) + *changed = sqlite3_changes(database); + sqlite3_finalize(statement); + return result; +} + +int bloom_library_job_network_observe(sqlite3 *database, sqlite3_int64 now, + int network_available, int *changed) +{ + if (database == NULL || changed == NULL || now < 0 || + (network_available != 0 && network_available != 1)) + return SQLITE_MISUSE; + *changed = 0; + int result = begin(database); + if (result == SQLITE_OK) + result = network_observe_locked(database, now, network_available, changed); + return finish(database, result); +} + int bloom_library_job_claim(sqlite3 *database, const char *owner, const char *token, sqlite3_int64 now, int network_available, int lease_seconds, BloomLibraryJob *job, int *found) @@ -537,30 +572,8 @@ int bloom_library_job_claim(sqlite3 *database, const char *owner, const char *to if (result == SQLITE_OK) result = writer_valid(database, owner, token, now, &writer_expires); sqlite3_stmt *statement = NULL; - if (result == SQLITE_OK && network_available) - result = sqlite3_prepare_v2( - database, - "UPDATE metadata_jobs SET state='queued',updated_at=?1 WHERE " - "state='waiting-network' AND next_attempt_at<=?1", - -1, &statement, NULL); - if (result == SQLITE_OK && network_available) - result = sqlite3_bind_int64(statement, 1, now); - if (result == SQLITE_OK && network_available) - result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK : sqlite3_errcode(database); - sqlite3_finalize(statement); - statement = NULL; - if (result == SQLITE_OK && !network_available) - result = sqlite3_prepare_v2( - database, - "UPDATE metadata_jobs SET state='waiting-network',updated_at=?1 WHERE " - "requires_network=1 AND state IN('queued','retry-wait') AND next_attempt_at<=?1", - -1, &statement, NULL); - if (result == SQLITE_OK && !network_available) - result = sqlite3_bind_int64(statement, 1, now); - if (result == SQLITE_OK && !network_available) - result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK : sqlite3_errcode(database); - sqlite3_finalize(statement); - statement = NULL; + if (result == SQLITE_OK) + result = network_observe_locked(database, now, network_available, NULL); sqlite3_int64 job_id = 0; if (result == SQLITE_OK) result = sqlite3_prepare_v2( diff --git a/src/bloomLibrary/bloom_library_jobs.h b/src/bloomLibrary/bloom_library_jobs.h index 5653fcfdd..37e1a80d9 100644 --- a/src/bloomLibrary/bloom_library_jobs.h +++ b/src/bloomLibrary/bloom_library_jobs.h @@ -73,6 +73,10 @@ int bloom_library_job_review_list(sqlite3 *database, sqlite3_int64 after_job_id, int bloom_library_job_retry(sqlite3 *database, sqlite3_int64 job_id, sqlite3_int64 now, int *changed); +/* Applies one exact connectivity observation without claiming or running work. */ +int bloom_library_job_network_observe(sqlite3 *database, sqlite3_int64 now, + int network_available, int *changed); + int bloom_library_job_writer_acquire(sqlite3 *database, const char *owner, const char *token, sqlite3_int64 now, int lease_seconds, int *acquired); int bloom_library_job_writer_renew(sqlite3 *database, const char *owner, const char *token, diff --git a/src/bloomLibrary/main.c b/src/bloomLibrary/main.c index 8ac93ff1d..52fbbf962 100644 --- a/src/bloomLibrary/main.c +++ b/src/bloomLibrary/main.c @@ -797,6 +797,9 @@ int main(int argc, char **argv) int review_request = argc >= 2 && strcmp(argv[1], "review") == 0; int retry_request = argc >= 2 && strcmp(argv[1], "retry") == 0; int storage_policy_request = argc >= 2 && strcmp(argv[1], "storage-policy") == 0; + int network_observe_request = + argc == 3 && strcmp(argv[1], "network-observe") == 0 && + (strcmp(argv[2], "associated") == 0 || strcmp(argv[2], "offline") == 0); int import_command = argc == 2 && strcmp(argv[1], "import-onion") == 0; int import_legacy = argc == 2 && strcmp(argv[1], "import-legacy") == 0; int import_gamelist = argc == 6 && strcmp(argv[1], "import-gamelist") == 0 && @@ -816,9 +819,9 @@ int main(int argc, char **argv) int mutate_favorite = argc >= 2 && strcmp(argv[1], "favorite") == 0; int mutate_recent = argc >= 2 && strcmp(argv[1], "recent") == 0; if (!status_command && !operations_request && !review_request && !retry_request && - !storage_policy_request && !import_command && !import_legacy && !import_gamelist && - !scrape_local && !artwork_local_all && !artwork_local_system && !scan_all && - !scan_system && !query_games && !mutate_favorite && !mutate_recent) { + !storage_policy_request && !network_observe_request && !import_command && !import_legacy && + !import_gamelist && !scrape_local && !artwork_local_all && !artwork_local_system && + !scan_all && !scan_system && !query_games && !mutate_favorite && !mutate_recent) { fprintf(stderr, "Usage: bloom-library status|operations|review --limit N [--after JOB_ID]|" "retry JOB_ID|storage-policy --quota-mib N --reserve-mib N|" @@ -826,6 +829,7 @@ int main(int argc, char **argv) "--changed|--all|--system SYSTEM|games --limit N [--after GAME_ID]|games " "--system SYSTEM --limit N [--after GAME_ID]|favorite set GAME_ID true|false|" "recent record GAME_ID|import-gamelist --system SYSTEM --file FILE|" + "network-observe associated|offline|" "scrape-missing --local --system SYSTEM|" "artwork-fallback --libretro-local [--system SYSTEM]|" "fingerprint-benchmark --root ROOT --path RELATIVE --buffer BYTES|" @@ -844,6 +848,22 @@ int main(int argc, char **argv) fprintf(stderr, "{\"schema\":1,\"error\":{\"code\":\"library_unavailable\"}}\n"); return 1; } + if (network_observe_request) { + int changed = 0; + int available = strcmp(argv[2], "associated") == 0; + int result = bloom_library_job_network_observe(database, current_time_seconds(), available, + &changed); + sqlite3_close(database); + if (result != SQLITE_OK) { + fprintf(stderr, + "{\"schema\":1,\"error\":{\"code\":\"network_observation_failed\"}}\n"); + return 1; + } + printf("{\"schema\":1,\"service\":\"bloom-library\",\"operation\":" + "\"network-observe\",\"connectivity\":\"%s\",\"jobs_changed\":%d}\n", + argv[2], changed); + return 0; + } if (import_command) { BloomLibraryImportResult imported; char error[160] = {0}; diff --git a/static/build/.tmp_update/bin/bloom-dev-deploy b/static/build/.tmp_update/bin/bloom-dev-deploy index 757901211..12476a3b0 100755 --- a/static/build/.tmp_update/bin/bloom-dev-deploy +++ b/static/build/.tmp_update/bin/bloom-dev-deploy @@ -82,6 +82,7 @@ allowed_path() { launch-session:bin/bloom-ra-session-prepare|\ launch-session:script/bloom-launch-trace.sh) return 0 ;; network-helpers:bin/bloom-settings|network-helpers:bin/bloom-network|\ + network-helpers:bin/bloom-network-metadata|\ network-helpers:bin/bloom-network-services|network-helpers:bin/bloom-network-transaction|\ network-helpers:bin/bloom-platform|network-helpers:bin/bloom-dev-ssh|\ network-helpers:bin/bloom-sftp-gate|network-helpers:bin/bloom-sftp-server|\ @@ -191,6 +192,7 @@ validate_manifest() { expected_paths="$(printf '%s\n' \ bin/bloom-settings \ bin/bloom-network \ + bin/bloom-network-metadata \ bin/bloom-network-services \ bin/bloom-network-transaction \ bin/bloom-platform \ diff --git a/static/build/.tmp_update/bin/bloom-network-metadata b/static/build/.tmp_update/bin/bloom-network-metadata new file mode 100755 index 000000000..1fadb0b43 --- /dev/null +++ b/static/build/.tmp_update/bin/bloom-network-metadata @@ -0,0 +1,94 @@ +#!/bin/sh + +set -u + +SYSTEM_ROOT=${BLOOM_NETWORK_METADATA_SYSTEM_ROOT:-/mnt/SDCARD/.tmp_update} +NETWORK=${BLOOM_NETWORK_METADATA_NETWORK_BIN:-$SYSTEM_ROOT/bin/bloom-network} +LIBRARY=${BLOOM_NETWORK_METADATA_LIBRARY_BIN:-$SYSTEM_ROOT/bin/bloom-library} +JQ=${BLOOM_NETWORK_METADATA_JQ_BIN:-$SYSTEM_ROOT/bin/jq} +LIB_DIR=${BLOOM_NETWORK_METADATA_LIB_DIR:-$SYSTEM_ROOT/lib} + +result() { + printf '{"schema":1,"service":"bloom-network-metadata","applied":%s,"state":"%s"}\n' \ + "$1" "$2" +} + +reconcile() { + [ -x "$NETWORK" ] && [ -x "$LIBRARY" ] && [ -x "$JQ" ] || { + result false dependency_unavailable + return 1 + } + event=$($NETWORK events status metadata 2>/dev/null) || { + result false event_unavailable + return 1 + } + fields=$(printf '%s\n' "$event" | "$JQ" -er ' + if .schema == 1 and .service == "bloom-network-events" and + .operation == "status" and .client == "metadata" and + (.connectivity == "unknown" or .connectivity == "associated" or + .connectivity == "offline") and + (.generation | type) == "number" and .generation >= 0 and + .generation <= 2147483647 and (.generation | floor) == .generation and + (.pending | type) == "boolean" + then "\(.connectivity) \(.generation) \(.pending)" + else error("invalid metadata network event") end + ' 2>/dev/null) || { + result false event_invalid + return 1 + } + set -- $fields + [ "$#" -eq 3 ] || { result false event_invalid; return 1; } + connectivity=$1 + generation=$2 + pending=$3 + if [ "$pending" = false ]; then + result false no_pending_event + return 0 + fi + case "$connectivity:$generation" in + associated:[1-9]*|offline:[1-9]*) ;; + *) result false event_invalid; return 1 ;; + esac + observation=$(LD_LIBRARY_PATH="$LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + "$LIBRARY" network-observe "$connectivity" 2>/dev/null) || { + result false observation_failed + return 1 + } + jobs_changed=$(printf '%s\n' "$observation" | "$JQ" -er \ + 'if .schema == 1 and .service == "bloom-library" and + .operation == "network-observe" and + (.connectivity == "associated" or .connectivity == "offline") and + (.jobs_changed | type) == "number" and .jobs_changed >= 0 and + .jobs_changed <= 2147483647 and (.jobs_changed | floor) == .jobs_changed + then .jobs_changed else error("invalid metadata observation") end' 2>/dev/null) || { + result false observation_invalid + return 1 + } + [ "$(printf '%s\n' "$observation" | "$JQ" -r '.connectivity')" = "$connectivity" ] || { + result false observation_invalid + return 1 + } + acknowledgement=$($NETWORK events ack metadata "$generation" 2>/dev/null) || { + result false acknowledgement_failed + return 1 + } + printf '%s\n' "$acknowledgement" | "$JQ" -e \ + --argjson generation "$generation" --arg connectivity "$connectivity" ' + .schema == 1 and .service == "bloom-network-events" and + .operation == "ack" and .client == "metadata" and + .connectivity == $connectivity and .generation == $generation and + .pending == false + ' >/dev/null 2>&1 || { + result false acknowledgement_invalid + return 1 + } + printf '{"schema":1,"service":"bloom-network-metadata","applied":true,' + printf '"state":"acknowledged","connectivity":"%s","generation":%s,' \ + "$connectivity" "$generation" + printf '"jobs_changed":%s}\n' "$jobs_changed" +} + +case "$#:${1:-}" in + 1:reconcile) reconcile ;; + *) result false invalid_arguments; exit 2 ;; +esac diff --git a/static/build/.tmp_update/bin/bloom-wifi b/static/build/.tmp_update/bin/bloom-wifi index cd097e49f..9c1853c49 100755 --- a/static/build/.tmp_update/bin/bloom-wifi +++ b/static/build/.tmp_update/bin/bloom-wifi @@ -18,6 +18,7 @@ SLEEP=${BLOOM_WIFI_SLEEP_BIN:-sleep} START_SLEEP=${BLOOM_WIFI_START_SLEEP_BIN:-sleep} SERVICES=${BLOOM_WIFI_SERVICES_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-network-services} NETWORK=${BLOOM_WIFI_NETWORK_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-network} +METADATA=${BLOOM_WIFI_METADATA_EVENT_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-network-metadata} NOHUP=${BLOOM_WIFI_NOHUP_BIN:-/usr/bin/nohup} SELF=${BLOOM_WIFI_SELF:-/mnt/SDCARD/.tmp_update/bin/bloom-wifi} INTERFACE=${BLOOM_WIFI_INTERFACE:-wlan0} @@ -55,7 +56,10 @@ require_executable() { [ -x "$1" ]; } notify_connectivity() { [ -x "$NETWORK" ] || return 0 - "$NETWORK" events publish "$1" >/dev/null 2>&1 || true + if "$NETWORK" events publish "$1" >/dev/null 2>&1; then + [ ! -x "$METADATA" ] || "$METADATA" reconcile >/dev/null 2>&1 || true + fi + return 0 } launch_dhcp() { diff --git a/test/shell/dev_deploy.bats b/test/shell/dev_deploy.bats index 2c97e08b0..eccd8dff3 100644 --- a/test/shell/dev_deploy.bats +++ b/test/shell/dev_deploy.bats @@ -159,6 +159,7 @@ stage_network_bundle() { for path in \ bin/bloom-settings \ bin/bloom-network \ + bin/bloom-network-metadata \ bin/bloom-network-services \ bin/bloom-network-transaction \ bin/bloom-platform \ diff --git a/test/shell/network_metadata.bats b/test/shell/network_metadata.bats new file mode 100644 index 000000000..d7607d56e --- /dev/null +++ b/test/shell/network_metadata.bats @@ -0,0 +1,112 @@ +#!/usr/bin/env bats + +setup() { + export SERVICE=/workspace/static/build/.tmp_update/bin/bloom-network-metadata + export BLOOM_NETWORK_METADATA_NETWORK_BIN="$BATS_TEST_TMPDIR/bloom-network" + export BLOOM_NETWORK_METADATA_LIBRARY_BIN="$BATS_TEST_TMPDIR/bloom-library" + export BLOOM_NETWORK_METADATA_JQ_BIN=/usr/bin/jq + export BLOOM_NETWORK_METADATA_LIB_DIR="$BATS_TEST_TMPDIR/lib" + export EVENT_STATUS="$BATS_TEST_TMPDIR/event-status.json" + export ACK_STATUS="$BATS_TEST_TMPDIR/ack-status.json" + export CLIENT_LOG="$BATS_TEST_TMPDIR/client.log" + mkdir "$BLOOM_NETWORK_METADATA_LIB_DIR" + cat >"$BLOOM_NETWORK_METADATA_NETWORK_BIN" <<'SH' +#!/bin/sh +case "$*" in + 'events status metadata') cat "$EVENT_STATUS" ;; + 'events ack metadata '*) + printf '%s\n' "$*" >>"$CLIENT_LOG" + [ ! -e "$ACK_STATUS.exit" ] || exit "$(cat "$ACK_STATUS.exit")" + cat "$ACK_STATUS" + ;; + *) exit 2 ;; +esac +SH + cat >"$BLOOM_NETWORK_METADATA_LIBRARY_BIN" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"$CLIENT_LOG" +[ ! -e "$BLOOM_NETWORK_METADATA_LIBRARY_BIN.exit" ] || + exit "$(cat "$BLOOM_NETWORK_METADATA_LIBRARY_BIN.exit")" +connectivity=${2:-} +printf '{"schema":1,"service":"bloom-library","operation":"network-observe","connectivity":"%s","jobs_changed":2}\n' "$connectivity" +SH + chmod +x "$BLOOM_NETWORK_METADATA_NETWORK_BIN" "$BLOOM_NETWORK_METADATA_LIBRARY_BIN" +} + +write_event() { + connectivity=$1 + generation=$2 + pending=$3 + printf '{"schema":1,"service":"bloom-network-events","operation":"status","client":"metadata","connectivity":"%s","generation":%s,"changed":false,"pending":%s}\n' \ + "$connectivity" "$generation" "$pending" >"$EVENT_STATUS" +} + +write_ack() { + connectivity=$1 + generation=$2 + printf '{"schema":1,"service":"bloom-network-events","operation":"ack","client":"metadata","connectivity":"%s","generation":%s,"changed":false,"pending":false}\n' \ + "$connectivity" "$generation" >"$ACK_STATUS" +} + +@test "metadata event consumer treats an absent pending generation as a no-op" { + write_event unknown 0 false + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [ "$output" = '{"schema":1,"service":"bloom-network-metadata","applied":false,"state":"no_pending_event"}' ] + [ ! -e "$CLIENT_LOG" ] +} + +@test "metadata event consumer observes association before exact acknowledgement" { + write_event associated 7 true + write_ack associated 7 + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"state":"acknowledged","connectivity":"associated","generation":7,"jobs_changed":2'* ]] + [ "$(sed -n '1p' "$CLIENT_LOG")" = 'network-observe associated' ] + [ "$(sed -n '2p' "$CLIENT_LOG")" = 'events ack metadata 7' ] +} + +@test "metadata event consumer observes offline state before exact acknowledgement" { + write_event offline 8 true + write_ack offline 8 + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"connectivity":"offline","generation":8,"jobs_changed":2'* ]] + [ "$(sed -n '1p' "$CLIENT_LOG")" = 'network-observe offline' ] + [ "$(sed -n '2p' "$CLIENT_LOG")" = 'events ack metadata 8' ] +} + +@test "metadata event consumer leaves generation pending when observation fails" { + write_event associated 9 true + write_ack associated 9 + printf 1 >"$BLOOM_NETWORK_METADATA_LIBRARY_BIN.exit" + run "$SERVICE" reconcile + [ "$status" -eq 1 ] + [[ "$output" == *'"state":"observation_failed"'* ]] + [ "$(cat "$CLIENT_LOG")" = 'network-observe associated' ] +} + +@test "metadata event consumer rejects malformed authority output without mutation" { + printf '%s\n' '{"schema":1,"client":"metadata","connectivity":"associated","generation":1,"pending":true}' >"$EVENT_STATUS" + run "$SERVICE" reconcile + [ "$status" -eq 1 ] + [[ "$output" == *'"state":"event_invalid"'* ]] + [ ! -e "$CLIENT_LOG" ] +} + +@test "metadata event consumer leaves generation pending when acknowledgement fails" { + write_event associated 10 true + write_ack associated 10 + printf 1 >"$ACK_STATUS.exit" + run "$SERVICE" reconcile + [ "$status" -eq 1 ] + [[ "$output" == *'"state":"acknowledgement_failed"'* ]] + [ "$(sed -n '1p' "$CLIENT_LOG")" = 'network-observe associated' ] + [ "$(sed -n '2p' "$CLIENT_LOG")" = 'events ack metadata 10' ] +} + +@test "metadata event consumer rejects arbitrary operations" { + run "$SERVICE" arbitrary + [ "$status" -eq 2 ] + [[ "$output" == *'"state":"invalid_arguments"'* ]] +} diff --git a/test/shell/wifi_backend.bats b/test/shell/wifi_backend.bats index 8a95c6eec..d48a53b2f 100644 --- a/test/shell/wifi_backend.bats +++ b/test/shell/wifi_backend.bats @@ -18,12 +18,14 @@ setup() { export BLOOM_WIFI_START_SLEEP_BIN=/bin/sleep export BLOOM_WIFI_SERVICES_BIN="$BATS_TEST_TMPDIR/network-services" export BLOOM_WIFI_NETWORK_BIN="$BATS_TEST_TMPDIR/bloom-network" + export BLOOM_WIFI_METADATA_EVENT_BIN="$BATS_TEST_TMPDIR/bloom-network-metadata" export BLOOM_WIFI_NOHUP_BIN=/usr/bin/nohup export BLOOM_WIFI_SELF="$SERVICE" export BLOOM_WIFI_WATCH_DIR="$BATS_TEST_TMPDIR/watcher" export BLOOM_WIFI_PROC_ROOT=/proc export WIFI_LOG="$BATS_TEST_TMPDIR/wifi.log" export EVENT_LOG="$BATS_TEST_TMPDIR/events.log" + export METADATA_LOG="$BATS_TEST_TMPDIR/metadata.log" mkdir -p "$BLOOM_WIFI_ROOT/sys/class/net/wlan0" printf down >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" printf 0 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" @@ -60,6 +62,10 @@ SH cat >"$BLOOM_WIFI_NETWORK_BIN" <<'SH' #!/bin/sh printf '%s\n' "$*" >>"$EVENT_LOG" +SH + cat >"$BLOOM_WIFI_METADATA_EVENT_BIN" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"$METADATA_LOG" SH cat >"$BLOOM_WIFI_IP_BIN" <<'SH' #!/bin/sh @@ -67,7 +73,7 @@ SH printf '2: wlan0 inet 192.0.2.8/24 scope global wlan0\n' SH chmod +x "$BLOOM_WIFI_IP_BIN" "$BLOOM_WIFI_SLEEP_BIN" "$BLOOM_WIFI_SERVICES_BIN" \ - "$BLOOM_WIFI_NETWORK_BIN" + "$BLOOM_WIFI_NETWORK_BIN" "$BLOOM_WIFI_METADATA_EVENT_BIN" chmod +x "$BLOOM_PLATFORM_BIN" "$BLOOM_WIFI_JSONVAL_BIN" } @@ -80,6 +86,7 @@ SH [ "$output" = '{"schema":1,"service":"bloom-wifi","supported":true,"applied":false,"state":"preserved"}' ] [ ! -s "$WIFI_LOG" ] [ "$(cat "$EVENT_LOG")" = 'events publish associated' ] + [ "$(cat "$METADATA_LOG")" = reconcile ] } @test "associated Plus without an address restarts canonical DHCP" { @@ -245,6 +252,21 @@ SH [ "$status" -eq 0 ] [[ "$output" == *'"applied":false,"state":"preserved"'* ]] [ ! -s "$WIFI_LOG" ] + [ ! -e "$METADATA_LOG" ] +} + +@test "metadata observation failure never restarts or rolls back healthy Wi-Fi" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + : >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/ipv4" + printf '#!/bin/sh\nexit 1\n' >"$BLOOM_WIFI_METADATA_EVENT_BIN" + chmod +x "$BLOOM_WIFI_METADATA_EVENT_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"applied":false,"state":"preserved"'* ]] + [ ! -s "$WIFI_LOG" ] + [ "$(cat "$EVENT_LOG")" = 'events publish associated' ] } @test "disabling Wi-Fi stops an in-flight address recovery watcher before radio teardown" { diff --git a/test/test_bloom_library_jobs.cpp b/test/test_bloom_library_jobs.cpp index 4353da9cc..d8e4f514e 100644 --- a/test/test_bloom_library_jobs.cpp +++ b/test/test_bloom_library_jobs.cpp @@ -193,6 +193,45 @@ TEST_F(BloomLibraryJobTest, PriorityClaimSkipsNetworkWorkWhileOffline) EXPECT_EQ(remote, job.job_id); } +TEST_F(BloomLibraryJobTest, ConnectivityObservationMovesOnlyDueNetworkWork) +{ + sqlite3_int64 local = enqueue("local-observe", "fingerprint", 0, 20); + sqlite3_int64 due = enqueue("due-network", "provider-refresh", 1, 10); + sqlite3_int64 delayed = enqueue("delayed-network", "provider-refresh", 1, 5); + ASSERT_EQ(SQLITE_OK, + sqlite3_exec(database_, + "UPDATE metadata_jobs SET state='retry-wait',next_attempt_at=110 " + "WHERE job_key='delayed-network'", + nullptr, nullptr, nullptr)); + + int changed = -1; + ASSERT_EQ(SQLITE_OK, bloom_library_job_network_observe(database_, 100, 0, &changed)); + EXPECT_EQ(1, changed); + BloomLibraryJob job{}; + ASSERT_EQ(SQLITE_OK, bloom_library_job_get(database_, local, &job)); + EXPECT_STREQ("queued", job.state); + ASSERT_EQ(SQLITE_OK, bloom_library_job_get(database_, due, &job)); + EXPECT_STREQ("waiting-network", job.state); + ASSERT_EQ(SQLITE_OK, bloom_library_job_get(database_, delayed, &job)); + EXPECT_STREQ("retry-wait", job.state); + + ASSERT_EQ(SQLITE_OK, bloom_library_job_network_observe(database_, 109, 1, &changed)); + EXPECT_EQ(1, changed); + ASSERT_EQ(SQLITE_OK, bloom_library_job_get(database_, due, &job)); + EXPECT_STREQ("queued", job.state); + ASSERT_EQ(SQLITE_OK, bloom_library_job_get(database_, delayed, &job)); + EXPECT_STREQ("retry-wait", job.state); + + ASSERT_EQ(SQLITE_OK, bloom_library_job_network_observe(database_, 110, 1, &changed)); + EXPECT_EQ(0, changed); + ASSERT_EQ(SQLITE_OK, bloom_library_job_network_observe(database_, 110, 0, &changed)); + EXPECT_EQ(2, changed); + ASSERT_EQ(SQLITE_OK, bloom_library_job_network_observe(database_, 110, 1, &changed)); + EXPECT_EQ(2, changed); + EXPECT_EQ(SQLITE_MISUSE, bloom_library_job_network_observe(database_, -1, 1, &changed)); + EXPECT_EQ(SQLITE_MISUSE, bloom_library_job_network_observe(database_, 110, 2, &changed)); +} + TEST_F(BloomLibraryJobTest, CheckpointsAreVersionedBoundedMonotonicAndLeaseOwned) { sqlite3_int64 job_id = enqueue("checkpoint"); diff --git a/tools/dev-deploy-manifest.py b/tools/dev-deploy-manifest.py index 262ed1d8e..3af0f52b6 100755 --- a/tools/dev-deploy-manifest.py +++ b/tools/dev-deploy-manifest.py @@ -44,6 +44,7 @@ "network-helpers": { "bin/bloom-settings", "bin/bloom-network", + "bin/bloom-network-metadata", "bin/bloom-network-services", "bin/bloom-network-transaction", "bin/bloom-platform", diff --git a/tools/legacy_manifest.py b/tools/legacy_manifest.py index 0215499c1..06f80ee03 100644 --- a/tools/legacy_manifest.py +++ b/tools/legacy_manifest.py @@ -60,6 +60,7 @@ "runtime-tmp-update-bin-bloom-lid", "runtime-tmp-update-bin-bloom-listener-audit", "runtime-tmp-update-bin-bloom-network", + "runtime-tmp-update-bin-bloom-network-metadata", "runtime-tmp-update-bin-bloom-network-services", "runtime-tmp-update-bin-bloom-network-transaction", "runtime-tmp-update-bin-bloom-remote-keys", From 4c264c153bfe8c58f1b22ff726c41cc65c2ea614 Mon Sep 17 00:00:00 2001 From: boburning Date: Sun, 30 Aug 2026 17:48:20 -0400 Subject: [PATCH 2/2] test(network): refresh metadata consumer fixtures --- test/shell/legacy_manifest.bats | 4 ++-- test/shell/transfer_policy.bats | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/shell/legacy_manifest.bats b/test/shell/legacy_manifest.bats index 948c7692e..17264267b 100644 --- a/test/shell/legacy_manifest.bats +++ b/test/shell/legacy_manifest.bats @@ -6,7 +6,7 @@ --manifest /workspace/build/legacy-manifest.json [ "$status" -eq 0 ] - [[ "$output" == "legacy manifest validate: 332 components" ]] + [[ "$output" == "legacy manifest validate: 333 components" ]] [ ! -e "/workspace/static/packages/RApp/SNK - Neo Geo (GnGeo)" ] [ ! -e "/workspace/static/packages/RApp/PICO-8 (PICO-8 standalone)" ] [ ! -e "/workspace/static/packages/RApp/SCUMM (ScummVM standalone)" ] @@ -104,7 +104,7 @@ import sys components = json.load(open(sys.argv[1], encoding="utf-8"))["components"] runtime = [item for item in components if item["id"].startswith("runtime-")] bloom = [item for item in runtime if item["source"] == "https://github.com/boburning/BloomOS"] -assert len(bloom) == 55 +assert len(bloom) == 56 assert all(item["resolution"] == "source-build" for item in bloom) assert all(item["source_revision"] == "release-commit" for item in bloom) PY diff --git a/test/shell/transfer_policy.bats b/test/shell/transfer_policy.bats index 42f7528eb..2c7adf63a 100755 --- a/test/shell/transfer_policy.bats +++ b/test/shell/transfer_policy.bats @@ -102,6 +102,7 @@ teardown() { for path in \ bin/bloom-settings \ bin/bloom-network \ + bin/bloom-network-metadata \ bin/bloom-network-services \ bin/bloom-network-transaction \ bin/bloom-platform \ @@ -129,7 +130,7 @@ teardown() { run python3 /workspace/tools/dev-deploy-manifest.py network-helpers "$payload" \ net02-fixture 0123456789abcdef0123456789abcdef01234567 "$manifest" [ "$status" -eq 0 ] || printf '%s\n' "$output" >&3 - [ "$(jq '.files | length' "$manifest")" -eq 17 ] + [ "$(jq '.files | length' "$manifest")" -eq 18 ] rm "$payload/bin/bloom-platform" run python3 /workspace/tools/dev-deploy-manifest.py network-helpers "$payload" \