From a7946b08940728cc0459469127e3b0bd59539032 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Mon, 1 Jun 2026 08:33:07 +0000 Subject: [PATCH 1/5] adds fibers v0.9.2 and bus v0.8.2 --- vendor/lua-bus | 2 +- vendor/lua-fibers | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/lua-bus b/vendor/lua-bus index f241bec9..57aae1bd 160000 --- a/vendor/lua-bus +++ b/vendor/lua-bus @@ -1 +1 @@ -Subproject commit f241bec922b5619b7580b291662d99455f267835 +Subproject commit 57aae1bd5f27248b07f7787a681dc427522e640e diff --git a/vendor/lua-fibers b/vendor/lua-fibers index 00e21dea..34675f72 160000 --- a/vendor/lua-fibers +++ b/vendor/lua-fibers @@ -1 +1 @@ -Subproject commit 00e21deaaa2edf8b5913a46cae5ff5b4501cc147 +Subproject commit 34675f722e6217f6965f0777b8d6675e0f2c2740 From 56d5fc4c29f74630156fa0071a105785bf0cf0f2 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Mon, 1 Jun 2026 10:32:30 +0000 Subject: [PATCH 2/5] updates env file --- .env | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env b/.env index 9ca7df83..7df682e1 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -FIBERS_VER=v0.9.1 +FIBERS_VER=v0.9.2 TRIE_VER=v0.3 -BUS_VER=v0.8.1 +BUS_VER=v0.8.2 UI_VER=a8c5965 From 80dfa3c4ec3387f3dc4375da8a764d7ec8bf40f9 Mon Sep 17 00:00:00 2001 From: Rich Thanki Date: Wed, 3 Jun 2026 11:19:27 +0000 Subject: [PATCH 3/5] instrumented, do not commit --- src/devicecode/main.lua | 3 + .../support/capability_dependencies.lua | 3 +- src/devicecode/support/leak_probe.lua | 667 +++++++ src/devicecode/support/request_owner.lua | 7 + src/devicecode/support/scoped_work.lua | 12 + src/services/device/action_manager.lua | 3 +- src/services/device/availability.lua | 3 +- src/services/device/config.lua | 3 +- src/services/device/observer.lua | 3 +- src/services/device/service.lua | 5 + src/services/hal.lua | 3 +- .../network/providers/openwrt/init.lua | 3 +- .../network/providers/openwrt/mwan3.lua | 3 +- .../network/providers/openwrt/observer.lua | 8 +- .../hal/backends/openwrt/uci_manager.lua | 13 +- src/services/hal/drivers/band.lua | 5 +- src/services/hal/drivers/filesystem.lua | 3 +- src/services/hal/drivers/modem.lua | 3 +- src/services/hal/drivers/radio.lua | 7 +- src/services/hal/managers/network.lua | 3 +- src/services/hal/managers/wired.lua | 5 +- src/services/http/exchange.lua | 5 +- src/services/http/service.lua | 3 +- src/services/http/transport/uri.lua | 5 +- src/services/http/transport/websocket.lua | 5 +- src/services/http/websocket.lua | 5 +- src/services/time.lua | 3 +- src/services/ui/http/listener.lua | 3 +- src/services/ui/http/request.lua | 3 +- src/services/ui/http/routes.lua | 5 +- src/services/ui/update/artifact_ingest.lua | 5 +- src/services/update/backends/component.lua | 3 +- src/services/update/bundled_probe.lua | 3 +- src/services/update/ingest.lua | 11 +- src/services/update/service.lua | 20 + tests/integration/openwrt_vm/Makefile | 14 +- tests/integration/openwrt_vm/env.sh | 12 + .../instrumented_vendor/lua-bus/src/bus.lua | 1675 +++++++++++++++++ .../instrumented_vendor/lua-bus/src/uuid.lua | 223 +++ .../lua-fibers/src/coxpcall.lua | 199 ++ .../lua-fibers/src/fibers.lua | 137 ++ .../lua-fibers/src/fibers/alarm.lua | 254 +++ .../lua-fibers/src/fibers/channel.lua | 193 ++ .../lua-fibers/src/fibers/cond.lua | 63 + .../lua-fibers/src/fibers/io/exec.lua | 740 ++++++++ .../lua-fibers/src/fibers/io/exec_backend.lua | 45 + .../src/fibers/io/exec_backend/core.lua | 175 ++ .../src/fibers/io/exec_backend/nixio.lua | 636 +++++++ .../src/fibers/io/exec_backend/pidfd.lua | 568 ++++++ .../src/fibers/io/exec_backend/sigchld.lua | 492 +++++ .../src/fibers/io/exec_backend/stdio.lua | 233 +++ .../lua-fibers/src/fibers/io/fd_backend.lua | 23 + .../src/fibers/io/fd_backend/core.lua | 304 +++ .../src/fibers/io/fd_backend/ffi.lua | 631 +++++++ .../src/fibers/io/fd_backend/nixio.lua | 666 +++++++ .../src/fibers/io/fd_backend/posix.lua | 485 +++++ .../lua-fibers/src/fibers/io/file.lua | 367 ++++ .../lua-fibers/src/fibers/io/mem_backend.lua | 158 ++ .../lua-fibers/src/fibers/io/poller.lua | 20 + .../lua-fibers/src/fibers/io/poller/core.lua | 183 ++ .../lua-fibers/src/fibers/io/poller/epoll.lua | 354 ++++ .../lua-fibers/src/fibers/io/poller/nixio.lua | 105 ++ .../src/fibers/io/poller/select.lua | 110 ++ .../lua-fibers/src/fibers/io/socket.lua | 486 +++++ .../lua-fibers/src/fibers/io/stream.lua | 1016 ++++++++++ .../lua-fibers/src/fibers/mailbox.lua | 497 +++++ .../lua-fibers/src/fibers/oneshot.lua | 79 + .../lua-fibers/src/fibers/op.lua | 779 ++++++++ .../lua-fibers/src/fibers/performer.lua | 50 + .../lua-fibers/src/fibers/pulse.lua | 196 ++ .../lua-fibers/src/fibers/runtime.lua | 205 ++ .../lua-fibers/src/fibers/sched.lua | 208 ++ .../lua-fibers/src/fibers/scope.lua | 834 ++++++++ .../lua-fibers/src/fibers/sleep.lua | 64 + .../lua-fibers/src/fibers/timer.lua | 221 +++ .../lua-fibers/src/fibers/utils/bytes.lua | 50 + .../lua-fibers/src/fibers/utils/bytes/ffi.lua | 276 +++ .../lua-fibers/src/fibers/utils/bytes/lua.lua | 308 +++ .../lua-fibers/src/fibers/utils/dlist.lua | 76 + .../src/fibers/utils/ffi_compat.lua | 60 + .../lua-fibers/src/fibers/utils/fifo.lua | 56 + .../lua-fibers/src/fibers/utils/time.lua | 34 + .../lua-fibers/src/fibers/utils/time/core.lua | 88 + .../lua-fibers/src/fibers/utils/time/ffi.lua | 167 ++ .../src/fibers/utils/time/linux.lua | 189 ++ .../src/fibers/utils/time/luaposix.lua | 137 ++ .../src/fibers/utils/time/nixio.lua | 176 ++ .../lua-fibers/src/fibers/wait.lua | 354 ++++ .../lua-fibers/src/fibers/waitgroup.lua | 88 + .../instrumented_vendor/lua-trie/src/trie.lua | 326 ++++ .../openwrt_vm/leak_probe_readme.md | 188 ++ .../scripts/ensure-devicecode-lua-http-deps | 745 ++++++++ .../openwrt_vm/scripts/ensure-large-disk | 318 ++++ .../integration/openwrt_vm/scripts/provision | 6 +- .../test_devicecode_leak_probe_full_stack.sh | 348 ++++ 95 files changed, 18484 insertions(+), 50 deletions(-) create mode 100644 src/devicecode/support/leak_probe.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-bus/src/bus.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-bus/src/uuid.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/coxpcall.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/alarm.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/channel.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/cond.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/core.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/nixio.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/pidfd.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/sigchld.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/stdio.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/core.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/ffi.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/nixio.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/posix.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/file.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/mem_backend.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/core.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/epoll.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/nixio.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/select.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/socket.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/stream.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/mailbox.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/oneshot.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/op.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/performer.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/pulse.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/runtime.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/sched.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/scope.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/sleep.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/timer.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/bytes.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/bytes/ffi.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/bytes/lua.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/dlist.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/ffi_compat.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/fifo.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/core.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/ffi.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/linux.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/luaposix.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/nixio.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/wait.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/waitgroup.lua create mode 100644 tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-trie/src/trie.lua create mode 100644 tests/integration/openwrt_vm/leak_probe_readme.md create mode 100755 tests/integration/openwrt_vm/scripts/ensure-devicecode-lua-http-deps create mode 100755 tests/integration/openwrt_vm/scripts/ensure-large-disk create mode 100755 tests/integration/openwrt_vm/tests/test_devicecode_leak_probe_full_stack.sh diff --git a/src/devicecode/main.lua b/src/devicecode/main.lua index 76b9249b..32b82110 100644 --- a/src/devicecode/main.lua +++ b/src/devicecode/main.lua @@ -7,6 +7,7 @@ local op = require 'fibers.op' local sleep = require 'fibers.sleep' local authz = require 'devicecode.authz' local busmod = require 'bus' +local leak_probe = require 'devicecode.support.leak_probe' local safe = require 'coxpcall' @@ -192,6 +193,8 @@ function M.run(scope, params) principal = authz.service_principal('main'), }) + leak_probe.start(scope, { conn = main_conn, note = 'devicecode main' }) + retain_main_state(main_conn, 'starting', { env = env, services = service_names, diff --git a/src/devicecode/support/capability_dependencies.lua b/src/devicecode/support/capability_dependencies.lua index 33b61d3d..4e4bb98b 100644 --- a/src/devicecode/support/capability_dependencies.lua +++ b/src/devicecode/support/capability_dependencies.lua @@ -16,6 +16,7 @@ local bus_cleanup = require 'devicecode.support.bus_cleanup' local queue = require 'devicecode.support.queue' local dep_failure = require 'devicecode.support.dependency_failure' local tablex = require 'shared.table' +local safe = require 'coxpcall' local M = {} local Dependencies = {} @@ -139,7 +140,7 @@ local function subscribe_for(conn, dep, opts) -- connection. A bus connection is needed only for the fallback topic -- subscription path below. if dep.ref ~= nil and type(dep.ref.get_status_sub) == 'function' then - local ok, sub, sub_err = pcall(function () + local ok, sub, sub_err = safe.pcall(function () return dep.ref:get_status_sub({ queue_len = dep.queue_len or opts.status_queue_len or opts.queue_len or 8, full = dep.full or opts.status_full or opts.full or 'drop_oldest', diff --git a/src/devicecode/support/leak_probe.lua b/src/devicecode/support/leak_probe.lua new file mode 100644 index 00000000..bc5f83f3 --- /dev/null +++ b/src/devicecode/support/leak_probe.lua @@ -0,0 +1,667 @@ +-- devicecode/support/leak_probe.lua +-- +-- Opt-in leak instrumentation for VM testing. +-- Enable with DEVICECODE_LEAK_PROBE=1. +-- Optional: +-- DEVICECODE_LEAK_PROBE_INTERVAL=60 +-- DEVICECODE_LEAK_PROBE_FILE=/tmp/devicecode-leak-probe.log +-- DEVICECODE_LEAK_PROBE_TOP=12 +-- DEVICECODE_LEAK_PROBE_BUS=1 +-- +-- The module deliberately stores identifiers and small strings only. It should +-- not retain service objects, scopes, commands, requests, streams or payloads. + +local M = {} + +local function getenv(name) + if os and os.getenv then return os.getenv(name) end +end + +local function truthy(v) + if v == nil or v == '' then return false end + v = tostring(v):lower() + return not (v == '0' or v == 'false' or v == 'no' or v == 'off') +end + +local enabled = truthy(getenv('DEVICECODE_LEAK_PROBE')) +local interval = tonumber(getenv('DEVICECODE_LEAK_PROBE_INTERVAL') or '') or 60 +if interval <= 0 then interval = 60 end +local top_n = tonumber(getenv('DEVICECODE_LEAK_PROBE_TOP') or '') or 10 +if top_n < 1 then top_n = 1 end + +local state = { + enabled = enabled, + started = false, + counters = {}, + gauges = {}, + notes = {}, + scopes = { + live = 0, + created = 0, + records = {}, -- id -> small record only + }, + exec = { + next_id = 0, + live = {}, -- id -> small record only + }, + bus = { + next_id = 0, + buses = {}, + retained = {}, -- topic key -> true + }, + mailboxes = { + next_id = 0, + live = {}, + }, + scoped_work = { + next_id = 0, + live = {}, + }, + request_owner = { + next_id = 0, + live = {}, + }, + ingest = { + instances = {}, + }, +} + +local function now() + local ok, fibers = pcall(require, 'fibers') + if ok and fibers and type(fibers.now) == 'function' then + local ok_now, t = pcall(fibers.now) + if ok_now and type(t) == 'number' then return t end + end + return os.time() +end + +local function bump(tbl, key, delta) + tbl[key] = (tbl[key] or 0) + (delta or 1) +end + +function M.enabled() + return enabled +end + +function M.bump(name, delta) + if not enabled then return end + bump(state.counters, name, delta or 1) +end + +function M.gauge(name, value) + if not enabled then return end + state.gauges[name] = value +end + +function M.note(name, value) + if not enabled then return end + state.notes[name] = tostring(value) +end + +local function string_limit(v, max) + v = tostring(v == nil and '' or v) + max = max or 96 + if #v > max then return v:sub(1, max - 3) .. '...' end + return v +end + +local function argv_label(argv) + if type(argv) ~= 'table' then return '' end + local parts = {} + for i = 1, math.min(#argv, 6) do parts[#parts + 1] = tostring(argv[i]) end + local s = table.concat(parts, ' ') + if #argv > 6 then s = s .. ' ...' end + return string_limit(s, 160) +end + +-- Scope instrumentation ------------------------------------------------------- +function M.scope_created(id, parent_id) + if not enabled then return end + state.scopes.created = state.scopes.created + 1 + state.scopes.live = state.scopes.live + 1 + state.scopes.records[id] = { + id = id, + parent = parent_id, + children = 0, + finalizers = 0, + spawned = 0, + cancelled = false, + closed = false, + join_started = false, + joined = false, + created_at = now(), + } + if parent_id and state.scopes.records[parent_id] then + local p = state.scopes.records[parent_id] + p.children = (p.children or 0) + 1 + end + M.bump('scope.created') +end + +function M.scope_child_detached(parent_id, child_id) + if not enabled then return end + local p = parent_id and state.scopes.records[parent_id] + if p then p.children = math.max(0, (p.children or 0) - 1) end + local ch = child_id and state.scopes.records[child_id] + if ch then ch.parent = nil; ch.detached = true end + M.bump('scope.child_detached') +end + +function M.scope_closed(id, reason) + if not enabled then return end + local r = state.scopes.records[id] + if r then r.closed = true; r.close_reason = string_limit(reason, 80) end + M.bump('scope.closed') +end + +function M.scope_cancelled(id, reason) + if not enabled then return end + local r = state.scopes.records[id] + if r then r.cancelled = true; r.cancel_reason = string_limit(reason, 80) end + M.bump('scope.cancelled') +end + +function M.scope_spawned(id) + if not enabled then return end + local r = state.scopes.records[id] + if r then r.spawned = (r.spawned or 0) + 1 end + M.bump('scope.spawned') +end + +function M.scope_finalizer_added(id) + if not enabled then return end + local r = state.scopes.records[id] + if r then r.finalizers = (r.finalizers or 0) + 1 end + M.bump('scope.finalizer_added') +end + +function M.scope_finalizer_removed(id, why) + if not enabled then return end + local r = state.scopes.records[id] + if r then r.finalizers = math.max(0, (r.finalizers or 0) - 1) end + M.bump('scope.finalizer_removed') + if why then M.bump('scope.finalizer_removed.' .. tostring(why)) end +end + +function M.scope_join_started(id) + if not enabled then return end + local r = state.scopes.records[id] + if r then r.join_started = true end + M.bump('scope.join_started') +end + +function M.scope_join_done(id, status) + if not enabled then return end + local r = state.scopes.records[id] + if r and not r.joined then + r.joined = true + r.status = status + state.scopes.live = math.max(0, state.scopes.live - 1) + state.scopes.records[id] = nil + end + M.bump('scope.join_done') + if status then M.bump('scope.join_done.' .. tostring(status)) end +end + +-- Exec instrumentation -------------------------------------------------------- +function M.exec_next_id() + if not enabled then return nil end + state.exec.next_id = state.exec.next_id + 1 + return state.exec.next_id +end + +function M.exec_created(id, scope_id, argv) + if not (enabled and id) then return end + state.exec.live[id] = { + id = id, + scope = scope_id, + argv = argv_label(argv), + created_at = now(), + started = false, + terminal = false, + cleaned = false, + } + M.bump('exec.created') +end + +function M.exec_started(id, pid) + if not (enabled and id) then return end + local r = state.exec.live[id] + if r then r.started = true; r.pid = pid end + M.bump('exec.started') +end + +function M.exec_exit(id, status, code, signal, err) + if not (enabled and id) then return end + local r = state.exec.live[id] + if r then + r.terminal = true + r.status = status + r.code = code + r.signal = signal + r.err = string_limit(err, 120) + end + M.bump('exec.terminal') + if status then M.bump('exec.terminal.' .. tostring(status)) end +end + +function M.exec_scope_exit(id) + if not (enabled and id) then return end + M.bump('exec.scope_exit_cleanup') +end + +function M.exec_cleaned(id, why) + if not (enabled and id) then return end + local r = state.exec.live[id] + if r then r.cleaned = true; state.exec.live[id] = nil end + M.bump('exec.cleaned') + if why then M.bump('exec.cleaned.' .. tostring(why)) end +end + +-- Bus instrumentation --------------------------------------------------------- +function M.bus_next_id() + if not enabled then return nil end + state.bus.next_id = state.bus.next_id + 1 + return state.bus.next_id +end + +function M.bus_created(id) + if not (enabled and id) then return end + state.bus.buses[id] = { connections = 0, retained = 0 } + M.bump('bus.created') +end + +function M.bus_connection_created(bus_id) + if not enabled then return end + local b = bus_id and state.bus.buses[bus_id] + if b then b.connections = (b.connections or 0) + 1 end + M.bump('bus.connection.created') +end + +function M.bus_connection_closed(bus_id) + if not enabled then return end + local b = bus_id and state.bus.buses[bus_id] + if b then b.connections = math.max(0, (b.connections or 0) - 1) end + M.bump('bus.connection.closed') +end + +function M.bus_feed_created(kind) + if not enabled then return end + M.bump('bus.' .. tostring(kind) .. '.created') + bump(state.gauges, 'bus.' .. tostring(kind) .. '.live', 1) +end + +function M.bus_feed_closed(kind) + if not enabled then return end + M.bump('bus.' .. tostring(kind) .. '.closed') + local k = 'bus.' .. tostring(kind) .. '.live' + state.gauges[k] = math.max(0, (state.gauges[k] or 0) - 1) +end + +function M.bus_retained(bus_id, key, topic) + if not enabled then return end + local k = tostring(bus_id or '?') .. ':' .. tostring(key) + if not state.bus.retained[k] then + state.bus.retained[k] = topic or true + local b = bus_id and state.bus.buses[bus_id] + if b then b.retained = (b.retained or 0) + 1 end + M.bump('bus.retained.new') + end + M.bump('bus.retain') +end + +function M.bus_unretained(bus_id, key) + if not enabled then return end + local k = tostring(bus_id or '?') .. ':' .. tostring(key) + if state.bus.retained[k] then + state.bus.retained[k] = nil + local b = bus_id and state.bus.buses[bus_id] + if b then b.retained = math.max(0, (b.retained or 0) - 1) end + M.bump('bus.retained.removed') + end + M.bump('bus.unretain') +end + +function M.bus_call_started() if enabled then M.bump('bus.call.started') end end +function M.bus_call_resolved(kind) + if enabled then M.bump('bus.call.resolved'); if kind then M.bump('bus.call.resolved.' .. tostring(kind)) end end +end + +-- Mailbox instrumentation ----------------------------------------------------- +function M.mailbox_next_id() + if not enabled then return nil end + state.mailboxes.next_id = state.mailboxes.next_id + 1 + return state.mailboxes.next_id +end + +function M.mailbox_created(id, cap, full) + if not (enabled and id) then return end + state.mailboxes.live[id] = { id = id, cap = cap or 0, full = tostring(full), senders = 1, closed = false, dropped = 0 } + M.bump('mailbox.created') + M.bump('mailbox.created.' .. tostring(full)) +end + +function M.mailbox_sender_cloned(id) + if not (enabled and id) then return end + local r = state.mailboxes.live[id] + if r then r.senders = (r.senders or 0) + 1 end + M.bump('mailbox.sender_cloned') +end + +function M.mailbox_sender_closed(id) + if not (enabled and id) then return end + local r = state.mailboxes.live[id] + if r then r.senders = math.max(0, (r.senders or 0) - 1) end + M.bump('mailbox.sender_closed') +end + +function M.mailbox_closed(id, reason) + if not (enabled and id) then return end + local r = state.mailboxes.live[id] + if r then + r.closed = true + r.reason = string_limit(reason, 80) + state.mailboxes.live[id] = nil + end + M.bump('mailbox.closed') +end + +function M.mailbox_dropped(id, full) + if not enabled then return end + local r = id and state.mailboxes.live[id] + if r then r.dropped = (r.dropped or 0) + 1 end + M.bump('mailbox.dropped') + if full then M.bump('mailbox.dropped.' .. tostring(full)) end +end + +-- devicecode support instrumentation ----------------------------------------- +function M.scoped_work_started(identity) + if not enabled then return nil end + state.scoped_work.next_id = state.scoped_work.next_id + 1 + local id = state.scoped_work.next_id + state.scoped_work.live[id] = { + id = id, + kind = identity and identity.kind or nil, + generation = identity and identity.generation or nil, + service_id = identity and identity.service_id or nil, + request_id = identity and identity.request_id or nil, + created_at = now(), + } + M.bump('scoped_work.started') + if identity and identity.kind then M.bump('scoped_work.started.' .. tostring(identity.kind)) end + return id +end + +function M.scoped_work_body_done(id) + if not (enabled and id) then return end + local r = state.scoped_work.live[id] + if r then r.body_done = true end + M.bump('scoped_work.body_done') +end + +function M.scoped_work_reaped(id, status) + if not (enabled and id) then return end + local r = state.scoped_work.live[id] + if r then r.reaped = true; r.status = status end + M.bump('scoped_work.reaped') + if status then M.bump('scoped_work.reaped.' .. tostring(status)) end +end + +function M.scoped_work_reported(id) + if not (enabled and id) then return end + state.scoped_work.live[id] = nil + M.bump('scoped_work.reported') +end + +function M.scoped_work_cancelled(id, reason) + if not (enabled and id) then return end + local r = state.scoped_work.live[id] + if r then r.cancel_reason = string_limit(reason, 80) end + M.bump('scoped_work.cancelled') +end + +function M.request_owner_created() + if not enabled then return nil end + state.request_owner.next_id = state.request_owner.next_id + 1 + local id = state.request_owner.next_id + state.request_owner.live[id] = { id = id, created_at = now(), done = false } + M.bump('request_owner.created') + return id +end + +function M.request_owner_resolved(id, kind) + if not (enabled and id) then return end + state.request_owner.live[id] = nil + M.bump('request_owner.resolved') + if kind then M.bump('request_owner.resolved.' .. tostring(kind)) end +end + +function M.ingest_instance_created(id) + if not enabled then return end + state.ingest.instances[tostring(id)] = { id = tostring(id), created_at = now(), closed = false } + M.bump('ingest.instance.created') +end + +function M.ingest_instance_closed(id, reason) + if not enabled then return end + local r = state.ingest.instances[tostring(id)] + if r then r.closed = true; r.reason = string_limit(reason, 80) end + M.bump('ingest.instance.closed') +end + +function M.ingest_instance_removed(id) + if not enabled then return end + state.ingest.instances[tostring(id)] = nil + M.bump('ingest.instance.removed') +end + +-- Snapshot/reporting ---------------------------------------------------------- +local function sorted_keys(t) + local keys = {} + for k in pairs(t or {}) do keys[#keys + 1] = k end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + return keys +end + +local function append_kv(parts, k, v) + parts[#parts + 1] = tostring(k) .. '=' .. tostring(v) +end + +local function count_table(t) + local n = 0 + for _ in pairs(t or {}) do n = n + 1 end + return n +end + +local function top_records(records, field, n) + local rows = {} + for _, r in pairs(records or {}) do + if (r[field] or 0) > 0 then rows[#rows + 1] = r end + end + table.sort(rows, function(a, b) return (a[field] or 0) > (b[field] or 0) end) + local out = {} + for i = 1, math.min(n or top_n, #rows) do + local r = rows[i] + out[#out + 1] = ('id:%s.%s:%s.parent:%s.children:%s.join:%s.cancel:%s'):format( + tostring(r.id), tostring(field), tostring(r[field] or 0), tostring(r.parent), tostring(r.children or 0), tostring(r.join_started), tostring(r.cancelled)) + end + return table.concat(out, ',') +end + +local function exec_summary() + local live, terminal_not_cleaned, running, pending = 0, 0, 0, 0 + local samples = {} + for _, r in pairs(state.exec.live) do + live = live + 1 + if r.terminal and not r.cleaned then terminal_not_cleaned = terminal_not_cleaned + 1 end + if r.started and not r.terminal then running = running + 1 end + if not r.started then pending = pending + 1 end + if #samples < top_n then + samples[#samples + 1] = ('id:%s.scope:%s.term:%s.argv:%s'):format(tostring(r.id), tostring(r.scope), tostring(r.terminal), tostring(r.argv)) + end + end + return live, terminal_not_cleaned, running, pending, table.concat(samples, ' | ') +end + +local function scoped_work_summary() + local live, reaped_not_reported, body_not_reaped = 0, 0, 0 + local by_kind = {} + for _, r in pairs(state.scoped_work.live) do + live = live + 1 + if r.reaped then reaped_not_reported = reaped_not_reported + 1 end + if r.body_done and not r.reaped then body_not_reaped = body_not_reaped + 1 end + by_kind[tostring(r.kind or '?')] = (by_kind[tostring(r.kind or '?')] or 0) + 1 + end + local kinds = {} + for _, k in ipairs(sorted_keys(by_kind)) do kinds[#kinds + 1] = k .. ':' .. by_kind[k] end + return live, reaped_not_reported, body_not_reaped, table.concat(kinds, ',') +end + +local function scope_summary() + local finalizers, cancelled, joining, children, closed = 0, 0, 0, 0, 0 + for _, r in pairs(state.scopes.records) do + finalizers = finalizers + (r.finalizers or 0) + children = children + (r.children or 0) + if r.cancelled then cancelled = cancelled + 1 end + if r.closed then closed = closed + 1 end + if r.join_started and not r.joined then joining = joining + 1 end + end + return finalizers, children, cancelled, closed, joining +end + +local function mailbox_summary() + local live, closed, senders, dropped = 0, 0, 0, 0 + for _, r in pairs(state.mailboxes.live) do + live = live + 1 + if r.closed then closed = closed + 1 end + senders = senders + (r.senders or 0) + dropped = dropped + (r.dropped or 0) + end + return live, closed, senders, dropped +end + +local function bus_summary() + local buses, conns, retained = 0, 0, 0 + for _, b in pairs(state.bus.buses) do + buses = buses + 1 + conns = conns + (b.connections or 0) + retained = retained + (b.retained or 0) + end + return buses, conns, retained +end + +local function ingest_summary() + local live, closed = 0, 0 + for _, r in pairs(state.ingest.instances) do + live = live + 1 + if r.closed then closed = closed + 1 end + end + return live, closed +end + +function M.snapshot() + local parts = {} + append_kv(parts, 't', now()) + append_kv(parts, 'mem_kb', string.format('%.1f', collectgarbage('count'))) + + local scope_finalizers, scope_children, scope_cancelled, scope_closed, scope_joining = scope_summary() + append_kv(parts, 'scope_live', state.scopes.live) + append_kv(parts, 'scope_records', count_table(state.scopes.records)) + append_kv(parts, 'scope_children', scope_children) + append_kv(parts, 'scope_finalizers', scope_finalizers) + append_kv(parts, 'scope_cancelled', scope_cancelled) + append_kv(parts, 'scope_closed', scope_closed) + append_kv(parts, 'scope_joining', scope_joining) + + local exec_live, exec_terminal_not_cleaned, exec_running, exec_pending, exec_samples = exec_summary() + append_kv(parts, 'exec_live', exec_live) + append_kv(parts, 'exec_terminal_not_cleaned', exec_terminal_not_cleaned) + append_kv(parts, 'exec_running', exec_running) + append_kv(parts, 'exec_pending', exec_pending) + + local mb_live, mb_closed, mb_senders, mb_dropped = mailbox_summary() + append_kv(parts, 'mailbox_live', mb_live) + append_kv(parts, 'mailbox_closed', mb_closed) + append_kv(parts, 'mailbox_senders', mb_senders) + append_kv(parts, 'mailbox_dropped', mb_dropped) + + local bus_count, bus_conns, bus_retained = bus_summary() + append_kv(parts, 'bus_count', bus_count) + append_kv(parts, 'bus_connections', bus_conns) + append_kv(parts, 'bus_retained', bus_retained) + + local sw_live, sw_reaped, sw_body_not_reaped, sw_kinds = scoped_work_summary() + append_kv(parts, 'scoped_work_live', sw_live) + append_kv(parts, 'scoped_work_reaped_not_reported', sw_reaped) + append_kv(parts, 'scoped_work_body_not_reaped', sw_body_not_reaped) + + append_kv(parts, 'request_owner_live', count_table(state.request_owner.live)) + local ingest_live, ingest_closed = ingest_summary() + append_kv(parts, 'ingest_instances_tracked', ingest_live) + append_kv(parts, 'ingest_instances_closed', ingest_closed) + + for _, k in ipairs(sorted_keys(state.gauges)) do + append_kv(parts, 'g.' .. k, state.gauges[k]) + end + for _, k in ipairs(sorted_keys(state.counters)) do + append_kv(parts, 'c.' .. k, state.counters[k]) + end + + local line = 'LEAK_PROBE ' .. table.concat(parts, ' ') + local details = { + line, + 'LEAK_PROBE_TOP_SCOPES ' .. top_records(state.scopes.records, 'finalizers', top_n), + 'LEAK_PROBE_EXEC_SAMPLES ' .. exec_samples, + 'LEAK_PROBE_SCOPED_WORK_KINDS ' .. sw_kinds, + } + return details +end + +local function write_lines(lines) + local path = getenv('DEVICECODE_LEAK_PROBE_FILE') + if path and path ~= '' then + local f = io.open(path, 'a') + if f then + for i = 1, #lines do f:write(lines[i], '\n') end + f:close() + return + end + end + for i = 1, #lines do print(lines[i]) end +end + +function M.report() + if not enabled then return end + collectgarbage('collect') + write_lines(M.snapshot()) +end + +function M.start(scope, opts) + if not enabled or state.started then return false end + state.started = true + opts = opts or {} + + if opts.note then M.note('note', opts.note) end + M.report() + + local ok_fibers, fibers = pcall(require, 'fibers') + local ok_sleep, sleep = pcall(require, 'fibers.sleep') + if not (ok_fibers and ok_sleep and scope and type(scope.spawn) == 'function') then + return true + end + + scope:spawn(function() + while true do + fibers.perform(sleep.sleep_op(interval)) + M.report() + if truthy(getenv('DEVICECODE_LEAK_PROBE_BUS')) and opts.conn and type(opts.conn.retain) == 'function' then + local snap = M.snapshot()[1] + pcall(function () opts.conn:retain({ 'obs', 'v1', 'leak_probe', 'snapshot' }, { line = snap, t = now() }) end) + end + end + end) + + return true +end + +return M diff --git a/src/devicecode/support/request_owner.lua b/src/devicecode/support/request_owner.lua index 0c06dec4..78e03483 100644 --- a/src/devicecode/support/request_owner.lua +++ b/src/devicecode/support/request_owner.lua @@ -9,6 +9,9 @@ local M = {} +local ok_probe, leak_probe = pcall(require, 'devicecode.support.leak_probe') +if not ok_probe then leak_probe = nil end + local RequestOwner = {} RequestOwner.__index = RequestOwner @@ -40,6 +43,7 @@ function M.new(request, opts) end return setmetatable({ + _probe_id = leak_probe and leak_probe.request_owner_created() or nil, _request = request, _done = false, _reply = opts.reply or default_reply, @@ -61,6 +65,7 @@ function RequestOwner:reply_once(value) end self._done = true + if leak_probe then leak_probe.request_owner_resolved(self._probe_id, 'reply') end return self._reply(self._request, value) end @@ -71,6 +76,7 @@ function RequestOwner:fail_once(reason) end self._done = true + if leak_probe then leak_probe.request_owner_resolved(self._probe_id, 'fail') end return self._fail(self._request, reason) end @@ -87,6 +93,7 @@ function RequestOwner:abandon_unresolved(_reason) end self._done = true + if leak_probe then leak_probe.request_owner_resolved(self._probe_id, 'abandon') end return true, nil end diff --git a/src/devicecode/support/scoped_work.lua b/src/devicecode/support/scoped_work.lua index e350d452..dd6d8c6e 100644 --- a/src/devicecode/support/scoped_work.lua +++ b/src/devicecode/support/scoped_work.lua @@ -22,6 +22,9 @@ local op = require 'fibers.op' local cond = require 'fibers.cond' local tablex = require 'shared.table' +local ok_probe, leak_probe = pcall(require, 'devicecode.support.leak_probe') +if not ok_probe then leak_probe = nil end + local M = {} local copy_table = tablex.shallow_copy @@ -181,6 +184,7 @@ local function start_impl(spec, opts) end local identity = copy_table(spec.identity) + local probe_work_id = leak_probe and leak_probe.scoped_work_started(identity) or nil local copy_result = spec.copy_result or copy_value local child, child_err = lifetime_scope:child() @@ -225,6 +229,7 @@ local function start_impl(spec, opts) end child:cancel(reason) + if leak_probe then leak_probe.scoped_work_cancelled(probe_work_id, reason) end return true, nil end @@ -258,6 +263,7 @@ local function start_impl(spec, opts) setup_result.cancel_owned_now(reason or 'scoped_work_start_failed') end body_done:signal() + if leak_probe then leak_probe.scoped_work_body_done(probe_work_id) end child:cancel(reason or 'scoped_work_start_failed') if cleanup_on_start_failure then @@ -302,6 +308,10 @@ local function start_impl(spec, opts) primary = copy_value(failure_primary) end store_once(status, report, primary) + if leak_probe then + leak_probe.scoped_work_reaped(probe_work_id, status) + if not spec.report then leak_probe.scoped_work_reported(probe_work_id) end + end end) if ok_reaper ~= true then @@ -357,6 +367,7 @@ local function start_impl(spec, opts) if ok ~= true then error(report_err or 'scoped_work_report_failed', 0) end + if leak_probe then leak_probe.scoped_work_reported(probe_work_id) end end) if ok_reporter ~= true then @@ -386,6 +397,7 @@ local function start_impl(spec, opts) -- This is wrapper-owned, not user-owned. body_done:signal() + if leak_probe then leak_probe.scoped_work_body_done(probe_work_id) end if not ok then error(ret, 0) diff --git a/src/services/device/action_manager.lua b/src/services/device/action_manager.lua index 19bad0c2..ed080634 100644 --- a/src/services/device/action_manager.lua +++ b/src/services/device/action_manager.lua @@ -12,6 +12,7 @@ local request_owner = require 'devicecode.support.request_owner' local cap_deps_mod = require 'devicecode.support.capability_dependencies' local dependency_mod = require 'services.device.dependencies' local backpressure = require 'services.device.backpressure' +local safe = require 'coxpcall' local M = {} @@ -64,7 +65,7 @@ end local function resolve_action_spec(component, action, req, base_spec) local mod = type(component) == 'table' and component.module or nil if type(mod) == 'table' and type(mod.action_spec) == 'function' then - local ok, spec, err = pcall(mod.action_spec, component, action, request_payload(req), base_spec) + local ok, spec, err = safe.pcall(mod.action_spec, component, action, request_payload(req), base_spec) if not ok then return nil, tostring(spec) end if spec == nil then return nil, err or 'unknown_action' end return spec, nil diff --git a/src/services/device/availability.lua b/src/services/device/availability.lua index de831b0a..56163445 100644 --- a/src/services/device/availability.lua +++ b/src/services/device/availability.lua @@ -5,6 +5,7 @@ -- owns no resources, and has no service-side effects. local tablex = require 'shared.table' +local safe = require 'coxpcall' local M = {} @@ -152,7 +153,7 @@ function M.component_status(rec) local base = base_component_status(rec) local mod = type(rec) == 'table' and rec.module or nil if type(mod) == 'table' and type(mod.availability) == 'function' then - local ok, refined = pcall(mod.availability, base, rec) + local ok, refined = safe.pcall(mod.availability, base, rec) if ok and type(refined) == 'table' then return set_status(base, refined) elseif not ok then diff --git a/src/services/device/config.lua b/src/services/device/config.lua index 977133c7..fda67e23 100644 --- a/src/services/device/config.lua +++ b/src/services/device/config.lua @@ -3,6 +3,7 @@ -- Raw Device configuration validation and normalisation. local catalogue = require 'services.device.catalogue' +local safe = require 'coxpcall' local M = {} @@ -33,7 +34,7 @@ function M.to_catalogue(raw) local cfg, err = M.normalise(raw) if err then return nil, err end - local ok, cat_or_err = pcall(function () + local ok, cat_or_err = safe.pcall(function () return catalogue.build(cfg) end) if not ok then diff --git a/src/services/device/observer.lua b/src/services/device/observer.lua index 041cdba5..8eab0330 100644 --- a/src/services/device/observer.lua +++ b/src/services/device/observer.lua @@ -9,6 +9,7 @@ local bus_cleanup = require 'devicecode.support.bus_cleanup' local queue = require 'devicecode.support.queue' local model = require 'services.device.model' local backpressure = require 'services.device.backpressure' +local safe = require 'coxpcall' local M = {} @@ -50,7 +51,7 @@ local function normalise_with_component(rec, kind, name, payload) return model.copy_value(payload), nil end - local ok, value, err = pcall(fn, name, payload, rec) + local ok, value, err = safe.pcall(fn, name, payload, rec) if not ok then return nil, tostring(value) end return value, err end diff --git a/src/services/device/service.lua b/src/services/device/service.lua index 3bed029a..69e49ea9 100644 --- a/src/services/device/service.lua +++ b/src/services/device/service.lua @@ -28,6 +28,9 @@ local tablex = require 'shared.table' local M = {} +local ok_probe, leak_probe = pcall(require, 'devicecode.support.leak_probe') +if not ok_probe then leak_probe = nil end + local DEFAULT_DONE_QUEUE = backpressure.policy.completions.default_len local DEFAULT_OBSERVATION_QUEUE = backpressure.policy.observations.default_len @@ -176,6 +179,7 @@ local function cancel_active_generation(state, reason) active.cancel_reason = reason or 'generation_replaced' state.generation_history = state.generation_history or {} state.generation_history[active.generation] = active + if leak_probe then leak_probe.gauge('device.generation_history', (function(t) local n=0; for _ in pairs(t or {}) do n=n+1 end; return n end)(state.generation_history)) end -- Public admission is a coordinator-visible resource, not merely a child -- scope finaliser concern. Release generation-owned endpoints immediately so @@ -350,6 +354,7 @@ local function start_generation(state, catalogue) state.generation_history = state.generation_history or {} state.generation_history[generation] = active + if leak_probe then leak_probe.gauge('device.generation_history', (function(t) local n=0; for _ in pairs(t or {}) do n=n+1 end; return n end)(state.generation_history)) end state.active = active return active, nil end diff --git a/src/services/hal.lua b/src/services/hal.lua index 33730e03..485b8e9d 100644 --- a/src/services/hal.lua +++ b/src/services/hal.lua @@ -10,6 +10,7 @@ local channel = require "fibers.channel" local sleep = require "fibers.sleep" local tablex = require 'shared.table' +local safe = require 'coxpcall' local perform = fibers.perform @@ -280,7 +281,7 @@ local function manager_terminate(manager_name, manager, reason) return nil, ('strict manager %q missing terminate'):format(tostring(manager_name)) end - local ok, a, b = pcall(function () + local ok, a, b = safe.pcall(function () return manager.terminate(reason) end) diff --git a/src/services/hal/backends/network/providers/openwrt/init.lua b/src/services/hal/backends/network/providers/openwrt/init.lua index 3b5ef94d..93426e8a 100644 --- a/src/services/hal/backends/network/providers/openwrt/init.lua +++ b/src/services/hal/backends/network/providers/openwrt/init.lua @@ -16,6 +16,7 @@ local shaper_mod = require 'services.hal.backends.network.providers.openwrt.tc_u local speedtest_mod = require 'services.hal.backends.network.providers.openwrt.speedtest' local names_mod = require 'services.hal.backends.network.providers.openwrt.names' local hal_types = require 'services.hal.types.core' +local safe = require 'coxpcall' local perform = fibers.perform local unpack = _G.unpack or rawget(table, 'unpack') @@ -1562,7 +1563,7 @@ function read_uci_packages(config) local c = uci_or_err.cursor(config.confdir or config.uci_confdir or '/etc/config', config.savedir or config.uci_savedir) local out = {} for _, pkg in ipairs(OWNED_PACKAGES) do - if type(c.load) == 'function' then pcall(function() c:load(pkg) end) end + if type(c.load) == 'function' then safe.pcall(function() c:load(pkg) end) end out[pkg] = type(c.get_all) == 'function' and c:get_all(pkg) or {} end return out, nil diff --git a/src/services/hal/backends/network/providers/openwrt/mwan3.lua b/src/services/hal/backends/network/providers/openwrt/mwan3.lua index 6612d1d2..a5494206 100644 --- a/src/services/hal/backends/network/providers/openwrt/mwan3.lua +++ b/src/services/hal/backends/network/providers/openwrt/mwan3.lua @@ -1,6 +1,7 @@ -- services/hal/backends/network/providers/openwrt/mwan3.lua -- MWAN3 UCI and live-weight helpers for the OpenWrt network provider. +local safe = require 'coxpcall' local unpack = _G.unpack or rawget(table, 'unpack') local M = {} @@ -187,7 +188,7 @@ local function default_restore(content) if not stdin then return nil, serr or 'failed to open iptables-restore stdin' end local ok, werr = stdin:write(content or '') if not ok then - pcall(function() stdin:close() end) + safe.pcall(function() stdin:close() end) cmd:kill() fibers.perform(cmd:run_op()) return nil, 'failed to write iptables-restore input: ' .. tostring(werr) diff --git a/src/services/hal/backends/network/providers/openwrt/observer.lua b/src/services/hal/backends/network/providers/openwrt/observer.lua index ba3f60c5..1f495159 100644 --- a/src/services/hal/backends/network/providers/openwrt/observer.lua +++ b/src/services/hal/backends/network/providers/openwrt/observer.lua @@ -302,7 +302,7 @@ function Observer:_stop_ubus_listener() local cmd = self.ubus_cmd self.ubus_cmd = nil - if cmd and cmd.kill then pcall(function () cmd:kill() end) end + if cmd and cmd.kill then safe.pcall(function () cmd:kill() end) end end function Observer:ubus_listener() @@ -331,7 +331,7 @@ function Observer:ubus_listener() if self.ubus_stream == stream then self.ubus_stream = nil end if self.ubus_cmd == cmd then self.ubus_cmd = nil end close_stream(stream) - pcall(function () cmd:kill() end) + safe.pcall(function () cmd:kill() end) if not self.closed then perform(sleep.sleep_op(1.0)) end end end @@ -352,7 +352,7 @@ function Observer:handle_socket_stream(st) end function Observer:socket_server() - pcall(function () file.unlink(self.socket_path) end) + safe.pcall(function () file.unlink(self.socket_path) end) local s, err = socket.listen_unix(self.socket_path, { ephemeral = true }) if not s then log(self, 'warn', { what = 'hotplug_socket_listen_failed', path = self.socket_path, err = tostring(err) }) @@ -394,7 +394,7 @@ function Observer:terminate(reason) self.active_streams = {} if self.listener and self.listener.close then self.listener:close() end if self.scope then self.scope:cancel(reason or 'observer terminated') end - pcall(function () file.unlink(self.socket_path) end) + safe.pcall(function () file.unlink(self.socket_path) end) return true, nil end diff --git a/src/services/hal/backends/openwrt/uci_manager.lua b/src/services/hal/backends/openwrt/uci_manager.lua index 8b53bda8..3bcc6976 100644 --- a/src/services/hal/backends/openwrt/uci_manager.lua +++ b/src/services/hal/backends/openwrt/uci_manager.lua @@ -28,6 +28,7 @@ local sleep = require 'fibers.sleep' local mailbox = require 'fibers.mailbox' local queue = require 'devicecode.support.queue' local file = require 'fibers.io.file' +local safe = require 'coxpcall' local M = {} local Manager = {} @@ -328,7 +329,7 @@ end local function revert_record(cursor, record) if cursor and type(cursor.revert) == 'function' then - pcall(function () cursor:revert(record.config) end) + safe.pcall(function () cursor:revert(record.config) end) end end @@ -359,7 +360,7 @@ local function snapshot_packages(cursor, packages) if not cursor then return out, nil end if type(cursor.get_all) ~= 'function' then return nil, 'uci get_all unavailable; cannot snapshot for rollback' end for _, pkg in ipairs(packages or {}) do - if type(cursor.load) == 'function' then pcall(function () cursor:load(pkg) end) end + if type(cursor.load) == 'function' then safe.pcall(function () cursor:load(pkg) end) end out[pkg] = copy_package_table(cursor:get_all(pkg) or {}) end return out, nil @@ -380,7 +381,7 @@ end local function restore_package(cursor, pkg, snapshot) if not cursor then return true, nil end - if type(cursor.revert) == 'function' then pcall(function () cursor:revert(pkg) end) end + if type(cursor.revert) == 'function' then safe.pcall(function () cursor:revert(pkg) end) end local ok, err = delete_all_sections(cursor, pkg) if ok ~= true then return nil, err end for secname, rec in pairs(snapshot or {}) do @@ -518,7 +519,7 @@ local function apply_with_cursor(cursor, record) if not cursor then return true, nil -- explicit fake/no-uci mode for tests and non-OpenWrt hosts. end - if type(cursor.load) == 'function' then pcall(function () cursor:load(record.config) end) end + if type(cursor.load) == 'function' then safe.pcall(function () cursor:load(record.config) end) end if record.replace_package == true then local ok, err = delete_all_sections(cursor, record.config) if ok ~= true then return nil, err end @@ -969,7 +970,7 @@ function Manager:_ensure_packages(packages, trace) return p end)()) if ok ~= true then return nil, err end - if self._cursor and type(self._cursor.load) == 'function' then pcall(function () self._cursor:load(pkg) end) end + if self._cursor and type(self._cursor.load) == 'function' then safe.pcall(function () self._cursor:load(pkg) end) end end log_manager(self, 'debug', (function () local p = trace_fields(trace) @@ -1365,7 +1366,7 @@ function Manager:submit_op(record, opts) end admitted_flag = true if type(opts.on_admitted) == 'function' then - local ok_admit, admit_err = pcall(opts.on_admitted) + local ok_admit, admit_err = safe.pcall(opts.on_admitted) if ok_admit ~= true then return false, tostring(admit_err or 'on_admitted failed'), true end end local result = fibers.perform(reply_rx:recv_op()) diff --git a/src/services/hal/drivers/band.lua b/src/services/hal/drivers/band.lua index 11f95c0c..a8210a5a 100644 --- a/src/services/hal/drivers/band.lua +++ b/src/services/hal/drivers/band.lua @@ -5,6 +5,7 @@ local cap_args = require "services.hal.types.capability_args" local fibers = require "fibers" local channel = require "fibers.channel" +local safe = require "coxpcall" local CONTROL_Q_LEN = 16 @@ -383,7 +384,7 @@ end ---@return boolean ok ---@return string? reason function BandDriver:apply() - local ok, err = pcall(function() self.backend:apply(self.staged) end) + local ok, err = safe.pcall(function() self.backend:apply(self.staged) end) if not ok then return false, tostring(err) end @@ -429,7 +430,7 @@ function BandDriver:control_manager() if type(fn) ~= 'function' then ok, reason = false, 'unknown verb: ' .. tostring(request.verb) else - local call_ok, r1, r2 = pcall(fn, self, request.opts) + local call_ok, r1, r2 = safe.pcall(fn, self, request.opts) if not call_ok then ok, reason = false, tostring(r1) else diff --git a/src/services/hal/drivers/filesystem.lua b/src/services/hal/drivers/filesystem.lua index 05ddda7b..63b6831b 100644 --- a/src/services/hal/drivers/filesystem.lua +++ b/src/services/hal/drivers/filesystem.lua @@ -7,6 +7,7 @@ local cap_args = require "services.hal.types.capability_args" local fibers = require "fibers" local sleep = require "fibers.sleep" local op = require "fibers.op" +local safe = require "coxpcall" local channel = require "fibers.channel" local file = require "fibers.io.file" @@ -247,7 +248,7 @@ function FSDriver:control_manager() ok = false reason = "no function exists for verb: " .. tostring(validation_err) else - local call_ok, fn_ok, fn_reason, fn_code = pcall(fn, self, root_name, request.opts) + local call_ok, fn_ok, fn_reason, fn_code = safe.pcall(fn, self, root_name, request.opts) if not call_ok then ok = false reason = "internal error: " .. tostring(fn_ok) diff --git a/src/services/hal/drivers/modem.lua b/src/services/hal/drivers/modem.lua index 9468a6ca..62047f47 100644 --- a/src/services/hal/drivers/modem.lua +++ b/src/services/hal/drivers/modem.lua @@ -14,6 +14,7 @@ local cache_mod = require "shared.cache" local fibers = require "fibers" local op = require "fibers.op" local channel = require "fibers.channel" +local safe = require "coxpcall" local sleep = require "fibers.sleep" local cond = require "fibers.cond" local pulse = require "fibers.pulse" @@ -634,7 +635,7 @@ function Modem:control_manager() ok = false reason = validation_err else - local call_ok, fn_ok, fn_reason, fn_code = pcall(fn, self, request.opts) + local call_ok, fn_ok, fn_reason, fn_code = safe.pcall(fn, self, request.opts) if not call_ok then ok = false reason = "internal error: " .. tostring(fn_ok) diff --git a/src/services/hal/drivers/radio.lua b/src/services/hal/drivers/radio.lua index 82c15fa9..e0b22c8d 100644 --- a/src/services/hal/drivers/radio.lua +++ b/src/services/hal/drivers/radio.lua @@ -5,6 +5,7 @@ local cap_args = require "services.hal.types.capability_args" local fibers = require "fibers" local channel = require "fibers.channel" +local safe = require "coxpcall" local sleep = require "fibers.sleep" local CONTROL_Q_LEN = 16 @@ -268,7 +269,7 @@ end ---@return boolean ok ---@return string? reason function RadioDriver:clear() - local ok, err = pcall(function() self.backend:clear() end) + local ok, err = safe.pcall(function() self.backend:clear() end) if not ok then return false, tostring(err) end @@ -287,7 +288,7 @@ end ---@return boolean ok ---@return string? reason function RadioDriver:apply() - local ok, err = pcall(function() self.backend:apply(self.staged) end) + local ok, err = safe.pcall(function() self.backend:apply(self.staged) end) if not ok then return false, tostring(err) end @@ -320,7 +321,7 @@ local function dispatch_rpc(driver, request) if type(fn) ~= 'function' then ok, reason = false, 'unknown verb: ' .. tostring(request.verb) else - local call_ok, r1, r2 = pcall(fn, driver, request.opts) + local call_ok, r1, r2 = safe.pcall(fn, driver, request.opts) if not call_ok then ok, reason = false, tostring(r1) else diff --git a/src/services/hal/managers/network.lua b/src/services/hal/managers/network.lua index af59886e..9b23e35d 100644 --- a/src/services/hal/managers/network.lua +++ b/src/services/hal/managers/network.lua @@ -9,6 +9,7 @@ local hal_types = require 'services.hal.types.core' local cap_types = require 'services.hal.types.capabilities' local control_loop = require 'services.hal.support.control_loop' local driver_mod = require 'services.hal.drivers.network' +local safe = require 'coxpcall' local M = strict.api_table() @@ -47,7 +48,7 @@ local function driver_method_op(method, req) return op.always(false, { ok = false, err = 'network driver missing ' .. opname }) end - local ok, driver_op = pcall(function () return fn(state.driver, req and req.opts or {}) end) + local ok, driver_op = safe.pcall(function () return fn(state.driver, req and req.opts or {}) end) if not ok then return op.always(false, { ok = false, err = tostring(driver_op) }) end diff --git a/src/services/hal/managers/wired.lua b/src/services/hal/managers/wired.lua index 04ce81aa..a671f017 100644 --- a/src/services/hal/managers/wired.lua +++ b/src/services/hal/managers/wired.lua @@ -6,6 +6,7 @@ -- Device service curates them into public cap/wired-provider/... surfaces. local fibers = require 'fibers' +local safe = require 'coxpcall' local op = require 'fibers.op' local channel = require 'fibers.channel' @@ -73,10 +74,10 @@ local function driver_result(provider_id, method, opts) local opname = tostring(method) .. '_op' local fn = driver[opname] if type(fn) ~= 'function' then return { ok = false, err = 'wired driver missing ' .. opname } end - local ok, driver_op = pcall(function () return fn(driver, opts or {}) end) + local ok, driver_op = safe.pcall(function () return fn(driver, opts or {}) end) if not ok then return { ok = false, err = tostring(driver_op) } end if type(driver_op) ~= 'table' then return { ok = false, err = opname .. ' did not return an Op' } end - local ok2, result = pcall(function () return fibers.perform(driver_op) end) + local ok2, result = safe.pcall(function () return fibers.perform(driver_op) end) if not ok2 then return { ok = false, err = tostring(result) } end if type(result) == 'table' then return result end return { ok = result == true, result = result } diff --git a/src/services/http/exchange.lua b/src/services/http/exchange.lua index 4c186258..24ee0562 100644 --- a/src/services/http/exchange.lua +++ b/src/services/http/exchange.lua @@ -5,6 +5,7 @@ local op = require 'fibers.op' local headers_mod = require 'services.http.headers' local terminate = require 'services.http.transport.terminate' +local safe = require 'coxpcall' local M = {} local HttpExchange = {} @@ -78,7 +79,7 @@ function HttpExchange:shutdown_op() self._close_reason = err or 'closed' local hook = self._on_terminate self._on_terminate = nil - if hook then pcall(hook, self, self._close_reason) end + if hook then safe.pcall(hook, self, self._close_reason) end return ok, err end) end @@ -90,7 +91,7 @@ function HttpExchange:terminate(reason) local hook = self._on_terminate self._on_terminate = nil terminate.terminate_stream(self._stream, self._close_reason) - if hook then pcall(hook, self, self._close_reason) end + if hook then safe.pcall(hook, self, self._close_reason) end return true end diff --git a/src/services/http/service.lua b/src/services/http/service.lua index dbc5a442..a66eb822 100644 --- a/src/services/http/service.lua +++ b/src/services/http/service.lua @@ -18,6 +18,7 @@ local config_watch = require 'devicecode.support.config_watch' local config_mod = require 'services.http.config' local service_events = require 'devicecode.support.service_events' local service_base = require 'devicecode.service_base' +local safe = require 'coxpcall' local M = {} local perform = fibers.perform @@ -206,7 +207,7 @@ function HttpService:_event_port(identity, opts) end function HttpService:_submit_registry_event(ev, label) - local ok, r1, r2 = pcall(function () + local ok, r1, r2 = safe.pcall(function () return self:_submit_event(ev, label or 'http_registry_event_report_failed', { fatal = true }) end) if ok then return r1, r2 end diff --git a/src/services/http/transport/uri.lua b/src/services/http/transport/uri.lua index b564d360..a74a9080 100644 --- a/src/services/http/transport/uri.lua +++ b/src/services/http/transport/uri.lua @@ -8,6 +8,7 @@ local http_request = require 'http.request' local http_util = require 'http.util' +local safe = require 'coxpcall' local M = {} @@ -45,7 +46,7 @@ function M.parse(uri) public_scheme = public_scheme:lower() local parse_uri, authority_scheme = lua_http_uri(uri, public_scheme) - local ok, req_or_err = pcall(function () + local ok, req_or_err = safe.pcall(function () return http_request.new_from_uri(parse_uri) end) if not ok or not req_or_err then return nil, normalise_error(req_or_err) end @@ -58,7 +59,7 @@ function M.parse(uri) if type(parsed_scheme) ~= 'string' or parsed_scheme == '' then return nil, 'invalid_args' end if type(authority) ~= 'string' or authority == '' then return nil, 'invalid_args' end - local ok_split, host, port = pcall(http_util.split_authority, authority, authority_scheme) + local ok_split, host, port = safe.pcall(http_util.split_authority, authority, authority_scheme) if not ok_split then return nil, 'invalid_args' end if type(host) ~= 'string' or host == '' then return nil, 'invalid_args' end diff --git a/src/services/http/transport/websocket.lua b/src/services/http/transport/websocket.lua index 506369d6..6ef745f5 100644 --- a/src/services/http/transport/websocket.lua +++ b/src/services/http/transport/websocket.lua @@ -7,6 +7,7 @@ local op = require 'fibers.op' local terminate = require 'services.http.transport.terminate' local headers_mod = require 'services.http.headers' +local safe = require 'coxpcall' local M = {} @@ -70,7 +71,7 @@ function WebSocket:_notify_terminated(reason) self._close_reason = reason or 'closed' local hook = self._on_terminate self._on_terminate = nil - if hook then pcall(hook, self, self._close_reason) end + if hook then safe.pcall(hook, self, self._close_reason) end return true end @@ -233,7 +234,7 @@ function ClientWebSocket:_notify_terminated(reason) self._close_reason = reason or 'closed' local hook = self._on_terminate self._on_terminate = nil - if hook then pcall(hook, self, self._close_reason) end + if hook then safe.pcall(hook, self, self._close_reason) end return true end diff --git a/src/services/http/websocket.lua b/src/services/http/websocket.lua index df928d9e..1769d8bc 100644 --- a/src/services/http/websocket.lua +++ b/src/services/http/websocket.lua @@ -3,6 +3,7 @@ local transport_ws = require 'services.http.transport.websocket' local op = require 'fibers.op' +local safe = require 'coxpcall' local M = {} @@ -31,8 +32,8 @@ local function notify_terminated(self, reason) self._terminate_hooks = {} local hook = self._on_terminate self._on_terminate = nil - if hook then pcall(hook, self, self._close_reason) end - for _, f in ipairs(hooks) do pcall(f, self, self._close_reason) end + if hook then safe.pcall(hook, self, self._close_reason) end + for _, f in ipairs(hooks) do safe.pcall(f, self, self._close_reason) end return true end diff --git a/src/services/time.lua b/src/services/time.lua index 6af95bed..fd5bc563 100644 --- a/src/services/time.lua +++ b/src/services/time.lua @@ -13,6 +13,7 @@ local time_utils = require "fibers.utils.time" local base = require "devicecode.service_base" local cap_sdk = require "services.hal.sdk.cap" +local safe = require 'coxpcall' local perform = fibers.perform @@ -49,7 +50,7 @@ local function apply_sync_state(state, conn, svc, is_synced, payload) if is_synced then if not state.time_source_installed then svc:obs_log('info', 'installing alarm time source from realtime clock') - local ok, err = pcall(alarm.set_time_source, time_utils.realtime) + local ok, err = safe.pcall(alarm.set_time_source, time_utils.realtime) if ok then state.time_source_installed = true svc:obs_log('info', 'alarm time source installed') diff --git a/src/services/ui/http/listener.lua b/src/services/ui/http/listener.lua index c1ff2025..c1a34cc2 100644 --- a/src/services/ui/http/listener.lua +++ b/src/services/ui/http/listener.lua @@ -10,6 +10,7 @@ local fibers = require 'fibers' local resource = require 'devicecode.support.resource' local scoped_work = require 'devicecode.support.scoped_work' local http_sdk = require 'services.http'.sdk +local safe = require 'coxpcall' local M = {} @@ -53,7 +54,7 @@ end local function request_id_of(ctx, next_id) if type(ctx) == 'table' then if type(ctx.id) == 'function' then - local ok, id = pcall(function () return ctx:id() end) + local ok, id = safe.pcall(function () return ctx:id() end) if ok and id ~= nil then return id end end if ctx.id ~= nil then return ctx.id end diff --git a/src/services/ui/http/request.lua b/src/services/ui/http/request.lua index 024fbd65..6202801b 100644 --- a/src/services/ui/http/request.lua +++ b/src/services/ui/http/request.lua @@ -12,6 +12,7 @@ local auth = require 'services.ui.auth' local user_operation = require 'services.ui.user_operation' local upload = require 'services.ui.update.upload' local resource = require 'devicecode.support.resource' +local safe = require 'coxpcall' local ok_cjson, cjson = pcall(require, 'cjson.safe') if not ok_cjson then cjson = require 'cjson' end @@ -28,7 +29,7 @@ local function header_one(headers, name) if v ~= nil then return v end end if type(headers.get) == 'function' then - local ok, v = pcall(function () return headers:get(string.lower(name)) end) + local ok, v = safe.pcall(function () return headers:get(string.lower(name)) end) if ok and v ~= nil then return v end end if type(headers) == 'table' then diff --git a/src/services/ui/http/routes.lua b/src/services/ui/http/routes.lua index 81d6eac6..03d5755c 100644 --- a/src/services/ui/http/routes.lua +++ b/src/services/ui/http/routes.lua @@ -2,6 +2,7 @@ -- -- Pure route decoding for UI HTTP requests. +local safe = require 'coxpcall' local M = {} local function split_path(path) @@ -15,7 +16,7 @@ end local function method_of(ctx) if ctx and type(ctx.method) == 'function' then - local ok, v = pcall(function () return ctx:method() end) + local ok, v = safe.pcall(function () return ctx:method() end) if ok and v ~= nil then return string.upper(tostring(v)) end end return string.upper(tostring((ctx and (ctx.method or ctx.verb)) or 'GET')) @@ -23,7 +24,7 @@ end local function path_of(ctx) if ctx and type(ctx.path) == 'function' then - local ok, v = pcall(function () return ctx:path() end) + local ok, v = safe.pcall(function () return ctx:path() end) if ok and v ~= nil then return v end end return (ctx and (ctx.path or ctx.uri)) or '/' diff --git a/src/services/ui/update/artifact_ingest.lua b/src/services/ui/update/artifact_ingest.lua index 22354155..34fcc105 100644 --- a/src/services/ui/update/artifact_ingest.lua +++ b/src/services/ui/update/artifact_ingest.lua @@ -8,6 +8,7 @@ -- in guards, because that would hide blocking provider calls behind an Op shape. local op = require 'fibers.op' +local safe = require 'coxpcall' local M = {} @@ -26,7 +27,7 @@ local function call_op_method(obj, method, owner, ...) local fn, err = require_op_method(obj, method, owner) if not fn then return op.always(nil, err) end - local ok, ev_or_err = pcall(function (...) + local ok, ev_or_err = safe.pcall(function (...) return fn(obj, ...) end, ...) @@ -57,7 +58,7 @@ function M.abort_now(handle, reason) return nil, 'artifact ingest handle must expose immediate abort_now' end - local ok, a, b = pcall(function () + local ok, a, b = safe.pcall(function () return handle:abort_now(reason) end) if not ok then diff --git a/src/services/update/backends/component.lua b/src/services/update/backends/component.lua index 99b2bd48..756a4f45 100644 --- a/src/services/update/backends/component.lua +++ b/src/services/update/backends/component.lua @@ -8,6 +8,7 @@ local op = require 'fibers.op' local model = require 'services.update.model' local topics = require 'services.update.topics' +local safe = require 'coxpcall' local M = {} local Backend = {} @@ -96,7 +97,7 @@ end local function describe_artifact(artifact) if type(artifact) == 'table' and type(artifact.describe) == 'function' then - local ok, rec = pcall(function () return artifact:describe() end) + local ok, rec = safe.pcall(function () return artifact:describe() end) if ok and type(rec) == 'table' then return rec end end return type(artifact) == 'table' and artifact or nil diff --git a/src/services/update/bundled_probe.lua b/src/services/update/bundled_probe.lua index acc28f37..75fb6ac2 100644 --- a/src/services/update/bundled_probe.lua +++ b/src/services/update/bundled_probe.lua @@ -11,6 +11,7 @@ local fibers = require 'fibers' local scoped_work = require 'devicecode.support.scoped_work' local queue = require 'devicecode.support.queue' local tablex = require 'shared.table' +local safe = require 'coxpcall' local M = {} @@ -18,7 +19,7 @@ local function copy(v) return tablex.deep_copy(v) end local function artifact_snapshot(v) if type(v) == 'table' and type(v.describe) == 'function' then - local ok, rec = pcall(function () return v:describe() end) + local ok, rec = safe.pcall(function () return v:describe() end) if ok and type(rec) == 'table' then rec = copy(rec) rec.ref = rec.ref or rec.artifact_ref diff --git a/src/services/update/ingest.lua b/src/services/update/ingest.lua index 4557ab48..272cbda0 100644 --- a/src/services/update/ingest.lua +++ b/src/services/update/ingest.lua @@ -14,6 +14,10 @@ local queue = require 'devicecode.support.queue' local request_owner = require 'devicecode.support.request_owner' local model = require 'services.update.model' local lifetime = require 'services.update.artifacts.lifetime' +local safe = require 'coxpcall' + +local ok_probe, leak_probe = pcall(require, 'devicecode.support.leak_probe') +if not ok_probe then leak_probe = nil end local M = {} @@ -28,7 +32,7 @@ local function copy(v) return model.deep_copy(v) end local function artifact_snapshot(artifact) if type(artifact) == 'table' and type(artifact.describe) == 'function' then - local ok, rec = pcall(function () return artifact:describe() end) + local ok, rec = safe.pcall(function () return artifact:describe() end) if ok and type(rec) == 'table' then rec = copy(rec) rec.ref = rec.ref or rec.artifact_ref @@ -141,11 +145,13 @@ function M.new_instance(scope, params) terminal_requested = false, _scope_closed = false, }, Instance) + if leak_probe then leak_probe.ingest_instance_created(self.ingest_id) end child:finally(function (_, status, primary) local reason = primary or ((status ~= 'ok') and status) or 'ingest_instance_closed' if self.state == 'open' then self.state = 'closed' end self.closed = true + if leak_probe then leak_probe.ingest_instance_closed(self.ingest_id, reason) end while #self.pending > 0 do local pending = table.remove(self.pending, 1) local owner = pending.owner or request_owner.new(pending.req) @@ -159,6 +165,7 @@ end function Instance:_close_scope(reason) if self._scope_closed then return true, nil end self._scope_closed = true + if leak_probe then leak_probe.ingest_instance_closed(self.ingest_id, reason or 'ingest_instance_closed') end if self._scope and type(self._scope.close) == 'function' then self._scope:close(reason or 'ingest_instance_closed') end @@ -362,6 +369,7 @@ local function create_instance_with_sink_now(state, req, payload, sink, owner) return nil, err or 'ingest_create_failed' end state._instances[ingest_id] = inst + if leak_probe then leak_probe.gauge('update.ingest.instances_table', (function(t) local n=0; for _ in pairs(t or {}) do n=n+1 end; return n end)(state._instances)) end owner = owner or owner_for(req) local ok, rerr = owner:reply_once({ ok = true, ingest = inst:snapshot() }) if ok ~= true then error(rerr or 'ingest_create_reply_failed', 0) end @@ -669,6 +677,7 @@ function State:handle_done(ctx, ev) fail_owner(pending.owner, pending.req, 'ingest_closed') end inst:_close_scope('ingest_closed') + if leak_probe then leak_probe.gauge('update.ingest.instances_table', (function(t) local n=0; for _ in pairs(t or {}) do n=n+1 end; return n end)(self._instances)) end return true end diff --git a/src/services/update/service.lua b/src/services/update/service.lua index 80a836d6..52eaa4a5 100644 --- a/src/services/update/service.lua +++ b/src/services/update/service.lua @@ -1416,6 +1416,26 @@ function M.start(conn, opts) params.name = opts.name or 'update' params.svc = svc + -- Leak-probe and VM runs may deliberately avoid the HAL control-store backed + -- durable job store. The OpenWrt VM can bring the control-store capability + -- up late or slowly enough for update admission to fail before the leak probe + -- has a useful steady-state window. Keep production semantics unchanged unless + -- the caller opts in explicitly through the environment or opts table. + local env_job_store = os.getenv('DEVICECODE_UPDATE_JOB_STORE') + local env_probe_memory = os.getenv('DEVICECODE_LEAK_PROBE_UPDATE_MEMORY_JOB_STORE') + local probe_memory = env_probe_memory ~= nil + and env_probe_memory ~= '' + and env_probe_memory ~= '0' + and env_probe_memory ~= 'false' + if opts.memory_job_store == true or opts.job_store_kind == 'memory' + or env_job_store == 'memory' + or probe_memory + then + params.job_store = nil + params.job_store_kind = 'memory' + params.memory_job_store = true + end + M.run(scope, params) svc:stopped({ reason = 'returned' }) diff --git a/tests/integration/openwrt_vm/Makefile b/tests/integration/openwrt_vm/Makefile index e3246f6a..a5f99b1a 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-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-devicecode-wired-static-provider 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 \ + preflight fetch verify reset run wait ensure-large-disk 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-leak-probe-full-stack 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-devicecode-wired-static-provider 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 @@ -23,6 +23,9 @@ run: wait: ./scripts/wait-ssh +ensure-large-disk: + ./scripts/ensure-large-disk + provision: ./scripts/provision @@ -78,6 +81,13 @@ test-devicecode-uci-manager-async-activation: test-devicecode-openwrt-no-blocking-os-io: ./tests/test_devicecode_openwrt_no_blocking_os_io.sh +test-devicecode-leak-probe-full-stack: + ./scripts/ensure-large-disk + ./scripts/wait-ssh + ./scripts/provision + ./scripts/ensure-devicecode-lua-http-deps + ./tests/test_devicecode_leak_probe_full_stack.sh + test-openwrt-network-provider-async-activation: ./scripts/wait-ssh ./tests/test_openwrt_network_provider_async_activation.sh diff --git a/tests/integration/openwrt_vm/env.sh b/tests/integration/openwrt_vm/env.sh index a31e2134..cf3727bc 100755 --- a/tests/integration/openwrt_vm/env.sh +++ b/tests/integration/openwrt_vm/env.sh @@ -78,8 +78,20 @@ OPENWRT_IMAGE_RAW="${OPENWRT_IMAGE_DIR}/${OPENWRT_IMAGE_NAME%.gz}" OPENWRT_SHA256SUMS_NAME="sha256sums" OPENWRT_SHA256SUMS="${OPENWRT_IMAGE_DIR}/sha256sums-${OPENWRT_VERSION}-${OPENWRT_ARCH}" OPENWRT_WORK_DISK="${OPENWRT_WORK_DIR}/openwrt-${OPENWRT_ARCH}.qcow2" +# Leak-probe and dependency-build runs may need substantially more root +# filesystem space than the stock OpenWrt image provides. scripts/ensure-large-disk +# uses this virtual size before provisioning/building HTTP dependencies. +OPENWRT_WORK_DISK_SIZE="${OPENWRT_WORK_DISK_SIZE:-2G}" +OPENWRT_GROW_WORK_DISK="${OPENWRT_GROW_WORK_DISK:-1}" +# Minimum free space required on / after guest-side growth before +# the build-deps leak probe attempts to install gcc/LuaRocks dependencies. +OPENWRT_MIN_ROOT_FREE_KB="${OPENWRT_MIN_ROOT_FREE_KB:-300000}" OPENWRT_PID="${OPENWRT_WORK_DIR}/qemu.pid" OPENWRT_LOG="${OPENWRT_WORK_DIR}/qemu.log" OPENWRT_PROVISION_MARKER="${OPENWRT_PROVISION_MARKER:-/etc/devicecode-vm-provisioned}" OPENWRT_PROVISION_FORCE="${OPENWRT_PROVISION_FORCE:-0}" + +# The leak-probe VM defaults to PUC Lua so OpenWrt's packaged native Lua modules +# load consistently. Override with DEVICECODE_LEAK_PROBE_LUA=luajit for runtime comparisons. +DEVICECODE_LEAK_PROBE_LUA="${DEVICECODE_LEAK_PROBE_LUA:-lua}" diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-bus/src/bus.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-bus/src/bus.lua new file mode 100644 index 00000000..6f779cc9 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-bus/src/bus.lua @@ -0,0 +1,1675 @@ +-- bus.lua +-- +-- Simple in-process bus built on fibers + trie. +-- +-- Public planes: +-- * state/event plane : publish, retain, unretain, subscribe, watch_retained +-- * command plane : bind, call +-- +-- Key properties: +-- * wildcard subscriptions (pubsub trie: wildcards allowed in stored keys; literal queries) +-- * retained messages (retained trie: literal stored keys; wildcards allowed in queries) +-- * retained watch feeds (wildcard watch patterns over retain/unretain lifecycle) +-- * retained materialised views for event-driven assertions/observation +-- * bounded endpoint calls with native request/reply (no public reply topics) +-- * immutable origin metadata attached to delivered bus objects +-- * trusted provenance is bus-owned; callers may attach only origin.extra +-- * principal-aware authorisation hooks on connection actions +---@module 'bus' + +local mailbox = require 'fibers.mailbox' +local cond = require 'fibers.cond' +local op = require 'fibers.op' +local performer = require 'fibers.performer' +local runtime = require 'fibers.runtime' +local scope_mod = require 'fibers.scope' +local sleep = require 'fibers.sleep' + +local ok_probe, leak_probe = pcall(require, 'devicecode.support.leak_probe') +if not ok_probe then leak_probe = nil end + +local trie = require 'trie' +local uuid = require 'uuid' + +local perform = performer.perform + +---@alias Topic any[] +---@alias FullPolicy '"drop_oldest"'|'"reject_newest"' + +local DEFAULT_Q_LEN = 10 +local DEFAULT_POLICY = 'drop_oldest' + +-------------------------------------------------------------------------------- +-- trie literal helper (escape hatch) +-------------------------------------------------------------------------------- + +local LIT_MT = getmetatable(trie.literal('x')) + +---@param tok any +---@return boolean +local function is_lit(tok) + return type(tok) == 'table' and getmetatable(tok) == LIT_MT +end + +---@param tok any +---@return any v +---@return boolean was_lit +local function unwrap_token(tok) + if is_lit(tok) then + return tok.v, true + end + return tok, false +end + +-------------------------------------------------------------------------------- +-- Validation / small helpers +-------------------------------------------------------------------------------- + +---@param p any +---@param level? integer +---@return FullPolicy|nil +local function assert_full_policy(p, level) + level = (level or 1) + 1 + if p == nil then return nil end + + if p == 'drop_newest' then + error('mailbox full policy "drop_newest" is deprecated; use "reject_newest"', level) + end + + if p == 'drop_oldest' or p == 'reject_newest' then + return p + end + + if p == 'block' then + error('bus delivery must be bounded; mailbox full policy "block" is not supported', level) + end + + error('invalid mailbox full policy: ' .. tostring(p), level) +end + +---@param t any +---@param errlvl integer +---@return integer n +local function array_len(t, errlvl) + if type(t) ~= 'table' then + error('tokens must be a table (dense array)', errlvl) + end + + local n = 0 + for _ in ipairs(t) do n = n + 1 end + + for k in pairs(t) do + if type(k) ~= 'number' or k < 1 or k % 1 ~= 0 or k > n then + error('token arrays must use 1..n integer keys only', errlvl) + end + end + + return n +end + +---@param topic any +---@param what string +---@param level? integer +---@return Topic +local function assert_topic(topic, what, level) + level = (level or 1) + 1 + local n = array_len(topic, level) + + for i = 1, n do + local v = topic[i] + local uv = unwrap_token(v) + local tv = type(uv) + if tv ~= 'string' and tv ~= 'number' then + error(('%s[%d] must be a string or number (got %s)'):format(what, i, tv), level) + end + end + + return topic +end + +---@param s_wild string|number +---@param m_wild string|number +---@param topic Topic +---@param what string +---@param level? integer +local function assert_concrete_topic(s_wild, m_wild, topic, what, level) + level = (level or 1) + 1 + local n = array_len(topic, level) + + for i = 1, n do + local raw, was_lit = unwrap_token(topic[i]) + if not was_lit and (raw == s_wild or raw == m_wild) then + error(('%s must be a concrete topic (no wildcards)'):format(what), level) + end + end +end + +---@param topic Topic +---@return string +local function topic_key(topic) + local n = array_len(topic, 3) + local parts = {} + + for i = 1, n do + local raw = unwrap_token(topic[i]) + if type(raw) == 'string' then + parts[#parts + 1] = 's' .. #raw .. ':' .. raw + else + local s = tostring(raw) + parts[#parts + 1] = 'n' .. #s .. ':' .. s + end + end + + return table.concat(parts, '|') +end + +---@param topic Topic +---@return string +local function topic_debug(topic) + local n = array_len(topic, 3) + local parts = {} + + for i = 1, n do + local raw, was_lit = unwrap_token(topic[i]) + parts[i] = was_lit and ('=' .. tostring(raw)) or tostring(raw) + end + + return table.concat(parts, '/') +end + +local function copy_table(t) + local out = {} + if not t then return out end + + for k, v in pairs(t) do + out[k] = v + end + + return out +end + +local function freeze_shallow(t, err) + local data = t or {} + + return setmetatable({}, { + __index = data, + __newindex = function () error(err or 'table is immutable', 2) end, + __pairs = function () return next, data, nil end, + __metatable = false, + }) +end + +---@param tx MailboxTx +---@param value any +---@return boolean|nil ok +---@return string|nil reason +local function mailbox_try_send(tx, value) + local send_op = tx:send_op(value) + local ready, ok, reason = send_op.try_fn() + + assert(ready, 'bus mailbox unexpectedly blocked') + + if ok == true then return true, nil end + if ok == nil then return nil, reason or 'closed' end + return false, reason or 'full' +end + +---@param tx MailboxTx +---@param value any +local function deliver_best_effort(tx, value) + mailbox_try_send(tx, value) +end + +local function deliver_required_or_close(tx, value, close_reason) + local ok, reason = mailbox_try_send(tx, value) + if ok == true then + return true + end + + tx:close(close_reason or reason or 'closed') + return false +end + +local function require_opts_table(name, opts, level) + if opts ~= nil and type(opts) ~= 'table' then + error(name .. ': opts must be a table (or nil)', (level or 1) + 1) + end + + return opts or {} +end + +local function resolve_queue_len(opts, default_len, name, level) + local qlen = opts.queue_len + if qlen == nil then qlen = default_len end + + if type(qlen) ~= 'number' or qlen < 0 then + error(name .. ': queue_len must be >= 0', (level or 1) + 1) + end + + return qlen +end + +local function resolve_feed_opts(opts, default_len, default_full, name, level) + opts = require_opts_table(name, opts, (level or 1) + 1) + + local qlen = resolve_queue_len(opts, default_len, name, (level or 1) + 1) + local full = assert_full_policy(opts.full or default_full, (level or 1) + 1) or DEFAULT_POLICY + + return opts, qlen, full +end + +local function count_keys(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +local function snapshot_keys(set) + local out = {} + for item in pairs(set) do + out[#out + 1] = item + end + return out +end + +local function clear_finaliser(obj) + if obj._detach_finaliser then + obj._detach_finaliser() + obj._detach_finaliser = nil + end +end + +local function own_in_scope(set, obj, detach_fn) + set[obj] = true + if leak_probe then leak_probe.bus_feed_created(obj._kind or 'retained_view') end + obj._detach_finaliser = scope_mod.current():finally(detach_fn) + return obj +end + +local function disconnect_all(set, f) + for _, item in ipairs(snapshot_keys(set)) do + f(item) + end +end + +-------------------------------------------------------------------------------- +-- Origin +-------------------------------------------------------------------------------- + +local function trusted_origin_base(conn) + local src = conn._origin_factory + if type(src) == 'function' then + src = src() or {} + end + + return copy_table(src) +end + +---@param conn Connection +---@param extra? table +---@return Origin +local function build_origin(conn, extra) + local out = trusted_origin_base(conn) + + if out.kind == nil then out.kind = 'local' end + + out.conn_id = conn._conn_id + out.principal = conn._principal + + if type(extra) == 'table' and next(extra) ~= nil then + out.extra = freeze_shallow(copy_table(extra), 'origin.extra is immutable') + end + + return freeze_shallow(out, 'origin is immutable') +end + +-------------------------------------------------------------------------------- +-- Authorisation +-------------------------------------------------------------------------------- + +---@param auth any +---@return function|nil +local function authoriser_callable(auth) + if auth == nil then return nil end + + if type(auth) == 'function' then + return function(ctx) + return auth(ctx) + end + end + + if type(auth) == 'table' then + if type(auth.allow) == 'function' then + return function(ctx) + return auth:allow(ctx) + end + end + + if type(auth.authorize) == 'function' then + return function(ctx) + return auth:authorize(ctx) + end + end + end + + return nil +end + +---@param bus Bus +---@param principal any +---@param action string +---@param topic Topic +---@param extra? table +---@param level? integer +---@return boolean ok +---@return any reason +local function authorize_action(bus, principal, action, topic, extra, level) + level = (level or 1) + 1 + + local fn = bus and bus._authoriser or nil + if fn == nil then + return true, nil + end + + local ok, reason = fn({ + bus = bus, + principal = principal, + action = action, + topic = topic, + extra = extra, + }) + + if ok == false or ok == nil then + return false, reason or 'forbidden' + end + + return true, nil +end + +---@param self Connection +---@param action string +---@param topic Topic +---@param extra? table +---@param level? integer +local function assert_authorized(self, action, topic, extra, level) + level = (level or 1) + 1 + + local ok, reason = authorize_action(self._bus, self._principal, action, topic, extra, level) + if not ok then + error( + ('permission denied for %s on %s: %s'):format( + tostring(action), + topic_debug(topic), + tostring(reason or 'forbidden') + ), + level + ) + end +end + +-------------------------------------------------------------------------------- +-- Delivered objects +-------------------------------------------------------------------------------- + +---@class Origin +---@field kind string +---@field conn_id string|nil +---@field principal any|nil +---@field link_id string|nil +---@field peer_node string|nil +---@field peer_sid string|nil +---@field generation integer|nil +---@field extra table|nil +local Origin = {} + +---@class Message +---@field topic Topic +---@field payload any +---@field origin Origin +local Message = {} +Message.__index = Message + +---@param topic Topic +---@param payload any +---@param origin Origin +---@return Message +local function new_msg(topic, payload, origin) + return setmetatable({ + topic = topic, + payload = payload, + origin = origin, + }, Message) +end + +---@class RetainedEvent +---@field op '"retain"'|'"unretain"'|'"replay_done"' +---@field topic Topic|nil +---@field payload any|nil +---@field origin Origin|nil +local RetainedEvent = {} +RetainedEvent.__index = RetainedEvent + +---@param op_name '"retain"'|'"unretain"'|'"replay_done"' +---@param topic Topic|nil +---@param payload? any +---@param origin? Origin +---@return RetainedEvent +local function new_retained_event(op_name, topic, payload, origin) + return setmetatable({ + op = op_name, + topic = topic, + payload = payload, + origin = origin, + }, RetainedEvent) +end + +local BUS_ORIGIN = freeze_shallow({ kind = 'bus' }, 'origin is immutable') + +local function new_replay_done_event() + return new_retained_event('replay_done', nil, nil, BUS_ORIGIN) +end + +---@class Request +---@field topic Topic +---@field payload any +---@field origin Origin +---@field _cond Cond +---@field _done boolean +---@field _status string +---@field _ok boolean|nil +---@field _value any +---@field _err any +local Request = {} +Request.__index = Request + +---@param topic Topic +---@param payload any +---@param origin Origin +---@return Request +local function new_request(topic, payload, origin) + return setmetatable({ + topic = topic, + payload = payload, + origin = origin, + _cond = cond.new(), + _done = false, + _status = 'pending', + _ok = nil, + _value = nil, + _err = nil, + }, Request) +end + +---@param value any +---@return boolean ok +function Request:reply(value) + if self._done then return false end + if leak_probe then leak_probe.bus_call_resolved('replied') end + + self._done = true + self._status = 'replied' + self._ok = true + self._value = value + self._err = nil + self._cond:signal() + + return true +end + +---@param err any +---@return boolean ok +function Request:fail(err) + if self._done then return false end + if leak_probe then leak_probe.bus_call_resolved('failed') end + + self._done = true + self._status = 'failed' + self._ok = false + self._value = nil + self._err = err + self._cond:signal() + + return true +end + +---@param reason? any +---@return boolean ok +function Request:abandon(reason) + if self._done then return false end + if leak_probe then leak_probe.bus_call_resolved('abandoned') end + + self._done = true + self._status = 'abandoned' + self._ok = false + self._value = nil + self._err = reason or 'abandoned' + self._cond:signal() + + return true +end + +---@return boolean +function Request:done() + return self._done +end + +---@return string status +---@return any value +---@return any err +function Request:status() + return self._status or 'pending', self._value, self._err +end + +---@return Op +function Request:done_op() + if self._done then + return op.always(self:status()) + end + + return self._cond:wait_op():wrap(function () + return self:status() + end) +end + +---@return Op +function Request:wait_reply_op() + if self._done then + if self._ok then + return op.always(self._value, nil) + end + + return op.always(nil, self._err) + end + + return self._cond:wait_op():wrap(function () + if self._ok then + return self._value, nil + end + + return nil, self._err + end) +end + +-------------------------------------------------------------------------------- +-- Common feed-handle behaviour +-------------------------------------------------------------------------------- + +---@class Feed +local Feed = {} +Feed.__index = Feed + +---@class Subscription : Feed +local Subscription = {} +Subscription.__index = Subscription +setmetatable(Subscription, { __index = Feed }) + +---@class RetainedWatch : Feed +local RetainedWatch = {} +RetainedWatch.__index = RetainedWatch +setmetatable(RetainedWatch, { __index = Feed }) + +---@class Endpoint : Feed +local Endpoint = {} +Endpoint.__index = Endpoint +setmetatable(Endpoint, { __index = Feed }) + +local function new_feed(mt, conn, kind, topic, tx, rx, extra) + local obj = { + _conn = conn, + _kind = kind, + _topic = topic, + _tx = tx, + _rx = rx, + _closed = cond.new(), + _detach_finaliser = nil, + } + + if extra then + for k, v in pairs(extra) do obj[k] = v end + end + + return setmetatable(obj, mt) +end + +local function new_subscription(conn, topic, tx, rx) + return new_feed(Subscription, conn, 'subscription', topic, tx, rx) +end + +local function new_retained_watch(conn, topic, tx, rx) + return new_feed(RetainedWatch, conn, 'retained_watch', topic, tx, rx) +end + +local function new_endpoint(conn, topic, key, tx, rx) + return new_feed(Endpoint, conn, 'endpoint', topic, tx, rx, { _key = key }) +end + +function Feed:kind() + return self._kind +end + +function Feed:topic() + return self._topic +end + +function Feed:why() + return self._rx:why() +end + +function Feed:dropped() + local tx = self._tx + return (tx and tx.dropped and tx:dropped()) or 0 +end + +---@param reason any +function Feed:_close(reason) + if not self._probe_closed and leak_probe then + leak_probe.bus_feed_closed(self._kind or 'feed') + self._probe_closed = true + end + if self._tx then self._tx:close(reason) end + if self._closed then self._closed:signal() end +end + +---@return Op +function Feed:closed_op() + local why = self:why() + if why ~= nil then + return op.always(tostring(why)) + end + + return self._closed:wait_op():wrap(function () + return tostring(self:why() or 'closed') + end) +end + +---@return Op +function Feed:recv_op() + return self._rx:recv_op():wrap(function (item) + if item == nil then + return nil, tostring(self._rx:why() or 'closed') + end + + return item, nil + end) +end + +function Feed:recv() + return perform(self:recv_op()) +end + +function Feed:iter() + return self._rx:iter() +end + +function Feed:payloads() + if getmetatable(self) ~= Subscription then + error('payloads expects a Subscription', 2) + end + + local it = self._rx:iter() + return function () + local msg = it() + return msg and msg.payload or nil + end +end + +function Feed:stats() + return { + dropped = self:dropped(), + topic = self._topic, + kind = self._kind, + } +end + +function Feed:close() + local conn = self._conn + if not conn then + self:_close(self._kind == 'endpoint' and 'unbound' or 'closed') + return true + end + + if getmetatable(self) == Subscription then + return conn:unsubscribe(self) + elseif getmetatable(self) == RetainedWatch then + return conn:unwatch_retained(self) + elseif getmetatable(self) == Endpoint then + return conn:unbind(self) + end + + error('unknown feed kind: ' .. tostring(self._kind), 2) +end + +function Feed:unsubscribe() + if getmetatable(self) ~= Subscription then + error('unsubscribe expects a Subscription', 2) + end + + return self:close() +end + +function Feed:unwatch() + if getmetatable(self) ~= RetainedWatch then + error('unwatch expects a RetainedWatch', 2) + end + + return self:close() +end + +function Feed:unbind() + if getmetatable(self) ~= Endpoint then + error('unbind expects an Endpoint', 2) + end + + return self:close() +end + +-------------------------------------------------------------------------------- +-- Retained materialised view +-------------------------------------------------------------------------------- + +---@class RetainedView +---@field _conn Connection|nil +---@field _bus Bus|nil +---@field _topic Topic +---@field _items table +---@field _version integer +---@field _changed Cond +---@field _closed Cond +---@field _closed_reason any +---@field _detach_finaliser function|nil +local RetainedView = {} +RetainedView.__index = RetainedView + +local function new_retained_view(conn, topic) + return setmetatable({ + _conn = conn, + _bus = conn._bus, + _topic = topic, + _items = {}, + _version = 0, + _changed = cond.new(), + _closed = cond.new(), + _closed_reason = nil, + _detach_finaliser = nil, + }, RetainedView) +end + +function RetainedView:version() + return self._version +end + +local function retained_view_bump(self) + self._version = self._version + 1 + self._changed:signal() + self._changed = cond.new() +end + +local function retained_view_set_msg(self, msg) + local key = topic_key(msg.topic) + self._items[key] = new_msg(msg.topic, msg.payload, msg.origin) + retained_view_bump(self) +end + +local function retained_view_delete_topic(self, topic) + local key = topic_key(topic) + + if self._items[key] == nil then + return + end + + self._items[key] = nil + retained_view_bump(self) +end + +function RetainedView:_ingest(ev) + if self._closed_reason ~= nil then + return + end + + if ev.op == 'retain' then + retained_view_set_msg(self, { + topic = ev.topic, + payload = ev.payload, + origin = ev.origin, + }) + elseif ev.op == 'unretain' then + retained_view_delete_topic(self, ev.topic) + elseif ev.op == 'replay_done' then + -- Materialised views are kept directly by the bus. Replay markers are + -- feed-only events and do not affect the view. + else + -- Future-proof: ignore unknown retained lifecycle events. + end +end + +function RetainedView:_close(reason) + if self._closed_reason ~= nil then return end + + if leak_probe then leak_probe.bus_feed_closed('retained_view') end + self._closed_reason = reason or 'closed' + + local bus = self._bus + self._bus = nil + + if bus then + bus:_remove_retained_view(self) + end + + local conn = self._conn + self._conn = nil + + if conn and conn._views then + conn._views[self] = nil + end + + clear_finaliser(self) + + retained_view_bump(self) + self._closed:signal() +end + +function RetainedView:close() + self:_close('closed') + return true +end + +function RetainedView:closed_op() + if self._closed_reason ~= nil then + return op.always(tostring(self._closed_reason)) + end + + return self._closed:wait_op():wrap(function () + return tostring(self._closed_reason or 'closed') + end) +end + +--- Wait until the retained view changes from last_seen, or closes. +--- +--- Return shape: +--- changed: version, nil +--- closed : nil, reason +---@param last_seen integer +---@return Op +function RetainedView:changed_op(last_seen) + if type(last_seen) ~= 'number' or last_seen % 1 ~= 0 then + error('retained_view.changed_op: last_seen must be an integer', 2) + end + + local function close_reason() + if self._closed_reason ~= nil then + return tostring(self._closed_reason) + end + return nil + end + + local why = close_reason() + if why ~= nil then + return op.always(nil, why) + end + + if self._version > last_seen then + return op.always(self._version, nil) + end + + return self._changed:wait_op():wrap(function () + local reason = close_reason() + if reason ~= nil then + return nil, reason + end + + return self._version, nil + end) +end + +local function copy_msg(msg) + if not msg then return nil end + return new_msg(msg.topic, msg.payload, msg.origin) +end + +function RetainedView:get(topic) + assert_topic(topic, 'topic', 2) + local bus = self._bus or (self._conn and self._conn._bus) + if bus then + assert_concrete_topic(bus._s_wild, bus._m_wild, topic, 'retained_view:get topic', 2) + end + return copy_msg(self._items[topic_key(topic)]) +end + +function RetainedView:snapshot() + local out = {} + + for _, msg in pairs(self._items) do + out[#out + 1] = copy_msg(msg) + end + + table.sort(out, function (a, b) + return topic_key(a.topic) < topic_key(b.topic) + end) + + return out +end + +function RetainedView:items() + return self:snapshot() +end + +-------------------------------------------------------------------------------- +-- Bus +-------------------------------------------------------------------------------- + +---@class Bus +---@field _q_length integer +---@field _full FullPolicy +---@field _topics any +---@field _retained any +---@field _retained_watchers any +---@field _retained_views any +---@field _conns table +---@field _s_wild string|number +---@field _m_wild string|number +---@field _endpoints table +---@field _authoriser function|nil +local Bus = {} +Bus.__index = Bus + +---@param conn Connection +---@param topic Topic +---@param qlen integer +---@param full FullPolicy +---@return Subscription +function Bus:_subscribe(conn, topic, qlen, full) + local subs = self._topics:retrieve(topic) + if not subs then + subs = {} + self._topics:insert(topic, subs) + end + + local tx, rx = mailbox.new(qlen, { full = full }) + local sub = new_subscription(conn, topic, tx, rx) + subs[sub] = true + + self._retained:each(topic, function (_k, retained_msg) + deliver_best_effort(tx, retained_msg) + end) + + return sub +end + +function Bus:_unsubscribe(sub) + local subs = self._topics:retrieve(sub._topic) + if not subs then return end + + subs[sub] = nil + + if next(subs) == nil then + self._topics:delete(sub._topic) + end +end + +function Bus:_publish(msg) + self._topics:each(msg.topic, function (_k, subs) + for sub in pairs(subs) do + if sub and sub._tx then + deliver_best_effort(sub._tx, msg) + end + end + end) +end + +function Bus:_notify_retained(ev) + self._retained_watchers:each(ev.topic, function (_k, watchers) + for rw in pairs(watchers) do + if rw and rw._tx then + deliver_best_effort(rw._tx, ev) + end + end + end) +end + +function Bus:_add_retained_view(view) + local views = self._retained_views:retrieve(view._topic) + if not views then + views = {} + self._retained_views:insert(view._topic, views) + end + + views[view] = true + + self._retained:each(view._topic, function (_k, retained_msg) + retained_view_set_msg(view, retained_msg) + end) +end + +function Bus:_remove_retained_view(view) + local views = self._retained_views:retrieve(view._topic) + if not views then return end + + views[view] = nil + + if next(views) == nil then + self._retained_views:delete(view._topic) + end +end + +function Bus:_notify_retained_views(ev) + self._retained_views:each(ev.topic, function (_k, views) + for view in pairs(views) do + view:_ingest(ev) + end + end) +end + +function Bus:_retain(msg) + self:_publish(msg) + self._retained:insert(msg.topic, msg) + if leak_probe then leak_probe.bus_retained(self._probe_id, topic_key(msg.topic), topic_debug(msg.topic)) end + + local ev = new_retained_event('retain', msg.topic, msg.payload, msg.origin) + self:_notify_retained(ev) + self:_notify_retained_views(ev) +end + +function Bus:_unretain(topic, origin) + self._retained:delete(topic) + if leak_probe then leak_probe.bus_unretained(self._probe_id, topic_key(topic)) end + + local ev = new_retained_event('unretain', topic, nil, origin) + self:_notify_retained(ev) + self:_notify_retained_views(ev) +end + +---@param conn Connection +---@param topic Topic +---@param qlen integer +---@param full FullPolicy +---@param replay boolean +---@return RetainedWatch +function Bus:_watch_retained(conn, topic, qlen, full, replay) + local watchers = self._retained_watchers:retrieve(topic) + if not watchers then + watchers = {} + self._retained_watchers:insert(topic, watchers) + end + + local tx, rx = mailbox.new(qlen, { full = full }) + local rw = new_retained_watch(conn, topic, tx, rx) + watchers[rw] = true + + if replay then + self._retained:each(topic, function (_k, retained_msg) + deliver_best_effort(tx, + new_retained_event('retain', retained_msg.topic, retained_msg.payload, retained_msg.origin)) + end) + + if not deliver_required_or_close(tx, new_replay_done_event(), 'replay_overflow') then + rw:_close('replay_overflow') + watchers[rw] = nil + + if next(watchers) == nil then + self._retained_watchers:delete(topic) + end + end + end + + return rw +end + +function Bus:_unwatch_retained(rw) + local watchers = self._retained_watchers:retrieve(rw._topic) + if not watchers then return end + + watchers[rw] = nil + + if next(watchers) == nil then + self._retained_watchers:delete(rw._topic) + end +end + +-------------------------------------------------------------------------------- +-- Connection +-------------------------------------------------------------------------------- + +---@class Connection +---@field _bus Bus|nil +---@field _principal any|nil +---@field _q_length integer +---@field _full FullPolicy +---@field _subs table +---@field _eps table +---@field _rws table +---@field _views table +---@field _detach_finaliser function|nil +---@field _disconnected boolean +---@field _conn_id string +---@field _origin_factory table|fun():table +local Connection = {} +Connection.__index = Connection + +local function assert_connected(self, level) + if self._disconnected or not self._bus then + error('connection is disconnected', (level or 1) + 1) + end +end + +local function new_connection(bus, principal, q_length, full, origin_factory) + return setmetatable({ + _bus = bus, + _principal = principal, + _q_length = q_length, + _full = full, + _subs = {}, + _eps = {}, + _rws = {}, + _views = {}, + _detach_finaliser = nil, + _disconnected = false, + _conn_id = tostring(uuid.new()), + _origin_factory = origin_factory or {}, + }, Connection) +end + +function Connection:is_disconnected() + return self._disconnected +end + +function Connection:principal() + return self._principal +end + +---@param opts? { principal?: any, origin_factory?: table|fun():table, origin_base?: table|fun():table } +---@return Connection +function Connection:derive(opts) + assert_connected(self, 1) + + opts = require_opts_table('derive', opts, 2) + + local bus = assert(self._bus) + if opts.principal == nil then + opts.principal = self._principal + end + + return bus:connect(opts) +end + +function Connection:dropped() + local n = 0 + + for _, set in ipairs({ self._subs, self._eps, self._rws }) do + for item in pairs(set) do + n = n + (item:dropped() or 0) + end + end + + return n +end + +function Connection:publish(topic, payload, opts) + assert_connected(self, 1) + assert_topic(topic, 'topic', 1) + + opts = require_opts_table('publish', opts, 2) + + assert_authorized(self, 'publish', topic, { + payload = payload, + opts = opts, + }, 1) + + self._bus:_publish(new_msg(topic, payload, build_origin(self, opts.extra))) + return true +end + +function Connection:retain(topic, payload, opts) + assert_connected(self, 1) + assert_topic(topic, 'topic', 1) + + opts = require_opts_table('retain', opts, 2) + + assert_authorized(self, 'retain', topic, { + payload = payload, + opts = opts, + }, 1) + + self._bus:_retain(new_msg(topic, payload, build_origin(self, opts.extra))) + return true +end + +function Connection:unretain(topic, opts) + assert_connected(self, 1) + assert_topic(topic, 'topic', 1) + + opts = require_opts_table('unretain', opts, 2) + + assert_authorized(self, 'unretain', topic, { + opts = opts, + }, 1) + + self._bus:_unretain(topic, build_origin(self, opts.extra)) + return true +end + +---@param topic Topic +---@param opts? table +---@return Subscription +function Connection:_subscribe_internal(topic, opts) + assert_connected(self, 1) + + local _, qlen, full = resolve_feed_opts(opts, self._q_length, self._full, '_subscribe_internal', 2) + local sub = self._bus:_subscribe(self, topic, qlen, full) + + return own_in_scope(self._subs, sub, function () + sub:unsubscribe() + end) +end + +function Connection:subscribe(topic, opts) + assert_connected(self, 1) + assert_topic(topic, 'topic', 1) + + assert_authorized(self, 'subscribe', topic, { + opts = opts, + }, 1) + + return self:_subscribe_internal(topic, opts) +end + +function Connection:unsubscribe(sub) + if not sub or getmetatable(sub) ~= Subscription then + error('unsubscribe expects a Subscription', 2) + end + + local owned = not not self._subs[sub] + + sub:_close('unsubscribed') + clear_finaliser(sub) + + if owned then + self._subs[sub] = nil + self._bus:_unsubscribe(sub) + end + + if sub._conn == self then sub._conn = nil end + + return true +end + +---@param topic Topic +---@param opts? table +---@return RetainedWatch +function Connection:_watch_retained_internal(topic, opts) + assert_connected(self, 1) + + opts = require_opts_table('_watch_retained_internal', opts, 2) + + local _, qlen, full = resolve_feed_opts(opts, self._q_length, self._full, '_watch_retained_internal', 2) + local replay = not not opts.replay + local rw = self._bus:_watch_retained(self, topic, qlen, full, replay) + + return own_in_scope(self._rws, rw, function () + rw:unwatch() + end) +end + +function Connection:watch_retained(topic, opts) + assert_connected(self, 1) + assert_topic(topic, 'topic', 1) + + assert_authorized(self, 'watch_retained', topic, { + opts = opts, + }, 1) + + return self:_watch_retained_internal(topic, opts) +end + +function Connection:unwatch_retained(rw) + if not rw or getmetatable(rw) ~= RetainedWatch then + error('unwatch_retained expects a RetainedWatch', 2) + end + + local owned = not not self._rws[rw] + + rw:_close('unwatched') + clear_finaliser(rw) + + if owned then + self._rws[rw] = nil + self._bus:_unwatch_retained(rw) + end + + if rw._conn == self then rw._conn = nil end + + return true +end + +---@param topic Topic +---@param opts? table +---@return RetainedView +function Connection:retained_view(topic, opts) + assert_connected(self, 1) + assert_topic(topic, 'topic', 1) + + opts = require_opts_table('retained_view', opts, 2) + + assert_authorized(self, 'watch_retained', topic, { + opts = opts, + }, 1) + + local view = new_retained_view(self, topic) + self._bus:_add_retained_view(view) + + return own_in_scope(self._views, view, function () + view:_close('scope_closed') + end) +end + +---@param topic Topic +---@param opts? table +---@return Endpoint +function Connection:_bind_internal(topic, opts) + assert_connected(self, 1) + + opts = require_opts_table('_bind_internal', opts, 2) + + local bus = assert(self._bus) + assert_concrete_topic(bus._s_wild, bus._m_wild, topic, 'bind topic', 2) + + local qlen = resolve_queue_len(opts, 1, '_bind_internal', 2) + + local key = topic_key(topic) + if bus._endpoints[key] ~= nil then + error('bind: topic is already bound', 2) + end + + local tx, rx = mailbox.new(qlen, { full = 'reject_newest' }) + local ep = new_endpoint(self, topic, key, tx, rx) + + bus._endpoints[key] = ep + + return own_in_scope(self._eps, ep, function () + ep:unbind() + end) +end + +function Connection:bind(topic, opts) + assert_connected(self, 1) + assert_topic(topic, 'topic', 1) + + assert_authorized(self, 'bind', topic, { + opts = opts, + }, 1) + + return self:_bind_internal(topic, opts) +end + +function Connection:unbind(ep) + if not ep or getmetatable(ep) ~= Endpoint then + error('unbind expects an Endpoint', 2) + end + + local bus = self._bus + + ep:_close('unbound') + clear_finaliser(ep) + + if self._eps[ep] then + self._eps[ep] = nil + + if bus and bus._endpoints and bus._endpoints[ep._key] == ep then + bus._endpoints[ep._key] = nil + end + end + + if ep._conn == self then ep._conn = nil end + + return true +end + +local function call_result_op(req, deadline) + if req:done() then + return req:wait_reply_op() + end + + if deadline == false then + return req:wait_reply_op() + end + + local now = runtime.now() + if deadline <= now then + req:abandon('timeout') + return op.always(nil, 'timeout') + end + + local timeout_ev = sleep.sleep_op(deadline - now):wrap(function () + req:abandon('timeout') + return nil, 'timeout' + end) + + return op.choice(req:wait_reply_op(), timeout_ev) +end + +function Connection:call_op(topic, payload, opts) + opts = require_opts_table('call_op', opts, 2) + + return op.guard(function () + assert_connected(self, 1) + assert_topic(topic, 'topic', 1) + + assert_authorized(self, 'call', topic, { + payload = payload, + opts = opts, + }, 1) + + local bus = assert(self._bus) + assert_concrete_topic(bus._s_wild, bus._m_wild, topic, 'call topic', 2) + + local key = topic_key(topic) + local ep = bus._endpoints[key] + + if not ep or not ep._tx then + return op.always(nil, 'no_route') + end + + local no_timeout = opts.timeout == false or opts.deadline == false + local timeout = (type(opts.timeout) == 'number') and opts.timeout or 1.0 + local deadline + if no_timeout then + deadline = false + elseif type(opts.deadline) == 'number' then + deadline = opts.deadline + else + deadline = runtime.now() + timeout + end + local req = new_request(topic, payload, build_origin(self, opts.extra)) + if leak_probe then leak_probe.bus_call_started() end + + local ok, reason = mailbox_try_send(ep._tx, req) + if ok ~= true then + local err = (ok == nil) and 'closed' or (reason or 'full') + req:abandon(err) + return op.always(nil, err) + end + + return call_result_op(req, deadline):on_abort(function () + req:abandon('aborted') + end) + end) +end + +function Connection:call(topic, payload, opts) + return perform(self:call_op(topic, payload, opts)) +end + +function Connection:disconnect() + if self._disconnected then return true end + + if leak_probe and self._bus then leak_probe.bus_connection_closed(self._bus._probe_id) end + clear_finaliser(self) + self._disconnected = true + + local bus = self._bus + self._bus = nil + + disconnect_all(self._subs, function (sub) + sub:_close('disconnected') + clear_finaliser(sub) + + if bus then bus:_unsubscribe(sub) end + + self._subs[sub] = nil + if sub._conn == self then sub._conn = nil end + end) + + disconnect_all(self._rws, function (rw) + rw:_close('disconnected') + clear_finaliser(rw) + + if bus then bus:_unwatch_retained(rw) end + + self._rws[rw] = nil + if rw._conn == self then rw._conn = nil end + end) + + disconnect_all(self._eps, function (ep) + ep:_close('disconnected') + + if bus and bus._endpoints and bus._endpoints[ep._key] == ep then + bus._endpoints[ep._key] = nil + end + + clear_finaliser(ep) + + self._eps[ep] = nil + if ep._conn == self then ep._conn = nil end + end) + + disconnect_all(self._views, function (view) + view:_close('disconnected') + end) + + if bus and bus._conns then + bus._conns[self] = nil + end + + return true +end + +function Connection:stats() + return { + dropped = self:dropped(), + subscriptions = count_keys(self._subs), + endpoints = count_keys(self._eps), + retained_watches = count_keys(self._rws), + retained_views = count_keys(self._views), + } +end + +-------------------------------------------------------------------------------- +-- Bus public API +-------------------------------------------------------------------------------- + +function Bus:connect(opts) + opts = require_opts_table('connect', opts, 2) + + local s = scope_mod.current() + local origin_factory = opts.origin_factory or opts.origin_base + local conn = new_connection(self, opts.principal, self._q_length, self._full, origin_factory) + + self._conns[conn] = true + if leak_probe then leak_probe.bus_connection_created(self._probe_id) end + + conn._detach_finaliser = s:finally(function () + conn._detach_finaliser = nil + conn:disconnect() + end) + + return conn +end + +function Bus:stats() + local connections = 0 + local dropped = 0 + local endpoints = 0 + local retained_watches = 0 + local retained_views = 0 + + for conn in pairs(self._conns) do + connections = connections + 1 + dropped = dropped + conn:dropped() + endpoints = endpoints + count_keys(conn._eps or {}) + retained_watches = retained_watches + count_keys(conn._rws or {}) + retained_views = retained_views + count_keys(conn._views or {}) + end + + return { + connections = connections, + dropped = dropped, + queue_len = self._q_length, + full_policy = self._full, + s_wild = self._s_wild, + m_wild = self._m_wild, + retained_watches = retained_watches, + retained_views = retained_views, + endpoints = endpoints, + } +end + +-------------------------------------------------------------------------------- +-- Constructor +-------------------------------------------------------------------------------- + +---@param params? { q_length?: integer, full?: FullPolicy, s_wild?: string|number, m_wild?: string|number, authoriser?: any } +---@return Bus +local function new(params) + params = params or {} + + local q_length = params.q_length + if q_length == nil then q_length = DEFAULT_Q_LEN end + + if type(q_length) ~= 'number' or q_length < 0 then + error('bus.new: q_length must be >= 0', 2) + end + + local full_default = assert_full_policy(params.full, 2) or DEFAULT_POLICY + local s_wild = params.s_wild or '+' + local m_wild = params.m_wild or '#' + + local authoriser = authoriser_callable(params.authoriser) + if params.authoriser ~= nil and not authoriser then + error('bus authoriser must be a function or table with :allow(ctx) / :authorize(ctx)', 2) + end + + local probe_id = leak_probe and leak_probe.bus_next_id() or nil + local b = setmetatable({ + _probe_id = probe_id, + _q_length = q_length, + _full = full_default, + _s_wild = s_wild, + _m_wild = m_wild, + _topics = trie.new_pubsub(s_wild, m_wild), + _retained = trie.new_retained(s_wild, m_wild), + _retained_watchers = trie.new_pubsub(s_wild, m_wild), + _retained_views = trie.new_pubsub(s_wild, m_wild), + _conns = setmetatable({}, { __mode = 'k' }), + _endpoints = {}, + _authoriser = authoriser, + }, Bus) + if leak_probe then leak_probe.bus_created(probe_id) end + return b +end + +return { + new = new, + + Bus = Bus, + Connection = Connection, + Subscription = Subscription, + RetainedWatch = RetainedWatch, + RetainedView = RetainedView, + Endpoint = Endpoint, + Message = Message, + RetainedEvent = RetainedEvent, + Request = Request, + Origin = Origin, + + -- Re-export trie literal helper for convenience. + literal = trie.literal, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-bus/src/uuid.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-bus/src/uuid.lua new file mode 100644 index 00000000..828bacc0 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-bus/src/uuid.lua @@ -0,0 +1,223 @@ +--------------------------------------------------------------------------------------- +-- Copyright 2012 Rackspace (original), 2013-2021 Thijs Schreijer (modifications) +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS-IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +-- see http://www.ietf.org/rfc/rfc4122.txt +-- +-- Note that this is not a true version 4 (random) UUID. Since `os.time()` precision is only 1 second, it would be hard +-- to guarantee spacial uniqueness when two hosts generate a uuid after being seeded during the same second. This +-- is solved by using the node field from a version 1 UUID. It represents the mac address. +-- +-- 28-apr-2013 modified by Thijs Schreijer from the original +-- [Rackspace code](https://github.com/kans/zirgo/blob/807250b1af6725bad4776c931c89a784c1e34db2/util/uuid.lua) +-- as a generic Lua module. +-- Regarding the above mention on `os.time()`; the modifications use the `socket.gettime()` function from LuaSocket +-- if available and hence reduce that problem (provided LuaSocket has been loaded before uuid). +-- +-- **Important:** the random seed is a global piece of data. Hence setting it is +-- an application level responsibility, libraries should never set it! +-- +-- See this issue; [https://github.com/Kong/kong/issues/478](https://github.com/Kong/kong/issues/478) +-- It demonstrates the problem of using time as a random seed. Specifically when used from multiple processes. +-- So make sure to seed only once, application wide. And to not have multiple processes do that +-- simultaneously. + + +local M = {} +local math = require('math') +local os = require('os') +local string = require('string') + +local bitsize = 32 -- bitsize assumed for Lua VM. See randomseed function below. +local lua_version = tonumber(_VERSION:match("%d%.*%d*")) -- grab Lua version used + +local MATRIX_AND = {{0,0},{0,1} } +local MATRIX_OR = {{0,1},{1,1}} +local HEXES = '0123456789abcdef' + +local math_floor = math.floor +local math_random = math.random +local math_abs = math.abs +local string_sub = string.sub +local to_number = tonumber +local assert = assert +local type = type + +-- performs the bitwise operation specified by truth matrix on two numbers. +local function BITWISE(x, y, matrix) + local z = 0 + local pow = 1 + while x > 0 or y > 0 do + z = z + (matrix[x%2+1][y%2+1] * pow) + pow = pow * 2 + x = math_floor(x/2) + y = math_floor(y/2) + end + return z +end + +local function INT2HEX(x) + local s,base = '',16 + local d + while x > 0 do + d = x % base + 1 + x = math_floor(x/base) + s = string_sub(HEXES, d, d)..s + end + while #s < 2 do s = "0" .. s end + return s +end + +---------------------------------------------------------------------------- +-- Creates a new uuid. Either provide a unique hex string, or make sure the +-- random seed is properly set. The module table itself is a shortcut to this +-- function, so `my_uuid = uuid.new()` equals `my_uuid = uuid()`. +-- +-- For proper use there are 3 options; +-- +-- 1. first require `luasocket`, then call `uuid.seed()`, and request a uuid using no +-- parameter, eg. `my_uuid = uuid()` +-- 2. use `uuid` without `luasocket`, set a random seed using `uuid.randomseed(some_good_seed)`, +-- and request a uuid using no parameter, eg. `my_uuid = uuid()` +-- 3. use `uuid` without `luasocket`, and request a uuid using an unique hex string, +-- eg. `my_uuid = uuid(my_networkcard_macaddress)` +-- +-- @return a properly formatted uuid string +-- @param hwaddr (optional) string containing a unique hex value (e.g.: `00:0c:29:69:41:c6`), to be used to compensate +-- for the lesser `math_random()` function. Use a mac address for solid results. If omitted, a fully randomized uuid +-- will be generated, but then you must ensure that the random seed is set properly! +-- @usage +-- local uuid = require("uuid") +-- print("here's a new uuid: ",uuid()) +function M.new(hwaddr) + -- bytes are treated as 8bit unsigned bytes. + local bytes = { + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255), + math_random(0, 255) + } + + if hwaddr then + assert(type(hwaddr)=="string", "Expected hex string, got "..type(hwaddr)) + -- Cleanup provided string, assume mac address, so start from back and cleanup until we've got 12 characters + local i,str = #hwaddr, hwaddr + hwaddr = "" + while i>0 and #hwaddr<12 do + local c = str:sub(i,i):lower() + if HEXES:find(c, 1, true) then + -- valid HEX character, so append it + hwaddr = c..hwaddr + end + i = i - 1 + end + assert( + #hwaddr == 12, + "Provided string did not contain at least 12 hex characters, retrieved '"..hwaddr.."' from '"..str.."'" + ) + + -- no split() in lua. :( + bytes[11] = to_number(hwaddr:sub(1, 2), 16) + bytes[12] = to_number(hwaddr:sub(3, 4), 16) + bytes[13] = to_number(hwaddr:sub(5, 6), 16) + bytes[14] = to_number(hwaddr:sub(7, 8), 16) + bytes[15] = to_number(hwaddr:sub(9, 10), 16) + bytes[16] = to_number(hwaddr:sub(11, 12), 16) + end + + -- set the version + bytes[7] = BITWISE(bytes[7], 0x0f, MATRIX_AND) + bytes[7] = BITWISE(bytes[7], 0x40, MATRIX_OR) + -- set the variant + bytes[9] = BITWISE(bytes[9], 0x3f, MATRIX_AND) + bytes[9] = BITWISE(bytes[9], 0x80, MATRIX_OR) + return INT2HEX(bytes[1])..INT2HEX(bytes[2])..INT2HEX(bytes[3])..INT2HEX(bytes[4]).."-".. + INT2HEX(bytes[5])..INT2HEX(bytes[6]).."-".. + INT2HEX(bytes[7])..INT2HEX(bytes[8]).."-".. + INT2HEX(bytes[9])..INT2HEX(bytes[10]).."-".. + INT2HEX(bytes[11])..INT2HEX(bytes[12]).. + INT2HEX(bytes[13])..INT2HEX(bytes[14]).. + INT2HEX(bytes[15])..INT2HEX(bytes[16]) +end + +---------------------------------------------------------------------------- +-- Improved randomseed function. +-- Lua 5.1 and 5.2 both truncate the seed given if it exceeds the integer +-- range. If this happens, the seed will be 0 or 1 and all randomness will +-- be gone (each application run will generate the same sequence of random +-- numbers in that case). This improved version drops the most significant +-- bits in those cases to get the seed within the proper range again. +-- @param seed the random seed to set (integer from 0 - 2^32, negative values will be made positive) +-- @return the (potentially modified) seed used +-- @usage +-- local socket = require("socket") -- gettime() has higher precision than os.time() +-- local uuid = require("uuid") +-- -- see also example at uuid.seed() +-- uuid.randomseed(socket.gettime()*10000) +-- print("here's a new uuid: ",uuid()) +function M.randomseed(seed) + seed = math_floor(math_abs(seed)) + if seed >= (2^bitsize) then + -- integer overflow, so reduce to prevent a bad seed + seed = seed - math_floor(seed / 2^bitsize) * (2^bitsize) + end + if lua_version < 5.2 then + -- 5.1 uses (incorrect) signed int + math.randomseed(seed - 2^(bitsize-1)) + else + -- 5.2 uses (correct) unsigned int + math.randomseed(seed) + end + return seed +end + +---------------------------------------------------------------------------- +-- Seeds the random generator. +-- It does so in 3 possible ways; +-- +-- 1. if in ngx_lua, use `ngx.time() + ngx.worker.pid()` to ensure a unique seed +-- for each worker. It should ideally be called from the `init_worker` context. +-- 2. use luasocket `gettime()` function, but it only does so when LuaSocket +-- has been required already. +-- 3. use `os.time()`: this only offers resolution to one second (used when +-- LuaSocket hasn't been loaded) +-- +-- **Important:** the random seed is a global piece of data. Hence setting it is +-- an application level responsibility, libraries should never set it! +-- @usage +-- local socket = require("socket") -- gettime() has higher precision than os.time() +-- -- LuaSocket loaded, so below line does the same as the example from randomseed() +-- uuid.seed() +-- print("here's a new uuid: ",uuid()) +function M.seed() + if package.loaded["socket"] and package.loaded["socket"].gettime then + return M.randomseed(package.loaded["socket"].gettime()*10000) + else + return M.randomseed(os.time()) + end +end + +return setmetatable( M, { __call = function(self, hwaddr) return self.new(hwaddr) end} ) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/coxpcall.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/coxpcall.lua new file mode 100644 index 00000000..2b09945b --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/coxpcall.lua @@ -0,0 +1,199 @@ +-- coxpcall.lua +-- +-- Coroutine-safe pcall/xpcall for Lua 5.1-style environments. +-- If the host already provides yield-safe pcall/xpcall (e.g. LuaJIT), this +-- module returns the native functions unchanged. +-- +-- This version aims to make Lua 5.1 tracebacks look closer to LuaJIT by: +-- * using debug.traceback(co, ...) for the failing coroutine, and +-- * splicing in โ€œouterโ€ call-site frames by walking the coroutine parent chain, +-- inserting a synthetic โ€œ[C]: in function 'xpcall'โ€ boundary each hop. + +local M = {} + +------------------------------------------------------------------------------- +-- Checks if (x)pcall function is coroutine safe +------------------------------------------------------------------------------- +local function isCoroutineSafe(func) + local co = coroutine.create(function () + return func(coroutine.yield, function () end) + end) + + coroutine.resume(co) + return coroutine.resume(co) +end + +-- Fast path: environment already has coroutine-safe pcall/xpcall +if isCoroutineSafe(pcall) and isCoroutineSafe(xpcall) then + M.pcall = pcall + M.xpcall = xpcall + M.running = coroutine.running + return M +end + +------------------------------------------------------------------------------- +-- Implements xpcall with coroutines +------------------------------------------------------------------------------- + +local performResume, handleReturnValue +local oldpcall, oldxpcall = pcall, xpcall +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } +end +local running = coroutine.running +local coromap = setmetatable({}, { __mode = 'k' }) + +local function id(trace) + return trace +end + +local function filter_outer_tb(tb) + if type(tb) ~= 'string' or tb == '' then + return nil + end + + local kept = {} + for line in tb:gmatch('[^\n]+') do + if line ~= 'stack traceback:' + and not line:match('^%s*$') + and not line:match('%(tail call%)') + and not line:find('coxpcall.lua', 1, true) + and not line:find("in function 'coroutine.resume'", 1, true) + and not line:find('handleReturnValue', 1, true) + and not line:find('performResume', 1, true) + then + kept[#kept + 1] = line + end + end + + if #kept == 0 then + return nil + end + return table.concat(kept, '\n') +end + +local function splice_chain(tb_inner, co, marker) + if type(tb_inner) ~= 'string' or tb_inner == '' then + return tb_inner + end + if not (debug and debug.traceback) then + return tb_inner + end + + marker = marker or "\t[C]: in function 'xpcall'" + + local out = tb_inner + local parent = coromap[co] + + while parent do + -- Level 3: drop the debug.traceback frame and the splice helper. + local tb_outer = debug.traceback(parent, '', 3) + tb_outer = filter_outer_tb(tb_outer) or '' + + if tb_outer and tb_outer ~= '' then + out = out .. '\n' .. marker .. '\n' .. tb_outer + end + + parent = coromap[parent] + end + + return out +end + +function handleReturnValue(err, co, status, ...) + if not status then + -- Error path from coroutine.resume(co, ...) + if err == id then + -- pcall semantics: propagate the original error object unchanged + return false, ... + end + + local e = ... + + -- Compute the failing coroutine traceback and splice in outer call-site frames. + local tb + if debug and debug.traceback then + tb = debug.traceback(co, tostring(e)) + tb = splice_chain(tb, co) + else + tb = tostring(e) + end + + -- Preserve idiom: xpcall(f, debug.traceback) + if err == debug.traceback then + return false, tb + end + + -- Call handler with (error_object, traceback_string). A 1-arg handler + -- will ignore the second argument. + local ok_h, handled = oldpcall(err, e, tb) + if not ok_h then + -- If the handler itself faults, xpcall reports that fault. + return false, handled + end + return false, handled + end + + if coroutine.status(co) == 'suspended' then + return performResume(err, co, coroutine.yield(...)) + else + return true, ... + end +end + +function performResume(err, co, ...) + return handleReturnValue(err, co, coroutine.resume(co, ...)) +end + +local function coxpcall(f, err, ...) + local current = running() + if not current then + -- Not in a coroutine: fall back to normal pcall/xpcall + if err == id then + return oldpcall(f, ...) + else + if select('#', ...) > 0 then + local oldf, params = f, pack(...) + f = function () return oldf(unpack(params, 1, params.n)) end + end + return oldxpcall(f, err) + end + else + local res, co = oldpcall(coroutine.create, f) + if not res then + local newf = function (...) return f(...) end + co = coroutine.create(newf) + end + coromap[co] = current + return performResume(err, co, ...) + end +end + +local function corunning(coro) + if coro ~= nil then + assert(type(coro) == 'thread', + 'Bad argument; expected thread, got: ' .. type(coro)) + else + coro = running() + end + while coromap[coro] do + coro = coromap[coro] + end + if coro == 'mainthread' then return nil end + return coro +end + +------------------------------------------------------------------------------- +-- Implements pcall with coroutines +------------------------------------------------------------------------------- + +local function copcall(f, ...) + return coxpcall(f, id, ...) +end + +M.pcall = copcall +M.xpcall = coxpcall +M.running = corunning + +return M diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers.lua new file mode 100644 index 00000000..5172d7b4 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers.lua @@ -0,0 +1,137 @@ +-- fibers.lua +---@module 'fibers' + +local Op = require 'fibers.op' +local Runtime = require 'fibers.runtime' +local Scope = require 'fibers.scope' +local Performer = require 'fibers.performer' + +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } +end + +local function raise_string(err) + if type(err) == 'string' or type(err) == 'number' then + error(err, 0) + end + error(tostring(err), 0) +end + +---------------------------------------------------------------------- +-- Core entry point +---------------------------------------------------------------------- + +--- Run a main function under the scheduler's root scope. +--- +--- main_fn is called as main_fn(scope, ...). +--- +--- On success: +--- returns ...results... from main_fn directly. +--- +--- On failure or cancellation: +--- raises a string/number (never a table). +--- +---@param main_fn fun(s: any, ...): any +---@param ... any +---@return any ... +local function run(main_fn, ...) + if Runtime.current_fiber() then error('fibers.run must not be called from inside a fiber', 2) end + if type(main_fn) ~= 'function' then error('fibers.run expects a function', 2) end + + local root = Scope.root() + local args = pack(...) + + local box = { + status = nil, -- 'ok'|'cancelled'|'failed' + primary = nil, -- primary/reason (for non-ok) + results = nil, -- packed results (for ok) + -- report = nil, -- optional: ScopeReport + } + + root:spawn(function () + -- Scope.run returns: + -- on ok: 'ok', rep, ...results... + -- on not ok: st, rep, primary + local r = pack(Scope.run(main_fn, unpack(args, 1, args.n))) + + local st = r[1] + -- local rep = r[2] + box.status = st + -- box.report = rep + + if st == 'ok' then + -- Preserve multi-return values for handoff back to the caller. + if r.n > 2 then + box.results = pack(unpack(r, 3, r.n)) + else + box.results = pack() + end + else + box.primary = r[3] + end + + Runtime.stop() + end) + + Runtime.main() + + if box.status == 'ok' then + local res = box.results + if res and res.n and res.n > 0 then + return unpack(res, 1, res.n) + end + return + end + + raise_string(box.primary or box.status or 'fibers.run: missing status') +end + +---------------------------------------------------------------------- +-- Spawn +---------------------------------------------------------------------- + +--- Spawn a fiber under the current scope. +--- fn is called as fn(...). +---@param fn fun(...): any +---@param ... any +---@return boolean ok, any|nil err +local function spawn(fn, ...) + if type(fn) ~= 'function' then error('fibers.spawn expects a function', 2) end + + local s = Scope.current() + local args = { ... } + + local function shim(_, ...) + return fn(...) + end + + return s:spawn(shim, unpack(args)) +end + +return { + spawn = spawn, + run = run, + + perform = Performer.perform, + + now = Runtime.now, + + choice = Op.choice, + guard = Op.guard, + with_nack = Op.with_nack, + always = Op.always, + never = Op.never, + bracket = Op.bracket, + + race = Op.race, + first_ready = Op.first_ready, + named_choice = Op.named_choice, + boolean_choice = Op.boolean_choice, + + -- Scope utilities re-exported + run_scope = Scope.run, -- now returns: st, rep, ... + run_scope_op = Scope.run_op, -- now yields: st, rep, ... + set_unscoped_error_handler = Scope.set_unscoped_error_handler, + current_scope = Scope.current, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/alarm.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/alarm.lua new file mode 100644 index 00000000..b9569272 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/alarm.lua @@ -0,0 +1,254 @@ +-- fibers/alarm.lua +-- +-- Wall-clock based alarms integrated with the fibers runtime. +-- +-- Each alarm is driven by a recurrence function: +-- next_time(last_fired :: epoch|nil, now :: epoch) -> next_epoch|nil +-- +-- Semantics: +-- * When next_time returns nil, the alarm becomes exhausted. +-- * When the alarm fires, callers receive: +-- true, alarm, last_fired_epoch, ... +-- * Once the alarm is exhausted, callers receive exactly once: +-- false, "no_more_recurrences", alarm, last_fired_epoch|nil +-- after which further wait_op() calls never fire. +-- * Multiple alarms scheduled for the same wall time will fire +-- in successive synchronisations (via CML choice and per-alarm state). +-- +-- Time initialisation: +-- * alarm.new() and alarm:wait_op() are safe before real time is known. +-- * No wait_op() will complete until set_time_source(...) has been called. +-- * A wait_op() started before set_time_source will first wait for time +-- to become ready, then for the next recurrence. +-- +-- Clock / civil time changes: +-- * alarm.time_changed() notifies all waiting alarms that the mapping +-- from monotonic time to civil time (UTC/zone) has changed. +-- * A wait_op() that is currently sleeping for the next recurrence +-- will be pre-empted, recompute its next firing time, and sleep again. +-- * This uses best-effort choice semantics: if a timer and a clock +-- change become ready together, either may win the choice. + +local op = require 'fibers.op' +local sleep_mod = require 'fibers.sleep' +local perform = require 'fibers.performer'.perform +local cond_mod = require 'fibers.cond' +local time = require 'fibers.utils.time' + +---@class Alarm +---@field _next_time fun(last: number|nil, now: number): number|nil +---@field _policy any +---@field _label string +---@field _last number|nil +---@field _next_wall number|nil +---@field _state "active"|"exhausted_pending"|"exhausted_done" +local Alarm = {} +Alarm.__index = Alarm + +---------------------------------------------------------------------- +-- Wall-clock source, readiness, and clock-change signalling +---------------------------------------------------------------------- + +-- Wall-clock "now" function (epoch seconds); replaced once real time is known. +local wall_now = time.realtime +local time_ready = false + +-- One-shot condition for โ€œtime is readyโ€. +local time_ready_cond = cond_mod.new() + +-- Multi-shot condition for โ€œclock / civil time has changedโ€. +-- This is recreated on every change so that each wait sees at most +-- one wake-up per change generation. +local clock_change_cond = cond_mod.new() + +--- Install the wall-clock time source. +--- May be called once, when real time is known (RTC, NTP, GNSS, etc.). +---@param now_fn fun(): number +local function set_time_source(now_fn) + assert(type(now_fn) == 'function', 'set_time_source expects a function') + assert(not time_ready, 'set_time_source may only be called once') + + wall_now = now_fn + time_ready = true + + -- Wake any fibers that were waiting for time to become ready. + time_ready_cond:signal() + + -- Treat "time became known" as a clock change for any alarms that + -- might start waiting after this point. + clock_change_cond:signal() + clock_change_cond = cond_mod.new() +end + +--- Notify alarms that the civil time mapping has changed. +--- Call this when: +--- * the system's wall clock is adjusted (e.g. after NTP sync), or +--- * the time zone used by recurrence functions has changed. +local function time_changed() + if not time_ready then + -- Before time_ready, no alarm has gone past the readiness gate, + -- so there is nothing meaningful to reschedule. + return + end + + clock_change_cond:signal() + clock_change_cond = cond_mod.new() +end + +---------------------------------------------------------------------- +-- Alarm object API +-- +-- Internal state: +-- _state : "active" | "exhausted_pending" | "exhausted_done" +-- _last : last fired wall-clock epoch (or nil) +-- _next_wall : next scheduled wall-clock epoch (or nil) +---------------------------------------------------------------------- + +function Alarm:is_active() + return self._state == 'active' +end + +--- Cancel the alarm permanently. +--- No further firings or exhaustion notification will be delivered. +function Alarm:cancel() + self._state = 'exhausted_done' + self._next_wall = nil +end + +-- Internal: ensure _next_wall is populated or update state on exhaustion. +---@param now number +---@return number|nil +function Alarm:_ensure_next(now) + if self._next_wall or self._state ~= 'active' then + return self._next_wall + end + + local t = self._next_time(self._last, now) + if not t then + -- No further recurrences: schedule exhaustion notification. + self._state = 'exhausted_pending' + return nil + end + + self._next_wall = t + return t +end + +--- Main CML-style operation: wait for the alarm to fire once. +-- +-- Returns an Op which, when performed, yields either: +-- +-- * On successful firing: +-- true, alarm, last_fired_epoch, ... +-- +-- * Once, when the recurrence sequence is exhausted: +-- false, "no_more_recurrences", alarm, last_fired_epoch|nil +-- +-- After the exhaustion notification has been delivered, further +-- wait_op() calls return an Op that never fires. +-- +-- Before set_time_source is called, a wait_op() will first block +-- until time becomes ready, and then behave exactly as if wait_op() +-- had been called afterwards. +-- +-- If alarm.time_changed() is called while this wait_op() is sleeping +-- for its next firing time, the sleep will be pre-empted and the +-- next firing time will be recomputed from the updated civil time. +function Alarm:wait_op() + return op.guard(function () + -- Fully inert: no more results of any kind. + if self._state == 'exhausted_done' then + return op.never() + end + + -- Time not yet initialised: wait once for readiness, then recurse. + if not time_ready then + local ev = time_ready_cond:wait_op() + return ev:wrap(function () + -- At this point, time_ready is true; perform a fresh wait. + return perform(self:wait_op()) + end) + end + + -- Normal path: real time is available. + local now = wall_now() + self:_ensure_next(now) + + -- If the recurrence has just been exhausted, deliver the + -- one-off exhaustion notification and then become inert. + if self._state == 'exhausted_pending' then + self._state = 'exhausted_done' + return op.always(false, 'no_more_recurrences', self, self._last) + end + + -- We have a valid next_wall at this point. + local next_wall = assert(self._next_wall, 'alarm internal error: missing next_wall') + local dt = next_wall - now + if dt < 0 then dt = 0 end + + -- Build a race between: + -- * sleeping until the scheduled time; and + -- * a clock/civil-time change. + -- + -- sleep_op(dt) yields no user-level values on success. + local sleep_ev = sleep_mod.sleep_op(dt) + local change_ev = clock_change_cond:wait_op() + + local choice_ev = op.boolean_choice(sleep_ev, change_ev) + + return choice_ev:wrap(function (is_sleep) + if is_sleep then + -- Timer completed: commit this firing. + self._last = next_wall + self._next_wall = nil + return true, self, self._last + else + -- Clock or time zone changed before the timer fired: + -- clear the stale schedule and recompute under new civil time. + self._next_wall = nil + return perform(self:wait_op()) + end + end) + end) +end + +-- Convenience alias: treat the alarm itself as an Event factory. +Alarm.event = Alarm.wait_op + +---------------------------------------------------------------------- +-- Constructors +---------------------------------------------------------------------- + +---@class AlarmNewParams +---@field next_time fun(last: number|nil, now: number): number|nil +---@field policy any? +---@field label string? + +--- Create a new alarm. +--- +---@param params AlarmNewParams +---@return Alarm +local function new(params) + assert(type(params) == 'table', 'alarm.new expects a parameter table') + local next_time = params.next_time + assert(type(next_time) == 'function', 'alarm.new: next_time function required') + + local self = setmetatable({ + _next_time = next_time, + _policy = params.policy, + _label = params.label or '', + + _last = nil, -- last fired wall-clock epoch + _next_wall = nil, -- next scheduled wall-clock epoch + _state = 'active', -- lifecycle state + }, Alarm) + + return self +end + +return { + Alarm = Alarm, + new = new, + set_time_source = set_time_source, + time_changed = time_changed, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/channel.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/channel.lua new file mode 100644 index 00000000..2568f533 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/channel.lua @@ -0,0 +1,193 @@ +-- fibers.channel +-- Concurrent ML style channels for communication between fibers. +---@module 'fibers.channel' + +local op = require 'fibers.op' +local fifo = require 'fibers.utils.fifo' +local dlist = require 'fibers.utils.dlist' +local perform = require 'fibers.performer'.perform + +--- Bidirectional communication channel between fibers. +---@class Channel +---@field buffer table|nil # optional FIFO buffer (nil for unbuffered) +---@field buffer_size integer +---@field getq table # cancellable wait-list of waiting receivers +---@field putq table # cancellable wait-list of waiting senders +local Channel = {} +Channel.__index = Channel + +--- Create a new channel. +---@param buffer_size? integer # buffered capacity (0 or nil for unbuffered) +---@return Channel +local function new(buffer_size) + buffer_size = buffer_size or 0 + + local buffer = nil + if buffer_size > 0 then + buffer = fifo.new() + end + + return setmetatable({ + buffer = buffer, + buffer_size = buffer_size, + getq = dlist.new(), -- waiting receivers + putq = dlist.new(), -- waiting senders + }, Channel) +end + +---------------------------------------------------------------------- +-- Helpers: cancellable wait-list entries +---------------------------------------------------------------------- + +--- Pop the next entry whose suspension is still waiting, if any. +---@param q any +---@return table|nil +local function pop_active(q) + while not q:empty() do + local entry = q:pop_head() + if not entry.suspension or entry.suspension:waiting() then + return entry + end + end + return nil +end + +---@param entry table +local function cleanup_get_entry(entry) + entry.suspension = nil + entry.wrap = nil +end + +---@param entry table +local function cleanup_put_entry(entry) + entry.val = nil + entry.suspension = nil + entry.wrap = nil +end + +--- Op that sends val on the channel. +--- For unbuffered channels, this synchronises with a receiver; for buffered +--- channels, it may complete when space is available in the buffer. +---@param val any +---@return Op +function Channel:put_op(val) + local getq, putq = self.getq, self.putq + local buffer, buffer_size = self.buffer, self.buffer_size + + local function try() + -- Case 1: rendezvous with a waiting receiver. + local recv = pop_active(getq) + if recv then + recv.suspension:complete(recv.wrap, val) + cleanup_get_entry(recv) + return true + end + -- Case 2: buffered channel with available space. + if buffer and buffer:length() < buffer_size then + buffer:push(val) + return true + end + -- Case 3: no receiver and no buffer space. + return false + end + + --- Enqueue as a waiting sender when the put cannot complete immediately. + ---@param suspension Suspension + ---@param wrap_fn WrapFn + local function block(suspension, wrap_fn) + ---@class ChannelPutEntry + ---@field val any + ---@field suspension Suspension|nil + ---@field wrap WrapFn|nil + local entry = { + val = val, + suspension = suspension, + wrap = wrap_fn, + } + local node = putq:push_tail(entry) + suspension:add_cleanup(function () + if node:remove() then + cleanup_put_entry(entry) + end + end) + end + + return op.new_primitive(nil, try, block) +end + +--- Op that receives a value from the channel. +--- May take from the buffer or rendezvous directly with a sender. +---@return Op +function Channel:get_op() + local getq, putq = self.getq, self.putq + local buffer = self.buffer + + local function pop_sender() + local sender = pop_active(putq) + if not sender then + return nil + end + -- Having chosen this sender, complete its suspension immediately. + sender.suspension:complete(sender.wrap) + return sender + end + + local function try() + local remote = pop_sender() + -- Case 1: take from buffer if there is a buffered value. + if buffer and buffer:length() > 0 then + local v = buffer:pop() + -- If there was a sender waiting, refill the buffer with its value. + if remote then + buffer:push(remote.val) + cleanup_put_entry(remote) + end + return true, v + end + -- Case 2: no buffered value; take directly from a sender. + if remote then + local v = remote.val + cleanup_put_entry(remote) + return true, v + end + -- Case 3: nothing available. + return false + end + + --- Enqueue as a waiting receiver when no value is immediately available. + ---@param suspension Suspension + ---@param wrap_fn WrapFn + local function block(suspension, wrap_fn) + ---@class ChannelGetEntry + ---@field suspension Suspension|nil + ---@field wrap WrapFn|nil + local entry = { + suspension = suspension, + wrap = wrap_fn, + } + local node = getq:push_tail(entry) + suspension:add_cleanup(function () + if node:remove() then + cleanup_get_entry(entry) + end + end) + end + + return op.new_primitive(nil, try, block) +end + +--- Synchronously send message on the channel. +---@param message any +function Channel:put(message) + return perform(self:put_op(message)) +end + +--- Synchronously receive a message from the channel. +---@return any +function Channel:get() + return perform(self:get_op()) +end + +return { + new = new, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/cond.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/cond.lua new file mode 100644 index 00000000..c6b259de --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/cond.lua @@ -0,0 +1,63 @@ +-- fibers/cond.lua +--- +-- Generic condition operations built on top of oneshot and op. +-- A condition can be waited on via an Op or by blocking in the current fiber. +---@module 'fibers.cond' + +local op = require 'fibers.op' +local oneshot = require 'fibers.oneshot' +local perform = require 'fibers.performer'.perform + +--- Condition variable backed by a one-shot. +---@class Cond +---@field _os Oneshot +local Cond = {} +Cond.__index = Cond + +--- Build an Op that becomes ready when the condition fires. +---@return Op +function Cond:wait_op() + local os = self._os + + return op.new_primitive( + nil, + function () + return os:is_triggered() + end, + --- Arrange to complete this suspension when the condition fires. + ---@param resumer Suspension + ---@param wrap_fn WrapFn + function (resumer, wrap_fn) + local cancel = os:add_waiter(function () + if resumer:waiting() then + resumer:complete(wrap_fn) + end + end) + -- ensure the waiter closure does not remain referenced if the wait is cancelled + resumer:add_cleanup(cancel) + end + ) +end + +--- Block the current fiber until the condition fires. +---@return any ... +function Cond:wait() + return perform(self:wait_op()) +end + +--- Signal the condition (idempotent). +function Cond:signal() + return self._os:signal() +end + +--- Create a new condition. +---@return Cond +local function new() + return setmetatable({ + _os = oneshot.new(), -- no extra callback + }, Cond) +end + +return { + new = new, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec.lua new file mode 100644 index 00000000..c4e8d607 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec.lua @@ -0,0 +1,740 @@ +-- fibers/io/exec.lua - Structured process execution bound to scopes +---@module 'fibers.io.exec' + +local Runtime = require 'fibers.runtime' +local ScopeMod = require 'fibers.scope' +local op = require 'fibers.op' +local sleep = require 'fibers.sleep' +local proc_mod = require 'fibers.io.exec_backend' +local stream_mod = require 'fibers.io.stream' + +local ok_probe, leak_probe = pcall(require, 'devicecode.support.leak_probe') +if not ok_probe then leak_probe = nil end + +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } +end + +local DEFAULT_SHUTDOWN_GRACE = 1.0 + +---@alias ExecStdin "inherit"|"null"|"pipe"|Stream +---@alias ExecStdout "inherit"|"null"|"pipe"|Stream +---@alias ExecStderr "inherit"|"null"|"pipe"|"stdout"|Stream + +--- ExecSpec: argv[1] is the programme to exec; argv[2..n] are its arguments. +---@class ExecSpec +---@field [integer] string # argv elements (1..n) +---@field cwd string|nil +---@field env table|nil +---@field flags table|nil +---@field stdin ExecStdin|nil +---@field stdout ExecStdout|nil +---@field stderr ExecStderr|nil +---@field shutdown_grace number|nil + +--- Normalised stream configuration passed through to the backend. +---@class ExecStreamConfig +---@field mode "inherit"|"null"|"pipe"|"stdout"|"stream" +---@field stream Stream|nil +---@field owned boolean # whether the Command owns and will close the stream + +---@alias CommandStatus "pending"|"running"|"exited"|"signalled"|"failed" + +--- Process handle returned by the backend. +---@class ProcHandle +---@field backend ExecBackend +---@field stdin Stream|nil +---@field stdout Stream|nil +---@field stderr Stream|nil + +--- Structured process command bound to a scope. +---@class Command +---@field _scope Scope +---@field _argv string[] +---@field _cwd string|nil +---@field _env table|nil +---@field _flags table +---@field _stdin ExecStreamConfig +---@field _stdout ExecStreamConfig +---@field _stderr ExecStreamConfig +---@field _shutdown_grace number +---@field _started boolean +---@field _done boolean +---@field _proc ProcHandle|nil +---@field _pid integer|nil +---@field _status CommandStatus +---@field _code integer|nil +---@field _signal integer|nil +---@field _err string|nil +---@field _finalizer_detach fun():boolean|nil +---@field _cleaned boolean +local Command = {} +Command.__index = Command + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +--- Normalise a user-facing stdio configuration into an ExecStreamConfig. +---@param value ExecStdin|ExecStdout|ExecStderr|Stream|nil +---@param is_stderr boolean +---@return ExecStreamConfig +local function norm_stream(value, is_stderr) + if value == nil then + return { mode = 'inherit', stream = nil, owned = true } + end + + local t = type(value) + if t == 'string' then + if value == 'inherit' or value == 'null' or value == 'pipe' then + return { mode = value, stream = nil, owned = true } + end + if is_stderr and value == 'stdout' then + return { mode = 'stdout', stream = nil, owned = true } + end + error('invalid stdio mode: ' .. tostring(value)) + end + + if stream_mod.is_stream(value) then + -- A user-supplied stream is not owned by the Command. + return { mode = 'stream', stream = value, owned = false } + end + + error('invalid stdio configuration: ' .. tostring(value)) +end + +---@param self Command +local function assert_not_started(self) + if self._started then + error('command already started') + end +end + +--- Perform an op using the current scope when it is still running, otherwise fall back to raw. +--- +--- Important: use Scope:try (status-first) rather than Scope:perform to avoid raising +--- cancellation sentinels from inside op commit/wrap code paths. +---@param ev Op +---@return any ... +local function perform_with_scope_or_raw(ev) + local s = ScopeMod.current() + + -- Scope:status() returns (st, v). We only care about the first. + local st = s and s.status and s:status() or nil + if s and s.try and st == 'running' then + local r = pack(s:try(ev)) + local rst = r[1] + if rst == 'ok' then + return unpack(r, 2, r.n) + end + -- If cancelled/failed, return a conventional triple where the last + -- value carries an error string. This avoids throwing from here. + local msg = r[2] + if msg == nil then + msg = (rst == 'cancelled') and 'scope cancelled' or 'scope failed' + end + return nil, nil, tostring(msg) + end + + return op.perform_raw(ev) +end + +---------------------------------------------------------------------- +-- Internal: process lifecycle bookkeeping +---------------------------------------------------------------------- + +--- Record a final exit status for this command, if not already done. +---@param code integer|nil +---@param signal integer|nil +---@param err string|nil +function Command:_record_exit(code, signal, err) + if self._done then return end + self._done = true + + if err then + self._status, self._err = 'failed', err + elseif signal ~= nil then + self._status, self._signal = 'signalled', signal + elseif code ~= nil then + self._status, self._code = 'exited', code + else + self._status, self._err = 'failed', 'unknown process status' + end + + self._code, self._signal = code, signal + if leak_probe then leak_probe.exec_exit(self._probe_id, self._status, code, signal, err) end +end + +--- Detach the scope finaliser once command-owned resources have been retired. +--- +--- Long-lived service scopes create many short-lived exec.Command objects. The +--- scope finaliser closure captures the command, so keeping the finaliser until +--- service shutdown retains every completed command, its backend and owned +--- streams. Terminal commands must therefore retire themselves instead of +--- waiting for scope exit. +---@param why string +function Command:_detach_finalizer(why) + local detach = self._finalizer_detach + if detach then + self._finalizer_detach = nil + local ok, err = pcall(detach) + if not ok and leak_probe then + leak_probe.bump('exec.finalizer_detach_error') + end + end +end + +--- Release command-owned resources after the backend has reached a terminal +--- state. This is deliberately immediate and non-yielding so it is safe from +--- Op wrap callbacks and from PUC Lua pcall boundaries. Stream:terminate() +--- closes owned fds synchronously; it does not try to drain or wait. +---@param why string +function Command:_cleanup_terminal(why) + if self._cleaned then return end + if not self._done then return end + + self:_detach_finalizer(why) + + for _, name in ipairs { 'stdin', 'stdout', 'stderr' } do + local cfg = self['_' .. name] + if cfg and cfg.stream and cfg.owned then + local ok, err = pcall(function () cfg.stream:terminate('exec_terminal') end) + if not ok and leak_probe then + leak_probe.bump('exec.stream_terminate_error') + end + cfg.stream = nil + end + end + + if self._proc and self._proc.backend then + local ok, err = self._proc.backend:close() + if not ok and leak_probe then + leak_probe.bump('exec.backend_close_error') + end + self._proc.backend = nil + end + self._proc = nil + self._cleaned = true + if leak_probe then leak_probe.exec_cleaned(self._probe_id, why or 'terminal') end +end + +--- Ensure the process has been started and a ProcHandle exists. +---@return boolean ok +---@return ProcHandle|nil proc +---@return string|nil err +function Command:_ensure_started() + if self._started then + if self._proc then + return true, self._proc, nil + end + if self._status == 'failed' then + return false, nil, self._err + end + return false, nil, 'exec: command started without backend' + end + self._started = true + + -- High-level process spec passed to the backend. + local spec = { + argv = self._argv, + cwd = self._cwd, + env = self._env, + flags = self._flags, + stdin = self._stdin, + stdout = self._stdout, + stderr = self._stderr, + } + + -- Backend returns a ProcHandle: + -- { backend = ExecBackend, stdin = Stream|nil, stdout = Stream|nil, stderr = Stream|nil } + local proc_handle, start_err = proc_mod.start(spec) + if not proc_handle then + self._status = 'failed' + self._done = true + self._err = start_err + if leak_probe then leak_probe.exec_exit(self._probe_id, 'failed', nil, nil, start_err) end + self:_cleanup_terminal('start_failed') + return false, nil, start_err + end + + self._proc = proc_handle + self._pid = proc_handle.backend and proc_handle.backend.pid or nil + self._status = 'running' + if leak_probe then leak_probe.exec_started(self._probe_id, self._pid) end + + -- If the backend created pipe streams for us, record them and mark them as owned. + if proc_handle.stdin then + self._stdin.stream = proc_handle.stdin + self._stdin.owned = true + end + if proc_handle.stdout then + self._stdout.stream = proc_handle.stdout + self._stdout.owned = true + end + if proc_handle.stderr then + self._stderr.stream = proc_handle.stderr + self._stderr.owned = true + end + + return true, proc_handle, nil +end + +---------------------------------------------------------------------- +-- Configuration setters +---------------------------------------------------------------------- + +function Command:set_stdin(v) + assert_not_started(self) + self._stdin = norm_stream(v, false) + return self +end + +function Command:set_stdout(v) + assert_not_started(self) + self._stdout = norm_stream(v, false) + return self +end + +function Command:set_stderr(v) + assert_not_started(self) + self._stderr = norm_stream(v, true) + return self +end + +function Command:set_cwd(v) + assert_not_started(self) + self._cwd = v + return self +end + +function Command:set_env(v) + assert_not_started(self) + self._env = v + return self +end + +function Command:set_flags(v) + assert_not_started(self) + self._flags = v or {} + return self +end + +function Command:set_shutdown_grace(v) + assert_not_started(self) + self._shutdown_grace = v + return self +end + +---------------------------------------------------------------------- +-- Introspection +---------------------------------------------------------------------- + +function Command:status() + local st = self._status + + if st == 'exited' then + return st, self._code, self._err + elseif st == 'signalled' then + return st, self._signal, self._err + elseif st == 'failed' then + return st, nil, self._err + elseif st == 'pending' or st == 'running' then + return st, nil, nil + end + + return st, nil, self._err +end + +function Command:pid() + return self._pid +end + +function Command:argv() + local out = {} + for i, v in ipairs(self._argv) do + out[i] = v + end + return out +end + +---------------------------------------------------------------------- +-- Signalling +---------------------------------------------------------------------- + +function Command:kill(sig) + if self._done then + return true, nil + end + if not self._started then + return false, 'command not started' + end + if self._status == 'failed' then + return false, self._err or 'command failed to start' + end + + local backend = self._proc and self._proc.backend or nil + if not backend then + return false, 'no backend available' + end + + if sig ~= nil and backend.send_signal then + return backend:send_signal(sig) + end + + if backend.kill then + return backend:kill() + elseif backend.terminate then + return backend:terminate() + elseif backend.send_signal then + return backend:send_signal() + end + + return false, 'backend does not support signalling' +end + +---------------------------------------------------------------------- +-- Stream accessors +---------------------------------------------------------------------- + +function Command:stdin_stream() + local cfg = self._stdin + if cfg.mode == 'inherit' or cfg.mode == 'null' then + return nil + end + if cfg.mode == 'stream' then + return cfg.stream + end + if cfg.mode == 'pipe' then + local ok, _, err = self:_ensure_started() + return ok and self._stdin.stream or nil, err + end + return nil +end + +function Command:stdout_stream() + local cfg = self._stdout + if cfg.mode == 'inherit' or cfg.mode == 'null' then + return nil + end + if cfg.mode == 'stream' then + return cfg.stream + end + if cfg.mode == 'pipe' then + local ok, _, err = self:_ensure_started() + return ok and self._stdout.stream or nil, err + end + return nil +end + +function Command:stderr_stream() + local cfg = self._stderr + if cfg.mode == 'inherit' or cfg.mode == 'null' then + return nil + end + if cfg.mode == 'stream' then + return cfg.stream + end + if cfg.mode == 'pipe' then + local ok, _, err = self:_ensure_started() + return ok and self._stderr.stream or nil, err + end + if cfg.mode == 'stdout' then + return self:stdout_stream() + end + return nil +end + +---------------------------------------------------------------------- +-- Ops: wait/run/shutdown/output +---------------------------------------------------------------------- + +function Command:run_op() + return op.guard(function () + local ok, proc, err = self:_ensure_started() + if not ok or not proc then + return op.always('failed', nil, nil, err) + end + if self._done then + return op.always(self._status, self._code, self._signal, self._err) + end + + return proc.backend:wait_op():wrap(function (...) + self:_record_exit(...) + local status, code, signal, err = self._status, self._code, self._signal, self._err + self:_cleanup_terminal('terminal') + return status, code, signal, err + end) + end) +end + +function Command:shutdown_op(grace) + return op.guard(function () + local ok, proc, err = self:_ensure_started() + if not (ok and proc) then + return op.always('failed', nil, nil, err) + end + if self._done then + return op.always(self._status, self._code, self._signal, self._err) + end + + local g = grace or self._shutdown_grace or DEFAULT_SHUTDOWN_GRACE + + -- Polite termination: delegate behaviour to backend. + if proc.backend and proc.backend.terminate then + proc.backend:terminate() + elseif proc.backend and proc.backend.send_signal then + proc.backend:send_signal() + end + + local choice_ev = op.boolean_choice( + self:run_op():wrap(function (status, code, signal, e) + return true, status, code, signal, e + end), + sleep.sleep_op(g):wrap(function () + return false + end) + ) + + return choice_ev:wrap(function (is_exit, status, code, signal, e) + if is_exit then + return status, code, signal, e + end + + -- Grace period elapsed. Try a forceful kill. + local kill_err + if proc.backend then + if proc.backend.kill then + local ok2, err2 = proc.backend:kill() + if not ok2 and err2 then + kill_err = err2 + end + elseif proc.backend.send_signal then + local ok2, err2 = proc.backend:send_signal() + if not ok2 and err2 then + kill_err = err2 + end + end + end + + -- Wait for completion, using the current scope when running (status-first), + -- otherwise falling back to raw waiting. + local code2, signal2, err2 = perform_with_scope_or_raw(proc.backend:wait_op()) + self:_record_exit(code2, signal2, err2) + local err_final = kill_err or err2 + return self._status, self._code, self._signal, err_final + end) + end) +end + +function Command:output_op() + return op.guard(function () + -- If stdout is currently inherited, default to piping for this helper. + if not self._started and (self._stdout.mode == 'inherit' or self._stdout.mode == nil) then + self._stdout = norm_stream('pipe', false) + end + + local ok, _, err = self:_ensure_started() + if not ok then + return op.always('', 'failed', nil, nil, err) + end + + local stream, serr = self:stdout_stream() + if not stream then + return op.always('', 'failed', nil, nil, serr or 'no stdout stream available') + end + + return stream:read_all_op():wrap(function (out, io_err) + local status, code, signal, perr = perform_with_scope_or_raw(self:run_op()) + local err_final = io_err or perr + return out or '', status, code, signal, err_final + end) + end) +end + +function Command:combined_output_op() + if self._stderr.mode == 'pipe' or self._stderr.mode == 'stream' then + error('combined_output_op: stderr must not already be a pipe or stream') + end + if not self._started and (self._stderr.mode == 'inherit' or self._stderr.mode == nil) then + self._stderr = norm_stream('stdout', true) + end + return self:output_op() +end + +---------------------------------------------------------------------- +-- Finaliser-only shutdown (non-interruptible) +---------------------------------------------------------------------- + +--- Best-effort shutdown used during scope finalisation. +--- This path must not be interruptible by scope cancellation. +---@param grace number|nil +function Command:_shutdown_uninterruptible(grace) + if not (self._started and not self._done) then + return + end + + local ok, proc = self:_ensure_started() + if not (ok and proc and proc.backend) then + return + end + + local g = grace or self._shutdown_grace or DEFAULT_SHUTDOWN_GRACE + + -- Polite termination. + if proc.backend.terminate then + proc.backend:terminate() + elseif proc.backend.send_signal then + proc.backend:send_signal() + end + + -- Race exit against grace timer without involving scope cancellation. + local is_exit, _, _, _, _ = op.perform_raw( + op.boolean_choice( + self:run_op():wrap(function (st, c, sig, perr) + return true, st, c, sig, perr + end), + sleep.sleep_op(g):wrap(function () + return false + end) + ) + ) + + if not is_exit then + -- Escalate. + if proc.backend.kill then + proc.backend:kill() + elseif proc.backend.send_signal then + proc.backend:send_signal() + end + + -- Ensure the process is waited for (uninterruptible). + local code2, signal2, err2 = op.perform_raw(proc.backend:wait_op()) + self:_record_exit(code2, signal2, err2) + self:_cleanup_terminal('scope_exit') + + -- Preserve any earlier status data if present. + -- (status/code/signal/e are unused here by design.) + return + end + + -- If it exited during the grace period, run_op has already recorded status. + -- Nothing more to do here. + return +end + +---------------------------------------------------------------------- +-- Scope cleanup +---------------------------------------------------------------------- + +function Command:_on_scope_exit() + if self._cleaned then return end + if leak_probe then leak_probe.exec_scope_exit(self._probe_id) end + if self._started and not self._done then + -- Non-interruptible best-effort shutdown. + self:_shutdown_uninterruptible(self._shutdown_grace) + end + + for _, name in ipairs { 'stdin', 'stdout', 'stderr' } do + local cfg = self['_' .. name] + if cfg.stream and cfg.owned then + local ok, err = op.perform_raw(cfg.stream:close_op()) + if not ok then + error(err or ('failed to close ' .. name .. ' stream')) + end + cfg.stream = nil + end + end + + if self._proc and self._proc.backend then + local ok, err = self._proc.backend:close() + if not ok then + error(err or 'failed to close process backend') + end + self._proc.backend = nil + end + self._proc = nil + self._cleaned = true + if leak_probe then leak_probe.exec_cleaned(self._probe_id, 'scope_exit') end +end + +---------------------------------------------------------------------- +-- Command construction +---------------------------------------------------------------------- + +---@param spec ExecSpec +---@return Command +local function command_from_spec(spec) + assert(Runtime.current_fiber(), 'exec.command must be called from inside a fiber') + local scope = ScopeMod.current() + + local argv = {} + local i = 1 + while spec[i] ~= nil do + argv[i] = assert(spec[i], 'argv must not contain nil') + i = i + 1 + end + assert(argv[1], 'exec.command: argv[1] must be non-nil') + + local probe_id = leak_probe and leak_probe.exec_next_id() or nil + local cmd = setmetatable({ + _probe_id = probe_id, + _scope = scope, + _argv = argv, + _cwd = spec.cwd, + _env = spec.env, + _flags = spec.flags or {}, + _stdin = norm_stream(spec.stdin, false), + _stdout = norm_stream(spec.stdout, false), + _stderr = norm_stream(spec.stderr, true), + _shutdown_grace = spec.shutdown_grace or DEFAULT_SHUTDOWN_GRACE, + _started = false, + _done = false, + _proc = nil, + _pid = nil, + _status = 'pending', + _code = nil, + _signal = nil, + _err = nil, + _finalizer_detach = nil, + _cleaned = false, + }, Command) + + if leak_probe then leak_probe.exec_created(probe_id, scope and scope._id or nil, argv) end + + cmd._finalizer_detach = scope:finally(function () + cmd:_on_scope_exit() + end) + + return cmd +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +local exec = {} + +---@class ExecBackendModule +---@field start fun(spec: ExecSpec): ProcHandle|nil, string|nil + +---@overload fun(spec: ExecSpec): Command +---@param ... any +---@return Command +function exec.command(...) + local n = select('#', ...) + if n == 1 and type((...)) == 'table' then + return command_from_spec((...)) + end + + assert(n > 0, 'exec.command: at least one argv element required') + local spec = {} + for i = 1, n do + spec[i] = assert(select(i, ...), 'argv must not contain nil') + end + return command_from_spec(spec) +end + +exec.Command = Command + +return exec diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend.lua new file mode 100644 index 00000000..7778775a --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend.lua @@ -0,0 +1,45 @@ +-- +-- Backend selector for process management. +-- Prefers pidfd backend where available, falls back to SIGCHLD/self-pipe. +-- +---@module 'fibers.io.exec_backend' + +---@class ExecProcSpec +---@field argv string[] +---@field env table|nil +---@field cwd string|nil +---@field flags table|nil +---@field stdin ExecStreamConfig +---@field stdout ExecStreamConfig +---@field stderr ExecStreamConfig + +--- Backend module interface. +---@class ExecBackendModule +---@field is_supported fun(): boolean +---@field start fun(spec: ExecProcSpec): ProcHandle|nil, string|nil + +---@type string[] +local candidates = { + 'fibers.io.exec_backend.pidfd', -- Linux pidfd backend + 'fibers.io.exec_backend.sigchld', -- Portable SIGCHLD + self-pipe backend (luaposix) + 'fibers.io.exec_backend.nixio', -- Portable SIGCHLD + self-pipe backend (nixio) +} + +---@type ExecBackendModule|nil +local chosen + +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + ---@cast mod ExecBackendModule + chosen = mod + break + end +end + +if not chosen then + error('fibers.io.exec_backend: no suitable process backend available on this platform') +end + +---@return ExecBackendModule +return chosen diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/core.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/core.lua new file mode 100644 index 00000000..73740e39 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/core.lua @@ -0,0 +1,175 @@ +-- fibers/io/exec_backend/core.lua +-- +-- Core glue for exec backends. +-- +-- This owns the public ExecBackend shape and semantics. +-- Backend modules provide only low-level primitives; build_backend +-- wires those into a concrete { start, ExecBackend, is_supported } module. +-- +---@module 'fibers.io.exec_backend.core' + +local waitmod = require 'fibers.wait' + +---@class ExecBackend +---@field pid integer|nil +---@field exited boolean +---@field status integer|nil +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil +---@field _state any +---@field _ops table +local ExecBackend = {} +ExecBackend.__index = ExecBackend + +--- Wait for the process to complete, returning (code, signal, err). +---@return Op +function ExecBackend:wait_op() + local ops = self._ops + local state = self._state + + local function step() + if self.exited then + return true, self.code, self.signal, self.err + end + + local done, code, signal, err = ops.poll(state) + if not done then + return false + end + + self.exited = true + self.code = code + self.signal = signal + self.err = err + return true, self.code, self.signal, self.err + end + + ---@param task Task + ---@param suspension Suspension + ---@param leaf_wrap WrapFn + local function register(task, suspension, leaf_wrap) + return ops.register_wait(state, task, suspension, leaf_wrap) + end + + local function wrap(code, signal, err) + return code, signal, err + end + + return waitmod.waitable(register, step, wrap) +end + +function ExecBackend:send_signal(sig) + local ops = self._ops + if ops.send_signal then + return ops.send_signal(self._state, sig) + end + return false, 'backend does not support send_signal' +end + +function ExecBackend:terminate() + local ops = self._ops + if ops.terminate then + return ops.terminate(self._state) + elseif ops.send_signal then + return ops.send_signal(self._state, nil) + end + return false, 'backend does not support terminate' +end + +function ExecBackend:kill() + local ops = self._ops + if ops.kill then + return ops.kill(self._state) + elseif ops.send_signal then + return ops.send_signal(self._state, nil) + end + return false, 'backend does not support kill' +end + +function ExecBackend:close() + local ops = self._ops + if ops.close then + return ops.close(self._state) + end + return true, nil +end + +---------------------------------------------------------------------- +-- Backend builder +---------------------------------------------------------------------- + +--- Build a concrete exec backend module from low-level ops. +--- +--- Required ops: +--- spawn(spec) -> state, streams, err|nil +--- state : backend-private state (must at least contain state.pid) +--- streams : { stdin = Stream|nil, stdout = Stream|nil, stderr = Stream|nil } +--- +--- poll(state) -> done:boolean, code|nil, signal|nil, err|nil +--- Non-blocking; done=false means โ€œstill runningโ€. +--- +--- register_wait(state, task, suspension, leaf_wrap) -> WaitToken +--- Register a Task to be run when progress may have been made. +--- +--- Optional ops: +--- send_signal(state, sig) -> ok:boolean, err|nil +--- terminate(state) -> ok:boolean, err|nil +--- kill(state) -> ok:boolean, err|nil +--- close(state) -> ok:boolean, err|nil +--- is_supported() -> boolean +--- +---@param ops table +---@return table backend_module -- { start = fn, ExecBackend = ExecBackend, is_supported = fn } +local function build_backend(ops) + assert(type(ops) == 'table', 'exec_backend ops must be a table') + assert(type(ops.spawn) == 'function', 'ops.spawn must be a function') + assert(type(ops.poll) == 'function', 'ops.poll must be a function') + assert(type(ops.register_wait) == 'function', 'ops.register_wait must be a function') + + local function start(spec) + local state, streams, err = ops.spawn(spec) + if not state then + return nil, err + end + + local backend = setmetatable({ + _ops = ops, + _state = state, + + pid = state.pid, -- for introspection + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + }, ExecBackend) + + local handle = { + backend = backend, + stdin = streams and streams.stdin or nil, + stdout = streams and streams.stdout or nil, + stderr = streams and streams.stderr or nil, + } + + return handle, nil + end + + local function is_supported() + if type(ops.is_supported) == 'function' then + return not not ops.is_supported() + end + return true + end + + return { + ExecBackend = ExecBackend, + start = start, + is_supported = is_supported, + } +end + +return { + ExecBackend = ExecBackend, + build_backend = build_backend, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/nixio.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/nixio.lua new file mode 100644 index 00000000..3bdf2d5c --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/nixio.lua @@ -0,0 +1,636 @@ +-- fibers/io/exec_backend/nixio.lua +-- +-- Nixio-based exec backend using a per-command โ€œreaperโ€ process and +-- a sentinel pipe for completion notifications. +-- +-- Topology per command: +-- parent +-- โ”œโ”€ reaper (Lua, this module) +-- โ”‚ โ””โ”€ child (exec'ed programme) +-- โ””โ”€ sentinel_r (read end of status pipe) +-- +-- Protocol on the sentinel pipe: +-- - reaper writes: "pid \n" +-- - later writes one of: +-- "exited \n" +-- "signaled \n" +-- "failed \n" +-- +-- Parent uses poller to wait for sentinel_r readability; when data +-- arrives, it parses lines and updates backend state. +-- +-- Uses nixio File objects as fds throughout. + +local core = require 'fibers.io.exec_backend.core' +local poller = require 'fibers.io.poller' +local runtime = require 'fibers.runtime' +local file_io = require 'fibers.io.file' +local stdio = require 'fibers.io.exec_backend.stdio' + +local ok, nixio = pcall(require, 'nixio') +if not ok or not nixio then + return { is_supported = function () return false end } +end + +local const = nixio.const or {} + +local unpack = rawget(table, 'unpack') or _G.unpack + +local DEV_NULL = '/dev/null' + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function errno_msg(prefix) + local eno = nixio.errno() + local estr = (nixio.strerror and nixio.strerror(eno)) or ('errno ' .. tostring(eno)) + if prefix then + return ('%s: %s'):format(prefix, estr) + end + return estr +end + +local function close_fd(f) + if f and f.close then + pcall(function () f:close() end) + end +end + +---------------------------------------------------------------------- +-- Stdio integration for exec_backend.stdio +---------------------------------------------------------------------- + +--- Open /dev/null for input or output (child side). +---@param is_output boolean +---@return any fd, string|nil err +local function open_dev_null(is_output) + local mode = is_output and 'w' or 'r' + local f, err = nixio.open(DEV_NULL, mode) + if not f then + return nil, err or errno_msg('open ' .. DEV_NULL) + end + return f, nil +end + +--- Create a pipe (child <-> parent). +---@return any rd, any wr, string|nil err +local function make_pipe() + local rd, wr = nixio.pipe() + if not rd or not wr then + return nil, nil, errno_msg('pipe') + end + return rd, wr, nil +end + +--- We rely on explicit close() in child/reaper instead of close-on-exec. +---@param _ any +---@return boolean, string|nil +local function set_cloexec(_) + return true, nil +end + +--- Wrap a parent-side fd into a Stream. +---@param role "stdin"|"stdout"|"stderr" +---@param fd any -- nixio File +---@return Stream +local function open_stream(role, fd) + if role == 'stdin' then + return file_io.fdopen(fd, 'w') + else + return file_io.fdopen(fd, 'r') + end +end + +---------------------------------------------------------------------- +-- Child exec path (runs in the real child process) +---------------------------------------------------------------------- + +--- Duplicate src onto dest_fd (0/1/2) using nixio.dup with nixio.stdin/stdout/stderr. +---@param src any -- nixio File +---@param dest_fd integer +local function setup_child_fd(src, dest_fd) + if not src then + return + end + + local curfd = src:fileno() + if curfd == dest_fd then + return + end + + local dest + if dest_fd == 0 then + dest = nixio.stdin + elseif dest_fd == 1 then + dest = nixio.stdout + elseif dest_fd == 2 then + dest = nixio.stderr + else + -- exec_backend.stdio only uses 0/1/2. + os.exit(127) + end + + local dup, _ = nixio.dup(src, dest) + if not dup then + os.exit(127) + end +end + +local function apply_child_env(env) + for name, value in pairs(env) do + if value == nil then + nixio.setenv(name) -- unset + else + local ok1, _ = nixio.setenv(name, tostring(value)) + if not ok1 then + os.exit(127) + end + end + end +end + +---@param child_spec table -- child-facing spec with *fd fields +---@param child_only table|nil +---@param parent_fds table|nil +---@param sentinel_w any|nil -- nixio File, closed in child +local function child_exec(child_spec, child_only, parent_fds, sentinel_w) + if sentinel_w then + close_fd(sentinel_w) + end + + if child_spec.cwd then + local ok1, _ = nixio.chdir(child_spec.cwd) + if not ok1 then + os.exit(127) + end + end + + if child_spec.flags and child_spec.flags.setsid then + local sid, _ = nixio.setsid() + if not sid then + os.exit(127) + end + end + + if child_spec.env then + apply_child_env(child_spec.env) + end + + setup_child_fd(child_spec.stdin_fd, 0) + setup_child_fd(child_spec.stdout_fd, 1) + setup_child_fd(child_spec.stderr_fd, 2) + + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + + local argv = child_spec.argv + local prog = assert(argv[1], 'child_exec: argv[1] must be non-nil') + + -- execp(executable, ...) sets argv[0] automatically. + local n = #argv + if n == 1 then + nixio.execp(prog) + else + local args = {} + for i = 2, n do + args[#args + 1] = argv[i] + end + nixio.execp(prog, unpack(args)) + end + + os.exit(127) +end + +---------------------------------------------------------------------- +-- Backend state helpers and parsing +---------------------------------------------------------------------- + +---@class NixioExecState +---@field reaper_pid integer -- pid of the reaper process +---@field pid integer|nil -- for introspection; updated to child pid when known +---@field child_pid integer|nil -- pid of the real exec'ed child +---@field sentinel any -- nixio File (read end) +---@field exited boolean +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil +---@field _buf string|nil +---@field _have_status boolean|nil +---@field _reaper_reaped boolean|nil + +local function parse_status_line_into_state(line, state) + line = line:gsub('\r', '') + local tag, rest = line:match('^(%S+)%s*(.*)$') + if not tag then + state.err = state.err or 'invalid status line from reaper' + state.exited = true + state._have_status = true + return + end + + if tag == 'pid' then + local cpid = tonumber(rest) + if cpid then + state.child_pid = cpid + state.pid = state.pid or cpid + end + return + elseif tag == 'exited' then + local code = tonumber(rest) or 0 + state.code = code + state.signal = nil + state.err = state.err or nil + state.exited = true + state._have_status = true + return + elseif tag == 'signaled' or tag == 'signalled' then + local sig = tonumber(rest) or 0 + state.code = nil + state.signal = sig + state.err = state.err or nil + state.exited = true + state._have_status = true + return + elseif tag == 'failed' then + local msg = rest ~= '' and rest or 'exec backend failed' + state.code = nil + state.signal = nil + state.err = msg + state.exited = true + state._have_status = true + return + else + state.err = state.err or ("unknown status tag '" .. tostring(tag) .. "'") + state.exited = true + state._have_status = true + return + end +end + +local function reap_reaper(state) + if state._reaper_reaped or not state.reaper_pid then + return + end + local pid, _, _ = nixio.waitpid(state.reaper_pid, 'nohang') + if pid and pid ~= 0 then + state._reaper_reaped = true + end +end + +---------------------------------------------------------------------- +-- Reaper process path +---------------------------------------------------------------------- + +--- Run in the per-command reaper process. +---@param child_spec table +---@param child_only table|nil +---@param parent_fds table|nil +---@param sentinel_r any -- nixio File (parent-side read end, close here) +---@param sentinel_w any -- nixio File (reaper-side writer) +local function reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) + -- Reaper does not need parent pipe ends or parent's sentinel read end. + stdio.close_parent_fds(parent_fds, close_fd) + close_fd(sentinel_r) + + -- Fork the real child. + local child_pid, err = nixio.fork() + if not child_pid then + if sentinel_w then + sentinel_w:write('failed ' .. (err or errno_msg('fork')) .. '\n') + close_fd(sentinel_w) + end + os.exit(127) + end + + if child_pid == 0 then + -- In the real child. + child_exec(child_spec, child_only, parent_fds, sentinel_w) + os.exit(127) + end + + -- In the reaper. + stdio.close_child_only(child_only, close_fd) + + -- Tell parent the real child pid. + if sentinel_w then + pcall(function () + sentinel_w:write(('pid %d\n'):format(child_pid)) + end) + end + + -- Wait for the real child to exit. + local pid, how, what + while true do + pid, how, what = nixio.waitpid(child_pid) + if pid ~= nil then + break + end + local eno = nixio.errno() + if eno ~= const.EINTR then + break + end + end + + local line + if not pid then + line = 'failed ' .. errno_msg('waitpid') .. '\n' + else + if how == 'exited' then + local code = tonumber(what) or 0 + line = ('exited %d\n'):format(code) + elseif how == 'signaled' or how == 'signalled' then + local sig = tonumber(what) or 0 + line = ('signaled %d\n'):format(sig) + else + line = ('failed unexpected %s %s\n'):format(tostring(how), tostring(what)) + end + end + + if sentinel_w then + pcall(function () + sentinel_w:write(line) + sentinel_w:close() + end) + end + + os.exit(0) +end + +---------------------------------------------------------------------- +-- Polling of sentinel in the parent +---------------------------------------------------------------------- + +--- Non-blocking poll of the sentinel pipe. +---@param state NixioExecState +---@return boolean done, integer|nil code, integer|nil signal, string|nil err +local function poll_state(state) + if state.exited then + return true, state.code, state.signal, state.err + end + + if not state.sentinel then + -- Sentinel has gone away without a status line. + if not state._have_status then + state.exited = true + state.err = state.err or 'reaper sentinel closed' + end + reap_reaper(state) + return true, state.code, state.signal, state.err + end + + local bufsize = const.buffersize or 256 + + while true do + local chunk, _ = state.sentinel:read(bufsize) + + if not chunk then + local eno = nixio.errno() + if eno == const.EAGAIN or eno == const.EWOULDBLOCK or eno == const.EINTR then + -- Nothing available right now. + break + end + + -- Hard error; treat as completion if we do not yet have a status. + close_fd(state.sentinel) + state.sentinel = nil + if not state._have_status then + state.exited = true + state.err = state.err or 'reaper sentinel closed' + end + reap_reaper(state) + break + end + + if #chunk == 0 then + -- EOF: writer closed pipe. + close_fd(state.sentinel) + state.sentinel = nil + if not state._have_status then + state.exited = true + state.err = state.err or 'reaper sentinel closed' + end + reap_reaper(state) + break + end + + state._buf = (state._buf or '') .. chunk + + while true do + local line, rest = state._buf:match('^(.-)\n(.*)$') + if not line then + break + end + state._buf = rest + parse_status_line_into_state(line, state) + end + + if state.exited then + close_fd(state.sentinel) + state.sentinel = nil + reap_reaper(state) + break + end + end + + if state.exited then + return true, state.code, state.signal, state.err + else + return false, nil, nil, nil + end +end + +---------------------------------------------------------------------- +-- exec_backend.core ops +---------------------------------------------------------------------- + +--- spawn(spec) -> state, streams, err +---@param spec ExecProcSpec +---@return NixioExecState|nil state,{stdin:Stream|nil,stdout:Stream|nil,stderr:Stream|nil}|nil streams,string|nil err +local function spawn(spec) + assert(type(spec) == 'table', 'ExecBackend.spawn: spec must be a table') + assert(type(spec.argv) == 'table' and spec.argv[1], + 'ExecBackend.spawn: spec.argv must be a non-empty array') + + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + -- Sentinel pipe: reaper writes, parent reads. + local sentinel_r, sentinel_w = nixio.pipe() + if not sentinel_r or not sentinel_w then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('pipe (sentinel)') + end + + -- We will use the sentinel in blocking mode temporarily for a + -- handshake to learn the real child pid, then switch to non-blocking. + sentinel_r:setblocking(true) + + -- Fork the per-command reaper. + local reaper_pid, ferr = nixio.fork() + if not reaper_pid then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + close_fd(sentinel_r) + close_fd(sentinel_w) + return nil, nil, ferr or errno_msg('fork (reaper)') + end + + if reaper_pid == 0 then + reaper_main(child_spec, child_only, parent_fds, sentinel_r, sentinel_w) + os.exit(127) + end + + -- Parent. + stdio.close_child_only(child_only, close_fd) + close_fd(sentinel_w) + + local state = { + reaper_pid = reaper_pid, + pid = reaper_pid, -- will be updated once child pid is known + child_pid = nil, + sentinel = sentinel_r, + exited = false, + code = nil, + signal = nil, + err = nil, + _buf = '', + _have_status = false, + _reaper_reaped = false, + } + + -- Handshake: read sentinel until we have seen a pid line and/or a + -- terminal status. This guarantees child_pid is known before any + -- external code can attempt to send signals. + local bufsize = const.buffersize or 256 + + while not state.child_pid and not state._have_status do + local chunk, rerr = sentinel_r:read(bufsize) + if not chunk then + close_fd(sentinel_r) + state.sentinel = nil + return nil, nil, rerr or errno_msg('sentinel handshake read') + end + if #chunk == 0 then + close_fd(sentinel_r) + state.sentinel = nil + return nil, nil, 'sentinel closed during handshake' + end + + state._buf = (state._buf or '') .. chunk + + while true do + local line, rest = state._buf:match('^(.-)\n(.*)$') + if not line then + break + end + state._buf = rest + parse_status_line_into_state(line, state) + end + end + + -- Switch sentinel to non-blocking for normal event-loop use. + sentinel_r:setblocking(false) + + local streams = stdio.build_parent_streams(parent_fds, open_stream) + + return state, streams, nil +end + +--- poll(state) -> done, code, signal, err +local function poll_backend(state) + return poll_state(state) +end + +--- register_wait(state, task, suspension, leaf_wrap) -> WaitToken +local function register_wait(state, task, _, _) + if not state.sentinel then + -- No fd to wait on; reschedule once so that step() can see terminal state. + local sched = runtime.current_scheduler + if sched and sched.schedule then + sched:schedule(task) + end + return { unlink = function () return false end } + end + + return poller.get():wait(state.sentinel, 'rd', task) +end + +--- Send a signal to the real child. +---@param state NixioExecState +---@param sig integer|nil +---@return boolean ok, string|nil err +local function send_signal(state, sig) + sig = sig or const.SIGTERM or 15 + + -- If already finished, nothing to do. + if state.exited then + return true, nil + end + + -- Process any queued sentinel data (should not normally change + -- child_pid, as the handshake has already seen the pid line). + poll_state(state) + + if state.exited then + return true, nil + end + + local target = state.child_pid + if not target then + -- As a last resort, fall back to the reaper pid; this should be + -- unreachable in normal operation because the handshake ensures + -- child_pid is known. + target = state.reaper_pid or state.pid + end + if not target then + return false, 'no child or reaper pid available' + end + + local ok1, err = nixio.kill(target, sig) + if not ok1 then + return false, err or errno_msg('kill') + end + return true, nil +end + +local function terminate(state) + return send_signal(state, const.SIGTERM or 15) +end + +local function kill_proc(state) + return send_signal(state, const.SIGKILL or 9) +end + +local function close_state(state) + close_fd(state.sentinel) + state.sentinel = nil + reap_reaper(state) + return true, nil +end + +local function is_supported() + return type(nixio) == 'table' + and type(nixio.fork) == 'function' + and type(nixio.waitpid) == 'function' + and type(nixio.execp) == 'function' + and type(nixio.pipe) == 'function' + and type(nixio.open) == 'function' +end + +local ops = { + spawn = spawn, + poll = poll_backend, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/pidfd.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/pidfd.lua new file mode 100644 index 00000000..b301be4e --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/pidfd.lua @@ -0,0 +1,568 @@ +-- fibers/io/exec_backend/pidfd.lua +-- +-- Linux pidfd-based process backend. +-- Uses fork + execvp + raw pidfd_open syscall + non-blocking waitpid. +-- +---@module 'fibers.io.exec_backend.pidfd' + +local core = require 'fibers.io.exec_backend.core' +local poller = require 'fibers.io.poller' +local runtime = require 'fibers.runtime' +local ffi_c = require 'fibers.utils.ffi_compat' +local file_io = require 'fibers.io.file' +local stdio = require 'fibers.io.exec_backend.stdio' + +local ffi = ffi_c.ffi +local C = ffi_c.C +local toint = ffi_c.tonumber +local get_errno = ffi_c.errno +local DEV_NULL = '/dev/null' + +local bit = rawget(_G, 'bit') or require 'bit32' + +---------------------------------------------------------------------- +-- FFI / CFFI availability +---------------------------------------------------------------------- + +if not (ffi_c.is_supported and ffi_c.is_supported()) then + return { is_supported = function () return false end } +end + +---------------------------------------------------------------------- +-- FFI declarations and constants +---------------------------------------------------------------------- + +local jit = rawget(_G, "jit") +local ARCH = ffi.arch or ((jit and jit.arch) or 'x64') + +ffi.cdef [[ + typedef int pid_t; + typedef unsigned int uint; + + long syscall(long number, ...); + + pid_t fork(void); + void _exit(int status); + int chdir(const char *path); + int setenv(const char *name, const char *value, int overwrite); + pid_t setsid(void); + int execvp(const char *file, char *const argv[]); + + int kill(pid_t pid, int sig); + int close(int fd); + + int fcntl(int fd, int cmd, ...); + + pid_t waitpid(pid_t pid, int *wstatus, int options); + pid_t getpid(void); + + int dup2(int oldfd, int newfd); + + int pipe(int pipefd[2]); + int open(const char *pathname, int flags, int mode); + + char *strerror(int errnum); +]] + +-- Raw syscall number for pidfd_open. +local SYS_pidfd_open = 434 -- Linux generic +if ARCH == 'mips' or ARCH == 'mipsel' then + -- See https://www.linux-mips.org/wiki/Syscall + SYS_pidfd_open = 4000 + 434 +end + +-- fcntl constants (Linux) +local F_GETFL = 3 +local F_SETFL = 4 +local F_GETFD = 1 +local F_SETFD = 2 +local O_NONBLOCK = 0x00000800 +local FD_CLOEXEC = 1 + +-- Minimal open() flags we need here. +local O_RDONLY = 0 +local O_WRONLY = 1 + +-- wait/errno constants (Linux) +local WNOHANG = 1 + +local EINTR = 4 +local ESRCH = 3 +local ECHILD = 10 +local ENOSYS = 38 + +-- Signals (Linux values). +local SIGTERM = 15 +local SIGKILL = 9 + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function strerror(e) + local s = C.strerror(e) + if s == nil then + return 'errno ' .. tostring(e) + end + return ffi.string(s) +end + +local function errno_msg(prefix, err, eno) + if err and err ~= '' then + return err + end + if eno then + return ('%s (errno %d)'):format(prefix, eno) + end + return prefix +end + +-- In the child we must not return to Lua on fatal errors. +local function must_child(ok) + if not ok then + C._exit(127) + end +end + +---------------------------------------------------------------------- +-- Raw pidfd_open syscall (musl/glibc-independent) +---------------------------------------------------------------------- + +local function pidfd_open_raw(pid, flags) + pid = ffi.new('pid_t', pid) + flags = ffi.new('uint', flags or 0) + + local fd = toint(C.syscall(SYS_pidfd_open, pid, flags)) + if fd == -1 then + local e = get_errno() + return nil, strerror(e), e + end + return fd, nil, nil +end + +---------------------------------------------------------------------- +-- fcntl helpers: set_nonblock / set_cloexec +---------------------------------------------------------------------- + +local getfl_fp = ffi.cast('int (*)(int, int)', C.fcntl) +local setfl_fp = ffi.cast('int (*)(int, int, int)', C.fcntl) + +local function set_nonblock(fd) + local before = assert(toint(getfl_fp(fd, F_GETFL))) + if before < 0 then + local e = get_errno() + return false, ('F_GETFL failed: %s'):format(strerror(e)), e + end + + local new_flags = bit.bor(before, O_NONBLOCK) + local rc = toint(setfl_fp(fd, F_SETFL, new_flags)) + if rc < 0 then + local e = get_errno() + return false, ('F_SETFL failed: %s'):format(strerror(e)), e + end + + -- Optional sanity check. + local after = assert(toint(getfl_fp(fd, F_GETFL))) + if after < 0 then + local e = get_errno() + return false, ('F_GETFL (post) failed: %s'):format(strerror(e)), e + end + + if bit.band(after, O_NONBLOCK) == 0 then + return false, + ('set_nonblock: O_NONBLOCK not set after F_SETFL; before=0x%x after=0x%x') + :format(before, after), + nil + end + + return true, nil, nil +end + +local getfd_fp = ffi.cast('int (*)(int, int)', C.fcntl) +local setfd_fp = ffi.cast('int (*)(int, int, int)', C.fcntl) + +local function set_cloexec(fd) + local before = assert(toint(getfd_fp(fd, F_GETFD))) + if before < 0 then + local e = get_errno() + return false, ('F_GETFD failed: %s'):format(strerror(e)), e + end + + local new_flags = bit.bor(before, FD_CLOEXEC) + local rc = toint(setfd_fp(fd, F_SETFD, new_flags)) + if rc < 0 then + local e = get_errno() + return false, ('F_SETFD failed: %s'):format(strerror(e)), e + end + + return true, nil, nil +end + +---------------------------------------------------------------------- +-- waitpid helpers (status inspection) +---------------------------------------------------------------------- + +local function WIFEXITED(status) + return bit.band(status, 0x7f) == 0 +end + +local function WEXITSTATUS(status) + return bit.rshift(status, 8) +end + +local function WIFSIGNALED(status) + local term = bit.band(status, 0x7f) + return term ~= 0 and term ~= 0x7f +end + +local function WTERMSIG(status) + return bit.band(status, 0x7f) +end + +---------------------------------------------------------------------- +-- Child-side helpers: argv/env/fd setup +---------------------------------------------------------------------- + +local function build_argv_c(argv) + local n = #argv + local cargv = ffi.new('char *[?]', n + 1) + + for i = 1, n do + local s = assert(argv[i], 'argv must not contain nil') + local cs = ffi.new('char[?]', #s + 1) + ffi.copy(cs, s) + cargv[i - 1] = cs + end + cargv[n] = nil + + return cargv +end + +local function setup_child_fd(src_fd, dest_fd) + if not src_fd or src_fd == dest_fd then + return + end + local rc = toint(C.dup2(src_fd, dest_fd)) + if rc < 0 then + must_child(false) + end +end + +local function apply_child_env(env) + for name, value in pairs(env) do + local v = value and tostring(value) or nil + local rc = C.setenv(name, v, 1) -- value == nil clears the variable + if rc ~= 0 then + must_child(false) + end + end +end + +---@param spec table -- child-facing spec with *fd fields +local function child_exec(spec) + if spec.cwd then + local rc = C.chdir(spec.cwd) + must_child(rc == 0) + end + + if spec.flags and spec.flags.setsid then + local rc = toint(C.setsid()) + must_child(rc ~= -1) + end + + if spec.env then + apply_child_env(spec.env) + end + + setup_child_fd(spec.stdin_fd, 0) + setup_child_fd(spec.stdout_fd, 1) + setup_child_fd(spec.stderr_fd, 2) + + do + local seen = {} + for _, fd in ipairs { spec.stdin_fd, spec.stdout_fd, spec.stderr_fd } do + if fd and fd > 2 and not seen[fd] then + seen[fd] = true + C.close(fd) + end + end + end + + local argv = spec.argv + local cargv = build_argv_c(argv) + + C.execvp(argv[1], cargv) + + -- If we reach here, execvp failed. + C._exit(127) +end + +---------------------------------------------------------------------- +-- Backend state helpers +---------------------------------------------------------------------- + +---@class PidfdState +---@field pid integer +---@field pidfd integer|nil +---@field exited boolean +---@field status integer|nil +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil + +local function finalise_state(st, status, code, signal, err) + if st.exited then + return + end + st.exited = true + st.status = status + st.code = code + st.signal = signal + st.err = err +end + +--- Blocking wait used only in the error path after a failed pidfd_open. +local function wait_blocking(pid) + local status_buf = ffi.new('int[1]') + while true do + local rpid = toint(C.waitpid(pid, status_buf, 0)) + if rpid == -1 then + local e = get_errno() + if e ~= EINTR then + return + end + else + -- Child reaped. + return + end + end +end + +--- Non-blocking wait on a single child. +---@param st PidfdState +---@return boolean done, integer|nil code, integer|nil signal, string|nil err +local function poll_state(st) + if st.exited then + return true, st.code, st.signal, st.err + end + + local status_buf = ffi.new('int[1]') + local rpid = toint(C.waitpid(st.pid, status_buf, WNOHANG)) + + if rpid == 0 then + -- Still running. + return false, nil, nil, nil + end + + if rpid == -1 then + local e = get_errno() + if e == ECHILD or e == ESRCH then + -- Child already gone or reaped elsewhere. + finalise_state(st, nil, nil, nil, nil) + return true, st.code, st.signal, st.err + end + finalise_state(st, nil, nil, nil, errno_msg('waitpid failed', nil, e)) + return true, st.code, st.signal, st.err + end + + local status = status_buf[0] + + if WIFEXITED(status) then + local code = WEXITSTATUS(status) + finalise_state(st, status, code, nil, nil) + elseif WIFSIGNALED(status) then + local sig = WTERMSIG(status) + finalise_state(st, status, nil, sig, nil) + else + -- Stopped/continued or other odd state; treat as โ€œcompleted, detail unknownโ€. + finalise_state(st, status, nil, nil, nil) + end + + return true, st.code, st.signal, st.err +end + +---------------------------------------------------------------------- +-- Stream / stdio integration via exec_stdio +---------------------------------------------------------------------- + +local function open_dev_null(is_output) + local flags = is_output and O_WRONLY or O_RDONLY + local fd = toint(C.open(DEV_NULL, flags, 0)) + if fd < 0 then + local e = get_errno() + return nil, errno_msg('failed to open ' .. DEV_NULL, nil, e) + end + return fd, nil +end + +local function make_pipe() + local pipefd = ffi.new('int[2]') + local rc = toint(C.pipe(pipefd)) + if rc ~= 0 then + local e = get_errno() + return nil, nil, errno_msg('pipe() failed', nil, e) + end + return toint(pipefd[0]), toint(pipefd[1]), nil +end + +local function close_fd(fd) + C.close(fd) +end + +local function open_stream(role, fd) + if role == 'stdin' then + return file_io.fdopen(fd, O_WRONLY) + else + -- stdout / stderr + return file_io.fdopen(fd, O_RDONLY) + end +end + +---------------------------------------------------------------------- +-- Backend ops for exec_backend.core +---------------------------------------------------------------------- + +--- spawn(spec) -> state, streams, err +---@param spec ExecProcSpec +---@return PidfdState|nil state, {stdin:Stream|nil, stdout:Stream|nil, stderr:Stream|nil}|nil streams, string|nil err +local function spawn(spec) + assert(type(spec) == 'table', 'ExecBackend.spawn: spec must be a table') + assert(type(spec.argv) == 'table' and spec.argv[1], + 'ExecBackend.spawn: spec.argv must be a non-empty array') + + -- Common stdio wiring. + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + -- Fork. + local pid = toint(C.fork()) + if pid < 0 then + local e = get_errno() + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('fork failed', nil, e) + end + + if pid == 0 then + child_exec(child_spec) -- never returns + end + + -- Parent: child-only fds no longer needed. + stdio.close_child_only(child_only, close_fd) + + -- Open pidfd. + local pidfd, perr, perrno = pidfd_open_raw(pid, 0) + if not pidfd then + C.kill(pid, SIGKILL) + wait_blocking(pid) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('pidfd_open failed', perr, perrno) + end + + local ok, e1 = set_nonblock(pidfd) + assert(ok, 'set_nonblock(pidfd) failed: ' .. tostring(e1)) + + ok, e1 = set_cloexec(pidfd) + assert(ok, 'set_cloexec(pidfd) failed: ' .. tostring(e1)) + + local state = { + pid = pid, + pidfd = pidfd, + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + } + + -- Common mapping from parent_fds -> Streams. + local streams = stdio.build_parent_streams(parent_fds, open_stream) + + return state, streams, nil +end + +--- poll(state) -> done, code, signal, err +local function poll(state) + return poll_state(state) +end + +--- register_wait(state, task, suspension, leaf_wrap) -> WaitToken +local function register_wait(state, task, _, _) + if not state.pidfd then + -- No pidfd: best-effort reschedule. + runtime.current_scheduler:schedule(task) + return { unlink = function () return false end } + end + return poller.get():wait(state.pidfd, 'rd', task) +end + +local function send_signal(state, sig) + sig = sig or SIGTERM + + local rc = toint(C.kill(state.pid, sig)) + if rc == 0 then + return true, nil + end + + local e = get_errno() + if e == ESRCH then + return true, nil + end + + return false, errno_msg('kill failed', nil, e) +end + +local function terminate(state) + return send_signal(state, SIGTERM) +end + +local function kill_proc(state) + return send_signal(state, SIGKILL) +end + +local function close_state(state) + if state.pidfd then + local rc = toint(C.close(state.pidfd)) + state.pidfd = nil + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + end + return true, nil +end + +---------------------------------------------------------------------- +-- Capability probe +---------------------------------------------------------------------- + +local function is_supported() + local pid = C.getpid() + local fd, _, eno = pidfd_open_raw(pid, 0) + if fd then + C.close(fd) + return true + end + + if eno == ENOSYS then + return false + end + + return true +end + +local ops = { + spawn = spawn, + poll = poll, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/sigchld.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/sigchld.lua new file mode 100644 index 00000000..b6f77a79 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/sigchld.lua @@ -0,0 +1,492 @@ +-- fibers/io/exec_backend/sigchld.lua +-- +-- SIGCHLD + self-pipe process backend (luaposix only). +-- +-- Portable POSIX backend for systems without pidfd_open. +-- Uses: +-- - SIGCHLD handler that writes to a non-blocking self-pipe +-- - poller watching the pipe +-- - wait(WNOHANG) per tracked child PID +-- - a Waitset to wake fibers blocked in wait_op(). +-- +---@module 'fibers.io.exec_backend.sigchld' + +local core = require 'fibers.io.exec_backend.core' +local unistd = require 'posix.unistd' +local syswait = require 'posix.sys.wait' +local psignal = require 'posix.signal' +local fcntl = require 'posix.fcntl' +local errno = require 'posix.errno' +local stdlib = require 'posix.stdlib' + +local poller = require 'fibers.io.poller' +local waitmod = require 'fibers.wait' +local runtime = require 'fibers.runtime' +local file_io = require 'fibers.io.file' +local stdio = require 'fibers.io.exec_backend.stdio' + +local bit = rawget(_G, 'bit') or require 'bit32' +local jit = rawget(_G, "jit") + +local DEV_NULL = '/dev/null' + +---------------------------------------------------------------------- +-- Global state for SIGCHLD handling +---------------------------------------------------------------------- + +--- All children we are responsible for: pid -> SigchldState +local children = {} + +--- Waiters keyed by pid: Waitset from fibers.wait. +local waiters = waitmod.new_waitset() + +--- Scheduler used to reschedule waiters; populated when the reaper starts. +---@type Scheduler|nil +local child_sched + +--- Self-pipe file descriptors. +---@type integer|nil +local sig_r +---@type integer|nil +local sig_w + +--- Reaper task flag. +local reaper_started = false + +local function errno_msg(prefix, err, eno) + if err and err ~= '' then + return err + end + if eno then + return ('%s (errno %d)'):format(prefix, eno) + end + return prefix +end + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function set_nonblock(fd) + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFL) + if flags == nil then + return nil, errno_msg('fcntl(F_GETFL)', err, eno) + end + local newflags = bit.bor(flags, fcntl.O_NONBLOCK) + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) + if ok == nil then + return nil, errno_msg('fcntl(F_SETFL)', err2, eno2) + end + return true +end + +local function set_cloexec(fd) + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFD) + if flags == nil then + return nil, errno_msg('fcntl(F_GETFD)', err, eno) + end + local newflags = bit.bor(flags, fcntl.FD_CLOEXEC or 0) + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFD, newflags) + if ok == nil then + return nil, errno_msg('fcntl(F_SETFD)', err2, eno2) + end + return true +end + +local function must_child(ok, _, _) + if not ok or ok == 0 then + unistd._exit(127) + end +end + +local function build_argt(argv) + local cmd = assert(argv[1], 'ProcSpec.argv[1] must be executable') + local argt = {} + argt[0] = cmd + for i = 2, #argv do + argt[i - 1] = argv[i] + end + return cmd, argt +end + +local function setup_child_fd(src_fd, dest_fd) + if src_fd == nil or src_fd == dest_fd then + return + end + local newfd, err, eno = unistd.dup2(src_fd, dest_fd) + if not newfd then + must_child(false, err, eno) + end +end + +local function apply_child_env(env) + for name, value in pairs(env) do + local ok, err, eno = stdlib.setenv(name, value and tostring(value) or nil) + if ok == nil then + must_child(false, err, eno) + end + end +end + +---------------------------------------------------------------------- +-- SIGCHLD self-pipe and reaper +---------------------------------------------------------------------- + +local function install_self_pipe_and_handler() + if sig_r ~= nil then + return + end + + local r, w, err, eno = unistd.pipe() + if not r then + error('exec_backend.sigchld: pipe() failed: ' .. errno_msg('pipe', err, eno)) + end + + local ok1, e1 = set_nonblock(r) + local ok2, e2 = set_nonblock(w) + local ok3, e3 = set_cloexec(r) + local ok4, e4 = set_cloexec(w) + if not (ok1 and ok2 and ok3 and ok4) then + if r then unistd.close(r) end + if w then unistd.close(w) end + error('exec_backend.sigchld: failed to configure self-pipe: ' + .. tostring(e1 or e2 or e3 or e4)) + end + + sig_r, sig_w = r, w + + local function handler() + unistd.write(sig_w, 'x') + end + + if jit and jit.off then + jit.off(handler, true) + end + + local flags = psignal.SA_RESTART + local old, serr, seno + if flags ~= nil then + old, serr, seno = psignal.signal(psignal.SIGCHLD, handler, flags) + else + old, serr, seno = psignal.signal(psignal.SIGCHLD, handler) + end + if not old and serr then + error('exec_backend.sigchld: signal(SIGCHLD) failed: ' + .. errno_msg('signal', serr, seno)) + end +end + +---------------------------------------------------------------------- +-- Backend state helpers +---------------------------------------------------------------------- + +---@class SigchldState +---@field pid integer +---@field exited boolean +---@field status integer|nil +---@field code integer|nil +---@field signal integer|nil +---@field err string|nil + +local function finalise_state(st, status, code, signal, err) + if st.exited then + return + end + st.exited = true + st.status = status + st.code = code + st.signal = signal + st.err = err +end + +local function poll_state(st) + if st.exited then + return true, st.code, st.signal, st.err + end + return false, nil, nil, nil +end + +local function drain_self_pipe() + if not sig_r then return end + + while true do + local s, _, eno = unistd.read(sig_r, 4096) + if s == nil then + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + break + end + break + end + if #s < 4096 then + break + end + end +end + +local function reap_known_children() + local to_remove = {} + + for pid, st in pairs(children) do + if st and not st.exited then + local rpid, how, v3, err, eno = syswait.wait(pid, syswait.WNOHANG) + if rpid == nil then + if eno == errno.ECHILD then + finalise_state(st, nil, nil, nil, nil) + to_remove[#to_remove + 1] = pid + elseif eno ~= errno.EINTR then + finalise_state(st, nil, nil, nil, errno_msg('wait failed', err, eno)) + to_remove[#to_remove + 1] = pid + end + elseif how ~= 'running' then + if how == 'exited' then + local code = v3 + finalise_state(st, v3, code, nil, nil) + else + local sig = v3 + finalise_state(st, v3, nil, sig, nil) + end + to_remove[#to_remove + 1] = pid + end + end + end + + if child_sched then + for i = 1, #to_remove do + local pid = to_remove[i] + children[pid] = nil + waiters:notify_all(pid, child_sched) + end + else + for i = 1, #to_remove do + children[to_remove[i]] = nil + end + end +end + +---@class ReaperTask : Task +local ReaperTask = {} +ReaperTask.__index = ReaperTask + +function ReaperTask:run() + if not sig_r or not child_sched then + return + end + + self.armed = false + drain_self_pipe() + reap_known_children() + + if sig_r then + self.armed = true + poller.get():wait(sig_r, 'rd', self) + end +end + +local function start_reaper() + if reaper_started then + return + end + + install_self_pipe_and_handler() + + reaper_started = true + child_sched = runtime.current_scheduler + + local task = setmetatable({}, ReaperTask) + if not task.armed then + task.armed = true + poller.get():wait(sig_r, 'rd', task) + end +end + +---------------------------------------------------------------------- +-- Child side exec +---------------------------------------------------------------------- + +---@param spec table -- child-facing spec with *fd fields +local function child_exec(spec) + if spec.cwd then + local ok, err, eno = unistd.chdir(spec.cwd) + if not ok then + must_child(false, err, eno) + end + end + + if spec.flags and spec.flags.setsid then + local res, err, eno + if unistd.setsid then + res, err, eno = unistd.setsid() + elseif unistd.setpid then + res, err, eno = unistd.setpid('s', 0) + end + if res == nil then + must_child(false, err, eno) + end + end + + if spec.env then + apply_child_env(spec.env) + end + + setup_child_fd(spec.stdin_fd, 0) + setup_child_fd(spec.stdout_fd, 1) + setup_child_fd(spec.stderr_fd, 2) + + do + local seen = {} + for _, fd in ipairs { spec.stdin_fd, spec.stdout_fd, spec.stderr_fd } do + if fd and fd > 2 and not seen[fd] then + seen[fd] = true + unistd.close(fd) + end + end + end + + local cmd, argt = build_argt(spec.argv) + unistd.execp(cmd, argt) + unistd._exit(127) +end + +---------------------------------------------------------------------- +-- Stream / stdio integration via exec_stdio +---------------------------------------------------------------------- + +local function open_dev_null(is_output) + local flags = is_output and fcntl.O_WRONLY or fcntl.O_RDONLY + local fd, err, eno = fcntl.open(DEV_NULL, flags, 0) + if not fd then + return nil, errno_msg('failed to open ' .. DEV_NULL, err, eno) + end + return fd, nil +end + +local function make_pipe() + local rd, wr, err, eno = unistd.pipe() + if not rd then + return nil, nil, errno_msg('pipe() failed', err, eno) + end + return rd, wr, nil +end + +local function close_fd(fd) + unistd.close(fd) +end + +local function open_stream(role, fd) + if role == 'stdin' then + return file_io.fdopen(fd, fcntl.O_WRONLY) + else + return file_io.fdopen(fd, fcntl.O_RDONLY) + end +end + +---------------------------------------------------------------------- +-- Backend ops for exec_backend.core +---------------------------------------------------------------------- + +--- spawn(spec) -> state, streams, err +---@param spec ExecProcSpec +---@return SigchldState|nil state, {stdin:Stream|nil, stdout:Stream|nil, stderr:Stream|nil}|nil streams, string|nil err +local function spawn(spec) + assert(type(spec) == 'table', 'ExecBackend.spawn: spec must be a table') + assert(type(spec.argv) == 'table' and spec.argv[1], + 'ExecBackend.spawn: spec.argv must be a non-empty array') + + install_self_pipe_and_handler() + start_reaper() + + -- Common stdio wiring. + local child_spec, child_only, parent_fds, cfg_err = + stdio.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + if not child_spec then + return nil, nil, cfg_err + end + + local pid, err, eno = unistd.fork() + if not pid then + stdio.close_child_only(child_only, close_fd) + stdio.close_parent_fds(parent_fds, close_fd) + return nil, nil, errno_msg('fork failed', err, eno) + end + + if pid == 0 then + child_exec(child_spec) + end + + -- Parent: child-only fds no longer needed. + stdio.close_child_only(child_only, close_fd) + + local state = { + pid = pid, + exited = false, + status = nil, + code = nil, + signal = nil, + err = nil, + } + + children[pid] = state + + local streams = stdio.build_parent_streams(parent_fds, open_stream) + + return state, streams, nil +end + +local function poll(state) + -- Fast path: if the child is already marked as exited, just report it. + if state.exited then + return true, state.code, state.signal, state.err + end + -- Ensure progress even when no scheduler is running + reap_known_children() + return poll_state(state) +end + + +local function register_wait(state, task, _, _) + start_reaper() + return waiters:add(state.pid, task) +end + +local function send_signal(state, sig) + sig = sig or psignal.SIGTERM + local rc, err, eno = psignal.kill(state.pid, sig) + if rc == 0 then + return true, nil + end + if rc == nil and eno == errno.ESRCH then + return true, nil + end + return false, errno_msg('kill failed', err, eno) +end + +local function terminate(state) + return send_signal(state, psignal.SIGTERM) +end + +local function kill_proc(state) + return send_signal(state, psignal.SIGKILL) +end + +local function close_state(state) + waiters:clear_key(state.pid) + return true, nil +end + +local function is_supported() + if rawget(_G, 'jit') then return false end -- rare LuaJit instability + return psignal.SIGCHLD ~= nil +end + +local ops = { + spawn = spawn, + poll = poll, + register_wait = register_wait, + send_signal = send_signal, + terminate = terminate, + kill = kill_proc, + close = close_state, + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/stdio.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/stdio.lua new file mode 100644 index 00000000..7e22c709 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/exec_backend/stdio.lua @@ -0,0 +1,233 @@ +-- fibers/io/exec_backend/stdio.lua +-- +-- Shared stdio wiring for exec backends. +-- +-- Takes ExecStreamConfig values (as constructed by fibers.exec) +-- and turns them into: +-- * child_spec.{stdin_fd, stdout_fd, stderr_fd} +-- * child_only : set of fds only used in the child +-- * parent_fds : { stdin = fd?, stdout = fd?, stderr = fd? } for pipes +-- +---@module 'fibers.io.exec_backend.stdio' + +local M = {} + +---@param s any +---@return integer|nil fd, string|nil err +local function stream_fileno(s) + if type(s) ~= 'table' then + return nil, 'stream is not a table' + end + local io_backend = s.io + if type(io_backend) ~= 'table' or type(io_backend.fileno) ~= 'function' then + return nil, 'stream backend does not support fileno()' + end + return io_backend:fileno() +end + +--- Build child stdio fd mapping and parent pipe ends from a high-level spec. +--- +--- Callbacks: +--- open_dev_null(is_output:boolean) -> fd|nil, err|nil +--- make_pipe() -> rd_fd|nil, wr_fd|nil, err|nil +--- set_cloexec(fd) -> ok:boolean, err|nil +--- close_fd(fd) -> () +--- +---@param spec ExecProcSpec +---@param open_dev_null fun(is_output: boolean): integer|nil, string|nil +---@param make_pipe fun(): integer|nil, integer|nil, string|nil +---@param set_cloexec fun(fd: integer): boolean, string|nil +---@param close_fd fun(fd: integer) +---@return table|nil child_spec, table|nil child_only, table|nil parent_fds, string|nil err +function M.build_child_stdio(spec, open_dev_null, make_pipe, set_cloexec, close_fd) + assert(type(spec) == 'table', 'build_child_stdio: spec must be a table') + assert(type(open_dev_null) == 'function', 'build_child_stdio: open_dev_null must be a function') + assert(type(make_pipe) == 'function', 'build_child_stdio: make_pipe must be a function') + assert(type(set_cloexec) == 'function', 'build_child_stdio: set_cloexec must be a function') + assert(type(close_fd) == 'function', 'build_child_stdio: close_fd must be a function') + + local child_only = {} -- [fd] = true (used only in child) + local parent_fds = {} -- stdin/stdout/stderr -> parent end for pipes + + local child_spec = { + argv = spec.argv, + cwd = spec.cwd, + env = spec.env, + flags = spec.flags, + stdin_fd = nil, + stdout_fd = nil, + stderr_fd = nil, + } + + local function fail(msg) + for fd in pairs(child_only) do + close_fd(fd) + end + for _, fd in pairs(parent_fds) do + if fd then + close_fd(fd) + end + end + return nil, nil, nil, msg + end + + --- Configure a single stdio stream. + --- kind: "stdin" | "stdout" | "stderr" + --- cfg : ExecStreamConfig|nil + local function configure_stream(kind, cfg) + -- Allow callers to omit stdin/stdout/stderr in the spec; treat as inherit. + cfg = cfg or { mode = 'inherit' } + + local is_output = (kind ~= 'stdin') + local field = kind .. '_fd' + local mode = cfg.mode or 'inherit' + + -- inherit + if mode == 'inherit' then + child_spec[field] = nil + return true + + -- /dev/null + elseif mode == 'null' then + local fd, err = open_dev_null(is_output) + if not fd then + return false, err + end + child_only[fd] = true + child_spec[field] = fd + return true + + -- pipe (child โ†” parent) + elseif mode == 'pipe' then + local rd, wr, err = make_pipe() + if not (rd and wr) then + return false, err + end + + local child_fd, parent_fd + if is_output then + -- child writes, parent reads + child_fd, parent_fd = wr, rd + else + -- child reads, parent writes + child_fd, parent_fd = rd, wr + end + + child_only[child_fd] = true + child_spec[field] = child_fd + parent_fds[kind] = parent_fd + + local ok, cerr = set_cloexec(parent_fd) + if not ok then + return false, cerr or ('set_cloexec(' .. kind .. ' parent fd) failed') + end + return true + + -- user-supplied stream: just borrow the underlying fd + elseif mode == 'stream' then + local fd, err = stream_fileno(cfg.stream) + if not fd then + return false, err + end + child_spec[field] = fd + return true + + -- stderr = "stdout" + elseif kind == 'stderr' and mode == 'stdout' then + -- stdout config may itself be missing; default to inherit in that case. + local out_cfg = spec.stdout or { mode = 'inherit' } + local out_mode = out_cfg.mode or 'inherit' + + if out_mode == 'inherit' and child_spec.stdout_fd == nil then + -- Share inherited stdout (fd 1). + child_spec.stderr_fd = 1 + elseif child_spec.stdout_fd ~= nil then + -- Share whatever stdout was configured to use. + child_spec.stderr_fd = child_spec.stdout_fd + else + return false, "stderr='stdout' but stdout not configured" + end + return true + + else + return false, 'invalid ' .. kind .. ' mode: ' .. tostring(mode) + end + end + + + do + local ok, err = configure_stream('stdin', spec.stdin) + if not ok then + return fail(err) + end + end + + do + local ok, err = configure_stream('stdout', spec.stdout) + if not ok then + return fail(err) + end + end + + do + local ok, err = configure_stream('stderr', spec.stderr) + if not ok then + return fail(err) + end + end + + return child_spec, child_only, parent_fds, nil +end + +--- Best-effort close of fds that are only needed in the child. +---@param child_only table|nil +---@param close_fd fun(fd: integer) +function M.close_child_only(child_only, close_fd) + if not child_only then return end + for fd in pairs(child_only) do + close_fd(fd) + end +end + +--- Best-effort close of parent pipe ends (used on error paths). +---@param parent_fds table|nil +---@param close_fd fun(fd: integer) +function M.close_parent_fds(parent_fds, close_fd) + if not parent_fds then return end + for _, fd in pairs(parent_fds) do + if fd then + close_fd(fd) + end + end +end + +--- Map parent pipe fds back into Streams, given an open_stream callback. +--- +--- open_stream(role, fd) -> Stream +--- +---@param parent_fds table|nil +---@param open_stream fun(role: '"stdin"'|'"stdout"'|'"stderr"', fd: integer): Stream +---@return { stdin: Stream|nil, stdout: Stream|nil, stderr: Stream|nil } +function M.build_parent_streams(parent_fds, open_stream) + parent_fds = parent_fds or {} + + local stdin, stdout, stderr + + if parent_fds.stdin then + stdin = open_stream('stdin', parent_fds.stdin) + end + if parent_fds.stdout then + stdout = open_stream('stdout', parent_fds.stdout) + end + if parent_fds.stderr then + stderr = open_stream('stderr', parent_fds.stderr) + end + + return { + stdin = stdin, + stdout = stdout, + stderr = stderr, + } +end + +return M diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend.lua new file mode 100644 index 00000000..d7c34c79 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend.lua @@ -0,0 +1,23 @@ +-- fibers/io/fd_backend.lua +-- +-- FD-backed backend shim. +-- Chooses the best available implementation: +-- 1. FFI-based (no luaposix dependency), +-- 2. luaposix-based (no FFI dependency). +-- +---@module 'fibers.io.fd_backend' + +local candidates = { + 'fibers.io.fd_backend.ffi', -- FFI / libc + 'fibers.io.fd_backend.posix', -- luaposix + 'fibers.io.fd_backend.nixio', -- nixio +} + +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + return mod + end +end + +error('fibers.io.fd_backend: no suitable fd backend available on this platform') diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/core.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/core.lua new file mode 100644 index 00000000..b908ffc3 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/core.lua @@ -0,0 +1,304 @@ +-- fibers/io/fd_backend/core.lua + +local poller = require 'fibers.io.poller' + +---@class FdBackend +---@field filename string|nil +---@field _fd integer|nil +---@field _ops table +local FdBackend = {} +FdBackend.__index = FdBackend + +function FdBackend:kind() + return 'fd' +end + +function FdBackend:fileno() + return self._fd +end + +function FdBackend:read_string(max) + if not self._fd then + return nil, 'closed' + end + + max = max or 4096 + if max <= 0 then + return '', nil + end + + return self._ops.read(self._fd, max) +end + +function FdBackend:write_string(str) + if not self._fd then + return nil, 'closed' + end + + local len = #str + if len == 0 then + return 0, nil + end + + return self._ops.write(self._fd, str, len) +end + +function FdBackend:seek(whence, off) + if not self._fd then + return nil, 'closed' + end + return self._ops.seek(self._fd, whence, off) +end + +function FdBackend:on_readable(task) + return poller.get():wait(assert(self._fd, 'closed fd'), 'rd', task) +end + +function FdBackend:on_writable(task) + return poller.get():wait(assert(self._fd, 'closed fd'), 'wr', task) +end + +function FdBackend:close() + if self._fd == nil then + return true, nil + end + + local fd = self._fd + self._fd = nil + + return self._ops.close(fd) +end + +---------------------------------------------------------------------- +-- Backend builder +---------------------------------------------------------------------- + +--- Build a concrete fd backend module from low-level ops. +--- +--- Required ops: +--- mkdir(path[, perms]) -> ok:boolean, err|nil +--- set_nonblock(fd) -> ok:boolean, err|nil +--- read(fd, max) -> s|nil, err|nil, want? +--- write(fd, s, len)-> n|nil, err|nil, want? +--- seek(fd, whence, off) -> pos|nil, err|nil +--- close(fd) -> ok:boolean, err|nil +--- +--- Optional file ops (used by fibers.io.file): +--- open_file(path, mode, perms) -> fd|nil, err|nil +--- pipe() -> rd_fd|nil, wr_fd|nil, err|nil +--- mktemp(prefix, perms) -> fd|nil, tmpname_or_err +--- fsync(fd) -> ok:boolean, err|nil +--- rename(old, new) -> ok:boolean, err|nil +--- unlink(path) -> ok:boolean, err|nil +--- decode_access(flags) -> readable:boolean, writable:boolean +--- ignore_sigpipe() -> ok:boolean, err|nil +--- +--- Optional socket ops (used by fibers.io.socket): +--- socket(domain, stype, protocol) -> fd|nil, err|nil +--- bind(fd, sa) -> ok:boolean, err|nil +--- listen(fd) -> ok:boolean, err|nil +--- accept(fd) -> newfd|nil, err|nil, again:boolean +--- connect_start(fd, sa) -> ok:boolean|nil, err|nil, inprogress:boolean +--- connect_finish(fd) -> ok:boolean, err|nil +--- +--- Optional metadata: +--- modes : table +--- permissions : table +--- AF_UNIX : integer +--- SOCK_STREAM : integer +--- is_supported() -> boolean +--- +---@param ops table +---@return table backend_module +local function build_backend(ops) + local required = { 'set_nonblock', 'read', 'write', 'seek', 'close' } + for _, k in ipairs(required) do + assert(type(ops[k]) == 'function', + 'fd_backend ops.' .. k .. ' must be a function') + end + + local function new(fd, opts) + opts = opts or {} + + if fd ~= nil then + local ok, err = ops.set_nonblock(fd) + if not ok then + error('fd_backend: set_nonblock(' .. tostring(fd) .. ') failed: ' + .. tostring(err)) + end + end + + local self = { + _fd = fd, + _ops = ops, + filename = opts.filename, + } + return setmetatable(self, FdBackend) + end + + local function is_supported() + if type(ops.is_supported) == 'function' then + return not not ops.is_supported() + end + return true + end + + -------------------------------------------------------------------- + -- File-level helpers + -------------------------------------------------------------------- + + local function mkdir(path, perms) + assert(type(ops.mkdir) == 'function', + 'fd_backend backend does not implement mkdir') + return ops.mkdir(path, perms) + end + + local function open_file(path, mode, perms) + assert(type(ops.open_file) == 'function', + 'fd_backend backend does not implement open_file') + return ops.open_file(path, mode, perms) + end + + local function pipe() + assert(type(ops.pipe) == 'function', + 'fd_backend backend does not implement pipe') + return ops.pipe() + end + + local function mktemp(prefix, perms) + assert(type(ops.mktemp) == 'function', + 'fd_backend backend does not implement mktemp') + return ops.mktemp(prefix, perms) + end + + local function fsync(fd) + if not ops.fsync then + return true, nil + end + return ops.fsync(fd) + end + + local function rename(oldpath, newpath) + assert(type(ops.rename) == 'function', + 'fd_backend backend does not implement rename') + return ops.rename(oldpath, newpath) + end + + local function unlink(path) + assert(type(ops.unlink) == 'function', + 'fd_backend backend does not implement unlink') + return ops.unlink(path) + end + + local function decode_access(flags) + if not ops.decode_access then + error('fd_backend backend does not implement decode_access') + end + return ops.decode_access(flags) + end + + local function ignore_sigpipe() + if ops.ignore_sigpipe then + return ops.ignore_sigpipe() + end + return true, nil + end + + local function init_nonblocking(fd) + return ops.set_nonblock(fd) + end + + local function close_fd(fd) + return ops.close(fd) + end + + -------------------------------------------------------------------- + -- Socket-level helpers (optional) + -------------------------------------------------------------------- + + local function socket(domain, stype, protocol) + if not ops.socket then + error('fd_backend backend does not implement socket()') + end + return ops.socket(domain, stype, protocol or 0) + end + + local function bind(fd, sa) + if not ops.bind then + error('fd_backend backend does not implement bind()') + end + return ops.bind(fd, sa) + end + + local function listen(fd) + if not ops.listen then + error('fd_backend backend does not implement listen()') + end + return ops.listen(fd) + end + + --- accept(fd) -> newfd|nil, err|nil, again:boolean + local function accept(fd) + if not ops.accept then + error('fd_backend backend does not implement accept()') + end + local newfd, err, again = ops.accept(fd) + return newfd, err, again + end + + --- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean + local function connect_start(fd, sa) + if not ops.connect_start then + error('fd_backend backend does not implement connect_start()') + end + local ok, err, inprogress = ops.connect_start(fd, sa) + return ok, err, inprogress + end + + --- connect_finish(fd) -> ok:boolean, err|nil + local function connect_finish(fd) + if not ops.connect_finish then + error('fd_backend backend does not implement connect_finish()') + end + return ops.connect_finish(fd) + end + + return { + new = new, + is_supported = is_supported, + + -- low-level helper + set_nonblock = init_nonblocking, + close_fd = close_fd, + + -- file-level helpers + mkdir = mkdir, + open_file = open_file, + pipe = pipe, + mktemp = mktemp, + fsync = fsync, + rename = rename, + unlink = unlink, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + -- socket-level helpers + socket = socket, + bind = bind, + listen = listen, + accept = accept, + connect_start = connect_start, + connect_finish = connect_finish, + + -- metadata (if provided) + modes = ops.modes or {}, + permissions = ops.permissions or {}, + AF_UNIX = ops.AF_UNIX, + AF_INET = ops.AF_INET, + SOCK_STREAM = ops.SOCK_STREAM, + } +end + +return { + build_backend = build_backend, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/ffi.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/ffi.lua new file mode 100644 index 00000000..546e6529 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/ffi.lua @@ -0,0 +1,631 @@ +-- fibers/io/fd_backend/ffi.lua +-- +-- FFI-based FD backend (no luaposix / syscall dependency). +-- Intended to be selected via fibers.io.fd_backend. +-- +---@module 'fibers.io.fd_backend.ffi' + +local core = require 'fibers.io.fd_backend.core' +local ffi_c = require 'fibers.utils.ffi_compat' + +if not ffi_c.is_supported() then + return { is_supported = function () return false end } +end + +local ffi = ffi_c.ffi +local C = ffi_c.C +local toint = ffi_c.tonumber +local get_errno = ffi_c.errno + +local ok_bit, bit_mod = pcall(function () + return rawget(_G, 'bit') or require 'bit32' +end) +if not ok_bit or not bit_mod then + return { is_supported = function () return false end } +end +local bit = bit_mod + +---@class sockaddr_un_cdata +---@field sun_family integer +---@field sun_path string|integer[] + +ffi.cdef [[ + typedef long ssize_t; + typedef long off_t; + + ssize_t read(int fd, void *buf, size_t count); + ssize_t write(int fd, const void *buf, size_t count); + off_t lseek(int fd, off_t offset, int whence); + int close(int fd); + int fcntl(int fd, int cmd, ...); + char *strerror(int errnum); + + int open(const char *pathname, int flags, int mode); + int pipe(int pipefd[2]); + int fsync(int fd); + int rename(const char *oldpath, const char *newpath); + int unlink(const char *pathname); + int mkdir(const char *pathname, int mode); + + typedef void (*sighandler_t)(int); + sighandler_t signal(int signum, sighandler_t handler); + + /* socket API */ + typedef unsigned short sa_family_t; + typedef unsigned int socklen_t; + + struct sockaddr { + sa_family_t sa_family; + char sa_data[14]; + }; + + struct sockaddr_un { + sa_family_t sun_family; + char sun_path[108]; + }; + + int socket(int domain, int type, int protocol); + int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); + int listen(int sockfd, int backlog); + int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); + int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); + int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen); + + typedef unsigned short in_port_t; + typedef unsigned int in_addr_t; + + struct in_addr { + in_addr_t s_addr; + }; + + struct sockaddr_in { + sa_family_t sin_family; + in_port_t sin_port; + struct in_addr sin_addr; + unsigned char sin_zero[8]; + }; + + unsigned short htons(unsigned short hostshort); + int inet_pton(int af, const char *src, void *dst); +]] + +-- POSIX fcntl command numbers on Linux. +local F_GETFL = 3 +local F_SETFL = 4 + +-- Linux O_* constants (values as on glibc/Linux; adjust if you support other ABIs). +local O_RDONLY = 0x0000 +local O_WRONLY = 0x0001 +local O_RDWR = 0x0002 +local O_ACCMODE = 0x0003 +local O_CREAT = 0x0040 +local O_EXCL = 0x0080 +local O_TRUNC = 0x0200 +local O_APPEND = 0x0400 +local O_NONBLOCK = 0x00000800 + +-- Permission bits (standard POSIX values). +local S_IRUSR = 0x0100 +local S_IWUSR = 0x0080 +local S_IRGRP = 0x0020 +local S_IROTH = 0x0004 +local S_IWGRP = 0x0010 +local S_IWOTH = 0x0002 +local S_IXUSR = 0x0040 +local S_IXGRP = 0x0008 +local S_IXOTH = 0x0001 + +-- Errno values (Linux). +local EAGAIN = 11 +local EWOULDBLOCK = 11 +local EINPROGRESS = 115 + +-- Socket constants (Linux ABI). +local AF_UNIX = 1 +local AF_INET = 2 +local SOCK_STREAM = 1 +local SOL_SOCKET = 1 +local SO_ERROR = 4 +local SOMAXCONN = 128 + +local SIGPIPE = 13 + +local function strerror(e) + local s = C.strerror(e) + if s == nil then + return 'errno ' .. tostring(e) + end + return ffi.string(s) +end + +---------------------------------------------------------------------- +-- fcntl helpers (casted to avoid varargs issues) +---------------------------------------------------------------------- + +local getfl_fp = ffi.cast('int (*)(int, int)', C.fcntl) +local setfl_fp = ffi.cast('int (*)(int, int, int)', C.fcntl) + +local function set_nonblock(fd) + local before = assert(toint(getfl_fp(fd, F_GETFL))) + if before < 0 then + local e = get_errno() + return false, ('F_GETFL failed: %s'):format(strerror(e)), e + end + + local new_flags = bit.bor(before, O_NONBLOCK) + local rc = toint(setfl_fp(fd, F_SETFL, new_flags)) + if rc < 0 then + local e = get_errno() + return false, ('F_SETFL failed: %s'):format(strerror(e)), e + end + + -- Optional sanity check. + local after = assert(toint(getfl_fp(fd, F_GETFL))) + if after < 0 then + local e = get_errno() + return false, ('F_GETFL (post) failed: %s'):format(strerror(e)), e + end + + if bit.band(after, O_NONBLOCK) == 0 then + return false, + ('set_nonblock: O_NONBLOCK not set after F_SETFL; before=0x%x after=0x%x') + :format(before, after), + nil + end + + return true, nil, nil +end + +---------------------------------------------------------------------- +-- Low-level ops implementing the core contract +---------------------------------------------------------------------- + +local SEEK = { set = 0, cur = 1, ['end'] = 2 } + +local function read_fd(fd, max) + local buf = ffi.new('char[?]', max) + local n = toint(C.read(fd, buf, max)) + + if n < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil, 'rd' -- would block + end + return nil, strerror(e) + end + + if n == 0 then + return '', nil -- EOF + end + + if n > max then + return nil, 'read returned ' .. tostring(n) .. ' bytes (max ' .. tostring(max) .. ')' + end + + return ffi.string(buf, n), nil +end + +local function write_fd(fd, str, len) + local buf = ffi.new('char[?]', len) + ffi.copy(buf, str, len) + + local n = toint(C.write(fd, buf, len)) + if n < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil, 'wr' -- would block + end + return nil, strerror(e) + end + + return n, nil +end + +local function seek_fd(fd, whence, off) + local w = SEEK[whence] + if not w then + return nil, 'bad whence: ' .. tostring(whence) + end + + local res = toint(C.lseek(fd, off, w)) + if res < 0 then + return nil, strerror(get_errno()) + end + + return res, nil +end + +local function close_fd(fd) + local rc = toint(C.close(fd)) + if rc ~= 0 then + return false, strerror(get_errno()) + end + return true, nil +end + +---------------------------------------------------------------------- +-- File-level helpers +---------------------------------------------------------------------- + +local function open_fd(path, flags, perms) + local c_path = ffi.new('char[?]', #path + 1) + ffi.copy(c_path, path) + local fd = toint(C.open(c_path, flags, perms or 0)) + if fd < 0 then + local e = get_errno() + return nil, strerror(e) + end + return fd, nil +end + +local function pipe_fd() + local fds = ffi.new('int[2]') + local rc = toint(C.pipe(fds)) + if rc ~= 0 then + local e = get_errno() + return nil, nil, strerror(e) + end + return toint(fds[0]), toint(fds[1]), nil +end + +local function fsync_fd(fd) + local rc = toint(C.fsync(fd)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +local function rename_file(oldpath, newpath) + local c_old = ffi.new('char[?]', #oldpath + 1) + ffi.copy(c_old, oldpath) + local c_new = ffi.new('char[?]', #newpath + 1) + ffi.copy(c_new, newpath) + + local rc = toint(C.rename(c_old, c_new)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +local function unlink_file(path) + local c_path = ffi.new('char[?]', #path + 1) + ffi.copy(c_path, path) + + local rc = toint(C.unlink(c_path)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +-- Mode and permission tables mirror the old file.lua behaviour, +-- but live in the backend. + +---@type table +local modes = { + r = O_RDONLY, + w = bit.bor(O_WRONLY, O_CREAT, O_TRUNC), + a = bit.bor(O_WRONLY, O_CREAT, O_APPEND), + ['r+'] = O_RDWR, + ['w+'] = bit.bor(O_RDWR, O_CREAT, O_TRUNC), + ['a+'] = bit.bor(O_RDWR, O_CREAT, O_APPEND), +} + +do + local binary_modes = {} + for k, v in pairs(modes) do + binary_modes[k .. 'b'] = v + end + for k, v in pairs(binary_modes) do + modes[k] = v + end +end + +---@type table +local permissions = {} +permissions['rw-r--r--'] = bit.bor(S_IRUSR, S_IWUSR, S_IRGRP, S_IROTH) +permissions['rw-rw-rw-'] = bit.bor(permissions['rw-r--r--'], S_IWGRP, S_IWOTH) +permissions['rwxr-xr-x'] = bit.bor( + S_IRUSR, S_IWUSR, S_IXUSR, + S_IRGRP, S_IXGRP, + S_IROTH, S_IXOTH +) +permissions['rwx------'] = bit.bor(S_IRUSR, S_IWUSR, S_IXUSR) + +local function mkdir_path(path, perms) + -- Normalise perms: nil -> sensible default for dirs, string -> lookup, number -> passthrough. + local p + if perms == nil then + p = permissions['rwxr-xr-x'] + elseif type(perms) == 'string' then + p = permissions[perms] or perms + else + p = perms + end + + local c_path = ffi.new('char[?]', #path + 1) + ffi.copy(c_path, path) + + local rc = toint(C.mkdir(c_path, p)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +local function open_file(path, mode, perms) + mode = mode or 'r' + local flags = modes[mode] + if not flags then + return nil, 'invalid mode: ' .. tostring(mode) + end + + local p + if perms == nil then + p = permissions['rw-rw-rw-'] + elseif type(perms) == 'string' then + p = permissions[perms] or perms + else + p = perms + end + + return open_fd(path, flags, p) +end + +local function mktemp(prefix, perms) + -- Normalise perms: nil -> default, string -> lookup in permissions table. + if perms == nil then + perms = permissions['rw-r--r--'] + elseif type(perms) == 'string' then + perms = permissions[perms] or perms + end + + -- Caller is responsible for seeding math.random appropriately. + local start = math.random(1e7) + local tmpnam, fd, err + + for i = start, start + 10 do + tmpnam = prefix .. '.' .. i + fd, err = open_fd(tmpnam, bit.bor(O_CREAT, O_RDWR, O_EXCL), perms) + if fd then + return fd, tmpnam + end + end + + return nil, ('failed to create temporary file %s: %s'):format( + tostring(tmpnam), + tostring(err) + ) +end + +local function decode_access(flags) + local acc = bit.band(flags, O_ACCMODE) + if acc == O_RDONLY then + return true, false + elseif acc == O_WRONLY then + return false, true + elseif acc == O_RDWR then + return true, true + end + -- Fallback: if we cannot interpret, assume read/write. + return true, true +end + +local function ignore_sigpipe() + -- Best-effort ignore of SIGPIPE. If this fails, we treat it as non-fatal. + local handler_t = ffi.typeof('sighandler_t') + local SIG_IGN = ffi.cast(handler_t, 1) + + local old = C.signal(SIGPIPE, SIG_IGN) + if old == nil then + local e = get_errno() + return false, strerror(e) + end + return true, nil +end + +---------------------------------------------------------------------- +-- Socket helpers (AF_UNIX, SOCK_STREAM, path string sockaddr) +---------------------------------------------------------------------- + +local function make_sockaddr_un(path) + local sa = ffi.new('struct sockaddr_un') + ---@cast sa sockaddr_un_cdata + sa.sun_family = AF_UNIX + + local maxlen = 108 - 1 + local p = path + if #p > maxlen then + p = p:sub(1, maxlen) + end + ffi.fill(sa.sun_path, 108) + ffi.copy(sa.sun_path, p) + + -- Full struct size is fine for bind/connect. + local len = ffi.sizeof('struct sockaddr_un') + return sa, len +end + +local function make_sockaddr_in(host, port) + if type(host) ~= 'string' or host == '' then + return nil, nil, 'host must be a non-empty string' + end + + port = tonumber(port) + if not port or port < 0 or port > 65535 then + return nil, nil, 'port must be 0..65535' + end + + local sa = ffi.new('struct sockaddr_in') + sa.sin_family = AF_INET + sa.sin_port = C.htons(tonumber(port)) + + local c_host = ffi.new('char[?]', #host + 1) + ffi.copy(c_host, host) + + local addr = ffi.new('struct in_addr[1]') + local rc = toint(C.inet_pton(AF_INET, c_host, addr)) + if rc ~= 1 then + if rc == 0 then + return nil, nil, 'invalid IPv4 address: ' .. tostring(host) + end + local e = get_errno() + return nil, nil, strerror(e) + end + + sa.sin_addr = addr[0] + ffi.fill(sa.sin_zero, 8) + + return sa, ffi.sizeof('struct sockaddr_in'), nil +end + +local function socket_fd(domain, stype, protocol) + local fd = toint(C.socket(domain, stype, protocol or 0)) + if fd < 0 then + local e = get_errno() + return nil, strerror(e), e + end + return fd, nil, nil +end + +local function bind_fd(fd, sa) + local c_sa, len, serr + + if type(sa) == 'string' then + -- AF_UNIX path + c_sa, len = make_sockaddr_un(sa) + elseif type(sa) == 'table' and sa.family == 'inet' then + -- AF_INET token: { family = 'inet', host = '1.2.3.4', port = 1234 } + c_sa, len, serr = make_sockaddr_in(sa.host, sa.port) + if not c_sa then + return false, serr, nil + end + else + return false, 'unsupported sockaddr representation', nil + end + + local rc = toint(C.bind(fd, ffi.cast('struct sockaddr *', c_sa), len)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e), e + end + return true, nil, nil +end + +local function listen_fd(fd) + local rc = toint(C.listen(fd, SOMAXCONN)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e), e + end + return true, nil, nil +end + +--- accept(fd) -> newfd|nil, err|nil, again:boolean +local function accept_fd(fd) + local new_fd = toint(C.accept(fd, nil, nil)) + if new_fd < 0 then + local e = get_errno() + if e == EAGAIN or e == EWOULDBLOCK then + return nil, nil, true + end + return nil, strerror(e), false + end + return new_fd, nil, false +end + +--- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean +local function connect_start_fd(fd, sa) + local c_sa, len, serr + + if type(sa) == 'string' then + -- AF_UNIX path + c_sa, len = make_sockaddr_un(sa) + elseif type(sa) == 'table' and sa.family == 'inet' then + -- AF_INET token + c_sa, len, serr = make_sockaddr_in(sa.host, sa.port) + if not c_sa then + return nil, serr, false + end + else + return nil, 'unsupported sockaddr representation', false + end + + local rc = toint(C.connect(fd, ffi.cast('struct sockaddr *', c_sa), len)) + if rc == 0 then + return true, nil, false + end + + local e = get_errno() + if e == EINPROGRESS then + return nil, nil, true + end + return nil, strerror(e), false +end + +--- connect_finish(fd) -> ok:boolean, err|nil +local function connect_finish_fd(fd) + local errval = ffi.new('int[1]') + local sz = ffi.new('socklen_t[1]', ffi.sizeof('int')) + local rc = toint(C.getsockopt(fd, SOL_SOCKET, SO_ERROR, errval, sz)) + if rc ~= 0 then + local e = get_errno() + return false, strerror(e) + end + local soerr = errval[0] + if soerr == 0 then + return true, nil + end + return false, strerror(soerr) +end + +---------------------------------------------------------------------- +-- Capability probe +---------------------------------------------------------------------- + +local function is_supported() + return true +end + +local ops = { + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + + mkdir = mkdir_path, + open_file = open_file, + pipe = pipe_fd, + mktemp = mktemp, + fsync = fsync_fd, + rename = rename_file, + unlink = unlink_file, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + -- socket ops + socket = socket_fd, + bind = bind_fd, + listen = listen_fd, + accept = accept_fd, + connect_start = connect_start_fd, + connect_finish = connect_finish_fd, + + modes = modes, + permissions = permissions, + + AF_UNIX = AF_UNIX, + AF_INET = AF_INET, + SOCK_STREAM = SOCK_STREAM, + + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/nixio.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/nixio.lua new file mode 100644 index 00000000..8692c655 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/nixio.lua @@ -0,0 +1,666 @@ +-- fibers/io/fd_backend/nixio.lua +-- +-- nixio-based FD backend (no FFI / luaposix dependency). +-- Intended to be selected via fibers.io.fd_backend. +-- +---@module 'fibers.io.fd_backend.nixio' + +local core = require 'fibers.io.fd_backend.core' +local nixio = require 'nixio' +local fs = require 'nixio.fs' + +local const = nixio.const or {} + +local EAGAIN = const.EAGAIN or 11 +local EWOULDBLOCK = const.EWOULDBLOCK or EAGAIN +local EINPROGRESS = const.EINPROGRESS or 115 +local EALREADY = const.EALREADY or 114 + +-- Where available, reuse nixioโ€™s numeric constants so callers see +-- sensible AF_* / SOCK_* values. Fall back to standard Linux values. +local AF_UNIX = const.AF_UNIX or 1 +local AF_INET = const.AF_INET or 2 +local AF_INET6 = const.AF_INET6 or 10 +local SOCK_STREAM = const.SOCK_STREAM or 1 +local SOCK_DGRAM = const.SOCK_DGRAM or 2 + +local function errno_msg(default, eno) + if eno == nil or eno == 0 then + return default + end + + if type(eno) ~= 'number' then + local n = tonumber(eno) + if n then + eno = n + else + eno = nixio.errno() + if eno == nil or eno == 0 then + return default + end + end + end + + local s = nixio.strerror(eno) + if not s or s == '' then + return default .. ' (errno ' .. tostring(eno) .. ')' + end + return s +end + +-- nixio APIs vary by build/version in whether they return: +-- nil, msg, errno +-- or +-- nil, errno, msg +-- This helper normalises the trailing two values. +local function norm_msg_eno(a, b) + local ta, tb = type(a), type(b) + + if ta == 'number' and tb == 'string' then + return b, a + end + if ta == 'string' and tb == 'number' then + return a, b + end + if ta == 'number' and b == nil then + return nil, a + end + if ta == 'string' and b == nil then + return a, nil + end + if ta == 'number' then + return nil, a + end + if tb == 'number' then + return nil, b + end + if ta == 'string' then + return a, nil + end + if tb == 'string' then + return b, nil + end + return nil, nil +end + +-- nixio.open expects perms as a mode string (e.g. "0644" or "rw-r--r--") +local DEFAULT_CREATE_PERMS = '0666' -- subject to umask + +local function norm_perms(perms) + if perms == nil then + return nil + end + local t = type(perms) + if t == 'string' then + return perms + end + if t == 'number' then + -- Caller may pass decimal 420 (0644) etc; convert to octal string. + return string.format('%04o', perms) + end + return perms +end + +local function is_create_mode(mode) + mode = mode or 'r' + local c = mode:sub(1, 1) + return (c == 'w' or c == 'a') +end + +---------------------------------------------------------------------- +-- Core ops: set_nonblock / read / write / seek / close +---------------------------------------------------------------------- + +-- fd here is a nixio.File or nixio.Socket +local function set_nonblock(fd) + if fd and fd.setblocking then + local ok, a, b = fd:setblocking(false) + if ok ~= nil and ok ~= false then + return true, nil, nil + end + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return false, errno_msg(msg or 'setblocking(false) failed', eno), eno + end + -- If there is no setblocking, treat as already non-blocking. + return true, nil, nil +end + +local function read_fd(fd, max) + if not fd then + return nil, 'closed' + end + + max = max or const.buffersize or 8192 + if max <= 0 then + return '', nil + end + + -- nixio.File:read / Socket:read both generally return: + -- data + -- nil, msg, errno OR nil, errno, msg + local data, a, b = fd:read(max) + + if type(data) == 'string' then + -- data may be "" at EOF; that is acceptable to callers. + return data, nil + end + + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + + if eno == EAGAIN or eno == EWOULDBLOCK then + -- Would block, signal โ€œnot ready yetโ€. + return nil, nil, 'rd' + end + + if not eno or eno == 0 then + -- Treat as EOF. + return '', nil + end + + return nil, errno_msg(msg or 'read failed', eno) +end + +local function write_fd(fd, str, len) + if not fd then + return nil, 'closed' + end + + len = len or #str + if len == 0 then + return 0, nil + end + + -- For files: File.write(buf, offset, length) + -- For sockets: Socket.send / write(buf, offset, length) โ€“ same shape. + local n, a, b = fd:write(str, 0, len) + + if type(n) == 'number' then + return n, nil + end + + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + + if eno == EAGAIN or eno == EWOULDBLOCK then + -- Would block. + return nil, nil, 'wr' + end + + return nil, errno_msg(msg or 'write failed', eno) +end + +local SEEK_MAP = { + set = 'set', + cur = 'cur', + ['end'] = 'end', +} + +local function seek_fd(fd, whence, off) + if not fd then + return nil, 'closed' + end + + whence = SEEK_MAP[whence] or whence or 'cur' + off = off or 0 + + if not fd.seek then + return nil, 'seek not supported on this descriptor' + end + + local pos, a, b = fd:seek(off, whence) + if pos == nil then + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return nil, errno_msg(msg or 'seek failed', eno) + end + return pos, nil +end + +local function close_fd(fd) + if not fd then + return true, nil + end + + local ok, a, b = fd:close() + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return false, errno_msg(msg or 'close failed', eno) + end + return true, nil +end + +---------------------------------------------------------------------- +-- File-level helpers: mkdir / open_file / pipe / mktemp / fsync / rename / unlink +---------------------------------------------------------------------- + +-- Basic symbolic permission presets for mkdir and file creation. +local function oct(s) + return tonumber(s, 8) +end + +local permissions = { + ['rw-r--r--'] = oct('644'), + ['rw-rw-rw-'] = oct('666'), + + -- Directories (execute bits are required for traversal). + ['rwxr-xr-x'] = oct('755'), + ['rwx------'] = oct('700'), +} + +-- nixio.open tolerates mode strings like "0644" and sometimes symbolic modes. +local function norm_open_perms(perms) + if perms == nil then return nil end + local t = type(perms) + if t == 'number' then + return string.format('%04o', perms) + end + if t == 'string' then + -- If a symbolic string matches our presets, convert to octal string. + local m = permissions[perms] + if m then + return string.format('%04o', m) + end + return perms + end + return perms +end + +-- nixio.fs.mkdir expects a mode string on some builds ("0755"), not a raw number. +local function norm_mkdir_mode(perms) + if perms == nil then + return '0755' + end + + local t = type(perms) + + if t == 'number' then + -- Decimal 511 -> "0777" + return string.format('%04o', perms) + end + + if t == 'string' then + local m = permissions[perms] + if m then + return string.format('%04o', m) + end + + -- Accept already-octal strings like "0755" + -- (and leave other strings untouched for nixio to interpret) + return perms + end + + return '0755' +end + +local function mkdir_path(path, perms) + local mode = norm_mkdir_mode(perms) + + local ok, a, b = fs.mkdir(path, mode) + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + return false, errno_msg(msg or 'mkdir failed', eno) + end + + return true, nil +end + +local function open_file(path, mode, perms) + mode = mode or 'r' + + local p = norm_open_perms(perms) + + -- If this is a creating mode and perms is nil, provide a default. + if p == nil and is_create_mode(mode) then + p = DEFAULT_CREATE_PERMS + end + + local f, a, b = nixio.open(path, mode, p) + if not f then + local msg, eno = norm_msg_eno(a, b) + return nil, errno_msg(msg or 'open failed', eno) + end + return f, nil +end + +local function pipe_fds() + local r, w, a, b = nixio.pipe() + if not r then + local msg, eno = norm_msg_eno(a, b) + return nil, nil, errno_msg(msg or 'pipe failed', eno) + end + return r, w, nil +end + +local function mktemp(prefix, perms) + local start = math.random(1e7) + local last_err + + local p = norm_open_perms(perms) or '0644' + + for i = start, start + 10 do + local tmpnam = prefix .. '.' .. i + local f, a, b = nixio.open(tmpnam, 'w+', p) + if f then + return f, tmpnam + end + local msg, eno = norm_msg_eno(a, b) + last_err = errno_msg(msg or 'mktemp open failed', eno) + end + + return nil, last_err or 'mktemp: failed to create temporary file' +end + +local function fsync_fd(fd) + if not fd or not fd.sync then + return true, nil + end + local ok, a, b = fd:sync(false) + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return false, errno_msg(msg or 'fsync failed', eno) + end + return true, nil +end + +local function rename_file(oldpath, newpath) + local ok, a, b = fs.rename(oldpath, newpath) + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + return false, errno_msg(msg or 'rename failed', eno) + end + return true, nil +end + +local function unlink_file(path) + local ok, a, b = fs.unlink(path) + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + return false, errno_msg(msg or 'unlink failed', eno) + end + return true, nil +end + +-- For this backend, integer open flags are not used; when decode_access +-- is called we can conservatively assume read/write. +local function decode_access(_) + return true, true +end + +local function ignore_sigpipe() + -- Best-effort ignore of SIGPIPE. + if nixio.signal and nixio.SIGPIPE then + local ok, a, b = nixio.signal(nixio.SIGPIPE, 'ign') + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + return false, errno_msg(msg or 'signal(SIGPIPE) failed', eno) + end + end + return true, nil +end + +---------------------------------------------------------------------- +-- Socket helpers +---------------------------------------------------------------------- + +local function domain_to_str(domain) + if domain == AF_UNIX then + return 'unix' + end + if AF_INET and domain == AF_INET then + return 'inet' + end + if AF_INET6 and domain == AF_INET6 then + return 'inet6' + end + error('fd_backend.nixio: unsupported address family: ' .. tostring(domain)) +end + +local function stype_to_str(stype) + if stype == SOCK_STREAM then + return 'stream' + end + if SOCK_DGRAM and stype == SOCK_DGRAM then + return 'dgram' + end + error('fd_backend.nixio: unsupported socket type: ' .. tostring(stype)) +end + +-- Normalise sockaddr tokens used by fibers.io.socket: +-- UNIX: +-- "/tmp/sock" +-- { family = "unix", path = "/tmp/sock" } +-- INET: +-- { family = "inet", host = "127.0.0.1", port = 1234 } +-- INET6: +-- { family = "inet6", host = "::1", port = 1234 } +local function norm_sockaddr(sa) + if type(sa) == 'string' then + return 'unix', sa, 0 + end + + if type(sa) ~= 'table' then + return nil, nil, nil, 'unsupported sockaddr representation' + end + + local fam = sa.family or sa.af + if fam == AF_UNIX then fam = 'unix' end + if fam == AF_INET then fam = 'inet' end + if fam == AF_INET6 then fam = 'inet6' end + + if fam == nil then + -- Reasonable fallback: table with port implies inet. + if sa.port ~= nil then + fam = 'inet' + elseif sa.path then + fam = 'unix' + end + end + + if fam == 'unix' then + local path = sa.path or sa.host + if type(path) ~= 'string' or path == '' then + return nil, nil, nil, 'invalid unix sockaddr' + end + return 'unix', path, 0 + end + + if fam == 'inet' or fam == 'inet6' then + local host = sa.host + local port = sa.port + if host ~= nil and type(host) ~= 'string' then + return nil, nil, nil, 'invalid ' .. fam .. ' host' + end + port = tonumber(port) + if port == nil then + return nil, nil, nil, 'invalid ' .. fam .. ' port' + end + return fam, host, port + end + + return nil, nil, nil, 'unsupported sockaddr family' +end + +--- socket(domain, stype, protocol) -> fd|nil, err|nil, eno|nil +local function socket_fd(domain, stype, _) + local d = domain_to_str(domain) + local t = stype_to_str(stype) + + local s, a, b = nixio.socket(d, t) + if not s then + local msg, eno = norm_msg_eno(a, b) + return nil, errno_msg(msg or 'socket failed', eno), eno + end + -- Returned โ€œfdโ€ is a nixio.Socket object. + return s, nil, nil +end + +--- bind(fd, sa) where fd is nixio.Socket +local function bind_fd(fd, sa) + if not fd then + return false, 'closed socket', nil + end + + local fam, host, port, nerr = norm_sockaddr(sa) + if not fam then + return false, nerr, nil + end + + local ok, a, b + ok, a, b = fd:bind(host, port) + + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return false, errno_msg(msg or 'bind failed', eno), eno + end + + return true, nil, nil +end + +local function listen_fd(fd) + if not fd then + return false, 'closed socket', nil + end + + local backlog = const.SOMAXCONN or 128 + local ok, a, b = fd:listen(backlog) + if ok == nil or ok == false then + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return false, errno_msg(msg or 'listen failed', eno), eno + end + return true, nil, nil +end + +--- accept(fd) -> newfd|nil, err|nil, again:boolean +local function accept_fd(fd) + if not fd then + return nil, 'closed socket', false + end + + -- nixio.Socket.accept() -> newsock, host, port | nil, msg, errno + -- Some builds may swap msg/errno on error; normalise. + local newsock, x, y = fd:accept() + if newsock then + return newsock, nil, false + end + + local msg, eno = norm_msg_eno(x, y) + eno = eno or nixio.errno() + if eno == EAGAIN or eno == EWOULDBLOCK then + return nil, nil, true + end + + return nil, errno_msg(msg or 'accept failed', eno), false +end + +--- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean +local function connect_start_fd(fd, sa) + if not fd then + return nil, 'closed socket', false + end + + local fam, host, port, nerr = norm_sockaddr(sa) + if not fam then + return nil, nerr, false + end + + local ok, a, b = fd:connect(host, port) + if ok then + return true, nil, false + end + + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + if eno == EINPROGRESS or eno == EALREADY or eno == EAGAIN then + -- Non-blocking connect in progress. + return nil, nil, true + end + + return nil, errno_msg(msg or 'connect failed', eno), false +end + +--- connect_finish(fd) -> ok:boolean, err|nil +local function connect_finish_fd(fd) + if not fd then + return false, 'closed socket' + end + + if not fd.getopt then + -- Fallback: if we cannot inspect SO_ERROR, assume success. + return true, nil + end + + local soerr, a, b = fd:getopt('socket', 'error') + if soerr == nil then + local msg, eno = norm_msg_eno(a, b) + eno = eno or nixio.errno() + return false, errno_msg(msg or 'getsockopt(SO_ERROR) failed', eno) + end + + soerr = tonumber(soerr) or 0 + if soerr == 0 then + return true, nil + end + + return false, errno_msg('connect error', soerr) +end + +---------------------------------------------------------------------- +-- Capability probe +---------------------------------------------------------------------- + +local function is_supported() + -- If this module loaded, nixio was already required successfully. + return true +end + +---------------------------------------------------------------------- +-- Assemble ops and build backend +---------------------------------------------------------------------- + +local ops = { + -- Core file/socket descriptor ops + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + + -- File-level helpers + open_file = open_file, + pipe = pipe_fds, + mktemp = mktemp, + fsync = fsync_fd, + rename = rename_file, + unlink = unlink_file, + mkdir = mkdir_path, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + -- Socket-level helpers + socket = socket_fd, + bind = bind_fd, + listen = listen_fd, + accept = accept_fd, + connect_start = connect_start_fd, + connect_finish = connect_finish_fd, + + -- Metadata for callers + modes = {}, -- nixio uses mode strings for open() + permissions = permissions, + + AF_UNIX = AF_UNIX, + AF_INET = AF_INET, + AF_INET6 = AF_INET6, + SOCK_STREAM = SOCK_STREAM, + SOCK_DGRAM = SOCK_DGRAM, + + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/posix.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/posix.lua new file mode 100644 index 00000000..4d485342 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/fd_backend/posix.lua @@ -0,0 +1,485 @@ +-- fibers/io/fd_backend/posix.lua +-- +-- luaposix-based FD backend (no FFI). +-- Intended to be selected via fibers.io.fd_backend. +-- +---@module 'fibers.io.fd_backend.posix' + +local core = require 'fibers.io.fd_backend.core' + +local unistd = require 'posix.unistd' +local stdio = require 'posix.stdio' +local fcntl = require 'posix.fcntl' +local pstat = require 'posix.sys.stat' +local errno = require 'posix.errno' +local psig = require 'posix.signal' +local socket_mod = require 'posix.sys.socket' + +local bit = rawget(_G, 'bit') or require 'bit32' + +local function errno_msg(prefix, err, eno) + if err and err ~= '' then + return err + end + if eno then + return ('%s (errno %d)'):format(prefix, eno) + end + return prefix +end + +---------------------------------------------------------------------- +-- set_nonblock / basic ops +---------------------------------------------------------------------- + +local function set_nonblock(fd) + local flags, err, eno = fcntl.fcntl(fd, fcntl.F_GETFL) + if flags == nil then + return false, errno_msg('fcntl(F_GETFL)', err, eno), eno + end + local newflags = bit.bor(flags, fcntl.O_NONBLOCK) + local ok, err2, eno2 = fcntl.fcntl(fd, fcntl.F_SETFL, newflags) + if ok == nil then + return false, errno_msg('fcntl(F_SETFL)', err2, eno2), eno2 + end + return true, nil, nil +end + +local function read_fd(fd, max) + local s, err, eno = unistd.read(fd, max) + if s == nil then + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + return nil, nil, 'rd' + end + return nil, errno_msg('read failed', err, eno) + end + return s, nil +end + +local function write_fd(fd, str, _) + local n, err, eno = unistd.write(fd, str) + if n == nil then + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + return nil, nil, 'wr' + end + return nil, errno_msg('write failed', err, eno) + end + return n, nil +end + +local SEEK = { set = unistd.SEEK_SET, cur = unistd.SEEK_CUR, ['end'] = unistd.SEEK_END } + +local function seek_fd(fd, whence, off) + local w = SEEK[whence] + if not w then + return nil, 'bad whence: ' .. tostring(whence) + end + local pos, err, eno = unistd.lseek(fd, off, w) + if pos == nil then + return nil, errno_msg('lseek failed', err, eno) + end + return pos, nil +end + +local function close_fd(fd) + local ok, err, eno = unistd.close(fd) + if ok == nil then + return false, errno_msg('close failed', err, eno) + end + return true, nil +end + +---------------------------------------------------------------------- +-- File-level helpers +---------------------------------------------------------------------- + +local modes = { + r = fcntl.O_RDONLY, + w = bit.bor(fcntl.O_WRONLY, fcntl.O_CREAT, fcntl.O_TRUNC), + a = bit.bor(fcntl.O_WRONLY, fcntl.O_CREAT, fcntl.O_APPEND), + ['r+'] = fcntl.O_RDWR, + ['w+'] = bit.bor(fcntl.O_RDWR, fcntl.O_CREAT, fcntl.O_TRUNC), + ['a+'] = bit.bor(fcntl.O_RDWR, fcntl.O_CREAT, fcntl.O_APPEND), +} + +do + local binary_modes = {} + for k, v in pairs(modes) do + binary_modes[k .. 'b'] = v + end + for k, v in pairs(binary_modes) do + modes[k] = v + end +end + +local permissions = {} +permissions['rw-r--r--'] = bit.bor(pstat.S_IRUSR, pstat.S_IWUSR, pstat.S_IRGRP, pstat.S_IROTH) +permissions['rw-rw-rw-'] = bit.bor(permissions['rw-r--r--'], pstat.S_IWGRP, pstat.S_IWOTH) + +permissions['rwxr-xr-x'] = bit.bor( + pstat.S_IRUSR, pstat.S_IWUSR, pstat.S_IXUSR, + pstat.S_IRGRP, pstat.S_IXGRP, + pstat.S_IROTH, pstat.S_IXOTH +) +permissions['rwx------'] = bit.bor(pstat.S_IRUSR, pstat.S_IWUSR, pstat.S_IXUSR) + +local function mkdir_path(path, perms) + local p + if perms == nil then + p = permissions['rwxr-xr-x'] + elseif type(perms) == 'string' then + p = permissions[perms] or perms + else + p = perms + end + + local ok, err, eno = pstat.mkdir(path, p) + if ok == nil then + return false, errno_msg('mkdir failed', err, eno) + end + return true, nil +end + +local function open_file(path, mode, perms) + mode = mode or 'r' + local flags = modes[mode] + if not flags then + return nil, 'invalid mode: ' .. tostring(mode) + end + + local p + if perms == nil then + p = permissions['rw-rw-rw-'] + elseif type(perms) == 'string' then + p = permissions[perms] or perms + else + p = perms + end + + local fd, err, eno = fcntl.open(path, flags, p) + if not fd then + return nil, errno_msg('open failed', err, eno) + end + return fd, nil +end + +local function pipe_fds() + local rd, wr, err, eno = unistd.pipe() + if not rd then + return nil, nil, errno_msg('pipe failed', err, eno) + end + return rd, wr, nil +end + +local function mktemp(prefix, perms) + if perms == nil then + perms = permissions['rw-r--r--'] + elseif type(perms) == 'string' then + perms = permissions[perms] or perms + end + + local start = math.random(1e7) + local tmpnam, fd, err, eno + + for i = start, start + 10 do + tmpnam = prefix .. '.' .. i + fd, err, eno = fcntl.open( + tmpnam, + bit.bor(fcntl.O_CREAT, fcntl.O_RDWR, fcntl.O_EXCL), + perms + ) + if fd then + return fd, tmpnam + end + end + + return nil, ('failed to create temporary file %s: %s'):format( + tostring(tmpnam), + tostring(errno_msg('open', err, eno)) + ) +end + +local function fsync_fd(fd) + local ok, err, eno = unistd.fsync(fd) + if ok == nil then + return false, errno_msg('fsync failed', err, eno) + end + return true, nil +end + +local function rename_file(oldpath, newpath) + local ok, err, eno = stdio.rename(oldpath, newpath) + if ok == nil then + return false, errno_msg('rename failed', err, eno) + end + return true, nil +end + +local function unlink_file(path) + local ok, err, eno = unistd.unlink(path) + if ok == nil then + return false, errno_msg('unlink failed', err, eno) + end + return true, nil +end + +local function decode_access(flags) + local o_wr = fcntl.O_WRONLY or 0 + local o_rdwr = fcntl.O_RDWR or 0 + local readable + if o_wr ~= 0 then + readable = (bit.band(flags, o_wr) ~= o_wr) + else + readable = true + end + + local writable = false + if o_wr ~= 0 and bit.band(flags, o_wr) ~= 0 then + writable = true + end + if o_rdwr ~= 0 and bit.band(flags, o_rdwr) ~= 0 then + writable = true + end + + if not readable and not writable then + readable = true + end + + return readable, writable +end + +local function ignore_sigpipe() + local ok, err, eno = psig.signal(psig.SIGPIPE, psig.SIG_IGN) + if ok == nil then + return false, errno_msg('signal(SIGPIPE)', err, eno) + end + return true, nil +end + +---------------------------------------------------------------------- +-- Socket helpers on top of posix.sys.socket +---------------------------------------------------------------------- + +local AF_UNIX = socket_mod.AF_UNIX +local AF_INET = socket_mod.AF_INET +local AF_INET6 = socket_mod.AF_INET6 +local SOCK_STREAM = socket_mod.SOCK_STREAM +local SOCK_DGRAM = socket_mod.SOCK_DGRAM + +-- Normalise sockaddr tokens used by higher layers into luaposix sockaddr tables. +-- Accepted inputs: +-- UNIX: +-- "/tmp/sock" +-- { family = "unix", path = "/tmp/sock" } +-- INET: +-- { family = "inet", host = "127.0.0.1", port = 1234 } +-- { family = "inet", addr = "127.0.0.1", port = 1234 } +-- INET6: +-- { family = "inet6", host = "::1", port = 1234 } +-- Raw luaposix sockaddr table: +-- { family = socket_mod.AF_INET, addr = "...", port = ... } +local function norm_sockaddr(sa) + if type(sa) == 'string' then + -- Convenience form: UNIX path + return { family = AF_UNIX, path = sa } + end + + if type(sa) ~= 'table' then + return nil, 'unsupported sockaddr representation' + end + + -- If this already looks like a luaposix sockaddr table, accept it. + if type(sa.family) == 'number' then + return sa + end + + local fam = sa.family or sa.af + if fam == 'unix' then + if not AF_UNIX then + return nil, 'AF_UNIX not supported' + end + local path = sa.path or sa.host + if type(path) ~= 'string' or path == '' then + return nil, 'invalid unix sockaddr path' + end + return { family = AF_UNIX, path = path } + end + + if fam == 'inet' then + if not AF_INET then + return nil, 'AF_INET not supported' + end + local addr = sa.addr or sa.host + local port = tonumber(sa.port) + if addr ~= nil and type(addr) ~= 'string' then + return nil, 'invalid inet sockaddr addr' + end + if port == nil then + return nil, 'invalid inet sockaddr port' + end + return { family = AF_INET, addr = addr, port = port } + end + + if fam == 'inet6' then + if not AF_INET6 then + return nil, 'AF_INET6 not supported' + end + local addr = sa.addr or sa.host + local port = tonumber(sa.port) + if addr ~= nil and type(addr) ~= 'string' then + return nil, 'invalid inet6 sockaddr addr' + end + if port == nil then + return nil, 'invalid inet6 sockaddr port' + end + return { family = AF_INET6, addr = addr, port = port } + end + + return nil, 'unsupported sockaddr family' +end + +--- Create a socket fd. +---@param domain integer +---@param stype integer +---@param protocol? integer +---@return integer|nil fd, string|nil err, integer|nil eno +local function socket_fd(domain, stype, protocol) + local fd, err, eno = socket_mod.socket(domain, stype, protocol or 0) + if fd == nil then + return nil, errno_msg('socket failed', err, eno), eno + end + return fd, nil, nil +end + +--- Bind a socket to an address token. +---@param fd integer +---@param sa any +---@return boolean ok, string|nil err, integer|nil eno +local function bind_fd(fd, sa) + local addr, aerr = norm_sockaddr(sa) + if not addr then + return false, aerr, nil + end + + local ok, err, eno = socket_mod.bind(fd, addr) + if ok == nil then + return false, errno_msg('bind failed', err, eno), eno + end + return true, nil, nil +end + +--- Put a listening socket into listen state. +---@param fd integer +---@return boolean ok, string|nil err, integer|nil eno +local function listen_fd(fd) + local backlog = socket_mod.SOMAXCONN or 128 + local ok, err, eno = socket_mod.listen(fd, backlog) + if ok == nil then + return false, errno_msg('listen failed', err, eno), eno + end + return true, nil, nil +end + +--- accept(fd) -> newfd|nil, err|nil, again:boolean +---@param fd integer +---@return integer|nil newfd, string|nil err, boolean again +local function accept_fd(fd) + local newfd, addr_or_err, errnum = socket_mod.accept(fd) + if newfd ~= nil then + return newfd, nil, false + end + + local eno = errnum + if eno == errno.EAGAIN or eno == errno.EWOULDBLOCK then + return nil, nil, true + end + + return nil, errno_msg('accept failed', addr_or_err, eno), false +end + +--- Start a non-blocking connect. +--- connect_start(fd, sa) -> ok|nil, err|nil, inprogress:boolean +---@param fd integer +---@param sa any +---@return boolean|nil ok, string|nil err, boolean inprogress +local function connect_start_fd(fd, sa) + local addr, aerr = norm_sockaddr(sa) + if not addr then + return nil, aerr, false + end + + local ok, err, eno = socket_mod.connect(fd, addr) + if ok ~= nil then + return true, nil, false + end + + if eno == errno.EINPROGRESS or eno == errno.EALREADY or eno == errno.EAGAIN then + return nil, nil, true + end + + return nil, errno_msg('connect failed', err, eno), false +end + +--- Complete a non-blocking connect using SO_ERROR. +---@param fd integer +---@return boolean ok, string|nil err +local function connect_finish_fd(fd) + local soerr, err, eno = socket_mod.getsockopt( + fd, socket_mod.SOL_SOCKET, socket_mod.SO_ERROR + ) + if soerr == nil then + return false, errno_msg('getsockopt(SO_ERROR) failed', err, eno) + end + + soerr = tonumber(soerr) or 0 + if soerr == 0 then + return true, nil + end + + return false, 'connect error errno ' .. tostring(soerr) +end + +local function is_supported() + return true +end + +---------------------------------------------------------------------- +-- Assemble ops and build backend +---------------------------------------------------------------------- + +local ops = { + set_nonblock = set_nonblock, + read = read_fd, + write = write_fd, + seek = seek_fd, + close = close_fd, + + open_file = open_file, + pipe = pipe_fds, + mktemp = mktemp, + fsync = fsync_fd, + rename = rename_file, + unlink = unlink_file, + mkdir = mkdir_path, + decode_access = decode_access, + ignore_sigpipe = ignore_sigpipe, + + socket = socket_fd, + bind = bind_fd, + listen = listen_fd, + accept = accept_fd, + connect_start = connect_start_fd, + connect_finish = connect_finish_fd, + + modes = modes, + permissions = permissions, + + AF_UNIX = AF_UNIX, + AF_INET = AF_INET, + AF_INET6 = AF_INET6, + SOCK_STREAM = SOCK_STREAM, + SOCK_DGRAM = SOCK_DGRAM, + + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/file.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/file.lua new file mode 100644 index 00000000..194c1f1d --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/file.lua @@ -0,0 +1,367 @@ +-- fibers/io/file.lua +-- +-- File-backed streams on top of fd_backend + stream. +-- +-- Exposes: +-- fdopen(fd, flags_or_mode[, filename]) -> Stream +-- open(filename[, mode[, perms]]) -> Stream | nil, err +-- pipe() -> read_stream, write_stream +-- mktemp(prefix[, perms]) -> fd, tmpname_or_err +-- tmpfile([perms[, tmpdir]]) -> Stream (auto-unlink on close) +-- init_nonblocking(fd) -> ok, err|nil +-- +---@module 'fibers.io.file' + +local stream = require 'fibers.io.stream' +local fd_back = require 'fibers.io.fd_backend' + +-- Best-effort: ignore SIGPIPE so write failures report via errno/return codes. +do + if fd_back.ignore_sigpipe then + fd_back.ignore_sigpipe() -- errors are treated as non-fatal + end +end + +---------------------------------------------------------------------- +-- Mode / permission policy (OS-agnostic) +---------------------------------------------------------------------- + +-- We keep the string conventions, but leave numeric mapping to the backend. +---@param mode string The file mode string (e.g., "r", "w", "a", "r+", "w+", "a+") +---@return boolean readable Returns true if the mode allows reading +---@return boolean writable Returns true if the mode allows writing +local function mode_access(mode) + assert(type(mode) == 'string', 'mode must be a string') + local plus = mode:find('+', 1, true) ~= nil + local c = mode:sub(1, 1) + + if c == 'r' then + if plus then + return true, true + else + return true, false + end + elseif c == 'w' or c == 'a' then + if plus then + return true, true + else + return false, true + end + else + error('invalid mode: ' .. tostring(mode)) + end +end + +---------------------------------------------------------------------- +-- Internal: wrap an fd as a Stream +---------------------------------------------------------------------- + +--- Wrap an fd in a Stream using fd_backend. +--- +--- flags_or_mode may be: +--- * number : backend-specific open flags (decode_access will be used) +--- * string : Lua-style mode string ("r", "w+", "rb", etc.) +--- * table : { readable = bool, writable = bool } +--- +---@param fd integer +---@param flags_or_mode any +---@param filename? string +---@return Stream +local function fdopen(fd, flags_or_mode, filename) + -- assert(type(fd) == "number", "fdopen: fd must be a number") + assert(fd ~= nil, 'fdopen: fd must be non-nil') + + local readable, writable + + local t = type(flags_or_mode) + if t == 'number' then + assert(fd_back.decode_access, 'backend does not implement decode_access') + readable, writable = fd_back.decode_access(flags_or_mode) + elseif t == 'string' then + readable, writable = mode_access(flags_or_mode) + elseif t == 'table' then + readable = not not flags_or_mode.readable + writable = not not flags_or_mode.writable + else + error('fdopen: invalid flags_or_mode: ' .. tostring(flags_or_mode)) + end + + local io = fd_back.new(fd, { filename = filename }) + + -- We no longer try to adjust buffer size based on fstat; stream.open + -- will apply its default buffer sizes. + return stream.open(io, readable, writable) +end + +---------------------------------------------------------------------- +-- Directories +---------------------------------------------------------------------- + +--- Create a directory. +--- +--- perms may be an integer mask or a symbolic string understood by the backend. +---@param path string +---@param perms? integer|string +---@return boolean|nil ok, string|nil err +local function mkdir(path, perms) + assert(type(path) == 'string' and path ~= '', 'mkdir: path must be a non-empty string') + + if not fd_back.mkdir then + return nil, 'backend does not implement mkdir' + end + + return fd_back.mkdir(path, perms) +end + +--- Best-effort classification of "already exists" errors across backends. +---@param err any +---@return boolean +local function is_eexist(err) + if err == nil then return false end + local s = tostring(err):lower() + -- Common shapes: "EEXIST", "file exists", "exists" + return s:find('eexist', 1, true) ~= nil + or s:find('file exists', 1, true) ~= nil + or s:find('exists', 1, true) ~= nil +end + +--- Create a directory path (mkdir -p). +--- +--- Semantics: +--- * creates parent components as required +--- * succeeds if the directory already exists +--- * returns nil,err on the first hard failure encountered +--- +--- perms may be an integer mask or a symbolic string understood by the backend. +---@param path string +---@param perms? integer|string +---@return boolean|nil ok, string|nil err +local function mkdir_p(path, perms) + assert(type(path) == 'string' and path ~= '', 'mkdir_p: path must be a non-empty string') + + if not fd_back.mkdir then + return nil, 'backend does not implement mkdir' + end + + -- Root is trivially present. + if path == '/' then + return true, nil + end + + -- Normalise repeated slashes; keep leading '/' if present. + local is_abs = path:sub(1, 1) == '/' + -- Collapse multiple slashes to single slash. + path = path:gsub('/+', '/') + + -- Strip trailing slash (except for "/"). + if #path > 1 and path:sub(-1) == '/' then + path = path:sub(1, -2) + end + + -- Split into components. + local parts = {} + for seg in path:gmatch('[^/]+') do + -- Skip "." segments; do not attempt to resolve ".." here. + if seg ~= '.' and seg ~= '' then + parts[#parts + 1] = seg + end + end + + -- Nothing to do (e.g. "." or "/."). + if #parts == 0 then + return true, nil + end + + local cur = is_abs and '' or nil + for i = 1, #parts do + local seg = parts[i] + if cur == nil then + cur = seg + elseif cur == '' then + cur = '/' .. seg + else + cur = cur .. '/' .. seg + end + + local ok, err = fd_back.mkdir(cur, perms) + if ok then + -- created + else + -- treat "already exists" as success; anything else is fatal + if not is_eexist(err) then + return nil, err + end + end + end + + return true, nil +end + +---------------------------------------------------------------------- +-- Open by filename +---------------------------------------------------------------------- + +--- Open a file by name as a Stream. +--- +--- mode : "r", "w", "a", "r+", "w+", "a+" (with optional "b" suffix) +--- perms : integer or symbolic string (e.g. "rw-rw-rw-"), backend-defined. +--- +---@param filename string +---@param mode? string +---@param perms? integer|string +---@return Stream|nil f, string|nil err +local function open_file(filename, mode, perms) + mode = mode or 'r' + + local fd, err = fd_back.open_file(filename, mode, perms) + if not fd then + return nil, err + end + + return fdopen(fd, mode, filename) +end + +---------------------------------------------------------------------- +-- Pipes +---------------------------------------------------------------------- + +--- Create a unidirectional pipe as two Streams (read, write). +---@return Stream r_stream, Stream w_stream +local function pipe() + local rd, wr, err = fd_back.pipe() + if not rd then + error(err or 'pipe() failed') + end + + local r_stream = fdopen(rd, 'r') + local w_stream = fdopen(wr, 'w') + return r_stream, w_stream +end + +---------------------------------------------------------------------- +-- mktemp / tmpfile +---------------------------------------------------------------------- + +--- Create a temporary file with a unique name (backend-level). +--- +--- perms may be an integer mask or a symbolic string understood by the backend. +---@param prefix string +---@param perms? integer|string +---@return integer|nil fd, string tmpname_or_err +local function mktemp(prefix, perms) + perms = perms or 'rw-r--r--' + local fd, tmpnam_or_err = fd_back.mktemp(prefix, perms) + if not fd then + return nil, tmpnam_or_err + end + return fd, tmpnam_or_err +end + +--- Create a temporary file wrapped as a Stream, with unlink-on-close semantics. +---@param perms? integer|string +---@param tmpdir? string +---@return Stream|nil f, string|nil err +local function tmpfile(perms, tmpdir) + perms = perms or 'rw-r--r--' + tmpdir = tmpdir or os.getenv('TMPDIR') or '/tmp' + ---@cast tmpdir string + + local fd, tmpnam_or_err = mktemp(tmpdir .. '/tmp', perms) + if not fd then + return nil, tmpnam_or_err + end + + ---@type Stream + local f = fdopen(fd, 'r+', tmpnam_or_err) + + -- We want unlink-on-close semantics by default, with a way to + -- disable that via :rename(). + local io = f.io + assert(io, 'tmpfile backend missing') + ---@cast io StreamBackend + + local old_close = assert(io.close, 'tmpfile backend missing close()') + + --- Rename the temporary file and disable unlink-on-close behaviour. + ---@param newname string + ---@return boolean|nil ok, string|nil err + function f:rename(newname) + -- Flush buffered data first. + self:flush() + + local real_fd = io.fileno and io:fileno() or fd + if real_fd then + fd_back.fsync(real_fd) + end + + local fname = assert(io.filename, 'tmpfile has no filename') + local ok, err = fd_back.rename(fname, newname) + if not ok then + return nil, ('failed to rename %s to %s: %s'):format( + tostring(fname), + tostring(newname), + tostring(err) + ) + end + + io.filename = newname + -- Disable remove-on-close: restore original close. + io.close = old_close + return true + end + + --- Close the fd and unlink the temporary file. + ---@return boolean ok, string|nil err + function io:close() + local ok, err = old_close(self) + if not ok then + return ok, err + end + + local fname = assert(self.filename, 'tmpfile has no filename') + local ok2, err2 = fd_back.unlink(fname) + if not ok2 then + return false, ('failed to remove %s: %s'):format( + tostring(fname), + tostring(err2) + ) + end + + return true, nil + end + + return f +end + +---------------------------------------------------------------------- +-- Compatibility helper +---------------------------------------------------------------------- + +--- Put an fd into non-blocking mode using the backend. +---@param fd integer +---@return boolean ok, string|nil err +local function init_nonblocking(fd) + return fd_back.set_nonblock(fd) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + fdopen = fdopen, + open = open_file, + pipe = pipe, + mktemp = mktemp, + tmpfile = tmpfile, + init_nonblocking = init_nonblocking, + mkdir = mkdir, + mkdir_p = mkdir_p, + rename = fd_back.rename, + unlink = fd_back.unlink, + + -- For callers that previously used file.modes / file.permissions, + -- re-export backend metadata if present. + modes = fd_back.modes or {}, + permissions = fd_back.permissions or {}, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/mem_backend.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/mem_backend.lua new file mode 100644 index 00000000..50dfb2fa --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/mem_backend.lua @@ -0,0 +1,158 @@ +-- fibers/io/mem_backend.lua +-- +-- In-memory duplex pair (pipe-like) backend. +-- +-- Backend contract towards fibers.io.stream: +-- * kind() -> "mem" +-- * fileno() -> nil +-- * read_string(max) -> str|nil, err|nil +-- - str == nil : would block +-- - str == "" : EOF +-- * write_string(str) -> n|nil, err|nil +-- - n == nil : would block +-- * on_readable(task) -> token{ unlink = fn } +-- * on_writable(task) -> token{ unlink = fn } +-- * close() -> ok, err|nil + +local runtime = require 'fibers.runtime' +local wait = require 'fibers.wait' +local bytes = require 'fibers.utils.bytes' + +local RingBuf = bytes.RingBuf + +local function new_half(bufsize) + local H = { + rx = RingBuf.new(bufsize or 4096), + r_wait = wait.new_waitset(), + w_wait = wait.new_waitset(), + peer = nil, + rx_closed = false, -- peer has closed its write side + closed = false, -- this half has been closed + } + + local function schedule_all(waitset, key) + local sched = runtime.current_scheduler + waitset:notify_all(key, sched) + end + + function H:kind() + return 'mem' + end + + function H:fileno() + return nil + end + + -------------------------------------------------------------------- + -- String-oriented I/O, for use by fibers.io.stream + -------------------------------------------------------------------- + + -- Read up to max bytes as a Lua string. + -- * returns "" when peer has closed and no more data โ†’ EOF. + -- * returns nil, nil when no data and not closed yet โ†’ would block. + function H:read_string(max) + local have = self.rx:read_avail() + if have == 0 then + if self.rx_closed then + -- EOF + return '', nil + end + -- Would block + return nil, nil + end + + local n = math.min(have, max or have) + local chunk = self.rx:take(n) + + -- Space freed โ†’ wake peer writers. + local peer = self.peer + if peer then + schedule_all(peer.w_wait, peer) + end + + return chunk, nil + end + + -- Write a Lua string into the peer's receive buffer. + -- * returns nil, "closed" if peer is gone. + -- * returns nil, nil when no space โ†’ would block. + function H:write_string(str) + local peer = self.peer + if not peer or peer.rx_closed then + return nil, 'closed' + end + + local len = #str + if len == 0 then + return 0, nil + end + + local room = peer.rx:write_avail() + if room == 0 then + if peer.rx_closed then + return nil, 'closed' + end + -- Would block. + return nil, nil + end + + local n = math.min(room, len) + peer.rx:put(str:sub(1, n)) + + -- Data available โ†’ wake peer readers. + schedule_all(peer.r_wait, peer) + + return n, nil + end + + -------------------------------------------------------------------- + -- Readiness registration + -------------------------------------------------------------------- + + function H:on_readable(task) + -- Key by this half; effectively one bucket. + return self.r_wait:add(self, task) + end + + function H:on_writable(task) + return self.w_wait:add(self, task) + end + + -------------------------------------------------------------------- + -- Lifecycle + -------------------------------------------------------------------- + + function H:close() + if self.closed then + return true + end + self.closed = true + + local peer = self.peer + self.peer = nil + + -- Wake any local waiters so they can observe closure. + schedule_all(self.r_wait, self) + schedule_all(self.w_wait, self) + + if peer then + -- Indicate EOF to the peer's read side and wake its waiters. + peer.rx_closed = true + schedule_all(peer.r_wait, peer) + schedule_all(peer.w_wait, peer) + peer.peer = nil + end + + return true + end + + return H +end + +local function pipe(bufsize) + local a, b = new_half(bufsize), new_half(bufsize) + a.peer, b.peer = b, a + return a, b +end + +return { pipe = pipe } diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller.lua new file mode 100644 index 00000000..16c22bc3 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller.lua @@ -0,0 +1,20 @@ +-- fibers/io/poller.lua +-- +-- Poller shim: chooses the best available backend. +-- Order matters: we prefer epoll (FFI) when possible, then fall back +-- to a pure-luaposix select/poll implementation. + +local candidates = { + 'fibers.io.poller.epoll', -- Linux + FFI/epoll + 'fibers.io.poller.select', -- luaposix poll/select + 'fibers.io.poller.nixio', -- nixio poll/select +} + +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + return mod + end +end + +error('fibers.io.poller: no suitable poller backend available on this platform') diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/core.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/core.lua new file mode 100644 index 00000000..fa38e57e --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/core.lua @@ -0,0 +1,183 @@ +-- fibers/io/poller/core.lua +-- +-- Core glue for poller backends. +-- +-- This module owns the public Poller shape and semantics. +-- Platform backends provide only low-level primitives; build_poller +-- wires those into a concrete { get, Poller, is_supported } module. +-- +-- Backend ops contract: +-- ops.new_backend() -> backend_state +-- ops.poll(backend_state, timeout_ms, rd_waitset, wr_waitset) -> events +-- events is a table: events[fd] = { rd = bool, wr = bool, err = bool } +-- ops.on_wait_change(backend_state, fd, want_rd, want_wr) -- optional +-- ops.close_backend(backend_state) -- optional +-- ops.is_supported() -> boolean -- optional +-- +---@module 'fibers.io.poller.core' + +local runtime = require 'fibers.runtime' +local wait = require 'fibers.wait' + +---@class Poller : TaskSource +---@field backend_state any +---@field rd Waitset +---@field wr Waitset +---@field _ops table +local Poller = {} +Poller.__index = Poller + +---------------------------------------------------------------------- +-- Internal helpers +---------------------------------------------------------------------- + +local function recompute_mask(self, fd) + local ops = self._ops + if not ops.on_wait_change then + return + end + local want_rd = not self.rd:is_empty(fd) + local want_wr = not self.wr:is_empty(fd) + ops.on_wait_change(self.backend_state, fd, want_rd, want_wr) +end + +local function seconds_to_ms(timeout) + if timeout == nil then + -- Non-blocking poll. + return 0 + elseif timeout < 0 then + -- โ€œInfiniteโ€ block. + return -1 + else + return math.floor(timeout * 1e3 + 0.5) + end +end + +---------------------------------------------------------------------- +-- Public methods +---------------------------------------------------------------------- + +--- Register a task as waiting on an fd for read or write readiness. +---@param fd integer +---@param dir '"rd"'|'"wr"' +---@param task Task +---@return WaitToken +function Poller:wait(fd, dir, task) + -- assert(type(fd) == "number", "fd must be number") + assert(fd ~= nil, 'fd must be non-nil') + assert(dir == 'rd' or dir == 'wr', "dir must be 'rd' or 'wr'") + + local ws = (dir == 'rd') and self.rd or self.wr + local token = ws:add(fd, task) + + -- Update backend subscription / mask. + recompute_mask(self, fd) + + -- Wrap unlink to keep backend state in sync with waitsets. + local original_unlink = token.unlink + local owner = self + + function token.unlink(tok) + local emptied = original_unlink(tok) + if emptied then + recompute_mask(owner, fd) + end + return emptied + end + + return token +end + +--- TaskSource hook: wait for events and schedule ready tasks. +---@param sched Scheduler +---@param _ number|nil -- current monotonic time (unused) +---@param timeout number|nil -- seconds +function Poller:schedule_tasks(sched, _, timeout) + local ops = self._ops + + local timeout_ms = seconds_to_ms(timeout) + local events = ops.poll(self.backend_state, timeout_ms, self.rd, self.wr) + if not events then + -- Backend chose to do nothing (e.g. no fds registered). + return + end + + for fd, flags in pairs(events) do + if flags.rd or flags.err then + self.rd:notify_all(fd, sched) + end + if flags.wr or flags.err then + self.wr:notify_all(fd, sched) + end + + -- Re-arm / update backend subscription after delivering events. + recompute_mask(self, fd) + end +end + +-- Used by the scheduler. +Poller.wait_for_events = Poller.schedule_tasks + +function Poller:close() + if self.backend_state and self._ops.close_backend then + self._ops.close_backend(self.backend_state) + end + self.backend_state = nil +end + +---------------------------------------------------------------------- +-- Builder +---------------------------------------------------------------------- + +--- Build a concrete poller module from low-level ops. +--- +--- See top-of-file comment for ops contract. +--- +---@param ops table +---@return table poller_module -- { get = fn, Poller = Poller, is_supported = fn } +local function build_poller(ops) + assert(type(ops) == 'table', 'poller ops must be a table') + assert(type(ops.new_backend) == 'function', 'ops.new_backend must be a function') + assert(type(ops.poll) == 'function', 'ops.poll must be a function') + + local function new_poller() + local backend_state = ops.new_backend() + local self = { + backend_state = backend_state, + rd = wait.new_waitset(), + wr = wait.new_waitset(), + _ops = ops, + } + return setmetatable(self, Poller) + end + + local singleton + + local function get() + if singleton then + return singleton + end + singleton = new_poller() + local sched = runtime.current_scheduler + assert(sched.add_task_source, 'scheduler must implement add_task_source') + sched:add_task_source(singleton) + return singleton + end + + local function is_supported() + if type(ops.is_supported) == 'function' then + return not not ops.is_supported() + end + return true + end + + return { + get = get, + Poller = Poller, + is_supported = is_supported, + } +end + +return { + build_poller = build_poller, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/epoll.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/epoll.lua new file mode 100644 index 00000000..13400da5 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/epoll.lua @@ -0,0 +1,354 @@ +-- fibers/io/poller/epoll.lua +-- +-- Linux epoll-based poller backend. +-- Supports LuaJIT (ffi) and PUC Lua with cffi via ffi_compat. +-- Intended to be selected via fibers.io.poller. +-- +---@module 'fibers.io.poller.epoll' + +local core = require 'fibers.io.poller.core' +local safe = require 'coxpcall' +local bit = rawget(_G, 'bit') or require 'bit32' +local ffi_c = require 'fibers.utils.ffi_compat' + +---------------------------------------------------------------------- +-- FFI / CFFI setup via ffi_compat +---------------------------------------------------------------------- + +if not (ffi_c.is_supported and ffi_c.is_supported()) then + return { is_supported = function () return false end } +end + +local ffi = ffi_c.ffi +local C = ffi_c.C +local ffi_tonumber = ffi_c.tonumber +local get_errno = ffi_c.errno + +local EPERM = 1 +local EINTR = 4 +local ENOENT = 2 +local EBADF = 9 + +local jit = rawget(_G, 'jit') +local ARCH = ffi.arch or ((jit and jit.arch) or 'x64') + +---------------------------------------------------------------------- +-- Low-level epoll bindings +---------------------------------------------------------------------- + +ffi.cdef [[ + typedef unsigned char uint8_t; + typedef unsigned int uint32_t; + typedef unsigned long long uint64_t; + + int epoll_create(int size); + int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); + int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); + + int close(int fd); + char *strerror(int errnum); +]] + +-- epoll_event layout differs by architecture. +if ARCH == 'x64' or ARCH == 'x86' then + ffi.cdef [[ + typedef struct epoll_event { + uint8_t raw[12]; // 4 bytes for events + 8 bytes for data + } epoll_event; + ]] +elseif ARCH == 'mips' or ARCH == 'mipsel' or ARCH == 'arm64' then + ffi.cdef [[ + typedef struct epoll_event { + uint32_t events; + uint64_t data; + } epoll_event; + ]] +else + error('fibers.io.poller.epoll: unsupported architecture ' .. tostring(ARCH)) +end + +-- Event bits. +local EPOLLIN = 0x00000001 +local EPOLLOUT = 0x00000004 +local EPOLLERR = 0x00000008 +local EPOLLHUP = 0x00000010 +local EPOLLRDHUP = 0x00002000 +local EPOLLONESHOT = bit.lshift(1, 30) + +local EPOLL_CTL_ADD = 1 +local EPOLL_CTL_DEL = 2 +local EPOLL_CTL_MOD = 3 + +-- Architecture-dependent field access. +local get_event, set_event, get_data, set_data + +if ARCH == 'x64' or ARCH == 'x86' then + get_event = function (ev) return ffi.cast('uint32_t*', ev.raw)[0] end + set_event = function (ev, value) ffi.cast('uint32_t*', ev.raw)[0] = value end + get_data = function (ev) return ffi.cast('uint64_t*', ev.raw + 4)[0] end + set_data = function (ev, value) ffi.cast('uint64_t*', ev.raw + 4)[0] = value end +else + get_event = function (ev) return ev.events end + set_event = function (ev, value) ev.events = value end + get_data = function (ev) return ev.data end + set_data = function (ev, value) ev.data = value end +end + +local function wrap_error(ret) + if ret == -1 then + local errno = get_errno() + local err = ffi.string(C.strerror(errno)) + return nil, err, errno + end + return ret, nil, nil +end + +local function epoll_create() + local fd, err, errno = wrap_error(C.epoll_create(1)) + if not fd then + error(err or ('epoll_create failed (errno ' .. tostring(errno) .. ')')) + end + return fd +end + +local function epoll_ctl_add(epfd, fd, mask) + local ev = ffi.new('struct epoll_event') + set_event(ev, mask) + set_data(ev, fd) + return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev)) +end + +local function epoll_ctl_mod(epfd, fd, mask) + local ev = ffi.new('struct epoll_event') + set_event(ev, mask) + set_data(ev, fd) + return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_MOD, fd, ev)) +end + +local function epoll_ctl_del(epfd, fd) + return wrap_error(C.epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nil)) +end + +local function epoll_wait(epfd, timeout_ms, max_events) + local events = ffi.new('struct epoll_event[?]', max_events) + local n = C.epoll_wait(epfd, events, max_events, timeout_ms or 0) + if n == -1 then + local errno = get_errno() + if errno == EINTR then + return {}, nil, errno + end + local err = ffi.string(C.strerror(errno)) + return nil, err, errno + end + + local res = {} + for i = 0, n - 1 do + local fd = assert(ffi_tonumber(get_data(events[i]))) + local event = assert(ffi_tonumber(get_event(events[i]))) + res[fd] = event + end + return res, nil, nil +end + +local function epoll_close(epfd) + return wrap_error(C.close(epfd)) +end + +---------------------------------------------------------------------- +-- Epoll wrapper object +---------------------------------------------------------------------- + +---@class EpollState +---@field epfd integer +---@field active_events table +---@field unpollable table -- fds that return EPERM to epoll_ctl +---@field maxevents integer +local Epoll = {} +Epoll.__index = Epoll + +local INITIAL_MAXEVENTS = 8 + +local function new_epoll() + local ret = { + epfd = epoll_create(), + active_events = {}, + unpollable = {}, + maxevents = INITIAL_MAXEVENTS, + } + return setmetatable(ret, Epoll) +end + +local RD = EPOLLIN + EPOLLRDHUP +local WR = EPOLLOUT +local ERR = EPOLLERR + EPOLLHUP + +local function die_ctl(opname, fd, err, errno) + error((opname .. ' failed for fd ' .. tostring(fd) .. ' (' .. tostring(err) .. ', errno ' .. tostring(errno) .. ')')) +end + +function Epoll:add(fd, events) + -- Once an fd is known to be unpollable, do not try to epoll_ctl it again. + if self.unpollable[fd] then + return + end + + local active = self.active_events[fd] or 0 + local eventmask = bit.bor(events, active, EPOLLONESHOT) + + -- Try MOD first (common case). + local ok, err, eno = epoll_ctl_mod(self.epfd, fd, eventmask) + if ok then + self.active_events[fd] = eventmask + return + end + + -- EPERM: fd type not supported by epoll (e.g. regular file). Treat as unpollable. + if eno == EPERM then + self.active_events[fd] = nil + self.unpollable[fd] = true + return + end + + -- Not currently registered (or MOD failed): try ADD. + local ok2, err2, eno2 = epoll_ctl_add(self.epfd, fd, eventmask) + if ok2 then + self.active_events[fd] = eventmask + return + end + + if eno2 == EPERM then + self.active_events[fd] = nil + self.unpollable[fd] = true + return + end + + die_ctl('epoll_ctl(ADD)', fd, err2 or err, eno2 or eno) +end + +function Epoll:poll(timeout_ms) + local evmap, err, errno = epoll_wait(self.epfd, timeout_ms or 0, self.maxevents) + if not evmap then + error(err or ('epoll_wait failed (errno ' .. tostring(errno) .. ')')) + end + + local count = 0 + for fd, _ in pairs(evmap) do + count = count + 1 + self.active_events[fd] = nil + end + if count == self.maxevents then + self.maxevents = self.maxevents * 2 + end + + return evmap +end + +function Epoll:del(fd) + -- If this fd was unpollable, there is no kernel state to delete. + if self.unpollable[fd] then + self.unpollable[fd] = nil + self.active_events[fd] = nil + return + end + + local ok, err, errno = epoll_ctl_del(self.epfd, fd) + if not ok then + if errno == ENOENT or errno == EBADF then + self.active_events[fd] = nil + return + end + die_ctl('epoll_ctl(DEL)', fd, err, errno) + end + + self.active_events[fd] = nil +end + +function Epoll:close() + epoll_close(self.epfd) + self.epfd = nil + self.active_events = {} + self.unpollable = {} +end + +---------------------------------------------------------------------- +-- Backend ops for poller.core +---------------------------------------------------------------------- + +local function new_backend() + return new_epoll() +end + +local function on_wait_change(ep, fd, want_rd, want_wr) + local mask = 0 + if want_rd then mask = bit.bor(mask, RD) end + if want_wr then mask = bit.bor(mask, WR) end + + if mask ~= 0 then + ep:add(fd, mask) + else + ep:del(fd) + end +end + +local function poll_backend(ep, timeout_ms, rd_waitset, wr_waitset) + local events = {} + + -- Synthesize readiness for fds that epoll cannot watch (EPERM). + -- This keeps Poller:wait and backend registration exception-free. + local had_synthetic = false + if ep.unpollable then + for fd, _ in pairs(ep.unpollable) do + local rd = rd_waitset and (not rd_waitset:is_empty(fd)) or false + local wr = wr_waitset and (not wr_waitset:is_empty(fd)) or false + if rd or wr then + had_synthetic = true + events[fd] = { rd = rd, wr = wr, err = false } + end + end + end + + -- If we have synthetic events to deliver, do not block in epoll_wait. + local real_timeout = had_synthetic and 0 or timeout_ms + + local evmap = ep:poll(real_timeout) + for fd, ev in pairs(evmap) do + local flags = { + rd = bit.band(ev, RD + ERR) ~= 0, + wr = bit.band(ev, WR + ERR) ~= 0, + err = bit.band(ev, ERR) ~= 0, + } + local cur = events[fd] + if cur then + -- Merge with any synthetic readiness. + cur.rd = cur.rd or flags.rd + cur.wr = cur.wr or flags.wr + cur.err = cur.err or flags.err + else + events[fd] = flags + end + end + + return events +end + +local function close_backend(ep) + ep:close() +end + +local function is_supported() + local ok = safe.pcall(function () + local e = new_epoll() + e:close() + end) + return ok +end + +local ops = { + new_backend = new_backend, + on_wait_change = on_wait_change, + poll = poll_backend, + close_backend = close_backend, + is_supported = is_supported, +} + +return core.build_poller(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/nixio.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/nixio.lua new file mode 100644 index 00000000..de3cae40 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/nixio.lua @@ -0,0 +1,105 @@ +-- fibers/io/poller/nixio.lua +-- +-- nixio.poll()-based poller backend (no epoll / luaposix dependency). +-- Intended to be selected via fibers.io.poller. +-- +---@module 'fibers.io.poller.nixio' + +local core = require 'fibers.io.poller.core' +local nixio = require 'nixio' + +---------------------------------------------------------------------- +-- Backend ops for poller.core +---------------------------------------------------------------------- + +local function new_backend() + -- No persistent kernel state required; everything is derived from the + -- current waitsets on each poll call. + return {} +end + +--- Build the fds table in the shape expected by nixio.poll: +--- fds[i] = { fd = , events = } +--- +--- Here the "fd" field is the nixio object itself; poll() accepts that. +local function build_fds(rd_waitset, wr_waitset) + local fds = {} + local index = 1 + + -- We need to iterate over the union of keys in both waitsets. + local seen = {} + + for fd, list in pairs(rd_waitset.buckets) do + if list and #list > 0 then + local events = nixio.poll_flags('in') + fds[index] = { fd = fd, events = events } + seen[fd] = index + index = index + 1 + end + end + + for fd, list in pairs(wr_waitset.buckets) do + if list and #list > 0 then + local pos = seen[fd] + if pos then + local e = fds[pos] + e.events = nixio.poll_flags(e.events, 'out') + else + local events = nixio.poll_flags('out') + fds[index] = { fd = fd, events = events } + seen[fd] = index + index = index + 1 + end + end + end + + return fds +end + +local function poll_backend(_, timeout_ms, rd_waitset, wr_waitset) + local fds = build_fds(rd_waitset, wr_waitset) + + -- nixio.poll(fds, timeout_ms) -> nready, fds' + local nready, fds_ret, _, _ = nixio.poll(fds, timeout_ms) + + if not nready then + -- Treat EINTR as benign; anything else can reasonably surface. + -- For a "simple" backend you can choose to treat all errors as + -- "no events"; if you prefer you can error() here instead. + return {} + end + + if nready == 0 then + return {} + end + + local events = {} + + for _, info in pairs(fds_ret) do + local revents = info.revents or 0 + if revents ~= 0 then + local flags = nixio.poll_flags(revents) + events[info.fd] = { + rd = not not (flags['in'] or flags.hup or flags.err or flags.nval), + wr = not not (flags.out or flags.err or flags.nval), + err = not not (flags.err or flags.nval), + } + end + end + + return events +end + +local function is_supported() + local ok = pcall(require, 'nixio') + return ok +end + +local ops = { + new_backend = new_backend, + poll = poll_backend, + -- on_wait_change not needed; state is rebuilt each poll. + is_supported = is_supported, +} + +return core.build_poller(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/select.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/select.lua new file mode 100644 index 00000000..6aaa3828 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/poller/select.lua @@ -0,0 +1,110 @@ +-- fibers/io/poller/select.lua +-- +-- luaposix.poll()-based poller backend (no epoll required). +-- Intended to be selected via fibers.io.poller. +-- +---@module 'fibers.io.poller.select' + +local core = require 'fibers.io.poller.core' + +-- Try to load luaposix poll support. +local ok, poll_mod = pcall(require, 'posix.poll') +if not ok or type(poll_mod) ~= 'table' or type(poll_mod.poll) ~= 'function' then + return { + is_supported = function () return false end, + } +end +local errno_mod = require 'posix.errno' + +local poll_fn = poll_mod.poll + +---------------------------------------------------------------------- +-- Backend ops for poller.core +---------------------------------------------------------------------- + +local function new_backend() + -- No persistent kernel state required for poll(); everything is + -- derived from the current waitsets on each poll call. + return {} +end + +--- Build the fds table in the shape expected by posix.poll.poll: +--- fds[fd] = { events = { IN = true, OUT = true } } +local function build_fds(rd_waitset, wr_waitset) + local fds = {} + + -- Any fd with one or more read waiters gets IN. + for fd, list in pairs(rd_waitset.buckets) do + if list and #list > 0 then + local e = fds[fd] + if not e then + e = { events = {} } + fds[fd] = e + end + e.events.IN = true + end + end + + -- Any fd with one or more write waiters gets OUT. + for fd, list in pairs(wr_waitset.buckets) do + if list and #list > 0 then + local e = fds[fd] + if not e then + e = { events = {} } + fds[fd] = e + end + e.events.OUT = true + end + end + + return fds +end + +local function poll_backend(_, timeout_ms, rd_waitset, wr_waitset) + local fds = build_fds(rd_waitset, wr_waitset) + + -- poll() with nfds == 0 is defined and just sleeps for timeout. + local nready, err, eno = poll_fn(fds, timeout_ms) + if nready == nil then + -- Treat EINTR as a benign interruption (e.g. SIGCHLD), same as epoll backend. + if eno == errno_mod.EINTR then + return {} + end + error(('%s (errno %s)'):format(tostring(err), tostring(eno))) + end + if nready == 0 then + return {} + end + + local events = {} + + -- luaposix reports readiness in fds[fd].revents with flags + -- such as IN, OUT, ERR, HUP, NVAL. + for fd, info in pairs(fds) do + local re = info.revents + if re then + local rd_flag = re.IN or re.HUP or re.ERR or re.NVAL + local wr_flag = re.OUT or re.ERR or re.NVAL + local err_flag = re.ERR or re.NVAL + + if rd_flag or wr_flag or err_flag then + events[fd] = { + rd = not not rd_flag, + wr = not not wr_flag, + err = not not err_flag, + } + end + end + end + + return events +end + +local ops = { + new_backend = new_backend, + poll = poll_backend, + -- on_wait_change: not needed for poll(); state is rebuilt each time. + is_supported = function () return true end, +} + +return core.build_poller(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/socket.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/socket.lua new file mode 100644 index 00000000..87a65950 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/socket.lua @@ -0,0 +1,486 @@ +-- fibers/io/socket.lua +-- +-- Socket helpers on top of fd_backend + stream. +-- +-- Exposes: +-- socket(domain, stype, protocol?) -> Socket +-- listen_unix(path, opts?) -> Socket (listening AF_UNIX) +-- connect_unix(path, stype?, proto?) -> Stream +-- listen_inet(host, port, opts?) -> Socket (listening AF_INET) +-- connect_inet(host, port, opts?) -> Stream +-- +-- Socket supports: +-- :bind(sa) +-- :listen() +-- :listen_unix(path) +-- :listen_inet(host, port) +-- :accept_op() +-- :accept() +-- :connect_op(sa) +-- :connect(sa) +-- :connect_unix_op(path) +-- :connect_unix(path) +-- :connect_inet_op(host, port) +-- :connect_inet(host, port) +-- :close() +-- +---@module 'fibers.io.socket' + +local wait = require 'fibers.wait' +local poller_mod = require 'fibers.io.poller' +local fd_backend = require 'fibers.io.fd_backend' +local stream_mod = require 'fibers.io.stream' +local perform = require 'fibers.performer'.perform + +---@class Socket +---@field fd integer|nil +local Socket = {} +Socket.__index = Socket + +---------------------------------------------------------------------- +-- Internal helpers +---------------------------------------------------------------------- + +--- Wrap an fd as a full-duplex Stream. +---@param fd integer +---@param filename? string +---@return Stream +local function fd_to_stream(fd, filename) + local io = fd_backend.new(fd, { filename = filename }) + return stream_mod.open(io, true, true) +end + +--- Create a new non-blocking socket object from an fd. +---@param fd integer +---@return Socket +local function new_socket(fd) + local ok, err = fd_backend.set_nonblock(fd) + if not ok then + fd_backend.close_fd(fd) + error('set_nonblock(socket fd) failed: ' .. tostring(err)) + end + return setmetatable({ fd = fd }, Socket) +end + +--- Return underlying fd or error if closed. +---@return integer +function Socket:_fd() + local fd = self.fd + assert(fd, 'socket is closed') + return fd +end + +--- Build an AF_INET sockaddr token understood by fd_backend. +---@param host string +---@param port number|string +---@return table|nil sa, any err +local function inet_sa(host, port) + if type(host) ~= 'string' or host == '' then + return nil, 'host must be a non-empty string' + end + + port = tonumber(port) + if not port or port < 0 or port > 65535 then + return nil, 'port must be 0..65535' + end + + return { + family = 'inet', + host = host, + port = math.floor(port), + } +end + +---------------------------------------------------------------------- +-- Constructors +---------------------------------------------------------------------- + +--- Create a new non-blocking socket via the backend. +---@param domain integer +---@param stype integer +---@param protocol? integer +---@return Socket|nil s, any err +local function socket(domain, stype, protocol) + local fd, err = fd_backend.socket(domain, stype, protocol or 0) + if not fd then + return nil, err + end + + local ok, nerr = fd_backend.set_nonblock(fd) + if not ok then + fd_backend.close_fd(fd) + return nil, nerr + end + + return new_socket(fd) +end + +---------------------------------------------------------------------- +-- Generic bind/listen helpers +---------------------------------------------------------------------- + +--- Bind this socket to an address token (UNIX path string or inet table). +---@param sa any +---@return boolean|nil ok, any err +function Socket:bind(sa) + local fd = self:_fd() + local ok, err = fd_backend.bind(fd, sa) + if not ok then + return nil, ('bind failed: %s'):format(tostring(err)) + end + return true +end + +--- Mark this socket as listening. +---@return boolean|nil ok, any err +function Socket:listen() + local fd = self:_fd() + local ok, err = fd_backend.listen(fd) + if not ok then + return nil, ('listen failed: %s'):format(tostring(err)) + end + return true +end + +---------------------------------------------------------------------- +-- Listening and address helpers (UNIX / INET) +---------------------------------------------------------------------- + +--- Listen on a UNIX-domain path using this Socket. +---@param path string +---@return boolean|nil ok, any err +function Socket:listen_unix(path) + local ok, err = self:bind(path) + if not ok then + return nil, err + end + return self:listen() +end + +--- Bind this socket to an IPv4 address/port. +---@param host string +---@param port number|string +---@return boolean|nil ok, any err +function Socket:bind_inet(host, port) + local sa, err = inet_sa(host, port) + if not sa then + return nil, err + end + return self:bind(sa) +end + +--- Listen on an IPv4 address/port using this Socket. +---@param host string +---@param port number|string +---@return boolean|nil ok, any err +function Socket:listen_inet(host, port) + local ok, err = self:bind_inet(host, port) + if not ok then + return nil, err + end + return self:listen() +end + +---------------------------------------------------------------------- +-- accept() as an Op +---------------------------------------------------------------------- + +--- Build an Op that accepts a connection and returns a Stream. +---@return Op +function Socket:accept_op() + local P = poller_mod.get() + local fd = self:_fd() + + local function step() + local new_fd, err, again = fd_backend.accept(fd) + if new_fd then + return true, new_fd, nil + end + if again then + return false + end + return true, nil, err + end + + local function register(task) + return P:wait(fd, 'rd', task) + end + + local function wrap(new_fd, err) + if not new_fd then + return nil, err + end + return fd_to_stream(new_fd) + end + + return wait.waitable(register, step, wrap) +end + +--- Accept a connection synchronously into a Stream. +---@return Stream|nil client, any err +function Socket:accept() + return perform(self:accept_op()) +end + +---------------------------------------------------------------------- +-- connect() as an Op (generic sockaddr token) +---------------------------------------------------------------------- + +--- Build an Op that connects this Socket to an address token. +--- sa may be: +--- * UNIX path string +--- * { family = 'inet', host = '1.2.3.4', port = 1234 } +---@param sa any +---@return Op +function Socket:connect_op(sa) + local P = poller_mod.get() + local fd = self:_fd() + local state = 'initial' + + local function step() + if state == 'initial' then + local ok, err, inprogress = fd_backend.connect_start(fd, sa) + if ok then + return true, true, nil + end + if inprogress then + state = 'waiting' + return false + end + return true, false, err + elseif state == 'waiting' then + local ok, err = fd_backend.connect_finish(fd) + if not ok then + return true, false, err + end + return true, true, nil + else + return true, false, 'invalid connect state' + end + end + + local function register(task) + return P:wait(fd, 'wr', task) + end + + local function wrap(ok, err) + if not ok then + return nil, err + end + local new_fd = fd + self.fd = nil -- hand ownership to Stream + return fd_to_stream(new_fd) + end + + return wait.waitable(register, step, wrap) +end + +--- Connect synchronously and return a Stream. +---@param sa any +---@return Stream|nil stream, any err +function Socket:connect(sa) + return perform(self:connect_op(sa)) +end + +---------------------------------------------------------------------- +-- UNIX-domain convenience +---------------------------------------------------------------------- + +--- Build an Op that connects this socket to a UNIX-domain path. +---@param path string +---@return Op +function Socket:connect_unix_op(path) + return self:connect_op(path) +end + +--- Connect synchronously to a UNIX-domain path. +---@param path string +---@return Stream|nil stream, any err +function Socket:connect_unix(path) + return perform(self:connect_unix_op(path)) +end + +--- Listen on a UNIX-domain path and return a listening Socket. +---@param path string +---@param opts? { stype?: integer, protocol?: integer, ephemeral?: boolean } +---@return Socket|nil s, any err +local function listen_unix(path, opts) + opts = opts or {} + + local stype = opts.stype or fd_backend.SOCK_STREAM + local protocol = opts.protocol or 0 + + local s, err = socket(fd_backend.AF_UNIX, stype, protocol) + if not s then + return nil, err + end + + local ok, lerr = s:listen_unix(path) + if not ok then + s:close() + return nil, lerr + end + + if opts.ephemeral then + local parent_close = s.close + function s:close() + local ok1, err1 = parent_close(self) + + local ok2, err2 = fd_backend.unlink(path) + if not ok2 then + return false, ('failed to remove %s: %s'):format( + tostring(path), + tostring(err2) + ) + end + + if ok1 == false then + return false, err1 + end + return true, nil + end + end + + return s +end + +--- Connect to a UNIX-domain socket path and return a Stream. +---@param path string +---@param stype? integer +---@param protocol? integer +---@return Stream|nil stream, any err +local function connect_unix(path, stype, protocol) + stype = stype or fd_backend.SOCK_STREAM + protocol = protocol or 0 + + local s, err = socket(fd_backend.AF_UNIX, stype, protocol) + if not s then + return nil, err + end + + local stream, cerr = s:connect_unix(path) + if not stream then + s:close() + return nil, cerr + end + return stream +end + +---------------------------------------------------------------------- +-- AF_INET convenience +---------------------------------------------------------------------- + +--- Build an Op that connects this socket to an IPv4 host/port. +---@param host string +---@param port number|string +---@return Op +function Socket:connect_inet_op(host, port) + local sa, err = inet_sa(host, port) + if not sa then + error(err, 2) + end + return self:connect_op(sa) +end + +--- Connect synchronously to an IPv4 host/port. +---@param host string +---@param port number|string +---@return Stream|nil stream, any err +function Socket:connect_inet(host, port) + return perform(self:connect_inet_op(host, port)) +end + +--- Listen on an IPv4 address/port and return a listening Socket. +---@param host string +---@param port number|string +---@param opts? { stype?: integer, protocol?: integer } +---@return Socket|nil s, any err +local function listen_inet(host, port, opts) + opts = opts or {} + + local stype = opts.stype or fd_backend.SOCK_STREAM + local protocol = opts.protocol or 0 + + local s, err = socket(fd_backend.AF_INET, stype, protocol) + if not s then + return nil, err + end + + local ok, lerr = s:listen_inet(host, port) + if not ok then + s:close() + return nil, lerr + end + + return s +end + +--- Connect to an IPv4 host/port and return a Stream. +--- opts.bind_host / opts.bind_port can be used to bind a source address/port first. +---@param host string +---@param port number|string +---@param opts? { stype?: integer, protocol?: integer, bind_host?: string, bind_port?: number|string } +---@return Stream|nil stream, any err +local function connect_inet(host, port, opts) + opts = opts or {} + + local stype = opts.stype or fd_backend.SOCK_STREAM + local protocol = opts.protocol or 0 + + local s, err = socket(fd_backend.AF_INET, stype, protocol) + if not s then + return nil, err + end + + if opts.bind_host ~= nil or opts.bind_port ~= nil then + local ok, berr = s:bind_inet(opts.bind_host or '0.0.0.0', opts.bind_port or 0) + if not ok then + s:close() + return nil, berr + end + end + + local stream, cerr = s:connect_inet(host, port) + if not stream then + s:close() + return nil, cerr + end + + return stream +end + +---------------------------------------------------------------------- +-- Lifecycle +---------------------------------------------------------------------- + +--- Close the underlying socket fd. +---@return boolean ok, any err +function Socket:close() + if self.fd then + local ok, err = fd_backend.close_fd(self.fd) + self.fd = nil + return ok, err + end + return true, nil +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + socket = socket, + + listen_unix = listen_unix, + connect_unix = connect_unix, + + listen_inet = listen_inet, + connect_inet = connect_inet, + + Socket = Socket, + + -- re-export useful constants for callers + AF_UNIX = fd_backend.AF_UNIX, + AF_INET = fd_backend.AF_INET, + SOCK_STREAM = fd_backend.SOCK_STREAM, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/stream.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/stream.lua new file mode 100644 index 00000000..5d44f9b9 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/io/stream.lua @@ -0,0 +1,1016 @@ +-- fibers/io/stream.lua + +local wait = require 'fibers.wait' +local bytes = require 'fibers.utils.bytes' +local op = require 'fibers.op' +local perform = require 'fibers.performer'.perform +local runtime = require 'fibers.runtime' + +local RingBuf = bytes.RingBuf +local LinearBuf = bytes.LinearBuf + +---@class StreamBackend +---@field read_string fun(self: StreamBackend, max: integer): string|nil, any|nil, any|nil +---@field write_string fun(self: StreamBackend, data: string): integer|nil, any|nil, any|nil +---@field on_readable fun(self: StreamBackend, task: Task): WaitToken +---@field on_writable fun(self: StreamBackend, task: Task): WaitToken +---@field close fun(self: StreamBackend): boolean, any|nil +---@field seek fun(self: StreamBackend, whence: string, offset: integer): integer|nil, any|nil +---@field nonblock fun(self: StreamBackend)|nil +---@field block fun(self: StreamBackend)|nil +---@field filename string|nil +---@field fileno fun(self: StreamBackend): integer|nil + +---@class Stream +---@field io StreamBackend|nil +---@field rx any|nil +---@field tx any|nil +---@field line_buffering boolean +---@field _bufmode '"no"'|'"line"'|'"full"'|nil +---@field _bufsize integer|nil +---@field _ws Waitset +---@field _closed boolean +---@field _closing boolean +---@field _sticky_rerr any|nil +---@field _sticky_werr any|nil +---@field _big string|nil +---@field _big_off integer +---@field _pump_task Task +---@field _pump_token WaitToken|nil +---@field _pump_scheduled boolean +---@field _close_done boolean +---@field _close_ok boolean|nil +---@field _close_err any|nil +---@field _rd_owner any|nil +---@field _wr_owner any|nil +local Stream = {} +Stream.__index = Stream + +local DEFAULT_BUFFER_SIZE = 2 ^ 12 +local BIG_WRITE_CHUNK = 64 * 1024 + +-- Single internal wait key. Everything that could unblock someone notifies K_STATE. +local K_STATE = 'state' +local WANT_STATE = K_STATE + +---------------------------------------------------------------------- +-- Small helpers +---------------------------------------------------------------------- + +local function sched() return runtime.current_scheduler end + +-- Lifecycle predicates. +function Stream:_has_backend() return self.io ~= nil end + +-- No backend, or already fully terminated. +function Stream:_is_dead() return self._closed or (self.io == nil) end + +-- In the close handshake, but not yet torn down. +function Stream:_is_closing() return self._closing and not self._closed end + +function Stream:_is_readable() return self.rx ~= nil end + +function Stream:_is_writable() return self.tx ~= nil end + +local function token2(t1, t2) + return { + unlink = function () + if t1 and t1.unlink then t1:unlink() end + if t2 and t2.unlink then t2:unlink() end + return false + end, + } +end + +local NO_TOKEN = { unlink = function () return false end } + +function Stream:_signal_state() + self._ws:notify_all(K_STATE, sched()) +end + +local function drained_tx(self) + return (not self._big) and self.tx and (self.tx:read_avail() == 0) +end + +---------------------------------------------------------------------- +-- Lane serialisation (read/write) +---------------------------------------------------------------------- + +local function new_lane(stream, field) + local owner = {} + + local function acquire() + local cur = stream[field] + if cur == nil or cur == owner then + stream[field] = owner + return true + end + return false + end + + local function release() + if stream[field] == owner then + stream[field] = nil + stream:_signal_state() + if stream._closing and not stream._close_done then + stream:_finish_close_if_ready() + end + end + end + + -- Common wrapper; probe releases on would-block, run holds the lane. + local function wrap(step, release_on_fail) + return function () + if not acquire() then return false, WANT_STATE end + + local ok, v = step() + if ok then return true, v end + + if release_on_fail then release() end + + return false, v or WANT_STATE + end + end + + local function wrap_probe(step) return wrap(step, true) end + + local function wrap_run(step) return wrap(step, false) end + + return { release = release, wrap_probe = wrap_probe, wrap_run = wrap_run } +end + +---------------------------------------------------------------------- +-- Backend wait registration +---------------------------------------------------------------------- + +local function make_register(self, opts) + opts = opts or {} + local primed = false + + return function (task, waker, want) + -- Always register internal state, so close/pump changes wake everyone. + local t_state = self._ws:add(K_STATE, task) + + if opts.prime_once and not primed then + primed = true + waker:wakeup(task) + end + + -- Internal waits (or unspecified wants) just wait on state changes. + if want == K_STATE or want == nil or not (want == 'rd' or want == 'wr') then + return token2(t_state, NO_TOKEN) + end + + local io = self.io + if not io then + -- Ensure the task runs again and the step observes closure. + waker:wakeup(task) + return token2(t_state, NO_TOKEN) + end + + local tok + if want == 'wr' and io.on_writable then + tok = io:on_writable(task) + else + tok = io:on_readable(task) + end + + return token2(t_state, tok) + end +end + +---------------------------------------------------------------------- +-- Construction +---------------------------------------------------------------------- + +---@param io_backend StreamBackend +---@param readable? boolean +---@param writable? boolean +---@param bufsize? integer +---@return Stream +local function open(io_backend, readable, writable, bufsize) + bufsize = bufsize or DEFAULT_BUFFER_SIZE + + local s = setmetatable({ + io = io_backend, + line_buffering = false, + _ws = wait.new_waitset(), + _big_off = 0, + }, Stream) + + if readable ~= false then s.rx = RingBuf.new(bufsize) end + if writable ~= false then s.tx = RingBuf.new(bufsize) end + + s._pump_task = { run = function () s:_pump() end } + return s +end + +---@param x any +---@return boolean +local function is_stream(x) + return type(x) == 'table' and getmetatable(x) == Stream +end + +function Stream:nonblock() + if self.io and self.io.nonblock then self.io:nonblock() end +end + +function Stream:block() + if self.io and self.io.block then self.io:block() end +end + +---------------------------------------------------------------------- +-- Close / terminate +---------------------------------------------------------------------- + +function Stream:_latch_close(ok, err) + if self._close_done then return end + self._close_done = true + self._close_ok = ok + self._close_err = err + self:_signal_state() +end + +function Stream:_unlink_pump_wait() + local pt = self._pump_token + self._pump_token = nil + if pt and pt.unlink then pt:unlink() end +end + +function Stream:terminate(_) + -- Idempotent. + if self._closed then + if not self._close_done then + if self._sticky_werr ~= nil then + self:_latch_close(nil, self._sticky_werr) + else + self:_latch_close(true, nil) + end + end + self:_signal_state() + return + end + + self._closed = true + self._closing = true + self._rd_owner = nil + self._wr_owner = nil + + self:_unlink_pump_wait() + + local io = self.io + self.io = nil + + self.rx, self.tx = nil, nil + self._big, self._big_off = nil, 0 + + if io and io.close then + pcall(function () io:close() end) + end + + if not self._close_done then + if self._sticky_werr ~= nil then + self:_latch_close(nil, self._sticky_werr) + else + self:_latch_close(true, nil) + end + end + + self:_signal_state() +end + +function Stream:_finish_close_if_ready() + if self._close_done or self._closed then return end + if not self._closing then return end + + -- If writable, wait for drain or sticky write error. + if self.tx then + if self._sticky_werr ~= nil then + self:_latch_close(nil, self._sticky_werr) + self:terminate('closed') + return + end + if not drained_tx(self) then + return + end + end + + -- Do not tear down while a lane is owned. + if self._rd_owner ~= nil or self._wr_owner ~= nil then + return + end + + self:_latch_close(true, nil) + self:terminate('closed') +end + +function Stream:_begin_close(_) + if self._closed then + self:_signal_state() + self:_finish_close_if_ready() + return + end + self._closing = true + self:_signal_state() + self:_finish_close_if_ready() +end + +---@return Op +function Stream:close_op() + local register = make_register(self, { prime_once = true }) + + local function probe_step() + if self._close_done then + return true, function () return self._close_ok, self._close_err end + end + return false, WANT_STATE + end + + local function run_step() + if not self._closing and not self._closed then + self:_begin_close('closing') + end + + -- Ensure pending output makes progress if any. + if self.tx then self:_kick_pump() end + + self:_finish_close_if_ready() + + if self._close_done then + return true, function () return self._close_ok, self._close_err end + end + return false, WANT_STATE + end + + return wait.waitable2(register, probe_step, run_step) + :wrap(function (th) + local ok, err = th() + if ok then return true, nil end + return nil, err + end) +end + +---------------------------------------------------------------------- +-- Read path (choice-safe; ALWAYS returns thunks on ready) +---------------------------------------------------------------------- + +---@param stream Stream +---@param buf any +---@param min integer +---@param max integer +---@param terminator string|nil +---@return fun(): boolean, any +---@return fun(): boolean, any +local function make_read_steps(stream, buf, min, max, terminator) + local tally = 0 + local term_target = nil + local want_hint = WANT_STATE + + local function term_enabled() + return terminator ~= nil and terminator ~= '' + end + + local function maybe_clamp() + if term_target or not term_enabled() or not stream.rx then return end + local loc = stream.rx:find(terminator) + if not loc then return end + local final = tally + loc + #terminator + if final <= max then + term_target = final + min, max = final, final + end + end + + local function drain_once() + if not stream.rx then return end + local avail = stream.rx:read_avail() + if avail <= 0 or tally >= max then return end + local need = math.min(avail, max - tally) + if need <= 0 then return end + local chunk = stream.rx:take(need) + if chunk and #chunk > 0 then + buf:append(chunk) + tally = tally + #chunk + end + end + + local function drain_all() + if not stream.rx then return end + while tally < max do + local before = tally + drain_once() + if tally == before then break end + end + end + + -- Terminal check used for both probe and run; does not perform backend I/O. + -- Always returns either nil (not terminal) or a thunk. + local function terminal_thunk() + if stream._sticky_rerr ~= nil then + maybe_clamp() + return function () + drain_all() + return buf, tally, stream._sticky_rerr + end + end + if stream:_is_dead() or stream:_is_closing() then + return function () return buf, tally, 'closed' end + end + return nil + end + + local function probe_step() + local th = terminal_thunk() + if th then return true, th end + + maybe_clamp() + if tally >= min then + return true, function () return buf, tally, nil end + end + + -- Choice-safe: if rx has enough to satisfy min, return a drain thunk. + local rx = stream.rx + if rx and tally < max then + local avail = rx:read_avail() + if avail > 0 then + local possible = tally + math.min(avail, max - tally) + if possible >= min then + return true, function () + drain_once() + return buf, tally, nil + end + end + end + end + + return false, want_hint + end + + local function run_step() + while true do + local th = terminal_thunk() + if th then return true, th end + + maybe_clamp() + drain_once() + if tally >= min then + return true, function () return buf, tally, nil end + end + + local io = stream.io + if not (io and io.read_string) then + return true, function () return buf, tally, 'backend does not support read_string' end + end + + local room = stream.rx:write_avail() + if room <= 0 then + return true, function () return buf, tally, 'buffer capacity exhausted' end + end + + local data, err, want = io:read_string(room) + if err ~= nil then + stream._sticky_rerr = err + stream:_signal_state() + return true, function () return buf, tally, err end + end + + if data == nil then + want_hint = want or 'rd' + return false, want_hint + end + + if data == '' then + -- EOF + return true, function () return buf, tally, nil end + end + + stream.rx:put(data) + end + end + + return probe_step, run_step +end + +---@param buf any +---@param opts? { min?: integer, max?: integer, terminator?: string, eof_ok?: boolean } +---@return Op +function Stream:read_into_op(buf, opts) + assert(self.rx, 'stream is not readable') + + opts = opts or {} + local min = opts.min or 1 + local max = opts.max or min + local terminator = opts.terminator + local eof_ok = not not opts.eof_ok + + local lane = new_lane(self, '_rd_owner') + local probe_step, run_step = make_read_steps(self, buf, min, max, terminator) + + probe_step = lane.wrap_probe(probe_step) + run_step = lane.wrap_run(run_step) + + local register = make_register(self, { prime_once = true }) + + local function wrap(th) + local ret_buf, cnt, err = th() + lane.release() + + if cnt == 0 and not eof_ok then + return nil, 0, err + end + return ret_buf, cnt, err + end + + local ev = wait.waitable2(register, probe_step, run_step, wrap) + return ev:on_abort(function () lane.release() end) +end + +function Stream:core_read_op(opts) + local buf = LinearBuf.new() + local ev = self:read_into_op(buf, opts) + + return ev:wrap(function (ret_buf, cnt, err) + if not ret_buf then return nil, 0, err end + local s = ret_buf:tostring() + if cnt == 0 and s == '' then + return nil, 0, err + end + return s, cnt, err + end) +end + +function Stream:read_some_op(max) + assert(type(max) == 'number' and max >= 0, 'read_some_op: max must be non-negative') + if max == 0 then return op.always('', nil) end + + return self:core_read_op { min = 1, max = max, eof_ok = true } + :wrap(function (s, cnt, err) + if err ~= nil then return nil, err end + if not s or cnt == 0 then return nil, nil end + return s, nil + end) +end + +function Stream:read_exactly_op(n) + assert(type(n) == 'number' and n >= 0, 'read_exactly_op: n must be non-negative') + if n == 0 then return op.always('', nil) end + + return self:core_read_op { min = n, max = n, eof_ok = false } + :wrap(function (s, cnt, err) + if err ~= nil then return nil, err end + if not s or cnt ~= n then return nil, 'short read' end + return s, nil + end) +end + +function Stream:read_line_op(opts) + assert(self.rx, 'stream is not readable') + + opts = opts or {} + local term = opts.terminator or '\n' + local keep_term = not not opts.keep_terminator + + local ev = self:core_read_op { + min = math.huge, + max = math.huge, + terminator = term, + eof_ok = true, + } + + return ev:wrap(function (s, cnt, err) + if err ~= nil then return nil, err end + if not s or cnt == 0 then return nil, nil end + + if not keep_term and #term > 0 and s:sub(- #term) == term then + s = s:sub(1, - #term - 1) + end + + return s, nil + end) +end + +function Stream:read_all_op() + assert(self.rx, 'stream is not readable') + + return self:core_read_op { min = math.huge, max = math.huge, eof_ok = true } + :wrap(function (s, _, err) + if not s then return '', err end + return s, err + end) +end + +---------------------------------------------------------------------- +-- Buffered write pump +---------------------------------------------------------------------- + +function Stream:_kick_pump() + if self._pump_scheduled then return end + if self:_is_dead() then return end + self._pump_scheduled = true + sched():schedule(self._pump_task) +end + +local function next_write_chunk(self) + if self._big then + if self._big_off >= #self._big then + self._big = nil + self._big_off = 0 + self:_signal_state() + return nil + end + local remaining = #self._big - self._big_off + local take = remaining + if take > BIG_WRITE_CHUNK then take = BIG_WRITE_CHUNK end + return self._big:sub(self._big_off + 1, self._big_off + take), 'big' + end + + if self.tx and self.tx:read_avail() > 0 then + local avail = self.tx:read_avail() + if avail > BIG_WRITE_CHUNK then avail = BIG_WRITE_CHUNK end + return self.tx:peek(avail), 'ring' + end + + return nil +end + +local function advance_after_write(self, mode, n) + if mode == 'big' then + self._big_off = self._big_off + n + if self._big_off >= #self._big then + self._big = nil + self._big_off = 0 + end + self:_signal_state() + return + end + + self.tx:advance_read(n) + self:_signal_state() +end + +function Stream:_pump() + self._pump_scheduled = false + + local io = self.io + if self:_is_dead() or not io then return false end + if self._sticky_werr then return false end + if not (self.tx or self._big) then return false end + + self:_unlink_pump_wait() + + local progressed = false + + while true do + if self._sticky_werr or self:_is_dead() then break end + + local chunk, mode = next_write_chunk(self) + if not chunk or #chunk == 0 then break end + + local n, err, want = io:write_string(chunk) + if err then + self._sticky_werr = err + self:_signal_state() + break + end + + if n == nil or n == 0 then + -- Would block: arm readiness (poller is responsible for any EPERM cases). + local w = (want == 'rd') and 'rd' or 'wr' + if w == 'rd' and io.on_readable then + self._pump_token = io:on_readable(self._pump_task) + else + self._pump_token = io:on_writable(self._pump_task) + end + break + end + + progressed = true + advance_after_write(self, mode, n) + end + + if drained_tx(self) then self:_signal_state() end + + self:_finish_close_if_ready() + return progressed +end + +---------------------------------------------------------------------- +-- Buffered write ops +---------------------------------------------------------------------- + +-- Shared output-lane op builder. +-- kind: +-- * 'write' : publish bytes (buffer/big) and return (n|nil, err|nil) +-- * 'flush' : wait until outbound is drained and return (true|nil, err|nil) +local function output_lane_op(self, kind, str) + assert(kind == 'write' or kind == 'flush', 'output_lane_op: bad kind') + + local lane = new_lane(self, '_wr_owner') + local register = make_register(self) + + local function pending() + return (self._big ~= nil) or (self.tx and self.tx:read_avail() > 0) + end + + local function drained() return not pending() end + + -- Decide whether we can accept `str` into the outbound queue. + -- Terminal/error cases are checked by step() before calling this. + local function can_accept(len) + local mode = self._bufmode or 'full' + + if mode == 'no' then + -- Do not allow queuing; require fully drained output. + if pending() then return false end + return true, 'big' + end + + -- Existing buffered behaviour. + if self._big then return false end + + local cap = self.tx:capacity() + if len <= self.tx:write_avail() then return true, 'ring' end + if self.tx:read_avail() == 0 and len > cap then return true, 'big' end + + return false + end + + local function publish(mode, s) + if mode == 'ring' then + self.tx:put(s) + else + self._big = s + self._big_off = 0 + end + end + + local function rollback_published(mode) + -- Only used in the idle-fast-path failure case. + -- Safe because was_idle implies there was no prior pending output. + if mode == 'ring' then + if self.tx then self.tx:reset() end + else + self._big, self._big_off = nil, 0 + end + end + + local function step(is_probe) + -- Sticky backend write error always wins. + if self._sticky_werr ~= nil then + local e = self._sticky_werr + return true, function () return nil, e end + end + + if kind == 'write' then + -- Writes do not proceed once closing/closed or backend absent. + if self:_is_dead() or self:_is_closing() then + return true, function () return nil, 'closed' end + end + + local len = #str + local ok, mode = can_accept(len) + if not ok then + if not is_probe then self:_kick_pump() end + return false, WANT_STATE + end + + local was_idle = drained() + + return true, function () + publish(mode, str) + + -- Opportunistic progress when previously idle; surfaces peer-close promptly. + local progressed = false + if was_idle then + progressed = self:_pump() or false + end + + -- If the very first attempt discovers a terminal error before any progress, + -- fail this write (and drop the just-published bytes). + if was_idle and not progressed and self._sticky_werr ~= nil then + local e = self._sticky_werr + rollback_published(mode) + self:_signal_state() + return nil, e + end + + if pending() and not self._pump_token then + self:_kick_pump() + end + + self:_signal_state() + return len, nil + end + end + + -- kind == 'flush' + if self:_is_dead() then + if drained() then + return true, function () return true, nil end + end + return true, function () return nil, 'closed' end + end + + if drained() then + return true, function () return true, nil end + end + + if is_probe then return false, WANT_STATE end + + self:_kick_pump() + return false, WANT_STATE + end + + local function probe_step() return step(true) end + local function run_step() return step(false) end + + probe_step = lane.wrap_probe(probe_step) + run_step = lane.wrap_run(run_step) + + local function wrap(th) + local a, b = th() + lane.release() + return a, b + end + + local ev = wait.waitable2(register, probe_step, run_step, wrap) + return ev:on_abort(function () lane.release() end) +end + +function Stream:core_write_op(str) + assert(self.tx, 'stream is not writable') + assert(type(str) == 'string', 'core_write_op expects a string') + if str == '' then return op.always(0, nil) end + return output_lane_op(self, 'write', str) +end + +local function flush_required_for_write(self, str) + local mode = self._bufmode or 'full' + if mode == 'no' then + return true + end + if mode == 'line' and type(str) == 'string' then + return str:find('\n', 1, true) ~= nil + end + return false +end + +function Stream:write_op(...) + assert(self.tx, 'stream is not writable') + + local count = select('#', ...) + if count == 0 then return op.always(0, nil) end + + local parts = {} + for i = 1, count do + local v = select(i, ...) + parts[i] = (type(v) == 'string') and v or tostring(v) + end + local str = table.concat(parts) + return self:core_write_op(str):wrap(function (n, err) + if n == nil then return nil, err end + + if flush_required_for_write(self, str) then + local ok, ferr = perform(self:flush_op()) + if ok == nil then return nil, ferr end + end + + return n, nil + end) +end + +function Stream:flush_op() + if not self.tx then return op.always(true, nil) end + return output_lane_op(self, 'flush') +end + +---------------------------------------------------------------------- +-- Misc +---------------------------------------------------------------------- + +function Stream:flush_input() + if self.rx then self.rx:reset() end + self:_signal_state() +end + +function Stream:seek(whence, offset) + self:flush() + if not (self.io and self.io.seek) then + return nil, 'stream is not seekable' + end + whence = whence or 'cur' + offset = offset or 0 + return self.io:seek(whence, offset) +end + +local function next_pow2(n) + if n <= 1 then return 1 end + local p = 1 + while p < n do p = p * 2 end + return p +end + +function Stream:setvbuf(mode, size) + if mode ~= 'no' and mode ~= 'line' and mode ~= 'full' then + error('bad mode: ' .. tostring(mode)) + end + + self._bufmode = mode + self.line_buffering = (mode == 'line') + + if size ~= nil then + assert(type(size) == 'number' and size > 0, 'setvbuf: size must be positive') + size = next_pow2(math.floor(size)) + self._bufsize = size + + if self.rx and self.rx:read_avail() == 0 then + self.rx = RingBuf.new(size) + end + if self.tx and (not self._big) and self.tx:read_avail() == 0 then + self.tx = RingBuf.new(size) + end + self:_signal_state() + end + + return self +end + +function Stream:filename() + return self.io and self.io.filename +end + +---------------------------------------------------------------------- +-- Synchronous convenience wrappers +---------------------------------------------------------------------- + +function Stream:read_line(opts) return perform(self:read_line_op(opts)) end + +function Stream:read_exactly(n) return perform(self:read_exactly_op(n)) end + +function Stream:read_some(max) return perform(self:read_some_op(max)) end + +function Stream:read_all() return perform(self:read_all_op()) end + +function Stream:write(...) return perform(self:write_op(...)) end + +function Stream:flush() return perform(self:flush_op()) end + +function Stream:close() return perform(self:close_op()) end + +---------------------------------------------------------------------- +-- Lua io-like compatibility +---------------------------------------------------------------------- + +function Stream:read_op(fmt) + assert(self.rx, 'stream is not readable') + + if fmt == nil or fmt == '*l' then return self:read_line_op() end + if fmt == '*L' then return self:read_line_op { keep_terminator = true } end + if fmt == '*a' then return self:read_all_op() end + + if type(fmt) == 'number' then + assert(fmt >= 0, 'read_op: n must be non-negative') + if fmt == 0 then return op.always('', nil) end + + return self:core_read_op { min = 1, max = fmt, eof_ok = true } + :wrap(function (s, cnt, err) + if err then return nil, err end + if not s or cnt == 0 then return nil, nil end + return s, nil + end) + end + + error('read_op: invalid format ' .. tostring(fmt)) +end + +function Stream:read(fmt) + return perform(self:read_op(fmt)) +end + +---------------------------------------------------------------------- +-- Module-level helpers +---------------------------------------------------------------------- + +--- Race a single line read across multiple named streams. +--- +--- When performed, returns: +--- name : string +--- line : string|nil +--- err : string|nil +---@param named_streams table +---@param opts? { terminator?: string, keep_terminator?: boolean, max?: integer } +---@return Op +local function merge_lines_op(named_streams, opts) + local arms = {} + for name, s in pairs(named_streams) do + arms[name] = s:read_line_op(opts) + end + return op.named_choice(arms) +end + +return { + open = open, + is_stream = is_stream, + merge_lines_op = merge_lines_op, + Stream = Stream, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/mailbox.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/mailbox.lua new file mode 100644 index 00000000..f1e07a5a --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/mailbox.lua @@ -0,0 +1,497 @@ +-- fibers/mailbox.lua +-- +-- Mailbox: closeable, drainable queue for fibers. +-- +-- Conventions +-- * nil payloads are forbidden; nil is reserved for end-of-stream. +-- * rx:recv() returns: +-- - a non-nil message, or +-- - nil when the mailbox is closed and drained. +-- rx:why() yields the close reason (if any). +-- * tx:send(v) returns: +-- - true if the message was accepted (delivered or enqueued), +-- - false, "full" if the message was not accepted due to capacity/policy, +-- - nil if the mailbox is closed (send rejected). +-- tx:why() yields the close reason (if any). +-- * Multi-producer: +-- - tx:clone() creates a new counted sender handle. +-- - each counted handle should be closed once finished. +-- - mailbox closes-for-send when the last counted handle closes. +-- +-- Full policies (when no receiver is waiting and the mailbox is full): +-- * "block" : sender blocks until space/receiver is available (default) +-- * "reject_newest" : reject the incoming value; send returns false, "full" +-- * "drop_oldest" : drop the oldest buffered value (if any), enqueue the new one; +-- send returns true (accepted), and dropped counter increments +-- +-- For rendezvous mailboxes (capacity == 0), "drop_oldest" behaves like "reject_newest". + +---@module 'fibers.mailbox' + +local op = require 'fibers.op' +local fifo = require 'fibers.utils.fifo' +local dlist = require 'fibers.utils.dlist' +local perform = require 'fibers.performer'.perform + +local ok_probe, leak_probe = pcall(require, 'devicecode.support.leak_probe') +if not ok_probe then leak_probe = nil end + +---@alias MailboxWant nil -- reserved for future extensions + +---@class MailboxState +---@field cap integer +---@field buf any|nil -- FIFO buffer when cap>0; nil for rendezvous +---@field getq any -- cancellable wait-list of waiting receivers +---@field putq any -- cancellable wait-list of waiting senders +---@field taskq any -- cancellable wait-list of task waiters for recv readiness +---@field closed boolean +---@field reason any|nil +---@field senders integer -- counted sender handles still open +---@field full '"block"'|'"reject_newest"'|'"drop_oldest"' +---@field dropped integer -- total number of dropped messages due to full policy + +---@class MailboxTx +---@field _st MailboxState +---@field _closed boolean -- this handle closed (idempotent) +---@field _counted boolean -- whether this handle contributes to st.senders +local Tx = {} +Tx.__index = Tx + +---@class MailboxRx +---@field _st MailboxState +local Rx = {} +Rx.__index = Rx + +---------------------------------------------------------------------- +-- Internal helpers +---------------------------------------------------------------------- + +--- Pop the next entry whose suspension is still waiting, if any. +---@param q any +---@return table|nil +local function pop_active(q) + while not q:empty() do + local e = q:pop_head() + local s = e.suspension + if not s or s:waiting() then + return e + end + end +end + +local function cleanup_recv_waiter(entry) + entry.suspension = nil + entry.wrap = nil +end + +local function cleanup_send_waiter(entry) + entry.val = nil + entry.suspension = nil + entry.wrap = nil +end + +local function cleanup_task_waiter(entry) + entry.task = nil + entry.waker = nil +end + +---@param st MailboxState +local function notify_task_waiters(st) + local q = st.taskq + if not q then return end + + while not q:empty() do + local e = q:pop_head() + if e and e.task and e.waker then + local task, waker = e.task, e.waker + waker:wakeup(task) + cleanup_task_waiter(e) + end + end +end + +---@param st MailboxState +---@return boolean +local function recv_may_succeed(st) + if st.closed then return true end + if st.buf and st.buf:length() > 0 then return true end + if st.putq and not st.putq:empty() then return true end + return false +end + +---@param st MailboxState +---@param reason any|nil +local function record_reason(st, reason) + if st.reason == nil and reason ~= nil then + st.reason = reason + end +end + +--- Close the mailbox state (idempotent), record reason, and wake blocked parties. +--- Receivers drain buffered values (if any), then receive nil. +--- Waiting senders are rejected (nil). +---@param st MailboxState +---@param reason any|nil +local function close_state(st, reason) + if st.closed then + record_reason(st, reason) + return + end + + st.closed = true + record_reason(st, reason) + if leak_probe then leak_probe.mailbox_closed(st._probe_id, reason) end + + -- Wake receivers: deliver buffered values first, then nil when buffer empty. + while true do + local recv = pop_active(st.getq) + if not recv then break end + + local v + if st.buf and st.buf:length() > 0 then + v = st.buf:pop() + end + recv.suspension:complete(recv.wrap, v) + cleanup_recv_waiter(recv) + end + + -- Reject senders (nil result means "closed"). + while true do + local snd = pop_active(st.putq) + if not snd then break end + snd.suspension:complete(snd.wrap, nil) + cleanup_send_waiter(snd) + end + + notify_task_waiters(st) +end + +---------------------------------------------------------------------- +-- Construction +---------------------------------------------------------------------- + +---@param full any +---@return '"block"'|'"reject_newest"'|'"drop_oldest"' +local function norm_full_policy(full, capacity) + if full == nil then full = 'block' end + if full ~= 'block' and full ~= 'reject_newest' and full ~= 'drop_oldest' then + error('mailbox.new: invalid full policy: ' .. tostring(full), 3) + end + -- Rendezvous mailboxes have no buffer; drop_oldest collapses to reject_newest. + if capacity == 0 and full == 'drop_oldest' then + full = 'reject_newest' + end + return full +end + +--- Create a mailbox. Returns (tx, rx). +---@param capacity? integer # 0 or nil -> rendezvous; >0 -> buffered capacity +---@param opts? { full?: '"block"'|'"reject_newest"'|'"drop_oldest"' } +---@return MailboxTx tx, MailboxRx rx +local function new(capacity, opts) + capacity = capacity or 0 + opts = opts or {} + local full = norm_full_policy(opts.full, capacity) + + ---@type MailboxState + local probe_id = leak_probe and leak_probe.mailbox_next_id() or nil + local st = { + _probe_id = probe_id, + cap = capacity, + buf = (capacity > 0) and fifo.new() or nil, + getq = dlist.new(), + putq = dlist.new(), + taskq = dlist.new(), + closed = false, + reason = nil, + senders = 1, + full = full, + dropped = 0, + } + + local tx = setmetatable({ _st = st, _closed = false, _counted = true }, Tx) + local rx = setmetatable({ _st = st }, Rx) + if leak_probe then leak_probe.mailbox_created(probe_id, capacity, full) end + return tx, rx +end + +---------------------------------------------------------------------- +-- Tx (sender) +---------------------------------------------------------------------- + +--- Return the mailbox close reason (if any). +---@return any|nil +function Tx:why() + return self._st.reason +end + +--- Return total number of dropped messages due to the full policy. +--- For reject_newest: counts incoming messages dropped (and send returns false,"full"). +--- For drop_oldest: counts buffered messages evicted to admit new ones. +---@return integer +function Tx:dropped() + return self._st.dropped or 0 +end + +--- Clone this sender handle (multi-producer). +--- If the mailbox or this handle is closed, returns an inert, uncounted handle. +---@return MailboxTx +function Tx:clone() + local st = self._st + if self._closed or st.closed then + return setmetatable({ _st = st, _closed = true, _counted = false }, Tx) + end + st.senders = st.senders + 1 + if leak_probe then leak_probe.mailbox_sender_cloned(st._probe_id) end + return setmetatable({ _st = st, _closed = false, _counted = true }, Tx) +end + +--- Close this sender handle (idempotent). +--- When the last counted sender closes, the mailbox closes-for-send. +---@param reason any|nil +---@return boolean ok +function Tx:close(reason) + local st = self._st + record_reason(st, reason) + + if self._closed then return true end + + self._closed = true + + -- If already uncounted, or mailbox already closed, nothing to do. + if not self._counted or st.closed then + self._counted = false + return true + end + + self._counted = false + st.senders = st.senders - 1 + if leak_probe then leak_probe.mailbox_sender_closed(st._probe_id) end + if st.senders <= 0 then + st.senders = 0 + close_state(st, reason) + end + + return true +end + +--- Op that sends a message. +--- When performed: +--- * true : accepted (delivered or enqueued) +--- * false, "full" : not accepted due to capacity/policy (reject_newest) +--- * nil : mailbox closed (send rejected) +---@param v any # MUST NOT be nil +---@return Op +function Tx:send_op(v) + assert(v ~= nil, 'mailbox.send: nil payload is not permitted') + + local st = self._st + local getq, putq, buf, cap = st.getq, st.putq, st.buf, st.cap + local full = st.full + + -- Full-policy handler returns: + -- ready:boolean_for_op, result1, result2 + -- where ready==true means the op is ready and result* are returned to the caller. + local function handle_full() + if full == 'block' then + -- Not ready; must block. + return false + end + + -- Some message is being discarded due to boundedness. + st.dropped = st.dropped + 1 + if leak_probe then leak_probe.mailbox_dropped(st._probe_id, full) end + + if full == 'drop_oldest' and buf then + -- Evict one buffered value to admit the new one. + -- (For cap==0, drop_oldest is normalised away to reject_newest.) + buf:pop() + buf:push(v) + notify_task_waiters(st) + return true, true + end + + -- reject_newest: do not admit v. + return true, false, 'full' + end + + local function try() + if st.closed or self._closed then + -- Ready: closed is signalled to caller by nil result. + return true, nil + end + + -- Rendezvous with a waiting receiver. + local recv = pop_active(getq) + if recv then + recv.suspension:complete(recv.wrap, v) + cleanup_recv_waiter(recv) + notify_task_waiters(st) + return true, true + end + + -- Buffered enqueue when there is space. + if buf and buf:length() < cap then + buf:push(v) + notify_task_waiters(st) + return true, true + end + + -- Full (buffered) or no receiver (rendezvous): apply full policy. + return handle_full() + end + + local function block(suspension, wrap_fn) + if st.closed or self._closed then + -- Resume sender with nil (closed). + return suspension:complete(wrap_fn, nil) + end + -- Only used for "block" policy. + local entry = { val = v, suspension = suspension, wrap = wrap_fn } + local node = putq:push_tail(entry) + suspension:add_cleanup(function () + if node:remove() then + cleanup_send_waiter(entry) + end + end) + end + + return op.new_primitive(nil, try, block) +end + +--- Register a task to be woken when recv may succeed (message arrives or close). +--- This does not expose the scheduler; callers provide a waker capability. +---@param task Task +---@param waker table +---@return WaitToken +function Rx:on_message(task, waker) + local st = self._st + assert(task and type(task) == 'table' and type(task.run) == 'function', + 'on_message: task must have :run()') + assert(waker and type(waker.wakeup) == 'function', + 'on_message: waker must support :wakeup(task)') + + if recv_may_succeed(st) then + waker:wakeup(task) + return { unlink = function () return false end } + end + + local entry = { task = task, waker = waker } + local node = st.taskq:push_tail(entry) + + return { + unlink = function () + if node:remove() then + cleanup_task_waiter(entry) + return true + end + return false + end, + } +end + +--- Synchronously send a message. +---@param v any +---@return boolean|nil ok +---@return string|nil reason -- "full" when ok==false +function Tx:send(v) + return perform(self:send_op(v)) +end + +---------------------------------------------------------------------- +-- Rx (receiver) +---------------------------------------------------------------------- + +--- Return the mailbox close reason (if any). +---@return any|nil +function Rx:why() + return self._st.reason +end + +--- Return total number of dropped messages due to the full policy. +---@return integer +function Rx:dropped() + return self._st.dropped or 0 +end + +--- Op that receives the next message. +--- When performed: a non-nil value, or nil when closed and drained. +---@return Op +function Rx:recv_op() + local st = self._st + local getq, putq, buf = st.getq, st.putq, st.buf + + local function try() + -- Prefer unblocking a waiting sender (if present); we may still return + -- a buffered value first. + local snd = pop_active(putq) + if snd then + -- Sender was accepted (delivered or enqueued-by-refill below). + snd.suspension:complete(snd.wrap, true) + end + + if buf and buf:length() > 0 then + local v = buf:pop() + -- If there was a sender waiting, refill the buffer with its value. + if snd then + buf:push(snd.val) + cleanup_send_waiter(snd) + end + return true, v + end + + if snd then + local v = snd.val + cleanup_send_waiter(snd) + return true, v + end + + if st.closed then + return true, nil + end + + return false + end + + ---@param suspension Suspension + ---@param wrap_fn WrapFn + local function block(suspension, wrap_fn) + if st.closed then + return suspension:complete(wrap_fn, nil) + end + local entry = { suspension = suspension, wrap = wrap_fn } + local node = getq:push_tail(entry) + suspension:add_cleanup(function () + if node:remove() then + cleanup_recv_waiter(entry) + end + end) + end + + return op.new_primitive(nil, try, block) +end + +--- Synchronously receive the next message. +---@return any|nil v +function Rx:recv() + return perform(self:recv_op()) +end + +--- Iterator over received messages, ending at nil (closed and drained). +---@return fun(): any|nil +function Rx:iter() + return function () + return self:recv() + end +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + new = new, + + Tx = Tx, + Rx = Rx, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/oneshot.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/oneshot.lua new file mode 100644 index 00000000..a8d310ec --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/oneshot.lua @@ -0,0 +1,79 @@ +-- fibers/oneshot.lua + +--- One-shot notification primitive. +---@module 'fibers.oneshot' + +---@alias OneshotWaiter fun() + +---@class Oneshot +---@field triggered boolean +---@field waiters table[] # list of { fn = OneshotWaiter|nil } +---@field on_after_signal fun()|nil +local Oneshot = {} +Oneshot.__index = Oneshot + +local function noop() end +--- Create a new one-shot. +---@param on_after_signal? fun() # optional callback run after signalling all waiters +---@return Oneshot +local function new(on_after_signal) + return setmetatable({ + triggered = false, + waiters = {}, + on_after_signal = on_after_signal, + }, Oneshot) +end + +--- Register a waiter. +--- If already triggered, the thunk is run immediately. +---@param thunk OneshotWaiter +---@return fun() cancel # idempotent deregistration thunk +function Oneshot:add_waiter(thunk) + if self.triggered then + thunk() + return noop + end + + local ws = self.waiters + local rec = { fn = thunk } + ws[#ws + 1] = rec + + return function () + -- idempotent; clearing fn drops the closure reference + rec.fn = nil + end +end + +--- Trigger the one-shot. +--- All waiters are run once; the optional callback runs afterwards. +--- Idempotent: subsequent calls after the first have no effect. +function Oneshot:signal() + if self.triggered then return end + self.triggered = true + + local ws = self.waiters + for i = 1, #ws do + local rec = ws[i] + ws[i] = nil + if rec then + local f = rec.fn + rec.fn = nil + if f then f() end + end + end + + local cb = self.on_after_signal + if cb then + cb() + end +end + +--- Check whether the one-shot has fired. +---@return boolean +function Oneshot:is_triggered() + return self.triggered +end + +return { + new = new, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/op.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/op.lua new file mode 100644 index 00000000..e52812ff --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/op.lua @@ -0,0 +1,779 @@ +-- fibers/op.lua + +--- Concurrent ML style operations for structured concurrency. +--- Provides composable operations (ops) that may complete immediately +--- or block, with support for choice, guards, negative acknowledgements +--- and abort/cleanup behaviour. +---@module 'fibers.op' + +local runtime = require 'fibers.runtime' +local safe = require 'coxpcall' +local oneshot = require 'fibers.oneshot' + +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } +end + +local function id_wrap(...) return ... end + +---------------------------------------------------------------------- +-- Suspensions and completion tasks +---------------------------------------------------------------------- + +--- A suspension of a fiber waiting on an op. +---@class Suspension : Task +---@field state "waiting"|"synchronized" +---@field sched Scheduler +---@field fiber Fiber +---@field wrap WrapFn|nil +---@field val table|nil +local Suspension = {} +Suspension.__index = Suspension + +---@class CompleteTask : Task +---@field suspension Suspension +---@field wrap WrapFn +---@field val table +local CompleteTask = {} +CompleteTask.__index = CompleteTask + +function Suspension:waiting() + return self.state == 'waiting' +end + +function Suspension:add_cleanup(f) + if type(f) ~= 'function' then + error('cleanup must be a function', 2) + end + + if self.cleaned then + safe.pcall(f) + return + end + + local cs = self.cleanups + if not cs then + cs = {} + self.cleanups = cs + end + + cs[#cs + 1] = f +end + +function Suspension:_run_cleanups() + if self.cleaned then return end + self.cleaned = true + + local cs = self.cleanups + self.cleanups = nil + if not cs then return end + + for i = #cs, 1, -1 do + safe.pcall(cs[i]) + cs[i] = nil + end +end + +function Suspension:wakeup(task) + self.sched:schedule(task) +end + +function Suspension:at_time(t, task) + return self.sched:schedule_at_time(t, task) +end + +function Suspension:after(dt, task) + return self.sched:schedule_after_sleep(dt, task) +end + +function Suspension:complete(wrap, ...) + assert(self:waiting()) + self.state = 'synchronized' + self.wrap = wrap + self.val = pack(...) + self:_run_cleanups() + self.sched:schedule(self) +end + +function Suspension:complete_and_run(wrap, ...) + assert(self:waiting()) + self.state = 'synchronized' + self:_run_cleanups() + return self.fiber:resume(wrap, ...) +end + +function Suspension:complete_task(wrap, ...) + return setmetatable({ + suspension = self, + wrap = wrap, + val = pack(...), + }, CompleteTask) +end + +function Suspension:run() + assert(not self:waiting()) + return self.fiber:resume(self.wrap, unpack(self.val, 1, self.val.n)) +end + +local function new_suspension(sched, fib) + return setmetatable({ + state = 'waiting', + sched = sched, + fiber = fib, + cleanups = nil, + cleaned = false, + }, Suspension) +end + +function CompleteTask:run() + if self.suspension:waiting() then + self.suspension:complete_and_run( + self.wrap, + unpack(self.val, 1, self.val.n) + ) + end +end + +function CompleteTask:cancel(reason) + if self.suspension:waiting() then + local msg = reason or 'cancelled' + + local function cancelled_wrap() + return false, msg + end + + self.suspension:complete(cancelled_wrap) + end +end + +---------------------------------------------------------------------- +-- Op type +---------------------------------------------------------------------- + +---@alias WrapFn fun(...: any): ... +---@alias TryFn fun(): boolean, ... +---@alias BlockFn fun(suspension: Suspension, wrap_fn: WrapFn) + +---@class NackCond +---@field wait_op fun(): Op +---@field signal fun() + +---@class CompiledLeaf +---@field try_fn TryFn +---@field block_fn BlockFn +---@field wrap WrapFn +---@field nacks NackCond[] + +---@class Op +---@field kind "prim"|"choice"|"guard"|"with_nack"|"wrap"|"abort" +---@field ops Op[]|nil +---@field builder fun(...: any): Op +---@field wrap_fn WrapFn|nil +---@field inner Op|nil +---@field abort_fn fun()|nil +---@field try_fn TryFn|nil +---@field block_fn BlockFn|nil +local Op = {} +Op.__index = Op + +local perform + +local function is_op(v) + return type(v) == 'table' and getmetatable(v) == Op +end + +--- Construct a primitive op. +---@param wrap_fn? WrapFn +---@param try_fn TryFn +---@param block_fn BlockFn +---@return Op +local function new_primitive(wrap_fn, try_fn, block_fn) + if type(try_fn) ~= 'function' then + error('new_primitive: try_fn must be a function', 2) + end + + if type(block_fn) ~= 'function' then + error('new_primitive: block_fn must be a function', 2) + end + + if wrap_fn ~= nil and type(wrap_fn) ~= 'function' then + error('new_primitive: wrap_fn must be a function or nil', 2) + end + + return setmetatable({ + kind = 'prim', + wrap_fn = wrap_fn or id_wrap, + try_fn = try_fn, + block_fn = block_fn, + }, Op) +end + +--- Delayed op builder; executed once per synchronisation. +---@param g fun(): Op +---@return Op +local function guard(g) + if type(g) ~= 'function' then + error('guard expects a function', 2) + end + + return setmetatable({ + kind = 'guard', + builder = g, + }, Op) +end + +--- CML-style with_nack. +---@param g fun(nack_op: Op): Op +---@return Op +local function with_nack(g) + if type(g) ~= 'function' then + error('with_nack expects a function', 2) + end + + return setmetatable({ + kind = 'with_nack', + builder = g, + }, Op) +end + +--- Op that is immediately ready with the given results. +---@param ... any +---@return Op +local function always(...) + local results = pack(...) + + return new_primitive( + nil, + function () + return true, unpack(results, 1, results.n) + end, + function () + error('always: block_fn should never run') + end + ) +end + +--- Op that never becomes ready. +---@return Op +local function never() + return new_primitive( + nil, + function () + return false + end, + function () + -- Intentionally never completes the suspension. + end + ) +end + +local function append_choice_arg(out, v, level) + level = level or 2 + + if is_op(v) then + if v.kind == 'choice' then + for i = 1, #(v.ops or {}) do + out[#out + 1] = v.ops[i] + end + else + out[#out + 1] = v + end + + return + end + + if type(v) == 'table' then + local n = #v + + for k in pairs(v) do + if type(k) ~= 'number' + or k < 1 + or k % 1 ~= 0 + or k > n + then + error('choice expects Op values or dense arrays of Op values', level) + end + end + + for i = 1, n do + append_choice_arg(out, v[i], level + 1) + end + + return + end + + error('choice expects Op values or dense arrays of Op values', level) +end + +--- Choice op over zero or more sub-ops. +--- +--- Empty choice is valid and never becomes ready. +--- Nested choices are flattened. +--- +--- Accepted forms: +--- choice(op_a, op_b) +--- choice({ op_a, op_b }) +--- choice(op_a, { op_b, op_c }) +--- choice() +--- choice({}) +---@param ... Op|Op[] +---@return Op +local function choice(...) + local ops = {} + + for i = 1, select('#', ...) do + append_choice_arg(ops, select(i, ...), 2) + end + + if #ops == 0 then return never() end + if #ops == 1 then return ops[1] end + + return setmetatable({ + kind = 'choice', + ops = ops, + }, Op) +end + +function Op:wrap(f) + if type(f) ~= 'function' then + error('wrap expects a function', 2) + end + + return setmetatable({ + kind = 'wrap', + inner = self, + wrap_fn = f, + }, Op) +end + +function Op:on_abort(f) + if type(f) ~= 'function' then + error('on_abort expects a function', 2) + end + + return setmetatable({ + kind = 'abort', + inner = self, + abort_fn = f, + }, Op) +end + +---------------------------------------------------------------------- +-- Nack conditions +---------------------------------------------------------------------- + +local function new_cond(opts) + local abort_fn = opts and opts.abort_fn or nil + + local os = oneshot.new(function () + if abort_fn then + safe.pcall(abort_fn) + end + end) + + local function wait_op() + assert(not abort_fn, 'abort-only cond has no wait_op') + + return new_primitive( + nil, + + function () + return os:is_triggered() + end, + + function (suspension, wrap_fn) + local cancel = os:add_waiter(function () + if suspension:waiting() then + suspension:complete(wrap_fn) + end + end) + + suspension:add_cleanup(cancel) + end + ) + end + + return { + wait_op = wait_op, + + signal = function () + os:signal() + end, + } +end + +---------------------------------------------------------------------- +-- Compile op tree +---------------------------------------------------------------------- + +---@param ev Op +---@param outer_wrap? WrapFn +---@param out? CompiledLeaf[] +---@param nacks? NackCond[] +---@return CompiledLeaf[] +local function compile_op(ev, outer_wrap, out, nacks) + out = out or {} + outer_wrap = outer_wrap or id_wrap + nacks = nacks or {} + + if ev.kind == 'choice' then + for _, sub in ipairs(ev.ops or {}) do + compile_op(sub, outer_wrap, out, nacks) + end + + elseif ev.kind == 'guard' then + local inner = ev.builder() + compile_op(inner, outer_wrap, out, nacks) + + elseif ev.kind == 'with_nack' then + local cond = new_cond() + local inner = ev.builder(cond.wait_op()) + local child_nacks = { unpack(nacks) } + + child_nacks[#child_nacks + 1] = cond + compile_op(inner, outer_wrap, out, child_nacks) + + elseif ev.kind == 'wrap' then + local f = assert(ev.wrap_fn) + + local new_outer = function (...) + return outer_wrap(f(...)) + end + + compile_op(ev.inner, new_outer, out, nacks) + + elseif ev.kind == 'abort' then + local cond = new_cond({ abort_fn = ev.abort_fn }) + local child_nacks = { unpack(nacks) } + + child_nacks[#child_nacks + 1] = cond + compile_op(ev.inner, outer_wrap, out, child_nacks) + + else + local function wrapped(...) + return outer_wrap(ev.wrap_fn(...)) + end + + out[#out + 1] = { + try_fn = ev.try_fn, + block_fn = ev.block_fn, + wrap = wrapped, + nacks = nacks, + } + end + + return out +end + +---------------------------------------------------------------------- +-- Nacks and readiness +---------------------------------------------------------------------- + +local function trigger_nacks(leaves, winner_index) + local winner_set + + if winner_index then + winner_set = {} + + for _, cond in ipairs(leaves[winner_index].nacks or {}) do + winner_set[cond] = true + end + end + + local signalled = {} + + for i = 1, #leaves do + if not winner_index or i ~= winner_index then + for j = #(leaves[i].nacks or {}), 1, -1 do + local cond = leaves[i].nacks[j] + + if cond + and not (winner_set and winner_set[cond]) + and not signalled[cond] + then + signalled[cond] = true + cond.signal() + end + end + end + end +end + +local function try_ready(leaves) + local n = #leaves + if n == 0 then return nil end + + local start = math.random(n) + + for k = 0, n - 1 do + local idx = ((start + k - 1) % n) + 1 + local leaf = leaves[idx] + local retval = pack(leaf.try_fn()) + + if retval[1] then + return idx, retval + end + end + + return nil +end + +local function apply_wrap(wrap, retval) + assert(retval ~= nil, 'apply_wrap: retval must not be nil') + return wrap(unpack(retval, 2, retval.n)) +end + +---------------------------------------------------------------------- +-- or_else +---------------------------------------------------------------------- + +--- Non-blocking choice: try this op, otherwise run fallback_thunk. +---@param fallback_thunk fun(): any +---@return Op +function Op:or_else(fallback_thunk) + if type(fallback_thunk) ~= 'function' then + error('or_else expects a function', 2) + end + + if self.kind == 'prim' then + local try_fn = assert(self.try_fn) + local wrap_fn = assert(self.wrap_fn) + + return new_primitive( + nil, + + function () + local r = pack(try_fn()) + + if r[1] then + return true, wrap_fn(unpack(r, 2, r.n)) + end + + return true, fallback_thunk() + end, + + function () + error('or_else(prim): block_fn should never run') + end + ) + end + + return guard(function () + local leaves = compile_op(self) + local idx, retval = try_ready(leaves) + + if idx then + trigger_nacks(leaves, idx) + + local results = pack(apply_wrap(leaves[idx].wrap, retval)) + return always(unpack(results, 1, results.n)) + end + + trigger_nacks(leaves, nil) + + local results = pack(fallback_thunk()) + return always(unpack(results, 1, results.n)) + end) +end + +---------------------------------------------------------------------- +-- Blocking path +---------------------------------------------------------------------- + +local function block_choice_op(sched, fib, leaves) + local suspension = new_suspension(sched, fib) + + for _, leaf in ipairs(leaves) do + leaf.block_fn(suspension, leaf.wrap) + end +end + +local function block_prim_op(sched, fib, prim) + local suspension = new_suspension(sched, fib) + prim.block_fn(suspension, prim.wrap_fn) +end + +---------------------------------------------------------------------- +-- Perform +---------------------------------------------------------------------- + +perform = function (ev) + if not runtime.current_fiber() then + error('perform_raw must be called from inside a fiber (use fibers.run as an entry point)', 2) + end + + if ev.kind == 'guard' then + return perform(ev.builder()) + end + + if ev.kind == 'prim' then + local r = pack(ev.try_fn()) + + if r[1] then + return ev.wrap_fn(unpack(r, 2, r.n)) + end + + local suspended = pack(runtime.suspend(block_prim_op, ev)) + local wrap = suspended[1] + + return wrap(unpack(suspended, 2, suspended.n)) + end + + local leaves = compile_op(ev) + + local idx, retval = try_ready(leaves) + if idx then + trigger_nacks(leaves, idx) + return apply_wrap(leaves[idx].wrap, retval) + end + + local suspended = pack(runtime.suspend(block_choice_op, leaves)) + local wrap = suspended[1] + + local winner_index + + for i, leaf in ipairs(leaves) do + if leaf.wrap == wrap then + winner_index = i + break + end + end + + trigger_nacks(leaves, winner_index) + + return wrap(unpack(suspended, 2, suspended.n)) +end + +---------------------------------------------------------------------- +-- bracket / finally +---------------------------------------------------------------------- + +local function bracket(acquire, release, use) + if type(acquire) ~= 'function' then + error('bracket: acquire must be a function', 2) + end + + if type(release) ~= 'function' then + error('bracket: release must be a function', 2) + end + + if type(use) ~= 'function' then + error('bracket: use must be a function', 2) + end + + return guard(function () + local res = acquire() + local used = use(res) + + local wrapped = used:wrap(function (...) + release(res, false) + return ... + end) + + return wrapped:on_abort(function () + release(res, true) + end) + end) +end + +function Op:finally(cleanup) + if type(cleanup) ~= 'function' then + error('finally expects a function', 2) + end + + return bracket( + function () return nil end, + function (_, aborted) cleanup(aborted) end, + function () return self end + ) +end + +---------------------------------------------------------------------- +-- Higher-level choice helpers +---------------------------------------------------------------------- + +local function race(ops, on_win) + if type(on_win) ~= 'function' then + error('race expects on_win callback', 2) + end + + if type(ops) ~= 'table' then + error('race expects a dense array of Op values', 2) + end + + local wrapped = {} + + for i, ev in ipairs(ops) do + if not is_op(ev) then + error('race expects a dense array of Op values', 2) + end + + wrapped[i] = ev:wrap(function (...) + return on_win(i, ...) + end) + end + + return choice(wrapped) +end + +local function first_ready(ops) + return race(ops, function (i, ...) + return i, ... + end) +end + +local function named_choice(arms) + if type(arms) ~= 'table' then + error('named_choice expects a table of Op values', 2) + end + + local ops, names = {}, {} + + for name, ev in pairs(arms) do + if not is_op(ev) then + error('named_choice expects a table of Op values', 2) + end + + names[#names + 1] = name + ops[#ops + 1] = ev + end + + return race(ops, function (i, ...) + return names[i], ... + end) +end + +local function boolean_choice(op_true, op_false) + if not is_op(op_true) or not is_op(op_false) then + error('boolean_choice expects two Op values', 2) + end + + return race({ op_true, op_false }, function (i, ...) + if i == 1 then + return true, ... + end + + return false, ... + end) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + perform_raw = perform, + new_primitive = new_primitive, + choice = choice, + guard = guard, + with_nack = with_nack, + bracket = bracket, + always = always, + never = never, + Op = Op, + race = race, + first_ready = first_ready, + named_choice = named_choice, + boolean_choice = boolean_choice, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/performer.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/performer.lua new file mode 100644 index 00000000..fe577543 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/performer.lua @@ -0,0 +1,50 @@ +-- fibers/performer.lua +--- +-- Scope-aware performer for ops. +-- Preferred entry point for synchronising on ops in normal code. +-- Delegates to the current scope if available, otherwise falls back +-- to the raw op.perform. +---@module 'fibers.performer' + +local Op = require 'fibers.op' +local Runtime = require 'fibers.runtime' + +---@type any +local scope_mod + +--- Get the current scope if the scope module has been loaded. +---@return Scope|nil +local function current_scope() + if not scope_mod then + scope_mod = require 'fibers.scope' + end + return scope_mod.current and scope_mod.current() or nil +end + +--- Check that a value is an Op instance. +---@param op any +local function assert_op(op) + if type(op) ~= 'table' or getmetatable(op) ~= Op.Op then + error(('perform: expected op, got %s (%s)'):format(type(op), tostring(op)), 3) + end +end + +--- Perform an op under the current scope, if any. +--- Must be called from inside a fiber. +---@param op Op +---@return any ... +local function perform(op) + if not Runtime.current_fiber() then error('perform be called from inside a fiber', 2) end + assert_op(op) + + local s = current_scope() + if s and s.perform then + return s:perform(op) + else + return Op.perform_raw(op) + end +end + +return { + perform = perform, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/pulse.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/pulse.lua new file mode 100644 index 00000000..b1fa75ca --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/pulse.lua @@ -0,0 +1,196 @@ +-- fibers/pulse.lua +-- +-- Pulse: a versioned broadcast notifier for fibers. +-- +-- Purpose +-- A Pulse holds a monotonic version counter. Callers can: +-- * read the current version (snapshot), +-- * signal an update (increments version, wakes all waiters), +-- * wait (as an Op) until the version advances past a seen value. +-- +-- Semantics +-- * This is a notification primitive, not a queue: updates coalesce. +-- * changed_op(last_seen) completes when version > last_seen. +-- * close(reason) is optional but useful for clean shutdown and end-of-stream. +-- +-- Return conventions (changed/next): +-- * on change: returns (version, nil) +-- * on closed: returns (nil, reason) +-- +---@module 'fibers.pulse' + +local op = require 'fibers.op' +local cond_mod = require 'fibers.cond' +local perform = require 'fibers.performer'.perform +local runtime = require 'fibers.runtime' + +local M = {} + +---@class Pulse +---@field _ver integer +---@field _cond any -- Cond (per-generation) +---@field _closed boolean +---@field _reason any|nil +local Pulse = {} +Pulse.__index = Pulse + +local function assert_in_fiber(errlvl) + if not runtime.current_fiber() then + error('pulse: must be called from inside a fiber', errlvl or 3) + end +end + +--- Create a new Pulse. +---@param initial_version? integer +---@return Pulse +function M.new(initial_version) + if initial_version ~= nil then + if type(initial_version) ~= 'number' or initial_version < 0 or initial_version % 1 ~= 0 then + error('pulse.new: initial_version must be a non-negative integer', 2) + end + end + + return setmetatable({ + _ver = initial_version or 0, + _cond = cond_mod.new(), + _closed = false, + _reason = nil, + }, Pulse) +end + +--- Create a Pulse tied to the current scope; closes on scope finalisation. +---@param opts? { close_reason?: any } +---@return Pulse +function M.scoped(opts) + assert_in_fiber(3) + + local fibers = require 'fibers' + local scope = fibers.current_scope() + + local p = M.new() + opts = opts or {} + + scope:finally(function (_, st, primary) + local reason = opts.close_reason + or primary + or ((st ~= 'ok') and st) + or 'scope finalised' + p:close(reason) + end) + + return p +end + +--- Current version (monotonic). +---@return integer +function Pulse:version() + return self._ver +end + +--- Whether this pulse is closed. +---@return boolean +function Pulse:is_closed() + return self._closed +end + +--- Close reason, if any. +---@return any|nil +function Pulse:why() + return self._reason +end + +--- Signal an update: increments version, wakes all waiters, and rolls the generation. +--- Returns the new version, or nil if closed. +---@return integer|nil version +function Pulse:signal() + if self._closed then + return nil + end + + self._ver = self._ver + 1 + + local c = self._cond + if c then + c:signal() + end + self._cond = cond_mod.new() + + return self._ver +end + +--- Close the pulse (idempotent) and wake all waiters. +---@param reason any|nil +---@return boolean ok +function Pulse:close(reason) + if self._reason == nil and reason ~= nil then + self._reason = reason + end + if self._closed then + return true + end + + self._closed = true + + local c = self._cond + if c then + c:signal() + end + + return true +end + +--- Op that completes when version > last_seen, or when closed. +---@param last_seen integer +---@return Op -- when performed: (version, nil) | (nil, reason) +function Pulse:changed_op(last_seen) + if type(last_seen) ~= 'number' or last_seen % 1 ~= 0 then + error('pulse.changed_op: last_seen must be an integer', 2) + end + + return op.guard(function () + if self._ver > last_seen then + return op.always(self._ver, nil) + end + + if self._closed then + return op.always(nil, self._reason) + end + + local c = self._cond + return c:wait_op():wrap(function () + if self._closed then + return nil, self._reason + end + -- A signal implies _ver advanced; return current snapshot. + return self._ver, nil + end) + end) +end + +--- Convenience op: wait for the next signal from "now" (evaluated at perform time). +---@return Op +function Pulse:next_op() + return op.guard(function () + local v = self._ver + return self:changed_op(v) + end) +end + +--- Synchronous convenience: block until changed since last_seen. +---@param last_seen integer +---@return integer|nil version +---@return any|nil reason +function Pulse:changed(last_seen) + return perform(self:changed_op(last_seen)) +end + +--- Synchronous convenience: block until next signal. +---@return integer|nil version +---@return any|nil reason +function Pulse:next() + return perform(self:next_op()) +end + +M.Pulse = Pulse + +return M diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/runtime.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/runtime.lua new file mode 100644 index 00000000..2dff83d9 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/runtime.lua @@ -0,0 +1,205 @@ +-- fibers/runtime.lua +--- +-- Runtime module for fibers. +-- Provides a global scheduler, fiber creation, suspension and error reporting. +---@module 'fibers.runtime' + +local sched = require 'fibers.sched' + +--- Identity helper used as the wrap function when resuming fibers. +---@generic T +---@param ... T +---@return T ... +local function id(...) + return ... +end + +--- Record of an uncaught fiber error. +---@class FiberErrorRecord +---@field fiber Fiber +---@field err any + +--- Record of a fiber waiting for an error notification. +---@class ErrorWaiterRecord +---@field fiber Fiber + +---@type FiberErrorRecord[] +local error_queue = {} + +---@type ErrorWaiterRecord[] +local error_waiters = {} + +--- Task used to wake a fiber waiting for an error. +---@class WaiterTask : Task +---@field waiter Fiber # waiting fiber +---@field err_fiber Fiber # fiber that failed +---@field err any # error value +local WaiterTask = {} +WaiterTask.__index = WaiterTask + +--- Resume the waiting fiber with (wrap, err_fiber, err). +function WaiterTask:run() + self.waiter:resume(id, self.err_fiber, self.err) +end + +--- Cooperative fiber object managed by the runtime. +---@class Fiber : Task +---@field coroutine thread +---@field alive boolean +---@field sockets table +---@field traceback string|nil +local Fiber = {} +Fiber.__index = Fiber + +---@type Fiber|nil +local _current_fiber + +---@type Scheduler +local current_scheduler = sched.new() + +--- Spawn a new fiber scheduled on the global scheduler. +--- The function is called as fn(wrap, ...), where wrap is typically the identity. +---@param fn fun(wrap: fun(...: any): any, ...: any) +local function spawn(fn) + local tb = debug.traceback('', 2):match('\n[^\n]*\n(.*)') or '' + if _current_fiber and _current_fiber.traceback then + tb = tb .. '\n' .. _current_fiber.traceback + end + + current_scheduler:schedule( + setmetatable({ + coroutine = coroutine.create(fn), + alive = true, + sockets = {}, + traceback = tb, + }, Fiber) + ) +end + +--- Resume execution of this fiber. +--- If the fiber is dead, an error is raised. +---@param wrap fun(...: any): any +---@param ... any +function Fiber:resume(wrap, ...) + assert(self.alive, 'dead fiber') + local saved_current_fiber = _current_fiber + _current_fiber = self + local ok, err = coroutine.resume(self.coroutine, wrap, ...) + _current_fiber = saved_current_fiber + + if coroutine.status(self.coroutine) == 'dead' then + self.alive = false + end + + if not ok then + -- Report uncaught error to any waiting fiber, or queue it. + if #error_waiters > 0 then + local waiter = table.remove(error_waiters, 1) + current_scheduler:schedule(setmetatable({ + waiter = waiter.fiber, + err_fiber = self, + err = err, + }, WaiterTask)) + else + error_queue[#error_queue + 1] = { + fiber = self, + err = err, + } + end + end +end + +--- Alias for :resume, so a Fiber can be scheduled as a Task. +Fiber.run = Fiber.resume + +--- Suspend this fiber until block_fn arranges to reschedule it. +--- block_fn receives (scheduler, fiber, ...). +---@param block_fn fun(scheduler: Scheduler, fiber: Fiber, ...: any) +---@param ... any +---@return any ... +function Fiber:suspend(block_fn, ...) + assert(_current_fiber == self) + block_fn(current_scheduler, assert(_current_fiber), ...) + return coroutine.yield() +end + +--- Return the captured creation traceback for this fiber, if any. +---@return string +function Fiber:get_traceback() + return self.traceback or 'No traceback available' +end + +--- Return the current Fiber object, or nil if not inside a fiber. +---@return Fiber|nil +local function current_fiber() + return _current_fiber +end + +--- Current scheduler time in monotonic seconds. +---@return number +local function now() + return current_scheduler:now() +end + +--- Suspend the current fiber using block_fn. +--- block_fn must arrange for the fiber to be rescheduled later. +---@param block_fn fun(scheduler: Scheduler, fiber: Fiber, ...: any) +---@param ... any +---@return any ... +local function suspend(block_fn, ...) + assert(_current_fiber, 'can only suspend from inside a fiber') + return _current_fiber:suspend(block_fn, ...) +end + +--- Yield the current fiber and re-queue it as runnable. +---@return any ... +local function yield() + assert(current_fiber(), 'can only yield from inside a fiber') + return suspend(function (scheduler, fiber) + scheduler:schedule(fiber) + end) +end + +--- Request that the global scheduler stops its main loop. +local function stop() + current_scheduler:stop() +end + +--- Wait for the next uncaught fiber error. +--- Returns the failing fiber and its error value. +---@return Fiber err_fiber +---@return any err +local function wait_fiber_error() + if #error_queue > 0 then + local rec = table.remove(error_queue, 1) + return rec.fiber, rec.err + end + + assert(_current_fiber, 'wait_fiber_error must be called from within a fiber') + + local function block_fn(_, fib) + error_waiters[#error_waiters + 1] = { fiber = fib } + end + + local _, err_fiber, err = _current_fiber:suspend(block_fn) + return err_fiber, err +end + +--- Run the main event loop using the global scheduler. +local function main() + return current_scheduler:main() +end + +return { + current_scheduler = current_scheduler, + current_fiber = current_fiber, + now = now, + suspend = suspend, + yield = yield, + wait_fiber_error = wait_fiber_error, + + -- fiber management + spawn_raw = spawn, + stop = stop, + main = main, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/sched.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/sched.lua new file mode 100644 index 00000000..7c80dd62 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/sched.lua @@ -0,0 +1,208 @@ +-- fibers/sched.lua + +--- Core cooperative scheduler for fiber tasks. +---@module 'fibers.sched' + +local time = require 'fibers.utils.time' +local timer = require 'fibers.timer' + +local MAX_SLEEP_TIME = 10 + +--- A runnable task with a :run() method invoked by the scheduler. +---@class Task +---@field run fun(self: Task) + +--- A source of tasks (timers, pollers, etc.) that can enqueue work on a scheduler. +---@class TaskSource +---@field schedule_tasks fun(self: TaskSource, sched: Scheduler, now: number) +---@field cancel_all_tasks fun(self: TaskSource, sched: Scheduler)|nil +---@field wait_for_events fun(self: TaskSource, sched: Scheduler, now: number, timeout: number)|nil + +--- Main scheduler state and API. +---@class Scheduler +---@field next Task[] # tasks runnable next turn +---@field cur Task[] # tasks being run this turn +---@field sources TaskSource[] # timer, poller, etc. +---@field wheel Timer # timer wheel using the same clock +---@field maxsleep number # maximum sleep interval in seconds +---@field get_time fun(): number # monotonic time source +---@field event_waiter TaskSource|nil # single source used for blocking waits (if any) +---@field done boolean +local Scheduler = {} +Scheduler.__index = Scheduler + +--- Create a new scheduler instance. +---@param get_time? fun(): number # monotonic time source (defaults to fibers.utils.time.monotonic) +---@return Scheduler +local function new(get_time) + local now_src = get_time or time.monotonic + local now = now_src() + + local ret = setmetatable({ + next = {}, + cur = {}, + sources = {}, + wheel = timer.new(now), + maxsleep = MAX_SLEEP_TIME, + get_time = now_src, + event_waiter = nil, + done = false, + }, Scheduler) + + --- Timer source: advances the wheel and schedules due tasks. + ---@class TimerTaskSource : TaskSource + ---@field wheel Timer + local timer_task_source = { wheel = ret.wheel } + + --- Advance the timer wheel and schedule any due tasks. + ---@param sched Scheduler + ---@param now_ number + function timer_task_source:schedule_tasks(sched, now_) + self.wheel:advance(now_, sched) + end + + function timer_task_source:cancel_all_tasks() + end + + ret:add_task_source(timer_task_source) + return ret +end + +--- Register a task source with this scheduler. +--- A source must implement :schedule_tasks(sched, now). +--- If the source implements :wait_for_events, it becomes the scheduler's +--- sole event waiter (overwriting any previous one). +---@param source TaskSource +function Scheduler:add_task_source(source) + table.insert(self.sources, source) + if source.wait_for_events then + self.event_waiter = source + end +end + +--- Schedule a task to be run on the next turn. +---@param task Task +function Scheduler:schedule(task) + table.insert(self.next, task) +end + +--- Get current monotonic time from the scheduler's clock source. +---@return number +function Scheduler:monotime() + return self.get_time() +end + +--- Get the last time observed by the timer wheel. +---@return number +function Scheduler:now() + return self.wheel.now +end + +--- Schedule a task at an absolute time. +---@param t number # absolute time on the scheduler clock +---@param task Task +function Scheduler:schedule_at_time(t, task) + return self.wheel:add_absolute(t, task) +end + +--- Schedule a task after a delay from the wheel's current time. +---@param dt number # delay in seconds +---@param task Task +function Scheduler:schedule_after_sleep(dt, task) + return self.wheel:add_delta(dt, task) +end + +--- Ask all registered sources to enqueue any ready tasks. +---@param now number +function Scheduler:schedule_tasks_from_sources(now) + for i = 1, #self.sources do + self.sources[i]:schedule_tasks(self, now) + end +end + +--- Run all tasks currently scheduled as runnable. +--- If now is nil, the current monotonic time is used. +---@param now? number +function Scheduler:run(now) + if now == nil then + now = self:monotime() + end + + self:schedule_tasks_from_sources(now) + + self.cur, self.next = self.next, self.cur + + for i = 1, #self.cur do + local task = self.cur[i] + self.cur[i] = nil + task:run() + end +end + +--- Compute the next time the scheduler may need to wake. +--- If there are runnable tasks, returns now() (do not sleep). +--- Otherwise defers to the timer wheel, which returns a time or math.huge. +---@return number +function Scheduler:next_wake_time() + if #self.next > 0 then + return self:now() + end + return self.wheel:next_entry_time() +end + +--- Block until the next event or timeout. +--- Uses an event_waiter (e.g. poller) if present, otherwise sleeps. +function Scheduler:wait_for_events() + local now = self:monotime() + local next_time = self:next_wake_time() + + local timeout = math.min(self.maxsleep, next_time - now) + if timeout < 0 then timeout = 0 end + + if self.event_waiter then + self.event_waiter:wait_for_events(self, now, timeout) + else + -- No poller installed; fall back to process-blocking sleep. + time._block(timeout) + end +end + +--- Request that the scheduler main loop stops after the current iteration. +function Scheduler:stop() + self.done = true +end + +--- Run the scheduler main loop until stopped. +--- Repeatedly waits for events and runs ready tasks. +function Scheduler:main() + self.done = false + repeat + self:wait_for_events() + self:run(self:monotime()) + until self.done +end + +--- Attempt to drain runnable work and ask sources to cancel outstanding tasks. +--- Sources are given an opportunity to cancel pending work; the scheduler +--- continues to drive sources (including timers) while draining. +--- Returns true if the runnable queue is drained within the iteration limit. +---@return boolean drained # true if work queue drained, false on iteration limit +function Scheduler:shutdown() + for _ = 1, 100 do + for i = 1, #self.sources do + local src = self.sources[i] + if src.cancel_all_tasks then + src:cancel_all_tasks(self) + end + end + + if #self.next == 0 then return true end + + self:run() + end + return false +end + +return { + new = new, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/scope.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/scope.lua new file mode 100644 index 00000000..84af27b4 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/scope.lua @@ -0,0 +1,834 @@ +-- fibers/scope.lua +-- +-- Stable core structured concurrency scopes that complement the Op layer. +-- +-- This module provides supervision โ€œscopesโ€ for cooperative fibers. Scopes are +-- intended to be the unit of lifetime, cancellation and failure accounting, +-- with explicit boundaries for crossing between scopes. +-- +-- Guarantees +-- * Structural lifetime: attached children are joined by the parent join, +-- including child finalisers, in attachment order. +-- * Admission gate: close() stops new spawn()/child() on the scope. +-- Joining also closes admission (but does not imply cancellation). +-- * Downward cancellation: cancel() closes admission and cascades to attached +-- children. Cancellation is a normal termination mode, distinct from failure. +-- * Fail-fast within a scope: the first non-cancellation fault marks the scope +-- failed, records a primary error, and cancels the scope to stop siblings. +-- * Join/finalisation is non-interruptible: join runs in a join worker and uses +-- op.perform_raw, so it is not interrupted by scope cancellation. +-- * Finalisers may perform only Ops that are ready now. During finalisation, +-- scope-aware perform attempts the Op immediately and raises if it would +-- suspend. This permits explicit try-now helpers built with or_else(), +-- while preventing hidden waits in cleanup paths. +-- * Scope-aware ops: +-- - try(ev) -> 'ok'|'failed'|'cancelled', ... +-- - perform(ev) -> returns results on ok; raises on failed/cancelled +-- (using a cancellation sentinel for cancelled). +-- * Boundaries (status-first, report-second): +-- - join_op() -> status, report, primary|nil +-- - run(fn, ...) -> status, report, ... (on not-ok: ... is primary) +-- - run_op(fn, ...) -> Op yielding status, report, ... (on not-ok: ... is primary) +-- +-- Notes +-- * Returning variable arity across boundaries follows Lua conventions. +-- As with any multi-return, trailing nil results are not preserved. +-- +-- Deliberate non-feature +-- * No implicit upward propagation of child failure into parent failure. +-- Child outcomes are reported (via reports), not escalated. +-- +---@module 'fibers.scope' + +local runtime = require 'fibers.runtime' +local waitgroup = require 'fibers.waitgroup' +local oneshot = require 'fibers.oneshot' +local op = require 'fibers.op' +local dlist = require 'fibers.utils.dlist' +local safe = require 'coxpcall' + +local ok_probe, leak_probe = pcall(require, 'devicecode.support.leak_probe') +if not ok_probe then leak_probe = nil end + +local DEBUG = false + +--- Enable/disable debug traceback capture. +---@param v boolean +local function set_debug(v) DEBUG = not not v end + +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } +end + +---------------------------------------------------------------------- +-- Cancellation sentinel (robust, non-colliding) +---------------------------------------------------------------------- + +local CANCEL_MT = { __name = 'fibers.cancelled' } + +---@class Cancelled +---@field reason any + +---@param reason any +---@return Cancelled +local function cancelled(reason) + return setmetatable({ reason = reason }, CANCEL_MT) +end + +---@param err any +---@return boolean +local function is_cancelled(err) + return type(err) == 'table' and getmetatable(err) == CANCEL_MT +end + +---@param err any +---@return any|nil +local function cancel_reason(err) + return is_cancelled(err) and err.reason or nil +end + +---------------------------------------------------------------------- +-- Error normalisation policy (xpcall handlers) +---------------------------------------------------------------------- + +local function with_tb(msg, tb) + return DEBUG and (tb or debug.traceback(msg, 2)) or msg +end + +local function tb_handler(e, tb) + return is_cancelled(e) and e or with_tb(tostring(e), tb) +end + +local function join_tb_handler(e, tb) + return is_cancelled(e) and with_tb('join raised cancellation: ' .. tostring(cancel_reason(e)), tb) + or with_tb(tostring(e), tb) +end + +local finaliser_handler = tb_handler + +local FINALISER_WAIT_ERR = 'attempted to perform a non-ready Op during scope finalisation' + +---------------------------------------------------------------------- +-- Types / state +---------------------------------------------------------------------- + +---@class ScopeChildOutcome +---@field id integer +---@field status 'ok'|'failed'|'cancelled' +---@field primary any +---@field report ScopeReport + +---@class ScopeReport +---@field id integer +---@field extra_errors any[] +---@field children ScopeChildOutcome[] + +---@class ScopeJoinOutcome +---@field st 'ok'|'failed'|'cancelled' +---@field primary any +---@field report ScopeReport + +---@class Scope +---@field _id integer +---@field _parent Scope|nil +---@field _children table +---@field _order Scope[] +---@field _wg Waitgroup +---@field _closed boolean +---@field _close_reason any|nil +---@field _close_os Oneshot +---@field _failed_primary any|nil -- primary failure (string/number) if failed +---@field _cancel_reason any|nil -- cancellation reason if cancelled +---@field _cancel_os Oneshot +---@field _extra_errors any[] +---@field _fault_os Oneshot +---@field _finalisers DList +---@field _finalising boolean +---@field _started boolean +---@field _join_started boolean +---@field _join_outcome ScopeJoinOutcome|nil +---@field _join_os Oneshot +local Scope = {} +Scope.__index = Scope + +-- Weak-key map: Fiber -> Scope for attribution of uncaught runtime fiber errors. +local fiber_scopes = setmetatable({}, { __mode = 'k' }) + +-- Process-wide root scope. +local root_scope + +-- Monotonic scope id sequence (local to the process). +local next_id = 0 + +local function current_fiber() + return runtime.current_fiber() +end + +---------------------------------------------------------------------- +-- Unscoped error handling +---------------------------------------------------------------------- + +local unscoped_error_handler = function (_, err) + io.stderr:write('Unscoped fiber error: ' .. tostring(err) .. '\n') +end + +---@param handler fun(fib:any, err:any) +local function set_unscoped_error_handler(handler) + if type(handler) ~= 'function' then error('unscoped error handler must be a function', 2) end + unscoped_error_handler = handler +end + +---------------------------------------------------------------------- +-- Current scope install/restore (fiber-local only) +---------------------------------------------------------------------- + +local function install_current_scope(s) + local fib = assert(current_fiber(), 'scope internal invariant violated: no current fiber') + local prev = fiber_scopes[fib] + fiber_scopes[fib] = s + return fib, prev +end + +local function restore_current_scope(fib, prev) + fiber_scopes[fib] = prev +end + +local function xpcall_in_scope(self, handler, f) + local fib, prev = install_current_scope(self) + local ok, res = safe.xpcall(f, handler) + restore_current_scope(fib, prev) + return ok, res +end + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +---@param t any[] +---@return any[] +local function copy_array(t) + local out = {} + for i = 1, #t do out[i] = t[i] end + return out +end + +---@param self Scope +---@return Scope[] +local function snapshot_children_set(self) + local snap = {} + for ch in pairs(self._children) do snap[#snap + 1] = ch end + return snap +end + +--- Build a primitive op from an oneshot-like readiness predicate. +---@param is_ready fun(): boolean +---@param os Oneshot +---@param get_values fun(): ... +---@param on_block? fun() +---@return Op +local function oneshot_value_op(is_ready, os, get_values, on_block) + return op.new_primitive(nil, function () + if is_ready() then + return true, get_values() + end + return false + end, function (suspension, wrap_fn) + local cancel = os:add_waiter(function () + if suspension:waiting() then suspension:complete(wrap_fn, get_values()) end + end) + suspension:add_cleanup(cancel) + if on_block then on_block() end + end) +end + +---@param self Scope +---@return 'ok'|'failed'|'cancelled', any +local function terminal_status(self) + if self._failed_primary ~= nil then return 'failed', self._failed_primary end + if self._cancel_reason ~= nil then return 'cancelled', self._cancel_reason end + return 'ok', nil +end + +---@param self Scope +---@param child_outcomes? ScopeChildOutcome[] +---@return ScopeReport +local function make_report(self, child_outcomes) + return { + id = self._id, + extra_errors = copy_array(self._extra_errors), + children = child_outcomes or {}, + } +end + +--- Return a rejection reason if the scope is not admitting new work; otherwise nil. +---@param self Scope +---@return string|nil +local function reject_reason(self) + if self._join_outcome ~= nil or self._join_started then return 'scope is joining' end + if self._failed_primary ~= nil then return 'scope has failed' end + if self._cancel_reason ~= nil then return 'scope is cancelled' end + if self._closed then return 'scope is closed' end + return nil +end + +---------------------------------------------------------------------- +-- Observational status (non-blocking snapshot) +---------------------------------------------------------------------- + +---@return string st +---@return any v +function Scope:status() + local out = self._join_outcome + if out ~= nil then return out.st, out.primary end + + if self._failed_primary ~= nil then return 'failed', self._failed_primary end + if self._cancel_reason ~= nil then return 'cancelled', self._cancel_reason end + return 'running', nil +end + +---@return string st +---@return any reason +function Scope:admission() + if self._closed then return 'closed', self._close_reason end + return 'open', nil +end + +---------------------------------------------------------------------- +-- Construction / root / current +---------------------------------------------------------------------- + +---@param parent Scope|nil +---@return Scope +local function new_scope(parent) + next_id = next_id + 1 + + local s = setmetatable({ + _id = next_id, + _parent = parent, + _children = {}, + _extra_errors = {}, + _order = {}, + _finalisers = dlist.new(), + _close_os = oneshot.new(), + _cancel_os = oneshot.new(), + _fault_os = oneshot.new(), + _join_os = oneshot.new(), + _wg = waitgroup.new(), + }, Scope) + + if leak_probe then leak_probe.scope_created(s._id, parent and parent._id or nil) end + + if parent then + parent._children[s] = true + parent._order[#parent._order + 1] = s + if parent._cancel_reason ~= nil then s:cancel(parent._cancel_reason) end + end + + return s +end + +---@return Scope +local function root() + if not root_scope then + root_scope = new_scope(nil) + runtime.spawn_raw(function () + while true do + local fib, err = runtime.wait_fiber_error() + if not is_cancelled(err) then + local s = fiber_scopes[fib] + if s then s:_record_fault(err) else unscoped_error_handler(fib, err) end + end + end + end) + end + return root_scope +end + +--- Return the current scope. +--- Inside a fiber: the fiber's scope, defaulting to root. +--- Outside fibers: always the root scope. +---@return Scope +local function current() + local fib = current_fiber() + return fib and (fiber_scopes[fib] or root()) or root() +end + +---------------------------------------------------------------------- +-- Child management (attachment) +---------------------------------------------------------------------- + +---@param self Scope +---@param child Scope +function Scope:_remove_child(child) + if child._parent ~= self then return end + if leak_probe then leak_probe.scope_child_detached(self._id, child._id) end + self._children[child] = nil + + local ord = self._order + for i = #ord, 1, -1 do + if ord[i] == child then + table.remove(ord, i) + break + end + end + + child._parent = nil +end + +function Scope:_detach_from_parent() + local p = self._parent + if p then p:_remove_child(self) end +end + +---@return Scope|nil child, any|nil err +function Scope:child() + local why = reject_reason(self) + if why then return nil, why end + return new_scope(self), nil +end + +---------------------------------------------------------------------- +-- Admission gate (close) +---------------------------------------------------------------------- + +---@param reason any|nil +function Scope:close(reason) + if self._join_outcome then return end + + if not self._closed then + self._closed = true + self._close_reason = (reason ~= nil) and reason or self._close_reason + self._close_os:signal() + if leak_probe then leak_probe.scope_closed(self._id, self._close_reason) end + elseif self._close_reason == nil and reason ~= nil then + self._close_reason = reason + end +end + +---@return Op +function Scope:close_op() + return oneshot_value_op( + function () return self._closed end, + self._close_os, + function () return 'closed', self._close_reason end + ) +end + +---------------------------------------------------------------------- +-- Cancellation / faults +---------------------------------------------------------------------- + +---@param reason any|nil +function Scope:cancel(reason) + if self._join_outcome then return end + + -- Cancellation implies admission is closed. + self:close(reason) + + if self._cancel_reason == nil then + self._cancel_reason = (reason ~= nil) and reason or 'scope cancelled' + self._cancel_os:signal() + if leak_probe then leak_probe.scope_cancelled(self._id, self._cancel_reason) end + end + + -- Cancel attached children (snapshot avoids mutation hazards). + local snap = snapshot_children_set(self) + for i = 1, #snap do snap[i]:cancel(self._cancel_reason) end +end + +function Scope:_record_fault(err) + if is_cancelled(err) then + return self:cancel(cancel_reason(err)) + end + + local e = (type(err) == 'string' or type(err) == 'number') and err or tostring(err) + + if self._failed_primary ~= nil then + self._extra_errors[#self._extra_errors + 1] = e + return + end + + self._failed_primary = e + self._fault_os:signal() + + -- single source of truth for cancellation + downward cascade + self:cancel(e) +end + +---@return Op +function Scope:cancel_op() + return oneshot_value_op( + function () return self._cancel_reason ~= nil end, + self._cancel_os, + function () return 'cancelled', self._cancel_reason end + ) +end + +---@return Op +function Scope:fault_op() + return oneshot_value_op( + function () return self._failed_primary ~= nil end, + self._fault_os, + function () return 'failed', self._failed_primary end + ) +end + +---@return Op +function Scope:not_ok_op() + return op.choice(self:fault_op(), self:cancel_op()):wrap(function () + if self._failed_primary ~= nil then return 'failed', self._failed_primary end + return 'cancelled', self._cancel_reason + end) +end + +---------------------------------------------------------------------- +-- Finalisers +---------------------------------------------------------------------- + +---@param f fun(aborted:boolean, status:'ok'|'failed'|'cancelled', primary:any|nil) +---@return fun() detach +function Scope:finally(f) + if type(f) ~= 'function' then error('scope:finally expects a function', 2) end + + local fib = current_fiber() + if not fib then error('scope:finally must be called from inside a fiber', 2) end + + local cur = fiber_scopes[fib] or root() + if self._started and cur ~= self then + error('once started scope:finally must be called from within the target scope', 2) + end + + if self._finalising or self._join_outcome ~= nil then + error('scope:finally: scope is finalising or has joined', 2) + end + + local node = self._finalisers:push_tail(f) + if leak_probe then leak_probe.scope_finalizer_added(self._id) end + local detached = false + return function () + if detached then return false end + detached = true + local removed = node:remove() + if removed and leak_probe then leak_probe.scope_finalizer_removed(self._id, 'detach') end + return removed + end +end + +---------------------------------------------------------------------- +-- Spawning (attached obligations) +---------------------------------------------------------------------- + +---@param fn fun(s:Scope, ...): any +---@param ... any +---@return boolean ok, any|nil err +function Scope:spawn(fn, ...) + local why = reject_reason(self) + if why then return false, why end + + -- From this point, treat the scope as having started work. + self._started = true + if leak_probe then leak_probe.scope_spawned(self._id) end + + local args = pack(...) + self._wg:add(1) + + runtime.spawn_raw(function () + local ok, err = xpcall_in_scope(self, tb_handler, function () + return fn(self, unpack(args, 1, args.n)) + end) + if not ok then self:_record_fault(err) end + self._wg:done() + end) + + return true, nil +end + +---------------------------------------------------------------------- +-- Join (non-interruptible finalisation) +---------------------------------------------------------------------- + +---@param self Scope +---@return ScopeChildOutcome[] +function Scope:_finalise_join_body() + self:close('joining') + + local children = copy_array(self._order) + local child_outcomes = {} + + op.perform_raw(self._wg:wait_op()) + + for i = 1, #children do + local ch = children[i] + if ch and ch._parent == self then + local st, rep, primary = op.perform_raw(ch:join_op()) + child_outcomes[#child_outcomes + 1] = { + id = ch._id, + status = st, + primary = primary, + report = rep, + } + self:_remove_child(ch) + end + end + + local st, primary = terminal_status(self) + local aborted = (st ~= 'ok') + + -- Freeze finaliser registration at the start of finalisation. + self._finalising = true + + local node = self._finalisers.tail + while node do + local prev = node.prev + local f = node.value + if node:remove() and leak_probe then leak_probe.scope_finalizer_removed(self._id, 'run') end -- ensure it cannot be run twice, and drop refs early + + if f then + local ok, err = safe.xpcall(function () + return f(aborted, st, primary) + end, finaliser_handler) + + if not ok then + if is_cancelled(err) then + self:_record_fault('finaliser raised cancellation: ' .. tostring(cancel_reason(err))) + else + self:_record_fault(err) + end + st, primary = terminal_status(self) + aborted = (st ~= 'ok') + end + end + + node = prev + end + + return child_outcomes +end + +function Scope:_start_join_worker() + if self._join_started then return end + self._started = true + self._join_started = true + if leak_probe then leak_probe.scope_join_started(self._id) end + + runtime.spawn_raw(function () + local child_outcomes + local ok, err = xpcall_in_scope(self, join_tb_handler, function () + child_outcomes = self:_finalise_join_body() + end) + if not ok then self:_record_fault(err) end + + local st, primary = terminal_status(self) + local rep = make_report(self, child_outcomes or {}) + + self._join_outcome = { st = st, primary = primary, report = rep } + if leak_probe then leak_probe.scope_join_done(self._id, st) end + self._join_os:signal() + self:_detach_from_parent() + end) +end + +---@return Op +function Scope:join_op() + return oneshot_value_op( + function () return self._join_outcome ~= nil end, + self._join_os, + function () + local out = assert(self._join_outcome, 'join signalled without outcome') + return out.st, out.report, out.primary + end, + function () self:_start_join_worker() end + ) +end + +---------------------------------------------------------------------- +-- Scope-aware op performance (status-first) +---------------------------------------------------------------------- + +---@param ev any +local function assert_op_value(ev) + if type(ev) ~= 'table' or getmetatable(ev) ~= op.Op then + error(('scope: expected op, got %s (%s)'):format(type(ev), tostring(ev)), 3) + end +end + +---@param ev Op +---@return Op +local function finalising_try_op(ev) + -- Finalisers run after the scope is already failed, cancelled or joining. They + -- still need to be able to use explicit immediate attempts such as: + -- + -- tx:send_op(value):or_else(function () return nil, 'not_ready' end) + -- + -- but finalisation must not hide a suspension. or_else performs only the + -- Op's readiness probe and chooses the fallback if it would block. For + -- composite Ops this also triggers losing-arm nacks/abort handlers, which is + -- the right cleanup behaviour for an attempted synchronisation that did not + -- commit. + return ev:wrap(function (...) + return 'ok', ... + end):or_else(function () + return 'failed', FINALISER_WAIT_ERR + end) +end + +---@param ev Op +---@return Op +function Scope:try_op(ev) + assert_op_value(ev) + + return op.guard(function () + if self._finalising then + return finalising_try_op(ev) + end + + if self._failed_primary ~= nil then return op.always('failed', self._failed_primary) end + if self._cancel_reason ~= nil then return op.always('cancelled', self._cancel_reason) end + + local body = ev:wrap(function (...) + if self._failed_primary ~= nil then return 'failed', self._failed_primary end + if self._cancel_reason ~= nil then return 'cancelled', self._cancel_reason end + return 'ok', ... + end) + + return op.choice(body, self:not_ok_op()) + end) +end + +---@param ev Op +---@return 'ok'|'failed'|'cancelled', ... +function Scope:try(ev) + if not current_fiber() then error('scope:try must be called from inside a fiber', 2) end + return op.perform_raw(self:try_op(ev)) +end + +---@param ev Op +---@return any ... +function Scope:perform(ev) + local r = pack(self:try(ev)) + local st = r[1] + if st == 'ok' then return unpack(r, 2, r.n) end + if st == 'cancelled' then error(cancelled(r[2]), 0) end + error(r[2] or 'scope failed', 0) +end + +---------------------------------------------------------------------- +-- Boundaries +---------------------------------------------------------------------- + +---@param body_fn fun(s:Scope, ...): ... +---@param ... any +---@return Op +local function run_op(body_fn, ...) + if type(body_fn) ~= 'function' then error('scope.run_op expects a function', 2) end + + local args = pack(...) + + return op.guard(function () + local parent = current() + + -- Admission fast path: return an already-ready op. + local why = reject_reason(parent) + if why then return op.always('cancelled', make_report(parent, {}), why) end + + -- Per-perform state (initially unset). + local child, child_err, results + + local function start_once() + if child ~= nil or child_err ~= nil then return end + + child, child_err = parent:child() + if not child then return end + + local ok_spawn, spawn_err = child:spawn(function (s) + local ok, err = safe.xpcall(function () + results = pack(body_fn(s, unpack(args, 1, args.n))) + end, tb_handler) + + if not ok then s:_record_fault(err) end + + s:close('body complete') + s:_start_join_worker() + end) + + if not ok_spawn then + child:_record_fault(spawn_err) + child:close('body spawn failed') + child:_start_join_worker() + end + end + + local function complete_from_join(suspension, wrap_fn) + local out = assert(child and child._join_outcome, + 'scope violated: child join signalled without outcome') + + if out.st == 'ok' then + local r = results or pack() + suspension:complete(wrap_fn, 'ok', out.report, unpack(r, 1, r.n)) + else + suspension:complete(wrap_fn, out.st, out.report, out.primary) + end + end + + local function try_fn() return false end + + local function block_fn(suspension, wrap_fn) + start_once() + + if not child then + suspension:complete(wrap_fn, 'cancelled', make_report(parent, {}), child_err) + return + end + + local cancel_join = child._join_os:add_waiter(function () + if suspension:waiting() then complete_from_join(suspension, wrap_fn) end + end) + suspension:add_cleanup(cancel_join) + + if child._join_outcome and suspension:waiting() then + complete_from_join(suspension, wrap_fn) + end + end + + local ev = op.new_primitive(nil, try_fn, block_fn) + + return ev:on_abort(function () + if not child then return end + + child:cancel('aborted') + child:_start_join_worker() + safe.pcall(function () op.perform_raw(child:join_op()) end) + end) + end) +end + +---@param body_fn fun(s:Scope, ...): ... +---@param ... any +---@return 'ok'|'failed'|'cancelled', ScopeReport, any ... +local function run(body_fn, ...) + if type(body_fn) ~= 'function' then error('scope.run expects a function body', 2) end + if not current_fiber() then error('scope.run must be called from inside a fiber', 2) end + return op.perform_raw(run_op(body_fn, ...)) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + root = root, + current = current, + Scope = Scope, + + run = run, + run_op = run_op, + + cancelled = cancelled, + is_cancelled = is_cancelled, + cancel_reason = cancel_reason, + + set_debug = set_debug, + + set_unscoped_error_handler = set_unscoped_error_handler, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/sleep.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/sleep.lua new file mode 100644 index 00000000..de84eb01 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/sleep.lua @@ -0,0 +1,64 @@ +-- Use of this source code is governed by the Apache 2.0 license; see COPYING. + +--- Sleep operations for fibers. +--- Provides ops and helpers for suspending fibers for a duration or until a deadline. +---@module 'fibers.sleep' + +local op = require 'fibers.op' +local runtime = require 'fibers.runtime' + +local perform = require 'fibers.performer'.perform + +--- Primitive op that becomes ready when the absolute time t is reached. +---@param t number # absolute time on the runtime clock +---@return Op +local function deadline_op(t) + local function try() + return runtime.now() >= t + end + + --- Schedule completion of the suspension at time t. + ---@param suspension Suspension + ---@param wrap_fn WrapFn + local function block(suspension, wrap_fn) + local cancel_timer = suspension:at_time(t, suspension:complete_task(wrap_fn)) + suspension:add_cleanup(cancel_timer) + end + + return op.new_primitive(nil, try, block) +end + +--- Op that sleeps until absolute time t. +---@param t number # absolute time on the runtime clock +---@return Op +local function sleep_until_op(t) + return deadline_op(t) +end + +--- Sleep until absolute time t. +---@param t number # absolute time on the runtime clock +local function sleep_until(t) + return perform(sleep_until_op(t)) +end + +--- Op that sleeps for a duration dt. +---@param dt number # delay in seconds +---@return Op +local function sleep_op(dt) + return op.guard(function () + return deadline_op(runtime.now() + dt) + end) +end + +--- Sleep for a duration dt. +---@param dt number # delay in seconds +local function sleep(dt) + return perform(sleep_op(dt)) +end + +return { + sleep = sleep, + sleep_op = sleep_op, + sleep_until = sleep_until, + sleep_until_op = sleep_until_op, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/timer.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/timer.lua new file mode 100644 index 00000000..e4e3b90e --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/timer.lua @@ -0,0 +1,221 @@ +-- fibers/timer.lua + +--- Monotonic timer built on a binary min-heap. +---@module 'fibers.timer' + +---@class TimerNode +---@field time number # absolute due time (monotonic seconds) +---@field obj any # scheduled payload +---@field index integer|nil # current heap index, nil when not queued + +---@alias TimerCancel fun(): boolean + +local floor, huge = math.floor, math.huge + +--- Simple min-heap keyed by node.time. +---@class Heap +---@field heap TimerNode[] +---@field size integer +local Heap = {} +Heap.__index = Heap + +---@return Heap +local function new_heap() + return setmetatable({ heap = {}, size = 0 }, Heap) +end + +---@param i integer +---@param j integer +function Heap:swap(i, j) + local heap = self.heap + heap[i], heap[j] = heap[j], heap[i] + heap[i].index = i + heap[j].index = j +end + +---@param node TimerNode +function Heap:push(node) + local size = self.size + 1 + self.size = size + node.index = size + self.heap[size] = node + self:heapify_up(size) +end + +---@return TimerNode|nil +function Heap:pop() + local size = self.size + if size == 0 then + return nil + end + + local heap = self.heap + local root = heap[1] + root.index = nil + + if size == 1 then + heap[1] = nil + self.size = 0 + return root + end + + local last = heap[size] + heap[size] = nil + self.size = size - 1 + heap[1] = last + last.index = 1 + self:heapify_down(1) + + return root +end + +---@param node TimerNode +---@return boolean +function Heap:remove(node) + local idx = node.index + if type(idx) ~= 'number' or idx < 1 or idx > self.size then + return false + end + + local heap = self.heap + if heap[idx] ~= node then + return false + end + + local size = self.size + node.index = nil + + if idx == size then + heap[size] = nil + self.size = size - 1 + return true + end + + local last = heap[size] + heap[size] = nil + self.size = size - 1 + heap[idx] = last + last.index = idx + + local parent = floor(idx / 2) + if idx > 1 and heap[idx].time < heap[parent].time then + self:heapify_up(idx) + else + self:heapify_down(idx) + end + + return true +end + +---@param idx integer +function Heap:heapify_up(idx) + local heap = self.heap + while idx > 1 do + local parent = floor(idx / 2) + if heap[parent].time <= heap[idx].time then + break + end + self:swap(parent, idx) + idx = parent + end +end + +---@param idx integer +function Heap:heapify_down(idx) + local heap = self.heap + local size = self.size + + while true do + local left = 2 * idx + local right = left + 1 + local smallest = idx + + if left <= size and heap[left].time < heap[smallest].time then + smallest = left + end + if right <= size and heap[right].time < heap[smallest].time then + smallest = right + end + + if smallest == idx then + break + end + + self:swap(idx, smallest) + idx = smallest + end +end + +---@class Timer +---@field now number # current timer time (monotonic seconds) +---@field heap Heap +local Timer = {} +Timer.__index = Timer + +--- Create a new timer instance. +---@param now number # initial monotonic time. +---@return Timer +local function new(now) + return setmetatable({ now = now, heap = new_heap() }, Timer) +end + +--- Schedule an object at absolute time t. +---@param t number # absolute due time +---@param obj any # payload to pass to the scheduler +---@return TimerCancel cancel # idempotent cancellation handle +function Timer:add_absolute(t, obj) + local node = { time = t, obj = obj, index = nil } + self.heap:push(node) + + local cancelled = false + return function () + if cancelled then + return false + end + + cancelled = true + local removed = self.heap:remove(node) + node.obj = nil + return removed + end +end + +--- Schedule an object after a delay from the current timer time. +---@param dt number # delay in seconds from self.now +---@param obj any # payload to pass to the scheduler +---@return TimerCancel cancel # idempotent cancellation handle +function Timer:add_delta(dt, obj) + return self:add_absolute(self.now + dt, obj) +end + +--- Get the time of the next scheduled entry, or math.huge if none exist. +---@return number +function Timer:next_entry_time() + local heap = self.heap + return heap.size > 0 and heap.heap[1].time or huge +end + +--- Pop the next scheduled entry without dispatching it. +---@return TimerNode|nil +function Timer:pop() + return self.heap:pop() +end + +--- Advance the timer to time t and dispatch all due entries. +---@param t number # new monotonic time +---@param sched { schedule: fun(self:any, obj:any) } +function Timer:advance(t, sched) + local heap = self.heap + + while heap.size > 0 and t >= heap.heap[1].time do + local node = assert(heap:pop()) -- non-nil since size>0 + local obj = node.obj + node.obj = nil + self.now = node.time + sched:schedule(obj) + end + + self.now = t +end + +return { new = new } diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/bytes.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/bytes.lua new file mode 100644 index 00000000..685e4954 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/bytes.lua @@ -0,0 +1,50 @@ +-- fibers/utils/bytes.lua +-- +-- Unified buffer abstraction: +-- * bytes.RingBuf : ring buffer for bytes +-- * bytes.LinearBuf : growable linear buffer +-- +-- Backend selection: +-- - FFI-backed (LuaJIT or cffi) : fibers.utils.bytes.ffi +-- - Pure Lua rope/string-based : fibers.utils.bytes.lua +-- +-- This shim picks the first supported backend. + +---@module 'fibers.utils.bytes' + +---------------------------------------------------------------------- +-- Forward type declarations for tooling +---------------------------------------------------------------------- + +---@class RingBuf +---@field size integer # total capacity (bytes) +---@field read_avail fun(self: RingBuf): integer # available bytes to read +---@field write_avail fun(self: RingBuf): integer # available space to write +---@field take fun(self: RingBuf, n: integer): string # remove n bytes and return them +---@field put fun(self: RingBuf, s: string) # append bytes +---@field reset fun(self: RingBuf) # clear buffer +---@field find fun(self: RingBuf, needle: string): integer|nil # find substring in readable region + +---@class LinearBuf +---@field append fun(self: LinearBuf, s: string) # append bytes +---@field tostring fun(self: LinearBuf): string # materialise as a single string +---@field reset fun(self: LinearBuf) # clear buffer (optional but common) + +---@class BytesBackend +---@field RingBuf { new: fun(size?: integer): RingBuf } +---@field LinearBuf { new: fun(): LinearBuf } +---@field is_supported fun(): boolean + +local candidates = { + 'fibers.utils.bytes.ffi', + 'fibers.utils.bytes.lua', +} + +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + return mod + end +end + +error('fibers.utils.bytes: no suitable bytes backend available on this platform') diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/bytes/ffi.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/bytes/ffi.lua new file mode 100644 index 00000000..38a1b9e5 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/bytes/ffi.lua @@ -0,0 +1,276 @@ +-- fibers/utils/bytes/ffi.lua +-- +-- FFI-backed byte buffers: +-- * RingBuf : fixed-capacity ring buffer +-- * LinearBuf : growable buffer + +---@module 'fibers.utils.bytes.ffi' + +local bit = rawget(_G, 'bit') or require 'bit32' +local ffi_c = require 'fibers.utils.ffi_compat' + +-- If there is no usable FFI layer, mark this backend unsupported. +if not (ffi_c.is_supported and ffi_c.is_supported()) then + return { + is_supported = function () return false end, + } +end + +local ffi = ffi_c.ffi +local band = bit.band + +ffi.cdef [[ + typedef unsigned int uint32_t; + typedef unsigned char uint8_t; + + typedef struct { + uint32_t read_idx; + uint32_t write_idx; + uint32_t size; + uint8_t buf[?]; + } fibers_ringbuf_t; +]] + +local ring_mt, lin_mt = {}, {} +ring_mt.__index = ring_mt +lin_mt.__index = lin_mt + +local ring_ct = ffi.metatype('fibers_ringbuf_t', ring_mt) + +local function to_u32(n) + return n % 2 ^ 32 +end + +local function pos(self, idx) + return band(idx, self.size - 1) +end + +---------------------------------------------------------------------- +-- RingBuf +---------------------------------------------------------------------- + +--- Initialise ring buffer. +function ring_mt:init(size) + assert(type(size) == 'number' and size > 0, 'RingBuf: positive size required') + assert(band(size, size - 1) == 0, 'RingBuf: size must be power of two') + self.size = size + self.read_idx = 0 + self.write_idx = 0 + return self +end + +function ring_mt:reset() + self.read_idx, self.write_idx = 0, 0 +end + +function ring_mt:read_avail() + return to_u32(self.write_idx - self.read_idx) +end + +function ring_mt:write_avail() + return self.size - self:read_avail() +end + +function ring_mt:is_empty() + return self.read_idx == self.write_idx +end + +function ring_mt:is_full() + return self:read_avail() == self.size +end + +local function copy_out(self, n) + local tmp = ffi.new('uint8_t[?]', n) + local size = self.size + local start = pos(self, self.read_idx) + local first = math.min(n, size - start) + + if first > 0 then + ffi.copy(tmp, self.buf + start, first) + end + + local rest = n - first + if rest > 0 then + ffi.copy(tmp + first, self.buf, rest) + end + + self.read_idx = self.read_idx + ffi.cast('uint32_t', n) + return tmp +end + +local function copy_in(self, src, n) + local size = self.size + local start = pos(self, self.write_idx) + local first = math.min(n, size - start) + + if first > 0 then + ffi.copy(self.buf + start, src, first) + end + + local rest = n - first + if rest > 0 then + ffi.copy(self.buf, src + first, rest) + end + + self.write_idx = self.write_idx + ffi.cast('uint32_t', n) +end + +function ring_mt:put(str) + assert(type(str) == 'string', 'RingBuf:put expects a string') + local n = #str + if n == 0 then return end + assert(n <= self:write_avail(), 'RingBuf: write would exceed capacity') + local tmp = ffi.new('uint8_t[?]', n) + ffi.copy(tmp, str, n) + copy_in(self, tmp, n) +end + +-- Opaque mark of the current write position (for tail rollback). +-- Intended for "publish then possibly roll back" patterns in higher layers. +function ring_mt:mark_write() + return self.write_idx +end + +-- Rewind the write position to a previously obtained mark. +-- Caller must ensure no consumer progress happened since the mark. +function ring_mt:rewind_write(mark) + -- mark is expected to be the cdata returned by mark_write() + self.write_idx = mark +end + +function ring_mt:take(n) + assert(type(n) == 'number' and n >= 0, 'RingBuf:take expects non-negative count') + local avail = self:read_avail() + if avail == 0 or n == 0 then + return '' + end + if n > avail then + n = avail + end + local tmp = copy_out(self, n) + return ffi.string(tmp, n) +end + +function ring_mt:tostring() + local n = self:read_avail() + if n == 0 then + return '' + end + local old = self.read_idx + local tmp = copy_out(self, n) + self.read_idx = old + return ffi.string(tmp, n) +end + +function ring_mt:find(pattern) + assert(type(pattern) == 'string' and #pattern > 0, + 'RingBuf:find expects non-empty string') + local s = self:tostring() + local i = s:find(pattern, 1, true) + return i and (i - 1) or nil +end + +local function RingBuf_new(size) + local self = ring_ct(size) + return ring_mt.init(self, size) +end + +function ring_mt:capacity() + return self.size +end + +function ring_mt:advance_read(n) + assert(type(n) == 'number' and n >= 0, 'RingBuf:advance_read expects non-negative count') + local avail = self:read_avail() + assert(n <= avail, 'RingBuf:advance_read out of range') + if n == 0 then return end + self.read_idx = self.read_idx + ffi.cast('uint32_t', n) +end + +function ring_mt:peek(n) + assert(type(n) == 'number' and n >= 0, 'RingBuf:peek expects non-negative count') + local avail = self:read_avail() + if avail == 0 or n == 0 then + return '' + end + if n > avail then + n = avail + end + + -- Like tostring() but only for n bytes, and without mutating read_idx. + local tmp = ffi.new('uint8_t[?]', n) + local size = self.size + local start = pos(self, self.read_idx) + local first = math.min(n, size - start) + + if first > 0 then + ffi.copy(tmp, self.buf + start, first) + end + + local rest = n - first + if rest > 0 then + ffi.copy(tmp + first, self.buf, rest) + end + + return ffi.string(tmp, n) +end + +---------------------------------------------------------------------- +-- LinearBuf +---------------------------------------------------------------------- + +local function LinearBuf_new(cap) + cap = cap or 4096 + assert(cap > 0, 'LinearBuf.new: positive initial capacity required') + local buf = ffi.new('uint8_t[?]', cap) + return setmetatable({ buf = buf, len = 0, cap = cap }, lin_mt) +end + +function lin_mt:reset() + self.len = 0 +end + +function lin_mt:ensure(extra) + local needed = self.len + extra + if needed <= self.cap then + return + end + + local new_cap = self.cap > 0 and self.cap or 1 + while new_cap < needed do + new_cap = new_cap * 2 + end + + local new_buf = ffi.new('uint8_t[?]', new_cap) + if self.len > 0 then + ffi.copy(new_buf, self.buf, self.len) + end + self.buf, self.cap = new_buf, new_cap +end + +function lin_mt:append(str) + assert(type(str) == 'string', 'LinearBuf:append expects a string') + local n = #str + if n == 0 then return end + self:ensure(n) + ffi.copy(self.buf + self.len, str, n) + self.len = self.len + n +end + +function lin_mt:tostring() + if self.len == 0 then + return '' + end + return ffi.string(self.buf, self.len) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + RingBuf = { new = RingBuf_new }, + LinearBuf = { new = LinearBuf_new }, + has_ffi = true, + is_supported = function () return true end, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/bytes/lua.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/bytes/lua.lua new file mode 100644 index 00000000..612269f9 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/bytes/lua.lua @@ -0,0 +1,308 @@ +-- fibers/utils/bytes/lua.lua +-- +-- Pure Lua byte buffers: +-- * RingBuf : fixed-capacity ring buffer (rope of strings) +-- * LinearBuf : growable buffer (rope of strings) + +---@module 'fibers.utils.bytes.lua' + +---------------------------------------------------------------------- +-- Shared rope helpers +---------------------------------------------------------------------- + +local function rope_tostring(chunks, head_idx, head_off) + local n = #chunks + if head_idx > n then + return '' + end + + local out = {} + local first = chunks[head_idx] + if head_off > 0 then + first = first:sub(head_off + 1) + end + out[1] = first + + for i = head_idx + 1, n do + out[#out + 1] = chunks[i] + end + + return table.concat(out) +end + +---------------------------------------------------------------------- +-- RingBuf +---------------------------------------------------------------------- + +---@class LuaRingBuf : RingBuf +local RingBuf_mt = {} +RingBuf_mt.__index = RingBuf_mt + +local function RingBuf_new(size) + assert(type(size) == 'number' and size > 0, + 'RingBuf.new: positive size required') + return setmetatable({ + chunks = {}, + head_idx = 1, + head_off = 0, + len = 0, + size = size, + }, RingBuf_mt) +end + +local function compact(self) + local hi = self.head_idx + if hi <= 8 and hi <= (#self.chunks / 2) then + return + end + for i = 1, hi - 1 do + self.chunks[i] = nil + end + local k = 1 + for j = hi, #self.chunks do + self.chunks[k] = self.chunks[j] + if k ~= j then + self.chunks[j] = nil + end + k = k + 1 + end + self.head_idx = 1 +end + +function RingBuf_mt:reset() + self.chunks = {} + self.head_idx = 1 + self.head_off = 0 + self.len = 0 +end + +function RingBuf_mt:read_avail() + return self.len +end + +function RingBuf_mt:write_avail() + return self.size - self.len +end + +function RingBuf_mt:is_empty() + return self.len == 0 +end + +function RingBuf_mt:is_full() + return self.len >= self.size +end + +function RingBuf_mt:advance_read(n) + assert(n >= 0 and n <= self.len, 'RingBuf:advance_read out of range') + if n == 0 then return end + + self.len = self.len - n + + local i = self.head_idx + local off = self.head_off + + while n > 0 and i <= #self.chunks do + local chunk = self.chunks[i] + local rem = #chunk - off + if n < rem then + off = off + n + n = 0 + else + n = n - rem + i = i + 1 + off = 0 + end + end + + self.head_idx = i + self.head_off = off + compact(self) +end + +function RingBuf_mt:write(src, count) + assert(type(src) == 'string', 'RingBuf:write expects string') + local n = count or #src + assert(n <= #src, 'RingBuf:write count > #src') + assert(n <= self:write_avail(), 'RingBuf: write xrun') + if n == 0 then return end + + local s = src + if n < #s then + s = s:sub(1, n) + end + self.len = self.len + n + self.chunks[#self.chunks + 1] = s +end + +function RingBuf_mt:read(_, count) + assert(count >= 0 and count <= self.len, 'RingBuf: read xrun') + if count == 0 then + return '' + end + + local out = {} + local need = count + local i = self.head_idx + local off = self.head_off + local n = #self.chunks + + while need > 0 and i <= n do + local chunk = self.chunks[i] + local rem = #chunk - off + local take = math.min(need, rem) + out[#out + 1] = chunk:sub(off + 1, off + take) + need = need - take + if take == rem then + i = i + 1 + off = 0 + else + off = off + take + end + end + + local s = table.concat(out) + self.head_idx = i + self.head_off = off + self.len = self.len - count + compact(self) + return s +end + +function RingBuf_mt:put(str) + assert(type(str) == 'string', 'RingBuf:put expects a string') + local n = #str + if n == 0 then return end + assert(n <= self:write_avail(), 'RingBuf: write would exceed capacity') + self:write(str, n) +end + +-- Opaque mark of the current write position (for tail rollback). +-- We only need enough state to drop newly appended chunks and restore len. +function RingBuf_mt:mark_write() + return { n = #self.chunks, len = self.len } +end + +-- Rewind the write position to a previously obtained mark. +-- Caller must ensure no consumer progress happened since the mark. +function RingBuf_mt:rewind_write(mark) + assert(type(mark) == 'table', 'RingBuf:rewind_write expects mark table') + local n = mark.n or 0 + local len = mark.len or 0 + + for i = #self.chunks, n + 1, -1 do + self.chunks[i] = nil + end + + self.len = len +end + +function RingBuf_mt:take(n) + assert(type(n) == 'number' and n >= 0, 'RingBuf:take expects non-negative count') + if self.len == 0 or n == 0 then + return '' + end + if n > self.len then + n = self.len + end + return self:read(nil, n) +end + +function RingBuf_mt:tostring() + if self.len == 0 then + return '' + end + return rope_tostring(self.chunks, self.head_idx, self.head_off) +end + +function RingBuf_mt:find(pattern) + assert(type(pattern) == 'string' and #pattern > 0, + 'RingBuf:find expects non-empty string') + local s = self:tostring() + local i = s:find(pattern, 1, true) + return i and (i - 1) or nil +end + +function RingBuf_mt:capacity() + return self.size +end + +function RingBuf_mt:peek(n) + assert(type(n) == 'number' and n >= 0, 'RingBuf:peek expects non-negative count') + if n == 0 or self.len == 0 then + return '' + end + if n > self.len then + n = self.len + end + + -- Same as read(nil, n) but without advancing. + local out = {} + local need = n + local i = self.head_idx + local off = self.head_off + local last = #self.chunks + + while need > 0 and i <= last do + local chunk = self.chunks[i] + local rem = #chunk - off + local take = math.min(need, rem) + out[#out + 1] = chunk:sub(off + 1, off + take) + need = need - take + if take == rem then + i = i + 1 + off = 0 + else + off = off + take + end + end + + return table.concat(out) +end + +---------------------------------------------------------------------- +-- LinearBuf +---------------------------------------------------------------------- + +---@class LuaLinearBuf : LinearBuf +local LinearBuf_mt = {} +LinearBuf_mt.__index = LinearBuf_mt + +local function LinearBuf_new(_) + return setmetatable({ + chunks = {}, + head_idx = 1, + head_off = 0, + len = 0, + }, LinearBuf_mt) +end + +function LinearBuf_mt:reset() + self.chunks = {} + self.head_idx = 1 + self.head_off = 0 + self.len = 0 +end + +function LinearBuf_mt:append(s) + if not s or #s == 0 then return end + self.len = self.len + #s + self.chunks[#self.chunks + 1] = s +end + +function LinearBuf_mt:tostring() + if self.len == 0 then + return '' + end + return rope_tostring(self.chunks, self.head_idx, self.head_off) +end + +---------------------------------------------------------------------- +-- Public API +---------------------------------------------------------------------- + +return { + RingBuf = { new = RingBuf_new }, + LinearBuf = { new = LinearBuf_new }, + has_ffi = false, + is_supported = function () return true end, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/dlist.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/dlist.lua new file mode 100644 index 00000000..25bb678e --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/dlist.lua @@ -0,0 +1,76 @@ +---@module 'fibers.utils.dlist' + +---@class DListNode +---@field list DList|nil +---@field prev DListNode|nil +---@field next DListNode|nil +---@field value any +local DListNode = {} +DListNode.__index = DListNode + +---@class DList +---@field head DListNode|nil +---@field tail DListNode|nil +---@field len integer +local DList = {} +DList.__index = DList + +function DListNode:remove() + local list = self.list + if not list then + return false + end + + local p, n = self.prev, self.next + if p then p.next = n else list.head = n end + if n then n.prev = p else list.tail = p end + + list.len = list.len - 1 + + self.list, self.prev, self.next = nil, nil, nil + self.value = nil + return true +end + +function DList:push_tail(value) + local node = setmetatable({ list = self, prev = self.tail, next = nil, value = value }, DListNode) + if self.tail then + self.tail.next = node + else + self.head = node + end + self.tail = node + self.len = self.len + 1 + return node +end + +function DList:pop_head() + local node = self.head + if not node then return nil end + local value = node.value + node:remove() + return value +end + +function DList:peek_head() + local node = self.head + return node and node.value or nil +end + +function DList:empty() + return self.len == 0 +end + +function DList:length() + return self.len +end + +---@return DList +local function new() + return setmetatable({ head = nil, tail = nil, len = 0 }, DList) +end + +return { + new = new, + DList = DList, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/ffi_compat.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/ffi_compat.lua new file mode 100644 index 00000000..2decd156 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/ffi_compat.lua @@ -0,0 +1,60 @@ +-- fibers/utils/ffi_compat.lua +-- +-- Unified wrapper around LuaJIT ffi and cffi. +-- +-- Exposes: +-- M.ffi : the provider module (luajit ffi or cffi) +-- M.C : ffi.C +-- M.tonumber : cdata-aware tonumber +-- M.type : cdata-aware type +-- M.errno : errno getter +-- M.is_null : NULL / nullptr check for pointers + +local ok, ffi = pcall(require, 'ffi') +local provider = 'luajit_ffi' + +if not ok then + local ok2, cffi = pcall(require, 'cffi') + if not ok2 then + return { + is_supported = function () return false end, + } + end + ffi = cffi + provider = 'cffi' +end + +-- Normalise helper functions. +local tonumber_fn = rawget(ffi, 'tonumber') or tonumber +local type_fn = rawget(ffi, 'type') or type + +local function errno() + -- Both LuaJIT ffi and cffi expose errno() in their API. + return ffi.errno() +end + +local function is_null(ptr) + -- For LuaJIT ffi, NULL pointers compare equal to nil. + -- For cffi, you must compare with cffi.nullptr. + if ptr == nil then + return true + end + local ffi_nullptr = rawget(ffi, 'nullptr') + if ffi_nullptr and ptr == ffi_nullptr then + return true + end + return false +end + +local M = { + ffi = ffi, + C = ffi.C, + provider = provider, + tonumber = tonumber_fn, + type = type_fn, + errno = errno, + is_null = is_null, + is_supported = function () return true end, +} + +return M diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/fifo.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/fifo.lua new file mode 100644 index 00000000..26321855 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/fifo.lua @@ -0,0 +1,56 @@ +--- fibers.fifo module +-- A simple FIFO queue that can handle nil values. +-- @module fibers.fifo + +local Fifo = {} +Fifo.__index = Fifo + +--- Create a new FIFO queue. +-- @treturn Fifo The created FIFO queue. +local function new() + return setmetatable({ + count = 0, -- total items ever pushed + first = 1, -- index of first item + items = {} -- storage table + }, Fifo) +end + +--- Push a value onto the queue. +-- @param x The value to push (can be nil) +function Fifo:push(x) + self.count = self.count + 1 + self.items[self.count] = x +end + +--- Check if the queue is empty. +-- @treturn boolean True if empty +function Fifo:empty() + return self.first > self.count +end + +--- Peek at the first item without removing it. +-- @return The first item +function Fifo:peek() + assert(not self:empty(), 'queue is empty') + return self.items[self.first] +end + +--- Remove and return the first item. +-- @return The first item +function Fifo:pop() + assert(not self:empty(), 'queue is empty') + local val = self.items[self.first] + self.items[self.first] = nil -- allow GC + self.first = self.first + 1 + return val +end + +--- Return the length of the queue. +-- @return The queue length +function Fifo:length() + return self.count - self.first + 1 +end + +return { + new = new +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time.lua new file mode 100644 index 00000000..1621b638 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time.lua @@ -0,0 +1,34 @@ +-- fibers.utils.time +-- +-- Top-level time provider shim. +-- +-- Backends (priority order): +-- 1. fibers.utils.time.ffi - clock_gettime + nanosleep (via ffi_compat) +-- 2. fibers.utils.time.luaposix - luaposix clock_gettime/nanosleep +-- 3. fibers.utils.time.nixio - nixio gettime, nanosleep + /proc/uptime +-- 4. fibers.utils.time.linux - /proc/uptime or os.time + os.execute("sleep") +-- +---@module 'fibers.utils.time' + +local candidates = { + 'fibers.utils.time.ffi', + 'fibers.utils.time.luaposix', + 'fibers.utils.time.nixio', + 'fibers.utils.time.linux', +} + +local chosen + +for _, name in ipairs(candidates) do + local ok, mod = pcall(require, name) + if ok and type(mod) == 'table' and mod.is_supported and mod.is_supported() then + chosen = mod + break + end +end + +if not chosen then + error('fibers.utils.time: no suitable time backend available on this platform') +end + +return chosen diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/core.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/core.lua new file mode 100644 index 00000000..4b954ee8 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/core.lua @@ -0,0 +1,88 @@ +-- fibers/utils/time/core.lua +-- +-- Core glue for time backends. +-- +---@class TimeSourceInfo +---@field name string +---@field resolution number +---@field monotonic boolean +---@field epoch string|nil + +---@class TimeSleepInfo +---@field name string +---@field resolution number +---@field clock "realtime"|"monotonic"|string + +---@class TimeOps +---@field realtime fun(): number +---@field monotonic fun(): number +---@field realtime_info TimeSourceInfo +---@field monotonic_info TimeSourceInfo +---@field _block fun(dt: number): boolean, string|nil +---@field block_info TimeSleepInfo +---@field is_supported fun(): boolean|nil -- optional + +local function build_backend(ops) + assert(type(ops) == 'table', 'time backend ops must be a table') + assert(type(ops.realtime) == 'function', 'time ops.realtime must be a function') + assert(type(ops.monotonic) == 'function', 'time ops.monotonic must be a function') + assert(type(ops._block) == 'function', 'time ops._block must be a function') + assert(type(ops.realtime_info) == 'table', 'time ops.realtime_info must be a table') + assert(type(ops.monotonic_info) == 'table', 'time ops.monotonic_info must be a table') + assert(type(ops.block_info) == 'table', 'time ops.block_info must be a table') + + local function realtime() + return ops.realtime() + end + + local function monotonic() + return ops.monotonic() + end + + local function block(dt) + assert(type(dt) == 'number' and dt >= 0, 'block: dt must be a non-negative number') + return ops._block(dt) + end + + local function info() + return { + realtime = ops.realtime_info, + monotonic = ops.monotonic_info, + sleep = ops.block_info, + } + end + + local function realtime_source() + return ops.realtime_info + end + + local function monotonic_source() + return ops.monotonic_info + end + + local function block_source() + return ops.block_info + end + + local function is_supported() + if type(ops.is_supported) == 'function' then + return not not ops.is_supported() + end + return true + end + + return { + realtime = realtime, + monotonic = monotonic, + _block = block, + info = info, + realtime_source = realtime_source, + monotonic_source = monotonic_source, + block_source = block_source, + is_supported = is_supported, + } +end + +return { + build_backend = build_backend, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/ffi.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/ffi.lua new file mode 100644 index 00000000..26ed3c49 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/ffi.lua @@ -0,0 +1,167 @@ +-- fibers/utils/time/ffi.lua +-- +-- Time backend using clock_gettime(2) and nanosleep(2) via ffi_compat. +-- Linux-oriented; requires fibers.utils.ffi_compat. +-- +---@module 'fibers.utils.time.ffi' + +local core = require 'fibers.utils.time.core' +local ffi_c = require 'fibers.utils.ffi_compat' + +if not (ffi_c.is_supported and ffi_c.is_supported()) then + return { is_supported = function () return false end } +end + +local ffi = ffi_c.ffi +local C = ffi_c.C +local toint = ffi_c.tonumber +local get_errno = ffi_c.errno + +ffi.cdef [[ + typedef long time_t; + typedef long suseconds_t; + + struct timespec { + time_t tv_sec; + long tv_nsec; + }; + + int clock_gettime(int clk_id, struct timespec *tp); + int clock_getres(int clk_id, struct timespec *tp); + int nanosleep(const struct timespec *req, struct timespec *rem); + char *strerror(int errnum); +]] + +-- Linux/glibc constants. +local CLOCK_REALTIME = 0 +local CLOCK_MONOTONIC = 1 + +local EINTR = 4 + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +local function strerror(e) + local s = C.strerror(e) + if s == nil then + return 'errno ' .. tostring(e) + end + return ffi.string(s) +end + +local function ts_to_seconds(ts) + return tonumber(ts.tv_sec) + tonumber(ts.tv_nsec) * 1e-9 +end + +local function read_clock(clk_id) + local ts = ffi.new('struct timespec[1]') + local rc = toint(C.clock_gettime(clk_id, ts)) + if rc ~= 0 then + local e = get_errno() + error('clock_gettime failed: ' .. strerror(e)) + end + return ts_to_seconds(ts[0]) +end + +local function clock_resolution(clk_id) + local ts = ffi.new('struct timespec[1]') + local rc = toint(C.clock_getres(clk_id, ts)) + if rc ~= 0 then + -- Fall back to a conservative default (1 ms) if the query fails. + return 1e-3 + end + return ts_to_seconds(ts[0]) +end + +---------------------------------------------------------------------- +-- Blocking sleep +---------------------------------------------------------------------- + +local function _block(dt) + if dt <= 0 then + return true, nil + end + + local req = ffi.new('struct timespec[1]') + local rem = ffi.new('struct timespec[1]') + + local sec = math.floor(dt) + local frac = dt - sec + local nsec = math.floor(frac * 1e9 + 0.5) + if nsec >= 1000000000 then + sec = sec + 1 + nsec = nsec - 1000000000 + end + + req[0].tv_sec = sec + req[0].tv_nsec = nsec + + while true do + local rc = toint(C.nanosleep(req, rem)) + if rc == 0 then + return true, nil + end + local e = get_errno() + if e == EINTR then + -- Interrupted; continue with remaining time. + req[0].tv_sec = rem[0].tv_sec + req[0].tv_nsec = rem[0].tv_nsec + else + return false, 'nanosleep failed: ' .. strerror(e) + end + end +end + +---------------------------------------------------------------------- +-- Metadata and support +---------------------------------------------------------------------- + +local realtime_res = clock_resolution(CLOCK_REALTIME) +local monotonic_res = clock_resolution(CLOCK_MONOTONIC) + +local function is_supported() + -- Probe both clocks once; treat errors as lack of support. + local ok = pcall(function () + read_clock(CLOCK_REALTIME) + read_clock(CLOCK_MONOTONIC) + end) + return ok +end + +local ops = { + realtime = function () + return read_clock(CLOCK_REALTIME) + end, + + monotonic = function () + return read_clock(CLOCK_MONOTONIC) + end, + + realtime_info = { + name = 'clock_gettime(CLOCK_REALTIME)', + resolution = realtime_res, + monotonic = false, + epoch = 'unix', + }, + + monotonic_info = { + name = 'clock_gettime(CLOCK_MONOTONIC)', + resolution = monotonic_res, + monotonic = true, + epoch = 'unspecified', + }, + + _block = _block, + + block_info = { + name = 'nanosleep', + resolution = monotonic_res, + clock = 'monotonic', + }, + + is_supported = is_supported, +} + + +return core.build_backend(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/linux.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/linux.lua new file mode 100644 index 00000000..e24abbbd --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/linux.lua @@ -0,0 +1,189 @@ +-- fibers/utils/time/linux.lua +-- +-- Pure Lua fallback time backend for Linux. +-- Monotonic time from /proc/uptime; blocking sleep via shell "sleep". +-- +---@module 'fibers.utils.time.linux' + +local core = require 'fibers.utils.time.core' + +---------------------------------------------------------------------- +-- Monotonic time: /proc/uptime +---------------------------------------------------------------------- + +local function read_uptime_raw() + local f = io.open('/proc/uptime', 'r') + if not f then + return nil + end + local line = f:read('*l') + f:close() + if not line then + return nil + end + local first = line:match('^(%S+)') + return first +end + +local function read_uptime() + local token = read_uptime_raw() + if not token then + return nil + end + local v = tonumber(token) + return v +end + +-- Estimate resolution from the number of fractional digits in /proc/uptime. +local monotonic_resolution = 1.0 +do + local token = read_uptime_raw() + if token then + local frac = token:match('%.([0-9]+)') + if frac and #frac > 0 then + monotonic_resolution = 10 ^ (- #frac) + end + end +end + +local function monotonic() + local v = read_uptime() + if not v then + error('fallback monotonic: /proc/uptime not available') + end + return v +end + +---------------------------------------------------------------------- +-- Realtime: prefer luasocket.gettime, fall back to os.time +---------------------------------------------------------------------- + +local realtime_fn +local realtime_name +local realtime_resolution + +do + local ok, socket = pcall(require, 'socket') + if ok and type(socket) == 'table' and type(socket.gettime) == 'function' then + realtime_fn = socket.gettime + realtime_name = 'socket.gettime' + realtime_resolution = 1e-6 -- approximate; depends on platform + else + realtime_fn = function () return os.time() end + realtime_name = 'os.time' + realtime_resolution = 1.0 + end +end + +local function realtime() + return realtime_fn() +end + +---------------------------------------------------------------------- +-- Blocking sleep: shell "sleep", fractional if available +---------------------------------------------------------------------- + +local function command_succeeded(a, b, c) + -- Lua 5.1: boolean, "exit"/"signal", code + if a == true then + return true + end + -- Lua 5.2+: numeric exit code + if type(a) == 'number' then + return a == 0 + end + -- Some implementations: nil, "exit", code + if a == nil and b == 'exit' then + return c == 0 + end + return false +end + +local function run_sleep(arg) + local cmd = 'sleep ' .. arg + local a, b, c = os.execute(cmd) + return command_succeeded(a, b, c) +end + +local has_fractional_sleep +do + local ok = run_sleep('0.01') + has_fractional_sleep = ok +end + +local function _block(dt) + if dt <= 0 then + return true, nil + end + + if has_fractional_sleep then + -- Round to centiseconds. + local centis = math.max(1, math.floor(dt * 100 + 0.5)) + local arg = string.format('%.2f', centis / 100.0) + local ok = run_sleep(arg) + if not ok then + -- As a last resort, busy-wait using monotonic time. + local start = monotonic() + while monotonic() - start < dt do end + return true, 'sleep command failed; used busy-wait' + end + return true, nil + else + -- Integral seconds only; round up so we do not undersleep. + local secs = math.ceil(dt) + local ok = run_sleep(tostring(secs)) + if not ok then + local start = monotonic() + while monotonic() - start < dt do end + return true, 'sleep command failed; used busy-wait' + end + return true, nil + end +end + +---------------------------------------------------------------------- +-- Capability and metadata +---------------------------------------------------------------------- + +local function is_supported() + -- This backend is only considered usable if /proc/uptime exists. + local f = io.open('/proc/uptime', 'r') + if f then + f:close() + return true + end + return false +end + +local ops = { + realtime = realtime, + monotonic = monotonic, + + realtime_info = { + name = realtime_name, + resolution = realtime_resolution, + monotonic = false, + epoch = 'unix', + }, + + monotonic_info = { + name = '/proc/uptime', + resolution = monotonic_resolution, + monotonic = true, + epoch = 'unspecified', + }, + + _block = _block, + + block_info = { + name = has_fractional_sleep + and 'sleep (shell, fractional)' + or 'sleep (shell, integral)', + resolution = has_fractional_sleep and 0.01 or 1.0, + clock = 'realtime', + }, + + is_supported = is_supported, +} + +return core.build_backend(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/luaposix.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/luaposix.lua new file mode 100644 index 00000000..8eb51a4e --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/luaposix.lua @@ -0,0 +1,137 @@ +-- fibers/utils/time/luaposix.lua +-- +-- Time backend using posix.time (clock_gettime/nanosleep) and posix.unistd. +-- +---@module 'fibers.utils.time.luaposix' + +local core = require 'fibers.utils.time.core' + +local ok_time, ptime = pcall(require, 'posix.time') +if not ok_time or type(ptime) ~= 'table' then + return { is_supported = function () return false end } +end + +local ok_unistd, unistd = pcall(require, 'posix.unistd') +if not ok_unistd or type(unistd) ~= 'table' then + return { is_supported = function () return false end } +end + +local errno = require 'posix.errno' + +local CLOCK_REALTIME = ptime.CLOCK_REALTIME +local CLOCK_MONOTONIC = ptime.CLOCK_MONOTONIC + +if not CLOCK_REALTIME or not CLOCK_MONOTONIC then + return { is_supported = function () return false end } +end + +---------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------- + +local function ts_to_seconds(ts) + return ts.tv_sec + ts.tv_nsec * 1e-9 +end + +local function read_clock(clk_id) + local ts, err = ptime.clock_gettime(clk_id) + if not ts then + error('clock_gettime failed: ' .. tostring(err)) + end + return ts_to_seconds(ts) +end + +local function clock_resolution(clk_id) + if type(ptime.clock_getres) == 'function' then + local ts = select(1, ptime.clock_getres(clk_id)) + if ts then + return ts_to_seconds(ts) + end + end + -- Fallback if clock_getres is missing or fails. + return 1e-3 +end + +---------------------------------------------------------------------- +-- Blocking sleep +---------------------------------------------------------------------- + +local function _block(dt) + if dt <= 0 then + return true, nil + end + + local sec = math.floor(dt) + local frac = dt - sec + local nsec = math.floor(frac * 1e9 + 0.5) + if nsec >= 1000000000 then + sec = sec + 1 + nsec = nsec - 1000000000 + end + + local req = { tv_sec = sec, tv_nsec = nsec } + + while true do + local ok, err, eno, rem = ptime.nanosleep(req) + if ok then + return true, nil + end + if eno == errno.EINTR and rem then + -- Interrupted; continue with remaining time. + req = rem + else + return false, err or ('nanosleep failed (errno ' .. tostring(eno) .. ')') + end + end +end + +---------------------------------------------------------------------- +-- Metadata and support +---------------------------------------------------------------------- + +local realtime_res = clock_resolution(CLOCK_REALTIME) +local monotonic_res = clock_resolution(CLOCK_MONOTONIC) + +local function is_supported() + local ok = pcall(function () + read_clock(CLOCK_MONOTONIC) + end) + return ok +end + +local ops = { + realtime = function () + return read_clock(CLOCK_REALTIME) + end, + + monotonic = function () + return read_clock(CLOCK_MONOTONIC) + end, + + realtime_info = { + name = 'posix.time.clock_gettime(CLOCK_REALTIME)', + resolution = realtime_res, + monotonic = false, + epoch = 'unix', + }, + + monotonic_info = { + name = 'posix.time.clock_gettime(CLOCK_MONOTONIC)', + resolution = monotonic_res, + monotonic = true, + epoch = 'unspecified', + }, + + _block = _block, + + block_info = { + name = 'posix.time.nanosleep', + resolution = monotonic_res, + clock = 'monotonic', + }, + + is_supported = is_supported, +} + + +return core.build_backend(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/nixio.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/nixio.lua new file mode 100644 index 00000000..7154a0a5 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/utils/time/nixio.lua @@ -0,0 +1,176 @@ +-- fibers/utils/time/nixio.lua +-- +-- Time backend using nixio.gettime, nixio.nanosleep and /proc/uptime. +-- +---@module 'fibers.utils.time.nixio' + +local core = require 'fibers.utils.time.core' + +local ok, nixio = pcall(require, 'nixio') +if not ok or type(nixio) ~= 'table' then + return { is_supported = function () return false end } +end + +---------------------------------------------------------------------- +-- Monotonic time via /proc/uptime +---------------------------------------------------------------------- + +local UPTIME_PATH = '/proc/uptime' + +--- Read the first field from /proc/uptime as a number. +---@return number|nil value, string|nil err +local function read_uptime() + local f, err = io.open(UPTIME_PATH, 'r') + if not f then + return nil, ('failed to open %s: %s'):format(UPTIME_PATH, tostring(err)) + end + + local line = f:read('*l') + f:close() + + if not line then + return nil, ('failed to read %s: empty file'):format(UPTIME_PATH) + end + + local first = line:match('^%s*(%S+)') + if not first then + return nil, ('failed to parse %s: no fields'):format(UPTIME_PATH) + end + + local val = tonumber(first) + if not val then + return nil, ("failed to parse %s: non-numeric uptime '%s'"):format( + UPTIME_PATH, + tostring(first) + ) + end + + return val, nil +end + +-- Probe once at load time so metadata and is_supported() can report accurately. +local monotonic_ok +do + local v = read_uptime() + monotonic_ok = (v ~= nil) +end + +---------------------------------------------------------------------- +-- Core time functions +---------------------------------------------------------------------- + +--- Wall-clock time: seconds since Unix epoch as a Lua number. +local function realtime() + -- nixio.gettime() already returns epoch seconds with fractional part. + return nixio.gettime() +end + +--- Monotonic time in seconds. +--- +--- Primary source: /proc/uptime (seconds since boot, fractional). +--- If this fails at call time for any reason, fall back to +--- nixio.gettime() rather than raising; monotonic_info.name still +--- reflects /proc/uptime as the intended primary source. +local function monotonic() + local v = read_uptime() + if v ~= nil then + return v + end + -- Degraded path: not truly monotonic, but avoids hard failure. + return nixio.gettime() +end + +---------------------------------------------------------------------- +-- Blocking sleep +---------------------------------------------------------------------- + +---@param dt number +---@return boolean ok, string|nil err +local function _block(dt) + if type(dt) ~= 'number' then + return false, 'sleep: dt must be a number' + end + if dt <= 0 then + return true, nil + end + + local deadline = monotonic() + dt + + while true do + local now = monotonic() + local remaining = deadline - now + if remaining <= 0 then + return true, nil + end + + local secs = math.floor(remaining) + if secs < 0 then secs = 0 end + + local frac = remaining - secs + if frac < 0 then frac = 0 end + local nsec = math.floor(frac * 1e9 + 0.5) + + -- nixio.nanosleep(seconds, nanoseconds) + local ok_ns, err, eno = nixio.nanosleep(secs, nsec) + if not ok_ns then + local msg = tostring(err or eno or '') + if msg ~= '' and msg ~= 'EINTR' then + return false, ('nixio.nanosleep failed: %s'):format(msg) + end + -- EINTR or unknown soft error: loop again and recompute remaining. + end + end +end + +---------------------------------------------------------------------- +-- Metadata and support +---------------------------------------------------------------------- + +local function is_supported() + -- Require: nixio present, gettime/nanosleep available, and /proc/uptime + -- readable at initialisation. + if type(nixio.gettime) ~= 'function' then + return false + end + if type(nixio.nanosleep) ~= 'function' then + return false + end + if not monotonic_ok then + return false + end + return true +end + +local ops = { + realtime = realtime, + + monotonic = monotonic, + + realtime_info = { + name = 'nixio.gettime', + resolution = 0.001, -- approximate; depends on platform + monotonic = false, + epoch = 'unix', + }, + + monotonic_info = { + name = '/proc/uptime', + -- /proc/uptime is typically centisecond resolution. + resolution = 0.01, + monotonic = true, + epoch = 'unspecified', + }, + + _block = _block, + + block_info = { + name = 'nixio.nanosleep', + resolution = 0.001, + clock = 'monotonic', + }, + + is_supported = is_supported, +} + + +return core.build_backend(ops) diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/wait.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/wait.lua new file mode 100644 index 00000000..c3d13018 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/wait.lua @@ -0,0 +1,354 @@ +--- +-- Wait module. +-- +-- Internal helper utilities for building blocking primitives: +-- +-- * Waitset: keyed sets of waiting tasks with unlink tokens. +-- * waitable(register, step, wrap_fn?): build an op from +-- a step function and a registration function. +-- +-- This module is intended for backend / primitive implementations +-- (pollers, in-memory pipes, streams, timers). Normal library users +-- should not need to depend on it directly. +-- +-- Design notes: +-- - This module is exception-neutral. It does not interpret Lua +-- errors as part of op semantics. +-- - step() and register(...) are assumed to be non-blocking and +-- non-yielding. If they raise, this is treated as a bug and the +-- surrounding scope/fiber machinery will surface the failure. +---@module 'fibers.wait' + +local op = require 'fibers.op' + +local unpack = rawget(table, 'unpack') or _G.unpack +local pack = rawget(table, 'pack') or function (...) + return { n = select('#', ...), ... } +end + +local function id_wrap(...) + return ... +end + +---------------------------------------------------------------------- +-- Waitset: keyed lists of tasks with unlink tokens +---------------------------------------------------------------------- + +--- Keyed set of scheduler tasks grouped by an arbitrary key. +---@class Waitset +---@field buckets table # key -> list of scheduler tasks +local Waitset = {} +Waitset.__index = Waitset + +--- Token returned from Waitset:add. +--- unlink() removes the task from the waitset; it is idempotent. +---@class WaitToken +---@field _waitset Waitset +---@field key any +---@field task Task +---@field unlink fun(self: WaitToken): boolean # true if bucket emptied + +--- Create a new Waitset instance. +---@return Waitset +local function new_waitset() + return setmetatable({ buckets = {} }, Waitset) +end + +--- Remove element at index i by swapping with the tail. +---@param t Task[] +---@param i integer +local function remove_at(t, i) + local n = #t + t[i] = t[n] + t[n] = nil +end + +--- Add a task under a given key. +-- +-- @param key Arbitrary key (fd, object, tag, etc.). +-- @param task Scheduler task object (must have :run()). +-- +-- @return token Table with token:unlink() -> bucket_empty:boolean. +---@param key any +---@param task Task +---@return WaitToken +function Waitset:add(key, task) + local buckets = self.buckets + local list = buckets[key] + if not list then + list = {} + buckets[key] = list + end + + list[#list + 1] = task + local idx = #list + local unlinked = false + + ---@class WaitToken + local token = { + _waitset = self, + key = key, + task = task, + } + + --- Unlink this task from the waitset. + --- Best-effort: falls back to a reverse scan if the stored index + --- has been invalidated by earlier removals. + ---@param tok WaitToken + ---@return boolean bucket_empty + function token.unlink(tok) + if unlinked then + return false + end + unlinked = true + + local bs = tok._waitset.buckets + local l = bs[tok.key] + if not l or #l == 0 then + return false + end + + -- Best-effort removal; index may be stale. + if idx <= #l and l[idx] == tok.task then + remove_at(l, idx) + else + for i = #l, 1, -1 do + if l[i] == tok.task then + remove_at(l, i) + break + end + end + end + + if #l == 0 then + bs[tok.key] = nil + return true + end + return false + end + + return token +end + +--- Take and remove all waiters for a key. +--- +--- Returns the list (which the caller may iterate and discard), or nil. +---@param key any +---@return Task[]|nil +function Waitset:take_all(key) + local list = self.buckets[key] + if not list then + return nil + end + self.buckets[key] = nil + return list +end + +--- Take and remove a single waiter (LIFO) for a key. +--- +--- Returns the task or nil. +---@param key any +---@return Task|nil +function Waitset:take_one(key) + local list = self.buckets[key] + if not list or #list == 0 then + return nil + end + local idx = #list + local task = list[idx] + list[idx] = nil + if #list == 0 then + self.buckets[key] = nil + end + return task +end + +--- Return whether there are no waiters for this key. +---@param key any +---@return boolean +function Waitset:is_empty(key) + local list = self.buckets[key] + return not list or #list == 0 +end + +--- Return the number of waiters for this key. +---@param key any +---@return integer +function Waitset:size(key) + local list = self.buckets[key] + return list and #list or 0 +end + +--- Remove all waiters for a single key without notifying them. +---@param key any +function Waitset:clear_key(key) + self.buckets[key] = nil +end + +--- Remove all waiters for all keys without notifying them. +function Waitset:clear_all() + self.buckets = {} +end + +--- Notify and schedule all waiters for a key. +---@param key any +---@param scheduler Scheduler +function Waitset:notify_all(key, scheduler) + local list = self:take_all(key) + if not list then return end + for i = 1, #list do + scheduler:schedule(list[i]) + list[i] = nil + end +end + +--- Notify and schedule a single waiter (LIFO) for a key. +---@param key any +---@param scheduler Scheduler +function Waitset:notify_one(key, scheduler) + local task = self:take_one(key) + if not task then return end + scheduler:schedule(task) +end + +---------------------------------------------------------------------- +-- waitable: (register, step, wrap_fn?) -> Op +-- waitable2: (register, probe_step, run_step, wrap_fn?) -> Op +---------------------------------------------------------------------- + +-- Normalise "want" without restricting it to rd/wr/any. +-- * nil/false -> nil +-- * 'any' is treated specially by register_with_want +-- * everything else is passed through to register(...) +local function normalise_want(want) + return (want == nil or want == false) and nil or want +end + +--- Build a waitable Op from a register function and two step functions. +-- +-- probe_step() -> done:boolean, ... +-- * Must be non-blocking and must not yield. +-- * Should be side-effect neutral when returning done==false. +-- * May return (false, want) where want is any token understood by register(). +-- +-- run_step() -> done:boolean, ... +-- * Must be non-blocking and must not yield. +-- * May perform stateful progress (e.g. fill buffers, advance state machines). +-- +-- register(task, waker, want) -> token +-- * Must arrange for task:run() when progress may be possible. +-- * want is passed through (except 'any', see below). +-- * token:unlink() (if present) is called on abort to cancel registration. +-- +-- waker capability: +-- * waker:wakeup(task) +-- * waker:at_time(t, task) +-- * waker:after(dt, task) +-- +-- Special want: +-- * want == 'any' registers both ('rd' and 'wr') and unlinks both on abort. +-- +---@param register fun(task: Task, waker: table, want: any): WaitToken +---@param probe_step fun(): boolean, ... +---@param run_step fun(): boolean, ... +---@param wrap_fn? WrapFn +---@return Op +local function waitable2(register, probe_step, run_step, wrap_fn) + assert(type(register) == 'function', 'waitable2: register must be a function') + assert(type(probe_step) == 'function', 'waitable2: probe_step must be a function') + assert(type(run_step) == 'function', 'waitable2: run_step must be a function') + + wrap_fn = wrap_fn or id_wrap + + return op.guard(function () + local token, last_want, cleanup_added, waker + + local function unlink() + local t = token + token = nil + if t and t.unlink then t:unlink() end + end + + local function capture_want(step_fn) + local r = pack(step_fn()) + last_want = r[1] and nil or normalise_want(r[2]) + return r + end + + local function register_any(task, waker_) + local t1 = register(task, waker_, 'rd') + local t2 = register(task, waker_, 'wr') + return { + unlink = function () + if t1 and t1.unlink then t1:unlink() end + if t2 and t2.unlink then t2:unlink() end + return false + end, + } + end + + local function arm(task, suspension, leaf_wrap, want) + if not cleanup_added then + cleanup_added = true + suspension:add_cleanup(unlink) + end + + unlink() + + if want == 'any' then + token = register_any(task, waker) -- see note below + else + token = register(task, waker, want) + end + end + + local function try() + local r = capture_want(probe_step) + return unpack(r, 1, r.n) + end + + local function block(suspension, leaf_wrap) + waker = { + wakeup = function (_, task_) suspension:wakeup(task_) end, + at_time = function (_, t, task_) suspension:at_time(t, task_) end, + after = function (_, dt, task_) suspension:after(dt, task_) end, + } + + local task + task = { + run = function () + if not suspension:waiting() then return end + + local r = capture_want(run_step) + if r[1] then + unlink() + return suspension:complete(leaf_wrap, unpack(r, 2, r.n)) + end + + arm(task, suspension, leaf_wrap, last_want) + end, + } + + -- Use want captured by the most recent try(). + arm(task, suspension, leaf_wrap, last_want) + end + + return op.new_primitive(wrap_fn, try, block):on_abort(unlink) + end) +end + + +--- Backwards-compatible wrapper: a single step is used for both probe and run. +---@param register fun(task: Task, waker: table, want: any): WaitToken +---@param step fun(): boolean, ... +---@param wrap_fn? WrapFn +---@return Op +local function waitable(register, step, wrap_fn) + return waitable2(register, step, step, wrap_fn) +end + +return { + new_waitset = new_waitset, + waitable = waitable, + waitable2 = waitable2, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/waitgroup.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/waitgroup.lua new file mode 100644 index 00000000..fb5ebbf5 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-fibers/src/fibers/waitgroup.lua @@ -0,0 +1,88 @@ +-- fibers/waitgroup.lua +--- +-- Wait group for tracking completion of a set of tasks. +-- A waitgroup supports generations: when the counter returns to zero, +-- the current generation completes and a new one starts on the next increment. +---@module 'fibers.waitgroup' + +local op = require 'fibers.op' +local perform = require 'fibers.performer'.perform +local cond_mod = require 'fibers.cond' + +--- Waitgroup with a counter and per-generation condition. +---@class Waitgroup +---@field _counter integer +---@field _cond Cond|nil # per-generation condition; nil when there is no active generation +local Waitgroup = {} +Waitgroup.__index = Waitgroup + +--- Create a new waitgroup. +---@return Waitgroup +local function new() + return setmetatable({ + _counter = 0, + _cond = nil, -- per-generation condition; nil when idle + }, Waitgroup) +end + +--- Adjust the waitgroup counter by delta. +--- When the counter returns to zero, the current generation completes. +---@param delta integer +function Waitgroup:add(delta) + if delta == 0 then + return + end + + local old_count = self._counter + local new_count = old_count + delta + + if new_count < 0 then + error('waitgroup counter goes negative') + end + + self._counter = new_count + + if new_count == 0 then + -- This generation completes: wake any waiters and drop the condition. + if self._cond then + self._cond:signal() + self._cond = nil + end + elseif old_count == 0 and new_count > 0 then + -- Starting a new generation: create a condition for new work. + self._cond = cond_mod.new() + end +end + +--- Decrement the waitgroup counter by one. +function Waitgroup:done() + self:add(-1) +end + +--- Build an Op that completes when the current generation drains. +---@return Op +function Waitgroup:wait_op() + -- Build the op lazily at perform time. + return op.guard(function () + -- If there is nothing outstanding, fire immediately. + if self._counter == 0 then + return op.always() + end + + -- Active generation: delegate to the generation's condition. + local cond = assert(self._cond, 'waitgroup internal error: missing condition for active generation') + return cond:wait_op() + end) +end + +--- Block until the current generation completes. +---@return any ... +function Waitgroup:wait() + return perform(self:wait_op()) +end + +return { + --- Construct a new waitgroup. + ---@return Waitgroup + new = new, +} diff --git a/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-trie/src/trie.lua b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-trie/src/trie.lua new file mode 100644 index 00000000..4f297964 --- /dev/null +++ b/tests/integration/openwrt_vm/fixtures/instrumented_vendor/lua-trie/src/trie.lua @@ -0,0 +1,326 @@ +-- trie.lua +-- Modes: +-- pubsub : wildcards allowed in stored keys; literal queries +-- retained : literal stored keys; wildcards allowed in queries +-- literal : exact match only +-- +-- No ordering guarantees: child iteration uses pairs(). + +local M = {} + +-------------------------------------------------------------------------------- +-- Literal wrapper (escape hatch) +-------------------------------------------------------------------------------- + +local LIT_MT = {} +LIT_MT.__index = LIT_MT + +function M.literal(v) + local tv = type(v) + if tv ~= "string" and tv ~= "number" then + error("literal value must be a string or number", 2) + end + return setmetatable({ v = v }, LIT_MT) +end + +-------------------------------------------------------------------------------- +-- Dense array validation +-------------------------------------------------------------------------------- + +local function array_len(t, errlvl) + if type(t) ~= "table" then + error("tokens must be a table (dense array)", errlvl) + end + + local n = 0 + for _ in ipairs(t) do n = n + 1 end + + for k in pairs(t) do + if type(k) ~= "number" or k < 1 or k % 1 ~= 0 or k > n then + error("token arrays must use 1..n integer keys only", errlvl) + end + end + + return n +end + +-------------------------------------------------------------------------------- +-- Core trie +-------------------------------------------------------------------------------- + +local function Node() + -- c : children map + -- has : whether this node stores a value + -- v : stored value + -- k : stored key tokens (presentation form, including '+'/'#' where applicable) + return { c = {}, has = false, v = nil, k = nil } +end + +local function insert(root, tokens_compiled, value, key_tokens) + local node = root + for i = 1, #tokens_compiled do + local t = tokens_compiled[i] + local child = node.c[t] + if not child then + child = Node() + node.c[t] = child + end + node = child + end + node.has, node.v, node.k = true, value, key_tokens + return true +end + +local function get_node(root, tokens_compiled) + local node = root + for i = 1, #tokens_compiled do + node = node.c[tokens_compiled[i]] + if not node then return nil end + end + return node +end + +local function remove(root, tokens_compiled) + local node, stack = root, {} + + for i = 1, #tokens_compiled do + local t = tokens_compiled[i] + local child = node.c[t] + if not child then return false end + stack[#stack + 1] = { node, t } + node = child + end + + if not node.has then return false end + node.has, node.v, node.k = false, nil, nil + + for i = #stack, 1, -1 do + local parent, tok = stack[i][1], stack[i][2] + local child = parent.c[tok] + if child.has or next(child.c) ~= nil then break end + parent.c[tok] = nil + end + + return true +end + +local function visit_all(start_node, visit) + local stack = { start_node } + while #stack > 0 do + local node = stack[#stack] + stack[#stack] = nil + if node.has then + visit(node.k, node.v) + end + for _, child in pairs(node.c) do + stack[#stack + 1] = child + end + end +end + +-------------------------------------------------------------------------------- +-- Token compilation +-------------------------------------------------------------------------------- + +-- Returns: +-- compiled : tokens with SW/MW sentinels substituted where allowed +-- shown : tokens in "presentation" form (keeps '+'/'#' symbols) +local function compile(cfg, tokens, allow_wild, errlvl) + local n = array_len(tokens, errlvl) + local compiled = {} + local shown = {} + + for i = 1, n do + local tok = tokens[i] + local was_lit = (getmetatable(tok) == LIT_MT) + + if was_lit then + tok = tok.v + else + local tt = type(tok) + if tt ~= "string" and tt ~= "number" then + error("token parts must be strings or numbers", errlvl) + end + end + + -- Default: shown == tok as provided (post literal unwrap). + shown[i] = tok + + if allow_wild and not was_lit then + if tok == cfg.single then + compiled[i] = cfg.SW + -- shown[i] stays as cfg.single (e.g. "+") + elseif tok == cfg.multi then + if i ~= n then + error("multi wildcard must be last", errlvl) + end + compiled[i] = cfg.MW + -- shown[i] stays as cfg.multi (e.g. "#") + else + compiled[i] = tok + end + else + compiled[i] = tok + end + end + + return compiled, shown +end + +-------------------------------------------------------------------------------- +-- Shared DFS walk +-------------------------------------------------------------------------------- + +local function dfs_walk(root, q, on_done, on_step) + local n = #q + local nodes = { root } + local idxs = { 1 } + local top = 1 + + local function push(child, next_i) + top = top + 1 + nodes[top] = child + idxs[top] = next_i + end + + while top > 0 do + local node = nodes[top] + local i = idxs[top] + nodes[top], idxs[top] = nil, nil + top = top - 1 + + if i > n then + on_done(node) + else + on_step(node, i, q[i], push) + end + end +end + +-------------------------------------------------------------------------------- +-- Matchers +-------------------------------------------------------------------------------- + +local function match_stored(root, cfg, q, visit) + local SW, MW = cfg.SW, cfg.MW + + dfs_walk( + root, q, + function(node) + if node.has then visit(node.k, node.v) end + local mwc = node.c[MW] + if mwc then visit_all(mwc, visit) end + end, + function(node, i, tok, push) + local child = node.c[tok] + if child then push(child, i + 1) end + + child = node.c[SW] + if child then push(child, i + 1) end + + child = node.c[MW] + if child then visit_all(child, visit) end + end + ) +end + +local function match_query(root, cfg, q, visit) + local SW, MW = cfg.SW, cfg.MW + + dfs_walk( + root, q, + function(node) + if node.has then visit(node.k, node.v) end + end, + function(node, i, tok, push) + if tok == MW then + visit_all(node, visit) + elseif tok == SW then + for _, child in pairs(node.c) do + push(child, i + 1) + end + else + local child = node.c[tok] + if child then push(child, i + 1) end + end + end + ) +end + +-------------------------------------------------------------------------------- +-- Constructors +-------------------------------------------------------------------------------- + +local MODES = { + pubsub = { key_wild = true, query_wild = false, matcher = match_stored }, + retained = { key_wild = false, query_wild = true, matcher = match_query }, + literal = { key_wild = false, query_wild = false, matcher = nil }, +} + +local function new(mode, single, multi) + local spec = MODES[mode] + if not spec then + error("unknown mode", 3) + end + + local cfg = { SW = {}, MW = {} } + + if spec.key_wild or spec.query_wild then + cfg.single = single or "+" + cfg.multi = multi or "#" + + local ts, tm = type(cfg.single), type(cfg.multi) + if (ts ~= "string" and ts ~= "number") or (tm ~= "string" and tm ~= "number") then + error("wildcard symbols must be strings or numbers", 3) + end + if cfg.single == cfg.multi then + error("wildcards must differ", 3) + end + end + + local root = Node() + local matcher = spec.matcher + local api = {} + + function api:insert(key, value) + if value == nil then error("value required", 2) end + local compiled, shown = compile(cfg, key, spec.key_wild, 4) + return insert(root, compiled, value, shown) + end + + function api:retrieve(key) + local compiled = compile(cfg, key, spec.key_wild, 4) + local node = get_node(root, compiled) + return (node and node.has) and node.v or nil + end + + function api:delete(key) + local compiled = compile(cfg, key, spec.key_wild, 4) + return remove(root, compiled) + end + + -- visit(key_tokens, value) + function api:each(query, visit) + if type(visit) ~= "function" then error("visit must be a function", 2) end + local q = compile(cfg, query, spec.query_wild, 4) + + if matcher then + matcher(root, cfg, q, visit) + else + local node = get_node(root, q) + if node and node.has then + visit(node.k, node.v) + end + end + + return true + end + + return api +end + +function M.new_pubsub(single, multi) return new("pubsub", single, multi) end +function M.new_retained(single, multi) return new("retained", single, multi) end +function M.new_literal() return new("literal") end + +return M diff --git a/tests/integration/openwrt_vm/leak_probe_readme.md b/tests/integration/openwrt_vm/leak_probe_readme.md new file mode 100644 index 00000000..44da99bb --- /dev/null +++ b/tests/integration/openwrt_vm/leak_probe_readme.md @@ -0,0 +1,188 @@ +# Devicecode leak-probe VM harness + +This harness runs the instrumented Devicecode tree inside the existing OpenWrt VM lane and collects leak-probe snapshots from a near-full service stack. + +From `tests/integration/openwrt_vm`: + +```sh +make run +make wait +make provision +make test-devicecode-leak-probe-full-stack +``` + +For a longer leak run: + +```sh +DEVICECODE_LEAK_PROBE_DURATION_S=3600 \ +DEVICECODE_LEAK_PROBE_INTERVAL=60 \ +make test-devicecode-leak-probe-full-stack +``` + +The default service list is: + +```text +monitor,hal,config,system,time,metrics,device,fabric,http,ui,update,net,wired,wifi,gsm +``` + +Override it with: + +```sh +DEVICECODE_LEAK_PROBE_SERVICES=hal,config,device,update make test-devicecode-leak-probe-full-stack +``` + +## Modes + +The default mode is `safe`: + +```sh +DEVICECODE_LEAK_PROBE_MODE=safe make test-devicecode-leak-probe-full-stack +``` + +It uses the normal file-backed config service, but rewrites the HAL network provider to the in-memory fake provider before placing the config under `/data/devicecode/configs`. This lets the full service stack start in the VM without rewriting OpenWrt's management network. + +To exercise the real OpenWrt network provider, use a disposable/reset VM disk and run: + +```sh +DEVICECODE_LEAK_PROBE_MODE=openwrt make test-devicecode-leak-probe-full-stack +``` + +That mode uses the config file as supplied and may alter `/etc/config/network`, firewall, DHCP, mwan3 and shaping state inside the VM. + +## Outputs + +Host-side logs are copied under: + +```text +tests/integration/openwrt_vm/work/leak-probe-/remote-logs/ +``` + +Important files: + +```text +probe.log raw LEAK_PROBE snapshots +devicecode.log stdout/stderr from the service stack +summary.txt first and last snapshots plus selected counters +env.txt interpreter, services, duration, mode and paths +``` + +Useful counters to compare over time: + +```text +mem_kb +scope_children +scope_finalizers +scope_cancelled +exec_terminal_not_cleaned +scoped_work_live +scoped_work_body_not_reaped +request_owner_live +bus_retained +``` + +A rising `exec_terminal_not_cleaned` or `scope_finalizers` count alongside `mem_kb` is a strong sign of retained process/scope cleanup state. A rising `scope_children` or `scope_cancelled` count usually points at unjoined child scopes. +## HTTP transport dependencies + +The full-stack leak probe starts `metrics`, `http` and `ui`, so the VM must have daurnimator/lua-http available. The Make target runs `scripts/ensure-devicecode-lua-http-deps` after provisioning. That helper installs native OpenWrt packages such as `cqueues`, `lpeg` and `luaossl` where available, and vendors the pure-Lua lua-http modules plus pure-Lua dependencies from GitHub into `/usr/share/lua`. + +If an existing VM has already been provisioned, this helper still runs; `OPENWRT_PROVISION_FORCE=1` is not required just to add lua-http. + + +## Interpreter + +The probe now defaults to `lua` in this VM so OpenWrt's packaged `cqueues` and `luaossl` modules can be used during the leak hunt. Override when deliberately comparing runtimes: + +```sh +DEVICECODE_LEAK_PROBE_LUA=luajit make test-devicecode-leak-probe-full-stack +DEVICECODE_LEAK_PROBE_LUA=texlua make test-devicecode-leak-probe-full-stack +``` + +The dependency helper validates `lua-http` under the selected runtime; the default is now PUC Lua for VM leak-hunting fidelity. + +## LuaJIT and HTTP-dependent services + +The harness first tries to make the lua-http/cqueues stack usable under the selected runtime. The default runtime is PUC Lua because this OpenWrt VM can load the packaged `cqueues` and `luaossl` modules there. If dependency validation fails, the leak probe still runs the core service set: + +```text +monitor,hal,config,system,time,device,fabric,update,net,wired,wifi,gsm +``` + +This excludes `metrics`, `http` and `ui`, which require lua-http/cqueues at +module load or service start. To require the HTTP stack and fail instead of +falling back, run: + +```sh +DEVICECODE_HTTP_DEPS_REQUIRED=1 make test-devicecode-leak-probe-full-stack +``` + +To force a particular service list, set `DEVICECODE_LEAK_PROBE_SERVICES`. + +## v10: building compat53 inside OpenWrt + +For a production-like LuaJIT run, the harness now assumes that OpenWrt is the +right build environment for the only non-trivial lua-http dependency not already +available as a suitable package: `compat53`. + +The leak-probe target now runs `scripts/ensure-large-disk` before provisioning. +By default it grows the qcow2 virtual disk to: + +```sh +OPENWRT_WORK_DISK_SIZE=2G +``` + +Override this if the VM needs more build space: + +```sh +OPENWRT_WORK_DISK_SIZE=4G make test-devicecode-leak-probe-full-stack +``` + +Disable the disk growth step with: + +```sh +OPENWRT_GROW_WORK_DISK=0 make test-devicecode-leak-probe-full-stack +``` + +`scripts/ensure-devicecode-lua-http-deps` now installs compiler/LuaRocks +packages where available and runs: + +```sh +luarocks --tree=/usr --lua-version=5.1 install compat53 +``` + +This builds compat53 inside the OpenWrt VM against the target runtime/libc, +then validates `compat53.string` and `compat53.utf8` under the selected Lua runtime. Logs from +that build are left at: + +```text +/tmp/devicecode-compat53-luarocks.log +``` + +If you deliberately want to use the old narrow fallback shims instead, set: + +```sh +DEVICECODE_COMPAT53_USE_LUAROCKS=0 DEVICECODE_COMPAT53_REQUIRED=0 \ + make test-devicecode-leak-probe-full-stack +``` + + +## v11: GPT fix during root filesystem growth + +The OpenWrt combined EFI image uses GPT. After the host qcow2 is enlarged, +the guest sees the larger `/dev/vda`, but the GPT backup header may still sit +at the old end of disk. In that state, `parted -s resizepart` can fail with +`Unable to satisfy all constraints`, leaving `/` at about 100 MB. + +The disk growth helper now runs a GPT fix step before resizing partition 2, +using `parted -f`/`--fix` where available and a pseudo-tty fallback for older +parted builds. It also fails early if `/` still has less than: + +```sh +OPENWRT_MIN_ROOT_FREE_KB=300000 +``` + +Raise or lower this threshold if needed for your image: + +```sh +OPENWRT_WORK_DISK_SIZE=4G OPENWRT_MIN_ROOT_FREE_KB=500000 \ + make test-devicecode-leak-probe-full-stack +``` diff --git a/tests/integration/openwrt_vm/scripts/ensure-devicecode-lua-http-deps b/tests/integration/openwrt_vm/scripts/ensure-devicecode-lua-http-deps new file mode 100755 index 00000000..c17f363a --- /dev/null +++ b/tests/integration/openwrt_vm/scripts/ensure-devicecode-lua-http-deps @@ -0,0 +1,745 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +VM_DIR="$(dirname "$SCRIPT_DIR")" + +# shellcheck disable=SC1091 +. "$VM_DIR/env.sh" + +"$SCRIPT_DIR/wait-ssh" + +"$SCRIPT_DIR/ssh" 'sh -s' <<'REMOTE_SH' +set -u + +log() { printf '%s\n' "[openwrt-vm/http-deps] $*" >&2; } +warn() { printf '%s\n' "[openwrt-vm/http-deps] WARN: $*" >&2; } +fail() { printf '%s\n' "[openwrt-vm/http-deps] FAIL: $*" >&2; exit 1; } +STATUS_FILE="${DEVICECODE_HTTP_DEPS_STATUS_FILE:-/tmp/devicecode-lua-http-deps.status}" +rm -f "$STATUS_FILE" + +choose_lua() { + # For the leak hunt we prefer PUC Lua in the OpenWrt VM. The VM's + # packaged cqueues/luaossl modules are known to load under `lua`, while + # the same feed packages may fail under LuaJIT on arm64 with + # `bad light userdata pointer`. Keep LuaJIT/texlua as explicit + # comparison runtimes via DEVICECODE_LEAK_PROBE_LUA. + if [ -n "${DEVICECODE_LEAK_PROBE_LUA:-}" ]; then + command -v "$DEVICECODE_LEAK_PROBE_LUA" >/dev/null 2>&1 || return 1 + printf '%s +' "$DEVICECODE_LEAK_PROBE_LUA" + return 0 + fi + if command -v lua >/dev/null 2>&1; then printf '%s +' lua; return 0; fi + for candidate in luajit luajit-openresty luajit-2.1.0-beta3 luajit-2.1; do + if command -v "$candidate" >/dev/null 2>&1; then printf '%s +' "$candidate"; return 0; fi + done + if command -v texlua >/dev/null 2>&1; then printf '%s +' texlua; return 0; fi + return 1 +} + +refresh_lua_bin() { + lua_bin="$(choose_lua)" || fail 'no lua, luajit or texlua interpreter found' + log "using Lua interpreter: $lua_bin ($($lua_bin -e 'io.write((_VERSION or "?") .. (jit and (" / " .. jit.version) or ""))' 2>/dev/null || true))" +} + +# Keep dependency checks stable across OpenWrt Lua/LuaJIT package.path layouts. +# Preserve both common OpenWrt layouts. The cqueues package, for +# example, installs /usr/lib/lua/cqueues.lua and /usr/lib/lua/_cqueues.so, +# so restricting LuaJIT to /usr/share/lua hides a package which opkg has +# correctly installed. +export LUA_PATH="./?.lua;./?/init.lua;/usr/share/lua/?.lua;/usr/share/lua/?/init.lua;/usr/share/lua/5.1/?.lua;/usr/share/lua/5.1/?/init.lua;/usr/lib/lua/?.lua;/usr/lib/lua/?/init.lua;/usr/lib/lua/5.1/?.lua;/usr/lib/lua/5.1/?/init.lua;;" +export LUA_CPATH="./?.so;./?/init.so;/usr/lib/lua/?.so;/usr/lib/lua/?/init.so;/usr/lib/lua/5.1/?.so;/usr/lib/lua/5.1/?/init.so;/usr/lib/lua/loadall.so;;" + +refresh_lua_bin +lua_require() { + "$lua_bin" - "$1" <<'LUA' >/dev/null 2>&1 +local mod = arg[1] +local ok = pcall(require, mod) +if not ok then os.exit(1) end +LUA +} + +lua_require_verbose() { + "$lua_bin" - "$1" <<'LUA' +local mod = arg[1] +local ok, res = pcall(require, mod) +if ok then + io.stderr:write(" OK ", mod, "\n") + os.exit(0) +end +io.stderr:write(" FAIL ", mod, "\n") +io.stderr:write(tostring(res), "\n") +os.exit(1) +LUA +} + +have_http_stack() { + lua_require http.request && lua_require http.server && lua_require http.websocket && lua_require cqueues +} + +if have_http_stack; then + log 'lua-http dependencies already available' + printf '%s\n' 'available' > "$STATUS_FILE" + exit 0 +fi + +log 'installing OpenWrt packages needed by lua-http/cqueues for the selected Lua runtime' +ip route replace default via 192.168.1.2 dev br-lan || true +mkdir -p /tmp/resolv.conf.d +printf 'nameserver 192.168.1.3\nnameserver 1.1.1.1\n' > /tmp/resolv.conf.d/resolv.conf.auto +printf 'nameserver 192.168.1.3\nnameserver 1.1.1.1\n' > /tmp/resolv.conf + +opkg_pkg_installed() { + pkg="$1" + opkg status "$pkg" 2>/dev/null | grep -q '^Status: .* installed' +} + +opkg_install_best_effort() { + pkg="$1" + if opkg_pkg_installed "$pkg"; then + log "package already installed: $pkg" + return 0 + fi + if opkg install "$pkg" >/tmp/devicecode-opkg-install.log 2>&1; then + log "installed package: $pkg" + return 0 + fi + warn "package not installed: $pkg" + if [ "${DEVICECODE_HTTP_DEPS_VERBOSE_OPKG:-0}" = "1" ]; then + cat /tmp/devicecode-opkg-install.log >&2 || true + fi + return 0 +} + +ensure_luajit_binary() { + # Do not trust package metadata alone here: on OpenWrt images `opkg status` + # can be misleading for package aliases or stale state, while production + # plainly needs an executable `luajit` on PATH. + if command -v luajit >/dev/null 2>&1; then + return 0 + fi + log 'luajit binary not found; installing OpenWrt luajit package' + if opkg install luajit >/tmp/devicecode-opkg-install-luajit.log 2>&1; then + : + else + cat /tmp/devicecode-opkg-install-luajit.log >&2 || true + fail 'could not install required luajit package' + fi + command -v luajit >/dev/null 2>&1 || fail 'luajit package installed but luajit binary is still not on PATH' +} + +if [ "${DEVICECODE_HTTP_DEPS_SKIP_OPKG:-0}" != "1" ]; then + opkg update || warn 'opkg update failed; continuing with any existing package lists' + # OpenWrt 24.10 has cqueues, lpeg, luaossl, lua-bit32, luabitop, + # lua-openssl and lua-lzlib in the packages feed for this target. Keep + # aliases best-effort because images/feeds vary across test environments. + for pkg in \ + ca-bundle ca-certificates wget-ssl wget \ + lua liblua liblua5.1.5 luajit \ + cqueues lua-cqueues \ + lpeg lua-lpeg \ + luaossl lua-luaossl lua-openssl \ + lua-bit32 luabitop bit32 \ + lua-lzlib lua-zlib \ + lua-cjson \ + luarocks lua-luarocks gcc make binutils pkgconf musl-dev libc-dev luajit-dev lua-dev liblua-dev \ + lua-basexx basexx lua-binaryheap binaryheap lua-fifo fifo lua-lpeg-patterns lpeg-patterns compat53 lua-compat53 + do + opkg_install_best_effort "$pkg" + done + # Do not force LuaJIT here. The default VM leak probe intentionally + # runs under PUC Lua so OpenWrt's packaged native modules are usable. +fi + +# Re-select after opkg installation. The first selection may legitimately +# have fallen back before the selected interpreter package was installed. +refresh_lua_bin + +lua_share="/usr/share/lua" +lua_share_51="/usr/share/lua/5.1" +lua_lib="/usr/lib/lua" +lua_lib_51="/usr/lib/lua/5.1" +mkdir -p "$lua_share" "$lua_share_51" "$lua_lib" "$lua_lib_51" + +# Make both OpenWrt Lua layouts work. Some images search /usr/share/lua +# while some Lua 5.1/LuaJIT-oriented layouts search /usr/share/lua/5.1. +install_lua_file() { + src="$1" + rel="$2" + for base in "$lua_share" "$lua_share_51" "$lua_lib" "$lua_lib_51"; do + mkdir -p "$base/$(dirname "$rel")" + cp "$src" "$base/$rel" + done +} + +install_lua_tree() { + src="$1" + rel="$2" + for base in "$lua_share" "$lua_share_51" "$lua_lib" "$lua_lib_51"; do + rm -rf "$base/$rel" + mkdir -p "$base/$(dirname "$rel")" + cp -R "$src" "$base/$rel" + done +} + +# BusyBox mktemp is stricter than GNU mktemp, and some OpenWrt builds reject +# templates such as /tmp/name.XXXXXX or suffix-bearing templates. Use a simple +# per-process directory instead; this helper is not run concurrently inside a +# single VM. +tmp="/tmp/devicecode-lua-http.$$" +rm -rf "$tmp" +mkdir -p "$tmp" +trap 'rm -rf "$tmp"' EXIT INT TERM + +fetch() { + url="$1" + out="$2" + if command -v wget >/dev/null 2>&1; then + wget -q -O "$out" "$url" && return 0 + wget --no-check-certificate -q -O "$out" "$url" && return 0 || true + fi + if command -v uclient-fetch >/dev/null 2>&1; then + uclient-fetch -q -O "$out" "$url" && return 0 + uclient-fetch --no-check-certificate -q -O "$out" "$url" && return 0 || true + fi + return 1 +} + +unpack_github() { + name="$1" + url="$2" + archive="$tmp/$name.tar.gz" + dir="$tmp/$name" + mkdir -p "$dir" + log "fetching $name" + fetch "$url" "$archive" || fail "could not download $url" + tar -xzf "$archive" -C "$dir" || fail "could not unpack $name" + root="$(find "$dir" -mindepth 1 -maxdepth 1 -type d | head -n 1 || true)" + [ -n "$root" ] || fail "archive for $name did not contain a top-level directory" + printf '%s\n' "$root" +} + +copy_tree() { + src="$1" + dst="$2" + rm -rf "$dst" + mkdir -p "$(dirname "$dst")" + cp -R "$src" "$dst" || fail "could not copy $src to $dst" +} + +write_compat53_fallbacks() { + # lua-http v0.4 loads compat53.module for Lua 5.1/LuaJIT assert + # semantics and compat53.string for string.pack/unpack. The upstream + # compat53 repository no longer necessarily provides a pure-Lua + # compat53/string.lua in the archive layout we fetch here, while OpenWrt + # may not package lua-compat53. For the VM leak probe we provide a small + # narrow fallback covering the formats lua-http needs to load and + # exercise HTTP/1 plus basic HTTP/2 framing. + for base in "$lua_share" "$lua_share_51" "$lua_lib" "$lua_lib_51"; do + mkdir -p "$base/compat53" + if [ ! -f "$base/compat53/module.lua" ]; then + cat > "$base/compat53/module.lua" <<'LUA' +local M = setmetatable({}, { __index = _G }) +function M.assert(cond, ...) + if cond then return cond, ... end + if select('#', ...) > 0 then error((...), 2) end + error('assertion failed!', 2) +end +M.string = setmetatable({}, { __index = string }) +M.table = setmetatable({ unpack = table.unpack or unpack }, { __index = table }) +M.math = setmetatable({}, { __index = math }) +return M +LUA + fi + if [ ! -f "$base/compat53/string.lua" ]; then + cat > "$base/compat53/string.lua" <<'LUA' +local bit = require 'bit' +local band, rshift = bit.band, bit.rshift + +local M = {} + +local function parse(fmt) + local endian = '>' + local out = {} + local i = 1 + while i <= #fmt do + local c = fmt:sub(i, i) + if c == ' ' then + i = i + 1 + elseif c == '>' or c == '<' or c == '=' then + endian = c + i = i + 1 + elseif c == 'B' or c == 'b' then + out[#out + 1] = { c, 1 } + i = i + 1 + elseif c == 'I' or c == 'i' then + local j = i + 1 + while j <= #fmt and fmt:sub(j, j):match('%d') do j = j + 1 end + local n = tonumber(fmt:sub(i + 1, j - 1)) or 4 + out[#out + 1] = { c, n } + i = j + elseif c == 'H' or c == 'h' then + out[#out + 1] = { c, 2 } + i = i + 1 + elseif c == 'L' or c == 'l' then + out[#out + 1] = { c, 4 } + i = i + 1 + else + error('unsupported compat53.string format item: ' .. c, 3) + end + end + return endian, out +end + +local function enc_uint(v, n, endian) + local bytes = {} + if endian == '<' then + for i = 1, n do bytes[i] = string.char(band(v, 0xff)); v = math.floor(v / 256) end + else + for i = n, 1, -1 do bytes[i] = string.char(band(v, 0xff)); v = math.floor(v / 256) end + end + return table.concat(bytes) +end + +local function dec_uint(s, pos, n, endian) + if pos + n - 1 > #s then error('data string too short', 3) end + local v = 0 + if endian == '<' then + for i = pos + n - 1, pos, -1 do v = v * 256 + s:byte(i) end + else + for i = pos, pos + n - 1 do v = v * 256 + s:byte(i) end + end + return v, pos + n +end + +local function signed(v, n) + local limit = 2 ^ (8 * n - 1) + if v >= limit then return v - 2 * limit end + return v +end + +function M.pack(fmt, ...) + local endian, items = parse(fmt) + local args = { ... } + local ai = 1 + local out = {} + for _, item in ipairs(items) do + local k, n = item[1], item[2] + local v = assert(args[ai], 'missing value for string.pack') + ai = ai + 1 + if k == 'b' then if v < 0 then v = v + 256 end end + if k == 'h' or k == 'i' or k == 'l' then if v < 0 then v = v + 2 ^ (8 * n) end end + out[#out + 1] = enc_uint(v, n, endian) + end + return table.concat(out) +end + +function M.unpack(fmt, s, pos) + pos = pos or 1 + local endian, items = parse(fmt) + local out = {} + for _, item in ipairs(items) do + local k, n = item[1], item[2] + local v + v, pos = dec_uint(s, pos, n, endian) + if k == 'b' or k == 'h' or k == 'i' or k == 'l' then v = signed(v, n) end + out[#out + 1] = v + end + out[#out + 1] = pos + return unpack(out, 1, #out) +end + +function M.packsize(fmt) + local _, items = parse(fmt) + local n = 0 + for _, item in ipairs(items) do n = n + item[2] end + return n +end + +return M +LUA + fi + if [ ! -f "$base/compat53/utf8.lua" ]; then + cat > "$base/compat53/utf8.lua" <<'LUA' +local M = {} +M.charpattern = '[%z-W94-ยค][ +8-91]*' +local function iscont(b) return b and b >= 0x80 and b <= 0xbf end +local function decode_at(s, i) + local b1 = s:byte(i) + if not b1 then return nil, i end + if b1 < 0x80 then return b1, i + 1 end + local n, min, cp + if b1 >= 0xc2 and b1 <= 0xdf then n, min, cp = 2, 0x80, b1 - 0xc0 + elseif b1 >= 0xe0 and b1 <= 0xef then n, min, cp = 3, 0x800, b1 - 0xe0 + elseif b1 >= 0xf0 and b1 <= 0xf4 then n, min, cp = 4, 0x10000, b1 - 0xf0 + else return nil, i + 1 end + for j = 2, n do + local b = s:byte(i + j - 1) + if not iscont(b) then return nil, i + 1 end + cp = cp * 64 + (b - 0x80) + end + if cp < min or cp > 0x10ffff or (cp >= 0xd800 and cp <= 0xdfff) then return nil, i + 1 end + return cp, i + n +end +function M.codes(s) + local i = 1 + return function() + if i > #s then return nil end + local pos = i + local cp, ni = decode_at(s, i) + if not cp then error('invalid UTF-8 code', 2) end + i = ni + return pos, cp + end +end +function M.codepoint(s, i, j) + i = i or 1; if i < 0 then i = #s + 1 + i end + j = j or i; if j < 0 then j = #s + 1 + j end + local out, p = {}, i + while p <= j do + local cp, ni = decode_at(s, p) + if not cp then error('invalid UTF-8 code', 2) end + out[#out+1] = cp; p = ni + end + return unpack(out, 1, #out) +end +function M.char(...) + local out = {} + for i = 1, select('#', ...) do + local cp = select(i, ...) + if cp < 0x80 then out[#out+1] = string.char(cp) + elseif cp < 0x800 then out[#out+1] = string.char(0xc0 + math.floor(cp/64), 0x80 + cp%64) + elseif cp < 0x10000 then out[#out+1] = string.char(0xe0 + math.floor(cp/4096), 0x80 + math.floor(cp/64)%64, 0x80 + cp%64) + else out[#out+1] = string.char(0xf0 + math.floor(cp/262144), 0x80 + math.floor(cp/4096)%64, 0x80 + math.floor(cp/64)%64, 0x80 + cp%64) end + end + return table.concat(out) +end +function M.len(s, i, j) + i = i or 1; if i < 0 then i = #s + 1 + i end + j = j or #s; if j < 0 then j = #s + 1 + j end + local n, p = 0, i + while p <= j do + local cp, ni = decode_at(s, p) + if not cp then return nil, p end + n = n + 1; p = ni + end + return n +end +function M.offset(s, n, i) + i = i or (n >= 0 and 1 or #s + 1) + if n == 0 then while i > 1 and iscont(s:byte(i)) do i = i - 1 end; return i end + if n > 0 then for _ = 1, n - 1 do local _, ni = decode_at(s, i); i = ni end; return i end + for _ = 1, -n do repeat i = i - 1 until i <= 1 or not iscont(s:byte(i)) end + return i +end +return M +LUA + fi + done +} + +install_compat53() { + root="$1" + log "installing compat53 from $root" + + # Prefer a real compat53 directory when the archive contains one, but do + # not let archive-layout surprises abort the dependency installer. The + # LuaJIT fallback below is enough for the VM leak probe and gives clear + # diagnostics if lua-http later needs more of compat53. + compat_dir="" + for candidate in \ + "$root/compat53" \ + "$root/lmod/compat53" \ + "$root/lualib/compat53" \ + "$root/lua/compat53" + do + if [ -d "$candidate" ]; then + compat_dir="$candidate" + break + fi + done + + if [ -z "$compat_dir" ]; then + compat_dir="$(find "$root" -type d -name compat53 2>/dev/null | head -n 1)" + fi + + if [ -n "$compat_dir" ]; then + install_lua_tree "$compat_dir" compat53 + else + warn "compat53 directory not found in archive; using narrow fallback shims" + fi + + compat_file="" + for candidate in \ + "$root/compat53.lua" \ + "$root/lua/compat53.lua" \ + "$root/lualib/compat53.lua" \ + "$root/lmod/compat53.lua" + do + if [ -f "$candidate" ]; then + compat_file="$candidate" + break + fi + done + if [ -z "$compat_file" ]; then + compat_file="$(find "$root" -type f -name compat53.lua 2>/dev/null | head -n 1)" + fi + if [ -n "$compat_file" ]; then + install_lua_file "$compat_file" compat53.lua + fi + + write_compat53_fallbacks + + if [ ! -f "$lua_share/compat53/module.lua" ] || [ ! -f "$lua_share/compat53/string.lua" ]; then + warn "compat53 install did not create expected modules; archive tree follows" + find "$root" -maxdepth 3 -type f 2>/dev/null | sed 's/^/[openwrt-vm\/http-deps] compat53 file: /' >&2 || true + fail 'compat53 fallback installation failed' + fi +} +install_file_by_name() { + root="$1" + name="$2" + dst="$3" + for candidate in "$root/$name" "$root/lib/$name" "$root/src/$name"; do + if [ -f "$candidate" ]; then + cp "$candidate" "$dst" + return 0 + fi + done + found="$(find "$root" -name "$name" -type f | head -n 1 || true)" + [ -n "$found" ] || return 1 + cp "$found" "$dst" +} + + + +ensure_lua51_headers() { + # OpenWrt runtime images do not normally include Lua development headers. + # LuaRocks may therefore fall back to a stale SDK include path such as + # /builder/shared-workdir/..., causing compat53 to fail at . + # Install vanilla Lua 5.1.5 headers for target-side module compilation. + incdir=/usr/include/lua5.1 + if [ -f "$incdir/lua.h" ] && [ -f "$incdir/luaconf.h" ]; then + printf '%s\n' "$incdir" + return 0 + fi + + mkdir -p "$incdir" /usr/include + archive="$tmp/lua-5.1.5.tar.gz" + dir="$tmp/lua-headers" + mkdir -p "$dir" + log 'fetching Lua 5.1.5 headers for compat53 build' + fetch 'https://www.lua.org/ftp/lua-5.1.5.tar.gz' "$archive" \ + || fail 'could not download Lua 5.1.5 headers' + tar -xzf "$archive" -C "$dir" || fail 'could not unpack Lua 5.1.5 headers' + root="$dir/lua-5.1.5" + [ -d "$root/src" ] || fail 'Lua 5.1.5 archive did not contain src directory' + for h in lua.h luaconf.h lauxlib.h lualib.h lua.hpp; do + [ -f "$root/src/$h" ] || [ "$h" = lua.hpp ] || fail "Lua 5.1 header missing from archive: $h" + if [ -f "$root/src/$h" ]; then + cp "$root/src/$h" "$incdir/$h" || fail "could not install Lua header $h" + cp "$root/src/$h" "/usr/include/$h" 2>/dev/null || true + fi + done + log "installed Lua 5.1 headers into $incdir" + printf '%s\n' "$incdir" +} + + +remove_compat53_native_shadow_lua_files() { + # Earlier harness versions installed narrow pure-Lua compat53 fallbacks. + # Once LuaRocks has produced native compat53/string.so or compat53/utf8.so, + # the corresponding Lua files are actively harmful: require() prefers + # LUA_PATH over LUA_CPATH, so a fallback file shadows the compiled module. + removed=0 + for mod in string utf8; do + found_so=0 + for so in \ + /usr/lib/lua/compat53/$mod.so \ + /usr/lib/lua/5.1/compat53/$mod.so + do + [ -f "$so" ] && found_so=1 + done + [ "$found_so" = "1" ] || continue + + for lua in \ + /usr/share/lua/compat53/$mod.lua \ + /usr/share/lua/5.1/compat53/$mod.lua \ + /usr/lib/lua/compat53/$mod.lua \ + /usr/lib/lua/5.1/compat53/$mod.lua + do + if [ -f "$lua" ]; then + rm -f "$lua" + removed=1 + fi + done + done + if [ "$removed" = "1" ]; then + log 'removed compat53 Lua fallback files that shadow native modules' + fi + return 0 +} +install_compat53_with_luarocks() { + remove_compat53_native_shadow_lua_files || true + # compat53 is the one lua-http dependency which is not purely a file copy on + # Lua 5.1/LuaJIT: it may need compiled modules such as compat53/utf8.so. + # Prefer a real LuaRocks build inside the VM, matching the target runtime and + # libc, rather than carrying broad compatibility shims in the leak harness. + if lua_require compat53.string && lua_require compat53.utf8; then + log 'compat53 runtime modules already load' + return 0 + fi + + if [ "${DEVICECODE_COMPAT53_USE_LUAROCKS:-1}" != "1" ]; then + warn 'DEVICECODE_COMPAT53_USE_LUAROCKS=0; leaving existing compat53 files/shims in place' + return 0 + fi + + if ! command -v luarocks >/dev/null 2>&1; then + warn 'luarocks command not found after opkg install attempts' + return 1 + fi + + log 'installing compat53 via LuaRocks into /usr' + rm -f /tmp/devicecode-compat53-luarocks.log + # OpenWrt's LuaRocks configuration may point at build-SDK include paths + # which do not exist on the running VM, so install Lua 5.1 headers and + # force LUA_INCDIR for the selected PUC Lua runtime. + lua_incdir="$(ensure_lua51_headers)" || return 1 + log "building compat53 with LUA_INCDIR=$lua_incdir using $lua_bin" + if luarocks --tree=/usr --lua-version=5.1 install compat53 LUA_INCDIR="$lua_incdir" LUA_BINDIR="$(dirname "$(command -v "$lua_bin")")" LUA_LIBDIR=/usr/lib >/tmp/devicecode-compat53-luarocks.log 2>&1; then + remove_compat53_native_shadow_lua_files || true + else + cat /tmp/devicecode-compat53-luarocks.log >&2 || true + warn 'LuaRocks compat53 install failed' + return 1 + fi + + if lua_require compat53.string && lua_require compat53.utf8; then + log 'compat53 installed and loadable via LuaRocks' + return 0 + fi + + warn 'compat53 LuaRocks install completed but modules still do not load; diagnostics follow' + lua_require_verbose compat53.string || true + lua_require_verbose compat53.utf8 || true + find /usr/share/lua /usr/lib/lua -path '*compat53*' -maxdepth 5 2>/dev/null | sed 's/^/[openwrt-vm\/http-deps] compat53 installed file: /' >&2 || true + return 1 +} + +install_pure_lua_deps() { + # Pin lua-http to the last tagged release rather than master. It is enough + # for request/server/websocket modules and better matches OpenWrt 24.10's + # cqueues/luaossl vintage. + http_root="$(unpack_github lua-http https://github.com/daurnimator/lua-http/archive/refs/tags/v0.4.tar.gz)" + [ -d "$http_root/http" ] || fail 'http directory not found in lua-http archive' + install_lua_tree "$http_root/http" http + log 'installed lua-http modules' + + compat53_root="$(unpack_github compat53 https://github.com/lunarmodules/lua-compat-5.3/archive/refs/heads/master.tar.gz)" + install_compat53 "$compat53_root" + log 'installed compat53 modules or fallback shims' + + basexx_root="$(unpack_github basexx https://github.com/aiq/basexx/archive/refs/heads/master.tar.gz)" + basexx_file="$tmp/basexx.lua" + install_file_by_name "$basexx_root" basexx.lua "$basexx_file" || fail 'basexx.lua not found in basexx archive' + install_lua_file "$basexx_file" basexx.lua + log 'installed basexx module' + + lpeg_patterns_root="$(unpack_github lpeg_patterns https://github.com/daurnimator/lpeg_patterns/archive/refs/heads/master.tar.gz)" + [ -d "$lpeg_patterns_root/lpeg_patterns" ] || fail 'lpeg_patterns directory not found in archive' + install_lua_tree "$lpeg_patterns_root/lpeg_patterns" lpeg_patterns + log 'installed lpeg_patterns modules' + + binaryheap_root="$(unpack_github binaryheap https://github.com/Tieske/binaryheap.lua/archive/refs/heads/master.tar.gz)" + binaryheap_file="$tmp/binaryheap.lua" + install_file_by_name "$binaryheap_root" binaryheap.lua "$binaryheap_file" || fail 'binaryheap.lua not found in archive' + install_lua_file "$binaryheap_file" binaryheap.lua + log 'installed binaryheap module' + + fifo_root="$(unpack_github fifo https://github.com/daurnimator/fifo.lua/archive/refs/heads/master.tar.gz)" + fifo_file="$tmp/fifo.lua" + install_file_by_name "$fifo_root" fifo.lua "$fifo_file" || fail 'fifo.lua not found in archive' + install_lua_file "$fifo_file" fifo.lua + log 'installed fifo module' +} + +install_pure_lua_deps + +if ! install_compat53_with_luarocks; then + if [ "${DEVICECODE_COMPAT53_REQUIRED:-1}" = "1" ]; then + fail "compat53 could not be built/loaded under $lua_bin; inspect /tmp/devicecode-compat53-luarocks.log and compat53/utf8 shim/native module ordering" + fi + warn 'continuing without compiled compat53 because DEVICECODE_COMPAT53_REQUIRED=0' +fi + +log 'checking lua-http runtime dependencies' +missing='' +# Include leaf modules so the diagnostic points to the real missing dependency, +# not only to http.request/http.server/http.websocket. +for mod in \ + cqueues cqueues.errno cqueues.socket lpeg \ + openssl openssl.rand openssl.pkey openssl.ssl openssl.ssl.context openssl.x509 openssl.x509.name openssl.x509.altname openssl.x509.verify_param \ + bit bit32 http.bit \ + basexx binaryheap fifo \ + lpeg_patterns.core lpeg_patterns.http lpeg_patterns.uri \ + compat53.module compat53.string compat53.utf8 \ + http.headers http.tls http.h1_connection http.h2_connection http.client \ + http.request http.server http.websocket + do + if lua_require "$mod"; then + : + else + missing="$missing $mod" + fi +done + +if [ -n "$missing" ]; then + cat >&2 <<'REPORT1' +[openwrt-vm/http-deps] Missing Lua modules after provisioning: +REPORT1 + printf '%s +' "$missing" >&2 + cat >&2 <<'REPORT2' + +Detailed require diagnostics follow. The first FAIL whose traceback mentions a +module not found is normally the real missing dependency. This harness now +defaults to PUC Lua in the VM because OpenWrt's packaged cqueues/luaossl +modules are known to load under lua on this image. Use +DEVICECODE_LEAK_PROBE_LUA=luajit only when deliberately comparing runtimes. +REPORT2 + for mod in $missing; do + lua_require_verbose "$mod" || true + done + cat >&2 <<'REPORT3' + +Installed package status for likely native dependencies: +REPORT3 + for pkg in lua liblua liblua5.1.5 luajit luajit-openresty cqueues lpeg luaossl lua-openssl lua-bit32 luabitop lua-lzlib; do + opkg status "$pkg" 2>/dev/null | sed -n '1,5p' >&2 || true + done + cat >&2 <<'REPORT4' + +Installed Lua/OpenWrt native files of interest: +REPORT4 + for pat in '*cqueues*' '*openssl*' '*lpeg*' '*bit*'; do + find /usr/lib/lua /usr/share/lua -name "$pat" 2>/dev/null | sed 's/^/[openwrt-vm\/http-deps] file: /' >&2 || true + done + printf 'unavailable missing=%s +' "$missing" > "$STATUS_FILE" + if [ "${DEVICECODE_HTTP_DEPS_REQUIRED:-0}" = "1" ]; then + fail "lua-http dependency check failed:$missing" + fi + warn "lua-http/cqueues dependencies unavailable under $lua_bin; continuing so the leak probe can run the core service set" + exit 0 +fi + +printf '%s +' 'available' > "$STATUS_FILE" +log 'lua-http/cqueues dependencies ready' +REMOTE_SH diff --git a/tests/integration/openwrt_vm/scripts/ensure-large-disk b/tests/integration/openwrt_vm/scripts/ensure-large-disk new file mode 100755 index 00000000..dece2969 --- /dev/null +++ b/tests/integration/openwrt_vm/scripts/ensure-large-disk @@ -0,0 +1,318 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +VM_DIR="$(dirname "$SCRIPT_DIR")" + +# shellcheck disable=SC1091 +. "$VM_DIR/env.sh" + +TARGET_SIZE="${OPENWRT_WORK_DISK_SIZE:-2G}" +ENABLE="${OPENWRT_GROW_WORK_DISK:-1}" +MIN_FREE_KB="${OPENWRT_MIN_ROOT_FREE_KB:-300000}" + +log() { printf '%s\n' "[openwrt-vm/disk] $*" >&2; } +warn() { printf '%s\n' "[openwrt-vm/disk] WARN: $*" >&2; } +fail() { printf '%s\n' "[openwrt-vm/disk] FAIL: $*" >&2; exit 1; } + +[ "$ENABLE" = "1" ] || { log 'disk growth disabled by OPENWRT_GROW_WORK_DISK=0'; exit 0; } + +if [ ! -f "$OPENWRT_WORK_DISK" ]; then + log "work disk not present; creating from base image" + "$SCRIPT_DIR/reset-disk" +fi + +# Fast path for reruns: if the VM already has enough free root space, avoid +# stopping/restarting it and avoid repeating partition work. This is common +# after the two-stage OpenWrt resize helper has completed. +if [ -f "$OPENWRT_PID" ] && kill -0 "$(cat "$OPENWRT_PID")" 2>/dev/null; then + if "$SCRIPT_DIR/wait-ssh" >/dev/null 2>&1; then + current_free="$("$SCRIPT_DIR/ssh" "df -k / | awk 'NR == 2 { print \$4 }'" 2>/dev/null || echo 0)" + case "$current_free" in ''|*[!0-9]*) current_free=0 ;; esac + if [ "$current_free" -ge "$MIN_FREE_KB" ]; then + log "root filesystem already has ${current_free}KB free; skipping disk growth" + exit 0 + fi + fi +fi + +was_running=0 +if [ -f "$OPENWRT_PID" ] && kill -0 "$(cat "$OPENWRT_PID")" 2>/dev/null; then + was_running=1 + log 'stopping VM before host-side qcow2 resize' + "$SCRIPT_DIR/stop-vm" || true +fi + +log "ensuring host qcow2 virtual size is at least $TARGET_SIZE" +if qemu-img resize "$OPENWRT_WORK_DISK" "$TARGET_SIZE" >/tmp/devicecode-qemu-img-resize.log 2>&1; then + cat /tmp/devicecode-qemu-img-resize.log >&2 || true +else + cat /tmp/devicecode-qemu-img-resize.log >&2 || true + warn "qemu-img resize returned non-zero; continuing with guest-side check" +fi + +log 'starting VM for guest-side root filesystem growth' +"$SCRIPT_DIR/run-vm" +"$SCRIPT_DIR/wait-ssh" + +# The OpenWrt combined EFI image uses GPT. After qemu-img resize, the kernel +# sees the larger /dev/vda, but the GPT backup header is still at the old end of +# disk. GNU parted therefore prompts to "fix" the table; plain `parted -s` does +# not answer that prompt, so resizepart later fails with "Unable to satisfy all +# constraints". Use parted -f/--fix where available, and fall back to the +# pseudo-tty input form used by older parted builds. +set +e +"$SCRIPT_DIR/ssh" 'sh -s' <&2; } +warn() { printf '%s\n' "[openwrt-vm/disk] WARN: \$*" >&2; } +fail() { printf '%s\n' "[openwrt-vm/disk] FAIL: \$*" >&2; exit 1; } + +free_kb() { df -k / | awk 'NR == 2 { print \$4 }'; } +show_df() { df -k / >&2 || true; } + +log 'current root filesystem usage before grow:' +show_df + +avail0="$(free_kb 2>/dev/null || echo 0)" +case "$avail0" in ''|*[!0-9]*) avail0=0 ;; esac +if [ "$avail0" -ge "$MIN_FREE_KB" ]; then + log "root filesystem already has ${avail0}KB free; no guest-side grow needed" + exit 0 +fi + +# Best-effort network setup for optional package installation. This mirrors the +# other OpenWrt VM provisioning helpers and is deliberately non-fatal. +ip route replace default via 192.168.1.2 dev br-lan || true +mkdir -p /tmp/resolv.conf.d +printf 'nameserver 192.168.1.3\nnameserver 1.1.1.1\n' > /tmp/resolv.conf.d/resolv.conf.auto +printf 'nameserver 192.168.1.3\nnameserver 1.1.1.1\n' > /tmp/resolv.conf + +opkg update >/tmp/devicecode-disk-opkg-update.log 2>&1 || warn 'opkg update failed during disk grow; using existing package lists' +for pkg in e2fsprogs resize2fs parted losetup blkid block-mount; do + if opkg status "\$pkg" 2>/dev/null | grep -q '^Status: .* installed'; then + : + else + opkg install "\$pkg" >/tmp/devicecode-disk-opkg-install-"\$pkg".log 2>&1 || warn "could not install optional disk package: \$pkg" + fi +done + +root_src="\$(awk '\$2 == "/" { print \$1; exit }' /proc/mounts)" +root_dev="\$root_src" +if [ "\$root_dev" = "/dev/root" ] && command -v readlink >/dev/null 2>&1; then + resolved="\$(readlink -f /dev/root 2>/dev/null || true)" + [ -n "\$resolved" ] && root_dev="\$resolved" +fi +if [ "\$root_dev" = "/dev/root" ]; then + for cand in /dev/vda2 /dev/sda2 /dev/mmcblk0p2; do + [ -b "\$cand" ] && { root_dev="\$cand"; break; } + done +fi + +case "\$root_dev" in + /dev/mmcblk*p[0-9]*|/dev/nvme*n*p[0-9]*) + partnum="\${root_dev##*p}" + disk_dev="\${root_dev%p\$partnum}" + ;; + /dev/*[0-9]*) + partnum="\$(printf '%s' "\$root_dev" | sed 's/.*[^0-9]\([0-9][0-9]*\)$/\1/')" + disk_dev="\$(printf '%s' "\$root_dev" | sed 's/[0-9][0-9]*$//')" + ;; + *) + warn "could not identify root block device from \$root_src; skipping partition resize" + partnum="" + disk_dev="" + ;; +esac + +fix_gpt_with_parted() { + d="\$1" + if ! command -v parted >/dev/null 2>&1; then + return 1 + fi + # Newer GNU parted: -f/--fix answers the GPT backup-table repair prompt. + parted -s -f "\$d" print >/tmp/devicecode-parted-fix.log 2>&1 && return 0 + parted -s --fix "\$d" print >/tmp/devicecode-parted-fix.log 2>&1 && return 0 + # Older builds sometimes need the explicit pseudo-tty form. Do not rely on + # this succeeding; it is a best-effort fallback. + printf 'Fix\n' | parted ---pretend-input-tty "\$d" print >/tmp/devicecode-parted-fix.log 2>&1 && return 0 + return 1 +} + +resize_partition_with_parted() { + d="\$1" + p="\$2" + if ! command -v parted >/dev/null 2>&1; then + return 1 + fi + parted -s -f "\$d" resizepart "\$p" 100% >/tmp/devicecode-parted-resize.log 2>&1 && return 0 + parted -s --fix "\$d" resizepart "\$p" 100% >/tmp/devicecode-parted-resize.log 2>&1 && return 0 + # Some parted builds still ask for confirmation because the partition is in + # use. Confirm once and continue; resize2fs below is the only filesystem step. + printf 'Yes\n' | parted ---pretend-input-tty "\$d" resizepart "\$p" 100% >/tmp/devicecode-parted-resize.log 2>&1 && return 0 + return 1 +} + +if [ -n "\$disk_dev" ] && [ -n "\$partnum" ]; then + log "attempting to grow partition \$partnum on \$disk_dev for root device \$root_dev" + if command -v parted >/dev/null 2>&1; then + log 'partition table before grow:' + parted -s -f "\$disk_dev" print >&2 2>/tmp/devicecode-parted-print-before.log || cat /tmp/devicecode-parted-print-before.log >&2 || true + + if fix_gpt_with_parted "\$disk_dev"; then + log 'GPT backup table check/fix completed' + else + warn 'parted could not auto-fix GPT backup table; resize may fail' + cat /tmp/devicecode-parted-fix.log >&2 || true + fi + + if resize_partition_with_parted "\$disk_dev" "\$partnum"; then + log "partition \$partnum resize command completed" + else + warn "partition \$partnum resize command failed" + cat /tmp/devicecode-parted-resize.log >&2 || true + fi + + log 'partition table after grow attempt:' + parted -s -f "\$disk_dev" print >&2 2>/tmp/devicecode-parted-print-after.log || cat /tmp/devicecode-parted-print-after.log >&2 || true + else + warn 'parted not available; cannot grow partition table' + fi + blockdev --rereadpt "\$disk_dev" >/dev/null 2>&1 || true + partprobe "\$disk_dev" >/dev/null 2>&1 || true +fi + +if command -v resize2fs >/dev/null 2>&1 && [ -b "\$root_dev" ]; then + log "running online resize2fs on \$root_dev" + resize2fs "\$root_dev" >/tmp/devicecode-resize2fs-online.log 2>&1 || { + warn "online resize2fs failed on \$root_dev" + cat /tmp/devicecode-resize2fs-online.log >&2 || true + } +else + warn "resize2fs unavailable or root device not found: \$root_dev" +fi + +log 'root filesystem usage after grow:' +show_df + +avail="\$(free_kb 2>/dev/null || echo 0)" +case "\$avail" in ''|*[!0-9]*) avail=0 ;; esac +if [ "\$avail" -lt "\$MIN_FREE_KB" ]; then + log "root filesystem has only \${avail}KB free after online resize; installing boot-time losetup resize helper" + cat >/etc/uci-defaults/80-devicecode-rootfs-resize <<'EOS' +#!/bin/sh +# Devicecode VM helper: OpenWrt ext4 combined images often cannot grow the +# mounted root filesystem online. The OpenWrt expand-root recipe performs the +# filesystem step after a reboot and uses losetup; this helper is a small, +# test-lane local equivalent. +LOG=/tmp/devicecode-rootfs-resize-boot.log +MARK=/etc/devicecode-rootfs-resized +log() { printf '%s\n' "[devicecode-rootfs-resize] \$*" >>"\$LOG"; } + +free_kb() { df -k / | awk 'NR == 2 { print \$4 }'; } +root_src="\$(awk '\$2 == "/" { print \$1; exit }' /proc/mounts)" +root_dev="\$root_src" +if [ "\$root_dev" = /dev/root ] && command -v readlink >/dev/null 2>&1; then + resolved="\$(readlink -f /dev/root 2>/dev/null || true)" + [ -n "\$resolved" ] && root_dev="\$resolved" +fi +if [ "\$root_dev" = /dev/root ]; then + for cand in /dev/vda2 /dev/sda2 /dev/mmcblk0p2; do + [ -b "\$cand" ] && { root_dev="\$cand"; break; } + done +fi + +log "boot-time resize starting: root_src=\$root_src root_dev=\$root_dev free_before=\$(free_kb 2>/dev/null || echo unknown)" + +if [ -b "\$root_dev" ] && command -v resize2fs >/dev/null 2>&1; then + if command -v losetup >/dev/null 2>&1; then + loop="\$(losetup -f 2>/dev/null || true)" + if [ -n "\$loop" ] && losetup "\$loop" "\$root_dev" >>"\$LOG" 2>&1; then + log "running resize2fs -f via loop \$loop" + resize2fs -f "\$loop" >>"\$LOG" 2>&1 || log "resize2fs via loop failed" + losetup -d "\$loop" >>"\$LOG" 2>&1 || true + else + log "could not attach loop for \$root_dev; falling back to direct resize2fs" + resize2fs "\$root_dev" >>"\$LOG" 2>&1 || log "direct resize2fs failed" + fi + else + log "losetup unavailable; falling back to direct resize2fs" + resize2fs "\$root_dev" >>"\$LOG" 2>&1 || log "direct resize2fs failed" + fi +else + log "resize2fs unavailable or root block device missing: \$root_dev" +fi + +log "boot-time resize finished: free_after=\$(free_kb 2>/dev/null || echo unknown)" +touch "\$MARK" +rm -f /etc/uci-defaults/80-devicecode-rootfs-resize +sync +reboot -f +EOS + chmod +x /etc/uci-defaults/80-devicecode-rootfs-resize + rm -f /etc/devicecode-rootfs-resized + log 'rebooting once so boot-time losetup resize helper can run' + sync + # Do the reboot from a detached background shell and return from SSH + # immediately. Some BusyBox/OpenSSH combinations otherwise keep the client + # blocked until TCP timeout, which makes the harness look hung even though + # the resize helper will run successfully. + (sh -c 'sleep 1; reboot -f' >/dev/null 2>&1 &) + exit 0 +fi +REMOTE +remote_resize_status=$? +set -e +if [ "$remote_resize_status" -ne 0 ]; then + warn "guest-side disk grow command returned $remote_resize_status; this is expected if it rebooted during the resize sequence" +fi + +# The boot-time helper above deliberately reboots again after running. Wait for +# the VM to return and for the marker file to appear before judging free space. +wait_for_root_resize_marker() { + deadline=$(( $(date +%s) + 240 )) + while [ "$(date +%s)" -lt "$deadline" ]; do + "$SCRIPT_DIR/wait-ssh" >/dev/null 2>&1 || true + if "$SCRIPT_DIR/ssh" '[ -f /etc/devicecode-rootfs-resized ] || [ ! -f /etc/uci-defaults/80-devicecode-rootfs-resize ]' >/dev/null 2>&1; then + return 0 + fi + sleep 3 + done + return 1 +} + +# If the remote block rebooted, SSH may be temporarily down. Waiting is harmless +# when no reboot was needed. +"$SCRIPT_DIR/wait-ssh" +if ! wait_for_root_resize_marker; then + warn 'boot-time rootfs resize marker did not appear before timeout' +fi +"$SCRIPT_DIR/wait-ssh" + +# Final validation and log collection. +"$SCRIPT_DIR/ssh" 'sh -s' <&2; } +fail() { printf '%s\n' "[openwrt-vm/disk] FAIL: \$*" >&2; exit 1; } +free_kb() { df -k / | awk 'NR == 2 { print \$4 }'; } + +if [ -f /tmp/devicecode-rootfs-resize-boot.log ]; then + log 'boot-time rootfs resize log:' + cat /tmp/devicecode-rootfs-resize-boot.log >&2 || true +fi +log 'root filesystem usage after final grow attempt:' +df -k / >&2 || true +avail="\$(free_kb 2>/dev/null || echo 0)" +case "\$avail" in ''|*[!0-9]*) avail=0 ;; esac +if [ "\$avail" -lt "\$MIN_FREE_KB" ]; then + fail "root filesystem still has only \${avail}KB free; need at least \${MIN_FREE_KB}KB for build-deps. Check /tmp/devicecode-*-resize*.log and /tmp/devicecode-parted-*.log in the VM." +fi +REMOTE + +if [ "$was_running" = "0" ]; then + # Leave the VM running. Subsequent Make targets expect SSH to be available, + # and starting it here is harmless for callers that were about to run tests. + : +fi diff --git a/tests/integration/openwrt_vm/scripts/provision b/tests/integration/openwrt_vm/scripts/provision index 677008b9..21405e13 100755 --- a/tests/integration/openwrt_vm/scripts/provision +++ b/tests/integration/openwrt_vm/scripts/provision @@ -60,7 +60,7 @@ opkg_update_with_retry # Install MWAN3 in a separate phase. Immediately after this SSH step returns, # provision installs a known-good three-WAN fixture before any further package # downloads can observe or trip over a half-configured mwan3 setup. -opkg install tc-full kmod-sched kmod-sched-core kmod-ifb kmod-veth ip-full lua libuci-lua +opkg install tc-full kmod-sched kmod-sched-core kmod-ifb kmod-veth ip-full lua luajit libuci-lua # Some OpenWrt targets expose 8021q as built-in kernel support and do not # publish a separate kmod-8021q package. VLAN capability is checked by the @@ -87,6 +87,10 @@ ip route replace default via 192.168.1.2 dev br-lan || true (opkg install luaposix || true) (opkg install lua-bit32 || true) (opkg install lua-cjson || true) +(opkg install luajit || true) +(opkg install cqueues || true) +(opkg install lpeg || true) +(opkg install luaossl || true) # Optional but useful for performance tests. Do not fail provisioning if unavailable # on a given target/release. diff --git a/tests/integration/openwrt_vm/tests/test_devicecode_leak_probe_full_stack.sh b/tests/integration/openwrt_vm/tests/test_devicecode_leak_probe_full_stack.sh new file mode 100755 index 00000000..c6393438 --- /dev/null +++ b/tests/integration/openwrt_vm/tests/test_devicecode_leak_probe_full_stack.sh @@ -0,0 +1,348 @@ +#!/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" +SCP_FROM="$VM_DIR/scripts/scp-from" + +# Duration and sampling are deliberately configurable. The default is long +# enough to prove that the full stack starts and emits several probe samples, +# but short enough for normal VM iteration. For leak hunting, run with e.g. +# DEVICECODE_LEAK_PROBE_DURATION_S=3600 DEVICECODE_LEAK_PROBE_INTERVAL=60. +DURATION_S="${DEVICECODE_LEAK_PROBE_DURATION_S:-300}" +INTERVAL_S="${DEVICECODE_LEAK_PROBE_INTERVAL:-30}" +CONFIG_TARGET="${DEVICECODE_LEAK_PROBE_CONFIG_TARGET:-bigbox-v1-cm-2}" +MODE="${DEVICECODE_LEAK_PROBE_MODE:-safe}" +HAL_MANAGERS="${DEVICECODE_LEAK_PROBE_HAL_MANAGERS:-}" +HAL_EXCLUDE_MANAGERS="${DEVICECODE_LEAK_PROBE_HAL_EXCLUDE_MANAGERS:-}" +REMOTE="${DEVICECODE_LEAK_PROBE_REMOTE:-/tmp/devicecode-leak-probe-tree}" +REMOTE_LOG_DIR="${DEVICECODE_LEAK_PROBE_REMOTE_LOG_DIR:-/tmp/devicecode-leak-probe}" +# FULL_SERVICES="monitor,hal,config,system,time,metrics,device,fabric,http,ui,update,net,wired,wifi,gsm" +FULL_SERVICES="monitor,hal,config,system,time,metrics,device,fabric,http,ui,update,net,wired,gsm" +CORE_SERVICES="monitor,hal,config,system,time,device,fabric,update,net,wired,wifi,gsm" +if [ -n "${DEVICECODE_LEAK_PROBE_SERVICES+x}" ]; then + SERVICES="$DEVICECODE_LEAK_PROBE_SERVICES" +else + HTTP_STATUS_FILE="${DEVICECODE_HTTP_DEPS_STATUS_FILE:-/tmp/devicecode-lua-http-deps.status}" + if "$SSH" "test -f '$HTTP_STATUS_FILE' && grep -q '^available' '$HTTP_STATUS_FILE'" >/dev/null 2>&1; then + SERVICES="$FULL_SERVICES" + else + SERVICES="$CORE_SERVICES" + printf '%s\n' "[leak-probe] lua-http/cqueues not available under selected Lua runtime; using core service set without metrics,http,ui" >&2 + fi +fi +LUA_BIN="${DEVICECODE_LEAK_PROBE_LUA:-lua}" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" +WORK="${DEVICECODE_LEAK_PROBE_WORK_DIR:-$VM_DIR/work/leak-probe-$RUN_ID}" + +fail() { + printf '%s\n' "[leak-probe] FAIL: $*" >&2 + exit 1 +} + +mkdir -p "$WORK" + +cat > "$WORK/run_devicecode_leak_probe.sh" <<'REMOTE_SH' +#!/usr/bin/env sh +set -eu + +remote_root="$1" +duration_s="$2" +interval_s="$3" +services="$4" +config_target="$5" +mode="$6" +log_dir="$7" +lua_bin_arg="${8:-}" +hal_managers_arg="${9:-}" +hal_exclude_managers_arg="${10:-}" + +fail() { + echo "[leak-probe/vm] FAIL: $*" >&2 + if [ -f "$log_dir/devicecode.log" ]; then + echo "--- devicecode.log tail ---" >&2 + tail -n 120 "$log_dir/devicecode.log" >&2 || true + fi + if [ -f "$log_dir/probe.log" ]; then + echo "--- probe.log tail ---" >&2 + tail -n 80 "$log_dir/probe.log" >&2 || true + fi + exit 1 +} + +choose_lua() { + if [ -n "$lua_bin_arg" ]; then + command -v "$lua_bin_arg" >/dev/null 2>&1 || fail "requested Lua interpreter not found: $lua_bin_arg" + printf '%s\n' "$lua_bin_arg" + return + fi + # For the leak hunt this VM defaults to PUC Lua, because the OpenWrt + # cqueues/luaossl packages load cleanly there. Set + # DEVICECODE_LEAK_PROBE_LUA=luajit when deliberately comparing runtimes. + if command -v lua >/dev/null 2>&1; then printf '%s\n' lua; return; fi + if command -v luajit >/dev/null 2>&1; then printf '%s\n' luajit; return; fi + if command -v texlua >/dev/null 2>&1; then printf '%s\n' texlua; return; fi + fail 'no lua, luajit or texlua interpreter found in VM' +} + +lua_bin="$(choose_lua)" + +rm -rf "$log_dir" +mkdir -p "$log_dir" \ + /data/devicecode/configs \ + /data/configs \ + /data/devicecode/artifacts/import \ + /data/devicecode/control/update \ + /tmp/devicecode-state \ + /tmp/devicecode-artifacts + +cat > /data/configs/mainflux.cfg <<'JSON' +{"networks":{"networks":[{"ssid":"Devicecode VM","name":"jan","encryption":"psk2","password":"devicecode-vm-test"}]}} +JSON + +# Build the config file consumed by the real config service. In safe mode we +# keep the default product config shape but avoid destructive OpenWrt network +# apply work by using the in-memory network provider. Set +# DEVICECODE_LEAK_PROBE_MODE=openwrt to exercise the real OpenWrt provider. +cd "$remote_root/src" +"$lua_bin" - "$config_target" "$mode" "$hal_managers_arg" "$hal_exclude_managers_arg" "$log_dir" <<'LUA' +local cjson = require 'cjson.safe' +local target, mode, hal_managers, hal_exclude_managers, log_dir = arg[1], arg[2], arg[3], arg[4], arg[5] +local function read(path) + local f, err = io.open(path, 'rb') + if not f then error(err, 0) end + local s = f:read('*a') + f:close() + return s +end +local function write(path, s) + local f, err = io.open(path, 'wb') + if not f then error(err, 0) end + f:write(s) + f:close() +end +local src = './configs/' .. target .. '.json' +local doc, err = cjson.decode(read(src)) +if not doc then error('decode ' .. src .. ': ' .. tostring(err), 0) end + +local function split_csv(s) + local out = {} + if type(s) ~= 'string' or s == '' then return out end + for item in s:gmatch('[^,]+') do + item = item:gsub('^%s+', ''):gsub('%s+$', '') + if item ~= '' then out[#out + 1] = item end + end + return out +end + +local function set_from_list(list) + local set = {} + for i = 1, #list do set[list[i]] = true end + return set +end + +local function filter_hal_managers(doc, include_csv, exclude_csv) + local hal = doc.hal and doc.hal.data + if type(hal) ~= 'table' then return end + + local include = set_from_list(split_csv(include_csv)) + local exclude = set_from_list(split_csv(exclude_csv)) + local has_include = next(include) ~= nil + + if not has_include and next(exclude) == nil then return end + + local preserved = { schema = hal.schema } + for name, value in pairs(hal) do + if name ~= 'schema' then + local keep = true + if has_include then keep = include[name] == true end + if exclude[name] then keep = false end + if keep then preserved[name] = value end + end + end + doc.hal.data = preserved +end + +local hal_filter_active = (type(hal_managers) == 'string' and hal_managers ~= '') + or (type(hal_exclude_managers) == 'string' and hal_exclude_managers ~= '') + +filter_hal_managers(doc, hal_managers, hal_exclude_managers) + +if mode == 'safe' then + -- In unfiltered safe-mode runs we preserve the historical behaviour and add + -- a fake network provider to avoid destructive OpenWrt network apply work. + -- When HAL manager include/exclude filters are active, do not reintroduce + -- network if the filter removed it; this keeps HAL-manager bisection honest. + if not hal_filter_active then + doc.hal = doc.hal or { rev = 1, data = {} } + doc.hal.data = doc.hal.data or {} + end + if doc.hal and doc.hal.data and doc.hal.data.network ~= nil then + doc.hal.data.network = doc.hal.data.network or {} + doc.hal.data.network.provider = 'fake' + doc.hal.data.network.backend = 'fake' + end + + -- Keep HTTP/UI alive in the VM but avoid accidental port conflicts when a + -- developer is also using port 8080 in another manual process. + if doc.ui and doc.ui.data and doc.ui.data.http then + doc.ui.data.http.host = doc.ui.data.http.host or '127.0.0.1' + doc.ui.data.http.port = tonumber(os.getenv('DEVICECODE_LEAK_PROBE_UI_PORT') or '') or doc.ui.data.http.port or 8080 + end +elseif mode == 'openwrt' then + -- Use the config as supplied. This may modify the VM's OpenWrt network + -- configuration. Prefer a disposable/reset VM disk for this mode. +else + error('unknown DEVICECODE_LEAK_PROBE_MODE: ' .. tostring(mode), 0) +end + +local function active_hal_manager_csv(doc) + local hal = doc.hal and doc.hal.data + if type(hal) ~= 'table' then return '' end + local names = {} + for name, _ in pairs(hal) do + if name ~= 'schema' then names[#names + 1] = name end + end + table.sort(names) + return table.concat(names, ',') +end + +write('/data/devicecode/configs/' .. target .. '.json', assert(cjson.encode(doc))) + +if type(log_dir) == 'string' and log_dir ~= '' then + write(log_dir .. '/hal-managers.env', + 'hal_managers_include=' .. tostring(hal_managers or '') .. '\n' .. + 'hal_managers_exclude=' .. tostring(hal_exclude_managers or '') .. '\n' .. + 'hal_managers_active=' .. active_hal_manager_csv(doc) .. '\n') +end +LUA + +hal_env="$(cat "$log_dir/hal-managers.env" 2>/dev/null || { + printf 'hal_managers_include=%s\n' "$hal_managers_arg" + printf 'hal_managers_exclude=%s\n' "$hal_exclude_managers_arg" + printf 'hal_managers_active=\n' +})" + +cat > "$log_dir/env.txt" < "$log_dir/devicecode.log" 2>&1 & +pid="$!" +echo "$pid" > "$log_dir/devicecode.pid" + +start="$(date +%s)" +early=0 +while :; do + now="$(date +%s)" + elapsed=$((now - start)) + if ! kill -0 "$pid" 2>/dev/null; then + early=1 + break + fi + if [ "$elapsed" -ge "$duration_s" ]; then + break + fi + sleep 5 + done + +if [ "$early" -eq 1 ]; then + wait "$pid" || rc="$?" + echo "exit_code=${rc:-0}" > "$log_dir/exit.txt" + fail "devicecode exited before ${duration_s}s" +fi + +kill "$pid" 2>/dev/null || true +sleep 2 +if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true +fi +wait "$pid" >/dev/null 2>&1 || true + +echo "completed_duration_s=$duration_s" > "$log_dir/exit.txt" + +[ -s "$log_dir/probe.log" ] || fail 'probe log was not created or is empty' +grep -q '^LEAK_PROBE ' "$log_dir/probe.log" || fail 'probe log contains no LEAK_PROBE main snapshots' + +# Emit a small VM-side summary for the host log. +{ + echo '--- leak probe env ---' + cat "$log_dir/env.txt" + echo '--- first probe snapshot ---' + grep '^LEAK_PROBE ' "$log_dir/probe.log" | head -n 1 || true + echo '--- last probe snapshot ---' + grep '^LEAK_PROBE ' "$log_dir/probe.log" | tail -n 1 || true + echo '--- highest-risk counters from last snapshot ---' + last="$(grep '^LEAK_PROBE ' "$log_dir/probe.log" | tail -n 1 || true)" + for key in mem_kb scope_live scope_children scope_finalizers scope_cancelled exec_live exec_terminal_not_cleaned scoped_work_live scoped_work_body_not_reaped request_owner_live bus_retained; do + printf '%s=' "$key" + printf '%s\n' "$last" | tr ' ' '\n' | awk -F= -v k="$key" '$1==k {print $2; found=1} END {if (!found) print ""}' + done + echo '--- last exec samples ---' + grep '^LEAK_PROBE_EXEC_SAMPLES ' "$log_dir/probe.log" | tail -n 1 || true + echo '--- last scoped-work kinds ---' + grep '^LEAK_PROBE_SCOPED_WORK_KINDS ' "$log_dir/probe.log" | tail -n 1 || true +} > "$log_dir/summary.txt" + +cat "$log_dir/summary.txt" +REMOTE_SH +chmod +x "$WORK/run_devicecode_leak_probe.sh" + +printf '%s\n' "[leak-probe] syncing instrumented tree to VM: $REMOTE" +"$SSH" "rm -rf '$REMOTE' '$REMOTE_LOG_DIR'; mkdir -p '$REMOTE'" +"$SCP_TO" "$ROOT_DIR/src" "$REMOTE/src" +"$SCP_TO" "$ROOT_DIR/vendor" "$REMOTE/vendor" +"$SCP_TO" "$WORK/run_devicecode_leak_probe.sh" "$REMOTE/run_devicecode_leak_probe.sh" + +printf '%s\n' "[leak-probe] running full-stack probe for ${DURATION_S}s, interval ${INTERVAL_S}s, mode ${MODE}" +if ! "$SSH" "sh '$REMOTE/run_devicecode_leak_probe.sh' '$REMOTE' '$DURATION_S' '$INTERVAL_S' '$SERVICES' '$CONFIG_TARGET' '$MODE' '$REMOTE_LOG_DIR' '$LUA_BIN' '$HAL_MANAGERS' '$HAL_EXCLUDE_MANAGERS'"; then + mkdir -p "$WORK/remote-logs" || true + "$SCP_FROM" "$REMOTE_LOG_DIR" "$WORK/remote-logs" >/dev/null 2>&1 || true + fail "remote leak probe failed; logs, if collected, are under $WORK/remote-logs" +fi + +mkdir -p "$WORK" +"$SCP_FROM" "$REMOTE_LOG_DIR" "$WORK/remote-logs" + +printf '%s\n' "[leak-probe] collected logs: $WORK/remote-logs" +if [ -f "$WORK/remote-logs/summary.txt" ]; then + cat "$WORK/remote-logs/summary.txt" +fi From 5c13f67db20da13232b205e6c41675963db52813 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Wed, 3 Jun 2026 14:59:32 +0000 Subject: [PATCH 4/5] fix: escape variables in ensure-large-disk script for proper evaluation --- tests/integration/openwrt_vm/scripts/ensure-large-disk | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/integration/openwrt_vm/scripts/ensure-large-disk b/tests/integration/openwrt_vm/scripts/ensure-large-disk index dece2969..1ff83a50 100755 --- a/tests/integration/openwrt_vm/scripts/ensure-large-disk +++ b/tests/integration/openwrt_vm/scripts/ensure-large-disk @@ -75,13 +75,14 @@ show_df() { df -k / >&2 || true; } log 'current root filesystem usage before grow:' show_df -avail0="$(free_kb 2>/dev/null || echo 0)" -case "$avail0" in ''|*[!0-9]*) avail0=0 ;; esac -if [ "$avail0" -ge "$MIN_FREE_KB" ]; then - log "root filesystem already has ${avail0}KB free; no guest-side grow needed" +avail0="\$(free_kb 2>/dev/null || echo 0)" +case "\$avail0" in ''|*[!0-9]*) avail0=0 ;; esac +if [ "\$avail0" -ge "\$MIN_FREE_KB" ]; then + log "root filesystem already has \${avail0}KB free; no guest-side grow needed" exit 0 fi + # Best-effort network setup for optional package installation. This mirrors the # other OpenWrt VM provisioning helpers and is deliberately non-fatal. ip route replace default via 192.168.1.2 dev br-lan || true From cf0237156f133f28a4dbb643568455a62fb6af76 Mon Sep 17 00:00:00 2001 From: Ryan Name Date: Thu, 4 Jun 2026 11:33:07 +0000 Subject: [PATCH 5/5] feat: add per-component memory usage testing scripts and configuration files --- .../onbox/test-mem-usage/all_managers | 13 + .../onbox/test-mem-usage/all_services | 15 ++ .../onbox/test-mem-usage/readme.md | 174 +++++++++++++ .../test-mem-usage-per-component-dropped.sh | 238 ++++++++++++++++++ 4 files changed, 440 insertions(+) create mode 100644 tests/integration/onbox/test-mem-usage/all_managers create mode 100644 tests/integration/onbox/test-mem-usage/all_services create mode 100644 tests/integration/onbox/test-mem-usage/readme.md create mode 100755 tests/integration/onbox/test-mem-usage/test-mem-usage-per-component-dropped.sh diff --git a/tests/integration/onbox/test-mem-usage/all_managers b/tests/integration/onbox/test-mem-usage/all_managers new file mode 100644 index 00000000..0f09986a --- /dev/null +++ b/tests/integration/onbox/test-mem-usage/all_managers @@ -0,0 +1,13 @@ +filesystem +network +wired +wlan +uart +artifact_store +control_store +time +platform +power +usb +modemcard +sysmon diff --git a/tests/integration/onbox/test-mem-usage/all_services b/tests/integration/onbox/test-mem-usage/all_services new file mode 100644 index 00000000..a1023a24 --- /dev/null +++ b/tests/integration/onbox/test-mem-usage/all_services @@ -0,0 +1,15 @@ +config +device +fabric +gsm +hal +http +metrics +monitor +net +system +time +ui +update +wifi +wired diff --git a/tests/integration/onbox/test-mem-usage/readme.md b/tests/integration/onbox/test-mem-usage/readme.md new file mode 100644 index 00000000..4476bee7 --- /dev/null +++ b/tests/integration/onbox/test-mem-usage/readme.md @@ -0,0 +1,174 @@ +# Per-Component Memory Usage Test + +This folder contains a helper script to run DeviceCode repeatedly while dropping one service or one HAL manager at a time, and capture memory snapshots for each run. + +Script: +- `test-mem-usage-per-component-dropped.sh` + +## What It Does + +The script runs these variants: +- Full baseline: all services enabled +- Drop one service at a time +- Drop one HAL manager at a time (by removing that manager from `hal.data` in config) + +For each variant, it: +- Starts `main.lua` +- Samples process memory every N seconds (`ps` RSS/VSZ) +- Stops after a fixed duration +- Writes logs into a variant-specific directory + +The original config is backed up and restored automatically (including on Ctrl+C/TERM). + +## Prerequisites + +- Run from this repository (or set `DEVICECODE_ROOT`) +- Shell with POSIX `sh` +- `luajit` (or another Lua binary via env var) +- A valid config file under either `src/configs` (source tree) or `configs` (build tree) +- The files in this folder: + - `all_services` + - `all_managers` + +## Quick Start + +From repository root: + +```sh +chmod +x tests/integration/onbox/test-mem-usage/test-mem-usage-per-component-dropped.sh +./tests/integration/onbox/test-mem-usage/test-mem-usage-per-component-dropped.sh +``` + +Default behavior: +- Config target: `bigbox-v1-cm-2` +- Runtime per variant: `120` seconds +- Sample interval: `5` seconds +- Logs: `/tmp/devicecode-per-component-dropped-` + +## Environment Variables + +You can override these when running: + +- `DEVICECODE_ROOT` + - Repository root path + - Default: auto-detected from script location + +- `DEVICECODE_APP_DIR` + - Directory where `main.lua` is executed from + - Default: `$DEVICECODE_ROOT/src` if it exists, otherwise `$DEVICECODE_ROOT` + +- `DEVICECODE_ALL_SERVICES_FILE` + - Path to services list file + - Default: `/all_services` + +- `DEVICECODE_ALL_MANAGERS_FILE` + - Path to managers list file + - Default: `/all_managers` + +- `CONFIG_TARGET` + - Config name without `.json` + - Default: `bigbox-v1-cm-2` + +- `DEVICECODE_CONFIG_DIR` + - Directory containing config JSON files + - Default: `$DEVICECODE_ROOT/src/configs` if `src` exists, otherwise `$DEVICECODE_ROOT/configs` + +- `DEVICECODE_LUA_BIN` + - Lua interpreter command + - Default: `luajit` + +- `DEVICECODE_RUN_SECONDS` + - How long each variant runs + - Default: `120` + +- `DEVICECODE_SAMPLE_SECONDS` + - Memory sample interval in seconds + - Default: `5` + +- `DEVICECODE_LOG_DIR` + - Output directory for logs + - Default: `/tmp/devicecode-per-component-dropped-` + +## Example Runs + +Use 60-second runs, sample every 2 seconds: + +```sh +DEVICECODE_RUN_SECONDS=60 \ +DEVICECODE_SAMPLE_SECONDS=2 \ +./tests/integration/onbox/test-mem-usage/test-mem-usage-per-component-dropped.sh +``` + +Use a different config target and explicit log directory: + +```sh +CONFIG_TARGET=bigbox-v1-cm-2 \ +DEVICECODE_LOG_DIR=/tmp/mem-usage-run-1 \ +./tests/integration/onbox/test-mem-usage/test-mem-usage-per-component-dropped.sh +``` + +Use plain Lua instead of luajit: + +```sh +DEVICECODE_LUA_BIN=lua \ +./tests/integration/onbox/test-mem-usage/test-mem-usage-per-component-dropped.sh +``` + +Run against a build tree (like on OpenWrt): + +```sh +DEVICECODE_ROOT=../build \ +./test-mem-usage-per-component-dropped.sh +``` + +## Output Structure + +Inside `DEVICECODE_LOG_DIR`, each variant gets its own folder, for example: + +- `all-enabled/` +- `drop-service-/` +- `drop-manager-/` + +Each variant folder contains: +- `devicecode.log` - stdout/stderr from `main.lua` +- `memory.log` - periodic `ps` snapshots for the process +- `services.csv` - services enabled for that run +- `.json` - config used in that run + +The script also stores a copy of the original config as: +- `/.original` + +## Input File Format + +`all_services` and `all_managers` should contain one name per line. + +Rules: +- Empty lines are ignored +- Lines starting with `#` are treated as comments + +## Troubleshooting + +- `missing file: ...` + - Ensure `all_services`, `all_managers`, and config JSON exist. + - If you exported `DEVICECODE_CONFIG_DIR` earlier, it may override auto-detection. Run `unset DEVICECODE_CONFIG_DIR` or set it explicitly to your build config path. + - The script prints resolved paths at startup (`root`, `app_dir`, `config_dir`) so you can verify what it is using. + +- `Lua interpreter not found: ...` + - Install the interpreter or set `DEVICECODE_LUA_BIN`. + +- `manager not present in hal.data: ...` + - The manager listed in `all_managers` is not present in selected config JSON under `hal.data`. + +- Script exits early + - Check variant `devicecode.log` for runtime errors. + +- `memory.log` has sample timestamps but no RSS/VSZ values + - On BusyBox/OpenWrt, `ps -o ... -p ...` may be unsupported. + - The script falls back to `/proc//status` and records `rss` (`VmRSS`) and `vsz` (`VmSize`). + - If still empty, verify `/proc//status` is readable for the target process. + +## Notes + +- The script kills the test process after `DEVICECODE_RUN_SECONDS` if still running. +- Labels with `/` are sanitized to `_` for folder names. +- Config restoration is guarded by traps (`EXIT`, `INT`, `TERM`). diff --git a/tests/integration/onbox/test-mem-usage/test-mem-usage-per-component-dropped.sh b/tests/integration/onbox/test-mem-usage/test-mem-usage-per-component-dropped.sh new file mode 100755 index 00000000..bec626ba --- /dev/null +++ b/tests/integration/onbox/test-mem-usage/test-mem-usage-per-component-dropped.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +ROOT_DIR="${DEVICECODE_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../../../.." && pwd)}" + +if [ -d "$ROOT_DIR/src" ]; then + DEFAULT_APP_DIR="$ROOT_DIR/src" + DEFAULT_CONFIG_DIR="$ROOT_DIR/src/configs" +else + DEFAULT_APP_DIR="$ROOT_DIR" + DEFAULT_CONFIG_DIR="$ROOT_DIR/configs" +fi + +SERVICES_FILE="${DEVICECODE_ALL_SERVICES_FILE:-$SCRIPT_DIR/all_services}" +MANAGERS_FILE="${DEVICECODE_ALL_MANAGERS_FILE:-$SCRIPT_DIR/all_managers}" +CONFIG_TARGET="${CONFIG_TARGET:-bigbox-v1-cm-2}" +APP_DIR="${DEVICECODE_APP_DIR:-$DEFAULT_APP_DIR}" +CONFIG_DIR="${DEVICECODE_CONFIG_DIR:-$DEFAULT_CONFIG_DIR}" +APP_DIR="${APP_DIR%/}" +CONFIG_DIR="${CONFIG_DIR%/}" +CONFIG_FILE="$CONFIG_DIR/$CONFIG_TARGET.json" +LUA_BIN="${DEVICECODE_LUA_BIN:-luajit}" +RUN_SECONDS="${DEVICECODE_RUN_SECONDS:-120}" +LOG_DIR="${DEVICECODE_LOG_DIR:-/tmp/devicecode-per-component-dropped-$(date -u +%Y%m%dT%H%M%SZ)}" + +if [ ! -f "$CONFIG_FILE" ]; then + for candidate in \ + "$ROOT_DIR/configs/$CONFIG_TARGET.json" \ + "$ROOT_DIR/src/configs/$CONFIG_TARGET.json" + do + if [ -f "$candidate" ]; then + CONFIG_FILE="$candidate" + CONFIG_DIR="$(dirname -- "$candidate")" + break + fi + done +fi + +fail() { + printf '%s\n' "[per-component] FAIL: $*" >&2 + exit 1 +} + +need_file() { + [ -f "$1" ] || fail "missing file: $1" +} + +need_file "$SERVICES_FILE" +need_file "$MANAGERS_FILE" +need_file "$CONFIG_FILE" +need_file "$APP_DIR/main.lua" +command -v "$LUA_BIN" >/dev/null 2>&1 || fail "Lua interpreter not found: $LUA_BIN" + +printf '%s\n' "[per-component] root=$ROOT_DIR app_dir=$APP_DIR config_dir=$CONFIG_DIR config_target=$CONFIG_TARGET" + +mkdir -p "$LOG_DIR" + +CONFIG_BACKUP="$LOG_DIR/$(basename "$CONFIG_FILE").original" +cp "$CONFIG_FILE" "$CONFIG_BACKUP" + +restore_config() { + cp "$CONFIG_BACKUP" "$CONFIG_FILE" +} + +trap restore_config EXIT +trap 'restore_config; exit 130' INT +trap 'restore_config; exit 143' TERM + +csv_from_file_except() { + drop="${1:-}" + csv='' + while IFS= read -r name || [ -n "$name" ]; do + case "$name" in + ''|'#'*) continue ;; + esac + [ "$name" = "$drop" ] && continue + if [ -z "$csv" ]; then + csv="$name" + else + csv="$csv,$name" + fi + done < "$SERVICES_FILE" + printf '%s\n' "$csv" +} + +write_config_without_manager() { + drop="$1" + "$LUA_BIN" - "$CONFIG_BACKUP" "$CONFIG_FILE" "$drop" <<'LUA' +local ok_safe, cjson = pcall(require, 'cjson.safe') +if not ok_safe then + cjson = require 'cjson' +end + +local src, dst, drop = arg[1], arg[2], arg[3] + +local function read_file(path) + local f, err = io.open(path, 'rb') + if not f then error(err, 0) end + local text = f:read('*a') + f:close() + return text +end + +local function write_file(path, text) + local f, err = io.open(path, 'wb') + if not f then error(err, 0) end + f:write(text) + f:close() +end + +local decoded, err = cjson.decode(read_file(src)) +if not decoded then + error('failed to decode ' .. src .. ': ' .. tostring(err), 0) +end + +local hal_data = decoded.hal and decoded.hal.data +if type(hal_data) ~= 'table' then + error('config has no hal.data table: ' .. src, 0) +end +if hal_data[drop] == nil then + error('manager not present in hal.data: ' .. tostring(drop), 0) +end + +hal_data[drop] = nil +write_file(dst, assert(cjson.encode(decoded))) +LUA +} + +sample_memory() { + label="$1" + pid="$2" + out="$3" + if ps -o pid= -p "$pid" >/dev/null 2>&1; then + sampler_mode='ps' + else + sampler_mode='procfs' + fi + i=0 + while kill -0 "$pid" 2>/dev/null; do + { + printf 'sample=%s time=%s pid=%s\n' "$i" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$pid" + if [ "$sampler_mode" = 'ps' ]; then + ps -o pid,ppid,rss,vsz,comm,args -p "$pid" 2>/dev/null || true + elif [ -r "/proc/$pid/status" ]; then + awk -v pid="$pid" ' + BEGIN { ppid="?"; vmrss="?"; vmsize="?"; name="?" } + /^Name:[[:space:]]+/ { name=$2 } + /^PPid:[[:space:]]+/ { ppid=$2 } + /^VmRSS:[[:space:]]+/ { vmrss=$2 " " $3 } + /^VmSize:[[:space:]]+/ { vmsize=$2 " " $3 } + END { + printf "pid=%s ppid=%s rss=%s vsz=%s comm=%s\n", pid, ppid, vmrss, vmsize, name + } + ' "/proc/$pid/status" + else + printf '%s\n' 'memory unavailable: ps format unsupported and /proc//status not readable' + fi + printf '\n' + } >> "$out" + i=$((i + 1)) + sleep "${DEVICECODE_SAMPLE_SECONDS:-5}" + done + printf '%s\n' "[per-component] memory sampler stopped for $label" >> "$out" +} + +run_variant() { + kind="$1" + name="$2" + services="$3" + label="$kind-$name" + case "$label" in + */*) label="$(printf '%s\n' "$label" | tr '/' '_')" ;; + esac + + restore_config + if [ "$kind" = "drop-manager" ]; then + write_config_without_manager "$name" + fi + + run_dir="$LOG_DIR/$label" + mkdir -p "$run_dir" + printf '%s\n' "$services" > "$run_dir/services.csv" + cp "$CONFIG_FILE" "$run_dir/$CONFIG_TARGET.json" + + printf '%s\n' "[per-component] starting $label for ${RUN_SECONDS}s" + ( + cd "$APP_DIR" + DEVICECODE_ENV=dev \ + DEVICECODE_SERVICES="$services" \ + DEVICECODE_CONFIG_DIR="$CONFIG_DIR" \ + CONFIG_TARGET="$CONFIG_TARGET" \ + "$LUA_BIN" main.lua + ) > "$run_dir/devicecode.log" 2>&1 & + pid="$!" + + sample_memory "$label" "$pid" "$run_dir/memory.log" & + sampler_pid="$!" + + elapsed=0 + while [ "$elapsed" -lt "$RUN_SECONDS" ] && kill -0 "$pid" 2>/dev/null; do + sleep 1 + elapsed=$((elapsed + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + wait "$pid" 2>/dev/null || true + + if kill -0 "$sampler_pid" 2>/dev/null; then + kill "$sampler_pid" 2>/dev/null || true + fi + wait "$sampler_pid" 2>/dev/null || true + + restore_config + printf '%s\n' "[per-component] finished $label; logs: $run_dir" +} + +all_services="$(csv_from_file_except '')" +[ -n "$all_services" ] || fail "no services listed in $SERVICES_FILE" + +run_variant all enabled "$all_services" + +while IFS= read -r service || [ -n "$service" ]; do + case "$service" in + ''|'#'*) continue ;; + esac + run_variant drop-service "$service" "$(csv_from_file_except "$service")" +done < "$SERVICES_FILE" + +while IFS= read -r manager || [ -n "$manager" ]; do + case "$manager" in + ''|'#'*) continue ;; + esac + run_variant drop-manager "$manager" "$all_services" +done < "$MANAGERS_FILE" + +printf '%s\n' "[per-component] complete; logs: $LOG_DIR"