diff --git a/src/services/hal/backends/radio/providers/openwrt/init.lua b/src/services/hal/backends/radio/providers/openwrt/init.lua index 8269631a..55fe8a39 100644 --- a/src/services/hal/backends/radio/providers/openwrt/init.lua +++ b/src/services/hal/backends/radio/providers/openwrt/init.lua @@ -1,15 +1,36 @@ -local impl = require "services.hal.backends.radio.providers.openwrt.impl" local file = require "fibers.io.file" ----Check whether OpenWrt UCI is available on this device. +local function file_exists(path) + local f, _ = file.open(path, 'r') + if not f then return false end + f:close() + return true +end + +local function path_has_command(name) + local path = os.getenv('PATH') or '' + for dir in path:gmatch('[^:]+') do + if file_exists(dir .. '/' .. name) then + return true + end + end + return false +end + +local backend = {} + +function backend.new(...) + local impl = require "services.hal.backends.radio.providers.openwrt.impl" + return impl.new(...) +end + +---Check whether OpenWrt UCI and iw are available on this device. ---@return boolean local function is_supported() - local f, _ = file.open('/etc/openwrt_release', 'r') - if f then f:close() return true end - return false + return file_exists('/etc/openwrt_release') and path_has_command('iw') end return { is_supported = is_supported, - backend = impl, + backend = backend, } diff --git a/src/services/hal/drivers/radio.lua b/src/services/hal/drivers/radio.lua index 03b30813..c88c6a5c 100644 --- a/src/services/hal/drivers/radio.lua +++ b/src/services/hal/drivers/radio.lua @@ -461,7 +461,12 @@ function RadioDriver:stats_loop() local connected = {} local interfaces_set = {} - backend:start_client_monitor() + local ok, err = backend:start_client_monitor() + + if not ok then + self.log:error({ what = 'radio_stats_loop_failed', id = id, err = err }) + return + end fibers.current_scope():finally(function() if backend and backend.terminate then @@ -486,7 +491,7 @@ function RadioDriver:stats_loop() end while true do - local name, val = fibers.perform(fibers.named_choice({ + local name, val, choice_err = fibers.perform(fibers.named_choice({ client_event = backend:watch_clients_op(), iface_update = self.iface_update_ch:get_op(), tick = sleep.sleep_op(report_period), @@ -501,7 +506,14 @@ function RadioDriver:stats_loop() elseif name == 'iface_update' then update_interfaces(val.op, val.name) elseif name == 'client_event' then - if val and interfaces_set[val.interface] then + if not val then + self.log:error({ + what = 'radio_stats_loop_failed', + id = id, + err = choice_err or 'client monitor stopped', + }) + break + elseif interfaces_set[val.interface] then on_client_event(emit_ch, id, connected, val) end elseif name == 'tick' then diff --git a/tests/integration/openwrt_vm/Makefile b/tests/integration/openwrt_vm/Makefile index 1a7cbe75..b1c2ca50 100644 --- a/tests/integration/openwrt_vm/Makefile +++ b/tests/integration/openwrt_vm/Makefile @@ -1,6 +1,6 @@ .PHONY: \ preflight fetch verify reset run wait provision provision-force ensure-mwan3 \ - stop ssh smoke logs render-default-configs print-default-configs baseline test-baseline test-tc-veth test-lua-uci test-devicecode-uci-manager test-devicecode-uci-manager-async-activation test-devicecode-openwrt-no-blocking-os-io test-devicecode-exec-containment test-openwrt-uart-stty-coreutils test-openwrt-network-provider-apply test-openwrt-network-provider-async-activation test-openwrt-network-provider-fw4-schema test-openwrt-network-provider-snapshot test-openwrt-network-provider-live-snapshot test-openwrt-network-observer-event-ingress test-openwrt-network-provider-vlan-mwan-shaping test-openwrt-network-provider-mwan-live-weights test-openwrt-network-provider-mwan-live-weights-fast test-openwrt-vm-generated-configs-expected test-openwrt-vm-mwan-connected test-openwrt-network-provider-segment-trunk test-openwrt-jan-client-per-host-shaping test-openwrt-shaping-idempotent-apply test-openwrt-segment-shaping-modes test-openwrt-wan-mark-shaping-contract test-openwrt-wan-mark-upload-exemption test-openwrt-wan-mark-download-capability test-openwrt-shaping-fast test-openwrt-shaping-clean test-devicecode-wired-static-provider test-devicecode-full-stack-mock-hal test-devicecode-bigbox-phase1-composition test-devicecode-bigbox-phase1-broken-trunk test-openwrt-jan-client-dhcp-dns test-openwrt-int-bridge-client-dhcp-dns test-openwrt-dnsmasq-multi-instance-resilience \ + stop ssh smoke logs render-default-configs print-default-configs baseline test-baseline test-tc-veth test-lua-uci test-devicecode-uci-manager test-devicecode-uci-manager-async-activation test-devicecode-openwrt-no-blocking-os-io test-devicecode-exec-containment test-devicecode-radio-missing-iw-no-hammer test-openwrt-uart-stty-coreutils test-openwrt-network-provider-apply test-openwrt-network-provider-async-activation test-openwrt-network-provider-fw4-schema test-openwrt-network-provider-snapshot test-openwrt-network-provider-live-snapshot test-openwrt-network-observer-event-ingress test-openwrt-network-provider-vlan-mwan-shaping test-openwrt-network-provider-mwan-live-weights test-openwrt-network-provider-mwan-live-weights-fast test-openwrt-vm-generated-configs-expected test-openwrt-vm-mwan-connected test-openwrt-network-provider-segment-trunk test-openwrt-jan-client-per-host-shaping test-openwrt-shaping-idempotent-apply test-openwrt-segment-shaping-modes test-openwrt-wan-mark-shaping-contract test-openwrt-wan-mark-upload-exemption test-openwrt-wan-mark-download-capability test-openwrt-shaping-fast test-openwrt-shaping-clean test-devicecode-wired-static-provider test-devicecode-full-stack-mock-hal test-devicecode-bigbox-phase1-composition test-devicecode-bigbox-phase1-broken-trunk test-openwrt-jan-client-dhcp-dns test-openwrt-int-bridge-client-dhcp-dns test-openwrt-dnsmasq-multi-instance-resilience \ setup-bridge-client-fabric teardown-bridge-client-fabric \ network-lab-fetch network-lab-start network-lab-wait network-lab-provision network-lab-sync network-lab-ssh network-lab-stop network-lab-test test-network-lab \ test openwrt-vm-test clean @@ -78,6 +78,10 @@ test-devicecode-uci-manager-async-activation: test-devicecode-openwrt-no-blocking-os-io: ./tests/test_devicecode_openwrt_no_blocking_os_io.sh +test-devicecode-radio-missing-iw-no-hammer: + ./scripts/wait-ssh + ./tests/test_devicecode_radio_missing_iw_no_hammer.sh + test-openwrt-uart-stty-coreutils: ./scripts/wait-ssh ./tests/test_openwrt_uart_stty_coreutils.sh @@ -269,7 +273,7 @@ test-scp: grep -q devicecode-openwrt-vm "$$tmp.out"; \ rm -f "$$tmp" "$$tmp.out" -test: test-baseline test-tc-veth test-lua-uci test-devicecode-uci-manager test-devicecode-uci-manager-async-activation test-devicecode-openwrt-no-blocking-os-io test-devicecode-exec-containment test-openwrt-uart-stty-coreutils test-openwrt-network-provider-apply test-openwrt-network-provider-async-activation test-openwrt-network-provider-fw4-schema test-openwrt-network-provider-snapshot test-openwrt-network-provider-live-snapshot test-openwrt-network-observer-event-ingress test-openwrt-network-provider-vlan-mwan-shaping test-openwrt-network-provider-mwan-live-weights test-openwrt-vm-generated-configs-expected test-openwrt-vm-mwan-connected test-openwrt-network-provider-segment-trunk test-openwrt-jan-client-per-host-shaping test-openwrt-shaping-idempotent-apply test-openwrt-segment-shaping-modes test-openwrt-wan-mark-shaping-contract test-openwrt-wan-mark-upload-exemption test-openwrt-wan-mark-download-capability test-devicecode-wired-static-provider test-devicecode-full-stack-mock-hal test-devicecode-bigbox-phase1-composition test-devicecode-bigbox-phase1-broken-trunk test-scp +test: test-baseline test-tc-veth test-lua-uci test-devicecode-uci-manager test-devicecode-uci-manager-async-activation test-devicecode-openwrt-no-blocking-os-io test-devicecode-exec-containment test-devicecode-radio-missing-iw-no-hammer test-openwrt-uart-stty-coreutils test-openwrt-network-provider-apply test-openwrt-network-provider-async-activation test-openwrt-network-provider-fw4-schema test-openwrt-network-provider-snapshot test-openwrt-network-provider-live-snapshot test-openwrt-network-observer-event-ingress test-openwrt-network-provider-vlan-mwan-shaping test-openwrt-network-provider-mwan-live-weights test-openwrt-vm-generated-configs-expected test-openwrt-vm-mwan-connected test-openwrt-network-provider-segment-trunk test-openwrt-jan-client-per-host-shaping test-openwrt-shaping-idempotent-apply test-openwrt-segment-shaping-modes test-openwrt-wan-mark-shaping-contract test-openwrt-wan-mark-upload-exemption test-openwrt-wan-mark-download-capability test-devicecode-wired-static-provider test-devicecode-full-stack-mock-hal test-devicecode-bigbox-phase1-composition test-devicecode-bigbox-phase1-broken-trunk test-scp openwrt-vm-test: preflight fetch verify stop reset run wait provision test @@ -280,4 +284,3 @@ clean: test-devicecode-exec-containment: @echo "==> devicecode exec containment" @$(RUN) tests/test_devicecode_exec_containment.sh - diff --git a/tests/integration/openwrt_vm/tests/test_devicecode_radio_missing_iw_no_hammer.sh b/tests/integration/openwrt_vm/tests/test_devicecode_radio_missing_iw_no_hammer.sh new file mode 100755 index 00000000..d684d6ca --- /dev/null +++ b/tests/integration/openwrt_vm/tests/test_devicecode_radio_missing_iw_no_hammer.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +VM_DIR="$(dirname "$SCRIPT_DIR")" +ROOT_DIR="$(CDPATH= cd -- "$VM_DIR/../../.." && pwd)" +SSH="$VM_DIR/scripts/ssh" +SCP_TO="$VM_DIR/scripts/scp-to" +REMOTE="/tmp/devicecode-radio-missing-iw-test" +WORK="$VM_DIR/work/radio-missing-iw-test" + +mkdir -p "$WORK" + +cat > "$WORK/run_devicecode_radio_missing_iw.lua" <<'LUA' +package.path = table.concat({ + './src/?.lua', './src/?/init.lua', + './vendor/lua-fibers/src/?.lua', './vendor/lua-fibers/src/?/init.lua', + './vendor/lua-bus/src/?.lua', './vendor/lua-bus/src/?/init.lua', + './vendor/lua-trie/src/?.lua', './vendor/lua-trie/src/?/init.lua', + package.path, +}, ';') + +local fibers = require 'fibers' +local provider = require 'services.hal.backends.radio.provider' + +local function fail(msg) error(msg, 2) end +local function eq(a, b, msg) + if a ~= b then + fail((msg or 'values differ') .. ': expected ' .. tostring(b) .. ', got ' .. tostring(a)) + end +end + +fibers.run(function() + local backend, err = provider.new('radio0') + + eq(backend, nil, 'radio backend should not be selected without iw in PATH') + assert(tostring(err or ''):match('no supported radio backend'), 'unexpected provider error: ' .. tostring(err)) + eq(package.loaded['services.hal.backends.radio.providers.openwrt.impl'], nil, + 'unsupported radio provider should not load the OpenWrt implementation') + print('devicecode radio missing iw no hammer: ok') +end) +LUA + +"$SSH" "rm -rf '$REMOTE'; mkdir -p '$REMOTE'" +"$SSH" "mkdir -p '$REMOTE/src/services/hal/backends/radio/providers/openwrt' '$REMOTE/vendor/lua-fibers'" +"$SCP_TO" "$ROOT_DIR/src/services/hal/backends/radio/provider.lua" "$REMOTE/src/services/hal/backends/radio/provider.lua" +"$SCP_TO" "$ROOT_DIR/src/services/hal/backends/radio/contract.lua" "$REMOTE/src/services/hal/backends/radio/contract.lua" +"$SCP_TO" "$ROOT_DIR/src/services/hal/backends/radio/providers/openwrt/init.lua" "$REMOTE/src/services/hal/backends/radio/providers/openwrt/init.lua" +"$SCP_TO" "$ROOT_DIR/vendor/lua-fibers/src" "$REMOTE/vendor/lua-fibers/src" +"$SCP_TO" "$WORK/run_devicecode_radio_missing_iw.lua" "$REMOTE/run_devicecode_radio_missing_iw.lua" +"$SSH" "mkdir -p /tmp/devicecode-no-iw-path && lua_bin=\"\$(command -v lua)\" && cd '$REMOTE' && PATH=/tmp/devicecode-no-iw-path \"\$lua_bin\" ./run_devicecode_radio_missing_iw.lua" diff --git a/tests/run.lua b/tests/run.lua index c6ee9e24..9e4862e9 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -46,6 +46,7 @@ local files = { "unit.hal.uart_driver_spec", "unit.hal.uart_manager_spec", "unit.hal.network_manager_spec", + "unit.hal.radio_driver_spec", "unit.hal.modem_linux_mm_spec", "unit.hal.modem_qmi_spec", "unit.hal.openwrt_network_observer_spec", diff --git a/tests/unit/hal/radio_driver_spec.lua b/tests/unit/hal/radio_driver_spec.lua new file mode 100644 index 00000000..6caea0bb --- /dev/null +++ b/tests/unit/hal/radio_driver_spec.lua @@ -0,0 +1,113 @@ +-- tests/unit/hal/radio_driver_spec.lua + +local tests = {} + +local function eq(a, b, msg) + if a ~= b then + error((msg or 'assertion failed') .. ': expected ' .. tostring(b) .. ', got ' .. tostring(a), 2) + end +end + +function tests.test_stats_loop_stops_when_client_monitor_cannot_start() + local channel = require 'fibers.channel' + local runfibers = require 'tests.support.run_fibers' + local radio = require 'services.hal.drivers.radio' + + runfibers.run(function () + local start_calls = 0 + local watch_calls = 0 + local errors = {} + + local backend = { + start_client_monitor = function () + start_calls = start_calls + 1 + return false, 'failed to start iw event: iw not found' + end, + + watch_clients_op = function () + watch_calls = watch_calls + 1 + error('watch_clients_op should not be called after monitor startup failure', 2) + end, + } + + local driver = setmetatable({ + id = 'radio0', + cap_emit_ch = channel.new(4), + iface_update_ch = channel.new(4), + report_period_ch = channel.new(1), + backend = backend, + log = { + error = function (_, row) + errors[#errors + 1] = row + end, + debug = function () end, + }, + }, radio.Driver) + + driver:stats_loop() + + eq(start_calls, 1, 'client monitor should only be started once') + eq(watch_calls, 0, 'stats loop should not watch clients after monitor startup failure') + eq(#errors, 1, 'startup failure should be logged once') + eq(errors[1].what, 'radio_stats_loop_failed', 'failure log event') + eq(errors[1].id, 'radio0', 'failure log id') + end, { timeout = 0.1 }) +end + +function tests.test_stats_loop_stops_when_client_monitor_stream_closes() + local channel = require 'fibers.channel' + local op = require 'fibers.op' + local runfibers = require 'tests.support.run_fibers' + local radio = require 'services.hal.drivers.radio' + + runfibers.run(function () + local start_calls = 0 + local watch_calls = 0 + local stop_calls = 0 + local errors = {} + + local backend = { + start_client_monitor = function () + start_calls = start_calls + 1 + return true, '' + end, + + watch_clients_op = function () + watch_calls = watch_calls + 1 + return op.always(nil, 'iw event stream closed') + end, + + stop_client_monitor_op = function () + stop_calls = stop_calls + 1 + return op.always(true, '') + end, + + terminate = function () return true, nil end, + } + + local driver = setmetatable({ + id = 'radio0', + cap_emit_ch = channel.new(4), + iface_update_ch = channel.new(4), + report_period_ch = channel.new(1), + backend = backend, + log = { + error = function (_, row) + errors[#errors + 1] = row + end, + debug = function () end, + }, + }, radio.Driver) + + driver:stats_loop() + + eq(start_calls, 1, 'client monitor should only be started once') + eq(watch_calls, 1, 'closed monitor stream should only be watched once') + eq(stop_calls, 1, 'started monitor should be stopped after stream failure') + eq(#errors, 1, 'stream failure should be logged once') + eq(errors[1].what, 'radio_stats_loop_failed', 'failure log event') + eq(errors[1].err, 'iw event stream closed', 'failure log err') + end, { timeout = 0.1 }) +end + +return tests