Skip to content

lazy-loading

Keep startup RAM small by loading tool/widget code on first use.

This example shows how to keep startup files small by registering lightweight proxy callbacks, then loading the larger tool or widget implementation only when Ethos actually calls it.

Lazy loading is useful on radios because Lua memory and per-cycle CPU time are limited. If a script registers several tools, widgets, or optional features at startup, loading every implementation immediately can spend memory on code that the pilot may never open during that model session.

Read "Known limitations" below before using this for a widget's or system tool's top-level registration. The tradeoff is real but it is not free, and it has gone wrong in practice for at least one production Ethos Lua suite.

Use lazy loading when:

  • A module is optional, such as a widget, setup tool, diagnostics page, or rarely used menu.
  • Startup memory matters more than the tiny delay of loading the module the first time it is used.
  • The registered item only needs static metadata at startup, such as name, key, or icon.

Avoid lazy loading when:

  • The module is required immediately for a background task or safety-critical behavior.
  • The module's top-level code must run during script initialization.
  • The first-use delay would happen in a hot path such as a frequent wakeup loop.
  • The widget/tool persists its own settings via read/write — see "Known limitations" below; wrapping these usually loads the module almost immediately anyway.

The reusable helper is lazy.lua. Copy that file next to your script, then wrap the callbacks in the registration table you pass to system.registerSystemTool() or system.registerWidget().

Files in this example:

  • main.lua loads the two tiny registration demos.
  • lazy.lua contains the reusable lazy-loading helper.
  • tool.lua demonstrates lazy registerSystemTool() registration.
  • widget.lua demonstrates lazy registerWidget() registration.
  • tool_impl.lua and widget_impl.lua contain the heavier callback code that is loaded on first use.

The pattern is:

  1. Keep only static metadata and registration code in the startup file.
  2. Put the real callback functions in an implementation module.
  3. Use lazy.wrap() to export proxy callbacks for the methods you want to defer.
  4. lazy.wrap() caches the loaded module and, the moment it loads (or fails to load), replaces every wrapped method with its final direct implementation — see "How the proxy resolves itself" below.
  5. Remember load failures so Ethos does not retry and print errors every tick.

tool_impl.lua is loaded only when the "Lua Lazy Tool" system tool is opened. widget_impl.lua is loaded only when the "Lua Lazy Widget" is created, painted, configured, or otherwise called by Ethos.

Minimal system tool registration:

local lazy = assert(loadfile("lazy.lua"))()

local tool = lazy.wrap({
  name = "My Tool",
}, "tool_impl.lua", {"create", "wakeup", "event", "close"})

system.registerSystemTool(tool)

Minimal widget registration:

local lazy = assert(loadfile("lazy.lua"))()

local widget = lazy.wrap({
  key = "my-widget",
  name = "My Widget",
}, "widget_impl.lua", {"create", "paint", "wakeup", "configure", "read", "write", "close"}, {
  create = function() return {} end,
})

system.registerWidget(widget)

This mirrors the approach used by larger scripts: register cheap metadata up front, defer optional code and assets until they are needed, and keep wakeup paths from doing unnecessary allocation once the module has loaded.

How the proxy resolves itself

A naive lazy proxy stays a proxy forever: every call, for the widget's or tool's entire remaining lifetime, pays an extra closure call plus a table lookup into the loaded module, even long after that module is cached. For a hot path like paint()/wakeup() — called on every frame or tick for as long as the widget is visible — that is a small but permanent, avoidable cost.

lazy.wrap() avoids this: the first call to any wrapped method loads the module (or records that it failed) and then overwrites every wrapped method on the registration table with its final, direct implementation (the module's function, or the fallback if one was provided and the module didn't supply that method). Every call after that first one goes straight to the real function — no proxy, no lookup. The only ongoing cost is the one-time cost you'd pay on first use regardless of how you loaded the module.

This relies on Ethos looking up widget.wakeup/tool.wakeup etc. fresh from the registration table on each call, rather than capturing a fixed reference to the function at registerWidget()/registerSystemTool() time. That matches observed behavior (widgets are commonly built by mutating fields on the same table after registration), but if you're targeting a specific firmware version and see stale proxy behavior, verify this on your own hardware before relying on it.

Known limitations

Deferring a widget's or tool's top-level registration this way has caused problems in practice. At least one production Ethos Lua suite tried exactly this pattern — lightweight proxy callbacks up front, real implementation loaded on first use — for its top-level registerWidget()/registerSystemTool()/registerTask() registrations, and reverted it after on-device testing showed it increased retained RAM over a session compared to just registering the real callbacks directly at startup. The suite's own migration note: "registers direct callbacks eagerly. This costs more startup RAM than lazy proxies, but avoids retained-RAM growth observed on device with the lazy callback layer."

The exact mechanism wasn't conclusively isolated, but it's consistent with Ethos's own form/widget system retaining some allocations outside Lua's normal GC reachability graph — a platform trait a userspace proxy layer can't work around. If you adopt this pattern for a widget or system tool the pilot uses every session (not an occasionally-opened diagnostics/setup tool), measure retained RAM on your actual target hardware across a realistic session before and after, rather than assuming lazy loading is a strict win. system.getMemoryUsage() (where available) or repeated collectgarbage("count") samples across a navigation loop are enough to catch this kind of regression.

Wrapping read/write/configure alongside paint/wakeup often doesn't defer loading as much as it looks like it should. Ethos typically calls a widget's read() as soon as it's placed on a screen, to restore persisted settings — independent of whether the pilot ever actually views or interacts with it. If your widget persists any setting (most do), wrapping read in the same lazy group as paint/wakeup means the "heavy" implementation module usually loads almost immediately anyway, at screen-load time, not at first paint. You still get the self-resolving-proxy benefit (see above) once that happens, but not the startup-RAM deferral the pattern advertises. If startup RAM is the actual goal, consider keeping create/read/write/configure eager and reserving lazy wrapping for callbacks that genuinely don't fire until the pilot interacts with the widget.

Install: copy the folder to SCRIPTS:/lazy-loading/ on the radio (or the simulator's scripts/ folder) and restart.

main.lua
-- Lua Lazy Loading example
--
-- main.lua only loads the tiny registration demos. The heavier callback
-- implementations are loaded lazily by tool.lua and widget.lua.

local toolDemo = assert(loadfile("tool.lua"))()
local widgetDemo = assert(loadfile("widget.lua"))()

local function init()
  toolDemo.init()
  widgetDemo.init()
end

return {init = init}
lazy.lua
-- Reusable lazy-loading helper.
--
-- Copy this file next to your script, then wrap registration callbacks with
-- lazy.wrap(). Only static metadata is kept in the startup file.
--
-- IMPORTANT: read the README's "Known limitations" section before using
-- this for a widget's or system tool's *top-level* registration. Deferring
-- top-level registerWidget()/registerSystemTool() callbacks behind a proxy
-- layer like this one has been tried and reverted in at least one
-- production Ethos Lua suite after on-device testing showed it increased
-- retained RAM over a session compared to registering the real callbacks
-- directly -- confirm the tradeoff on your own target hardware before
-- relying on it for something the pilot uses every session.
--
-- The proxy below is self-eliminating: the *first* call to any wrapped
-- method pays the loadfile() + table-lookup indirection cost, and that
-- same call replaces every wrapped method on `registration` with the real
-- module's function (or the fallback, if loading failed) so every call
-- after the first goes straight to the real function with no further
-- proxy overhead. This matters for hot paths like paint()/wakeup(), which
-- Ethos calls on every frame/tick for as long as the widget is visible --
-- without self-elimination, *every one* of those calls, for the widget's
-- entire remaining lifetime, would pay the extra indirection, not just
-- the first.

local lazy = {}

local function createModuleLoader(path)
  local module = nil
  local attempted = false

  -- Returns (module, justCompleted). justCompleted is true exactly once,
  -- on whichever call actually performs the load attempt (success or
  -- failure) -- callers use it to know when it's safe to resolve every
  -- wrapped method to its final, direct implementation.
  return function()
    if attempted then
      return module, false
    end
    attempted = true

    local chunk, err = loadfile(path)
    if not chunk then
      print("Lazy load failed: " .. path .. " (" .. tostring(err) .. ")")
      return nil, true
    end

    local ok, result = pcall(chunk)
    if not ok then
      print("Lazy module errored: " .. path .. " (" .. tostring(result) .. ")")
      return nil, true
    end

    if type(result) ~= "table" then
      print("Lazy module did not return a table: " .. path)
      return nil, true
    end

    module = result
    print("Lazy loaded: " .. path)
    return module, true
  end
end

-- Overwrites every wrapped method on `registration` with its final,
-- direct implementation (module function, or fallback if the module
-- didn't provide one / failed to load). Methods with neither are left
-- as-is (still the proxy, which will keep returning nil harmlessly).
local function resolveRegistration(registration, callbacks, fallback, mod)
  for i = 1, #callbacks do
    local method = callbacks[i]
    local fn = mod and mod[method]
    if type(fn) ~= "function" and fallback then
      fn = fallback[method]
    end
    if fn ~= nil then
      registration[method] = fn
    end
  end
end

local function makeProxy(loadModule, method, fallback, onResolved)
  return function(...)
    local mod, justCompleted = loadModule()
    if justCompleted then
      onResolved(mod)
    end

    local fn = mod and mod[method]
    if type(fn) ~= "function" and fallback then
      fn = fallback[method]
    end
    if type(fn) == "function" then
      return fn(...)
    end
    return fn
  end
end

function lazy.wrap(registration, path, callbacks, fallback)
  local loadModule = createModuleLoader(path)

  local function onResolved(mod)
    resolveRegistration(registration, callbacks, fallback, mod)
  end

  for i = 1, #callbacks do
    local method = callbacks[i]
    registration[method] = makeProxy(loadModule, method, fallback, onResolved)
  end

  return registration
end

return lazy
tool.lua
-- registerSystemTool lazy-loading demo.
--
-- This file is intentionally small. It registers metadata immediately and
-- defers the real callbacks to tool_impl.lua.

local lazy = assert(loadfile("lazy.lua"))()

local tool = lazy.wrap({
  name = "Lua Lazy Tool",
}, "tool_impl.lua", {"create", "wakeup", "event", "close"})

local function init()
  system.registerSystemTool(tool)
end

return {init = init}
tool_impl.lua
-- Loaded only when the "Lua Lazy Tool" system tool is opened.

local progress = nil
local progressValue = 0

local function closeProgress()
  if progress then
    progress:close()
    progress = nil
  end
  progressValue = 0
end

local function create()
  local data = {
    count = 3,
    enabled = true,
  }

  local line = form.addLine("Loaded module")
  form.addStaticText(line, nil, "tool_impl.lua")

  line = form.addLine("Count")
  local countField = form.addNumberField(line, nil, 1, 10,
    function() return data.count end,
    function(value) data.count = value end)
  countField:default(3)

  line = form.addLine("Enabled")
  form.addBooleanField(line, nil,
    function() return data.enabled end,
    function(value) data.enabled = value end)

  line = form.addLine("Progress")
  form.addTextButton(line, nil, "Start",
    function()
      closeProgress()
      progress = form.openProgressDialog("Lazy tool", "Working from a lazy-loaded module...")
    end)

  return data
end

local function wakeup(data)
  if not progress then
    return
  end

  progressValue = progressValue + 2
  if progressValue >= 100 then
    closeProgress()
  else
    progress:value(progressValue)
  end
end

local function event(data, category, value, x, y)
  print("Lazy tool event:", category, value, x, y)
  return false
end

local function close(data)
  closeProgress()
end

return {
  create = create,
  wakeup = wakeup,
  event = event,
  close = close,
}
widget.lua
-- registerWidget lazy-loading demo.
--
-- This file is intentionally small. It registers metadata immediately and
-- defers the real callbacks to widget_impl.lua.

local lazy = assert(loadfile("lazy.lua"))()

local widget = lazy.wrap({
  key = "lazy",
  name = "Lua Lazy Widget",
}, "widget_impl.lua", {"create", "paint", "wakeup", "configure", "read", "write", "close"}, {
  create = function() return {} end,
})

local function init()
  system.registerWidget(widget)
end

return {init = init}
widget_impl.lua
-- Loaded only when the "Lua Lazy Widget" is created, painted, configured, etc.

local function resetCachedValue(widget)
  widget.value = nil
  widget.text = "--"
end

local function create()
  return {
    source = nil,
    value = nil,
    text = "--",
  }
end

local function paint(widget)
  local w, h = lcd.getWindowSize()
  local text = widget.text or "--"

  lcd.font(FONT_STD)
  local textW, textH = lcd.getTextSize(text)
  lcd.drawText((w - textW) / 2, (h - textH) / 2, text)
end

local function wakeup(widget)
  local source = widget.source
  local value = source and source:value() or nil

  if widget.value ~= value then
    widget.value = value
    widget.text = source and source:stringValue() or "--"
    lcd.invalidate()
  end
end

local function configure(widget)
  local line = form.addLine("Source")
  form.addSourceField(line, nil,
    function() return widget.source end,
    function(value)
      widget.source = value
      resetCachedValue(widget)
      lcd.invalidate()
    end)
end

local function read(widget)
  widget.source = storage.read("source")
  resetCachedValue(widget)
end

local function write(widget)
  storage.write("source", widget.source)
end

local function close(widget)
  resetCachedValue(widget)
end

return {
  create = create,
  paint = paint,
  wakeup = wakeup,
  configure = configure,
  read = read,
  write = write,
  close = close,
}