feat(net): unify boot eventloop and service bus
This commit is contained in:
parent
db02eef960
commit
e66fb87bde
11
CLAUDE.md
11
CLAUDE.md
@ -24,15 +24,16 @@ Use [`docs/README.md`](docs/README.md) as the entrypoint for CC:Tweaked, CraftOS
|
||||
## Architecture
|
||||
|
||||
- `apis/eventloop.lua` is the single-threaded event loop around `os.pullEventRaw`; consider using it everywhere async behavior is needed. A handler that returns `api.STOP` auto-unregisters.
|
||||
- `startup/servers.lua` creates a single boot eventloop at `_G.bootEventLoop` and runs it alongside the shell via `parallel.waitForAny`. Servers register handlers on it and return; programs call services via `os.pullEvent` without touching the loop. See [ADR-0015](docs/adrs/adr-0015-unified-boot-eventloop-and-service-bus.md).
|
||||
- `apis/libtest.lua` is the lightweight test helper used by scripts under `tests/`; `/programs/runtest.lua` discovers tests, renders suite output, and owns the `__TRAPOS_TEST_OK__` success marker.
|
||||
- `apis/net.lua` builds modem packet messaging, routing, and request/response RPC on the event loop. `sendRequest` returns `ok, result, packet` and defaults to a 0.5s timeout.
|
||||
- A router (`/programs/router.lua`) must be running somewhere on the network; without it, packets lack `routerId`, `isPacketOk` rejects them, and cross-machine messaging silently fails.
|
||||
- `servers/` listen for requests and start loops; `programs/` are clients that send requests and exit.
|
||||
- Well-known channels: `9` ping, `10` router/default routing. Keep duplicated constants in sync.
|
||||
- `apis/net.lua` is a service-name bus on a single channel (`10`). `net.serve(name, handler)` registers a server handler; `net.call(name, payload, { destId, timeout })` and `net.send(name, payload, { destId })` are the client surface. `require('/apis/net')()` returns a singleton bound to `_G.bootEventLoop` when present, otherwise an ephemeral instance.
|
||||
- A router (`/programs/router.lua`) must be running somewhere on the network; it registers a `modem_message` handler that stamps `routerId`, resolves label-addressed packets via a TTL map populated by `servers/net-registrar.lua`, and rebroadcasts. Without it, packets stay unrouted and consumers ignore them.
|
||||
- `servers/` register handlers on the boot eventloop and return; `programs/` are clients that exit.
|
||||
- Single well-known channel: `10` (the bus). Service multiplexing happens inside the packet body.
|
||||
|
||||
## Boot And Install
|
||||
|
||||
- `startup/servers.lua` starts `/programs`, the shell, and configured servers via `parallel.waitForAll`.
|
||||
- `startup/servers.lua` creates the boot eventloop, runs autostart server files (which register handlers and return), then runs the shell and the eventloop in parallel via `parallel.waitForAny`.
|
||||
- Preserve `periphemu` guards used for CraftOS-PC emulation; see [`docs/craftos_pc_glossary.md`](docs/craftos_pc_glossary.md) for upstream emulator references.
|
||||
- TrapOS ships as packages, each described by `packages/<name>/ccpm.json` (`name`, `version`, `dependencies`, `files`, `autostart`); `packages/index.json` lists them. Source files stay in place — descriptors only reference them. To ship a new file, add it to the right package's `files` (and `autostart` if it is a server). `packages/trapos/ccpm.json` is the full OS meta-package. See [ADR-0010](docs/adrs/adr-0010-ccpm-package-manager.md).
|
||||
- `install-ccpm.lua` is the one-time wget bootstrap. It installs only `trapos-core`/`ccpm`, seeds the default `guillaumearm/cc-libs` registry on `master` (or `next` with `--beta`), and tells users to run `ccpm update` then `ccpm install trapos`.
|
||||
|
||||
49
apis/librouter.lua
Normal file
49
apis/librouter.lua
Normal file
@ -0,0 +1,49 @@
|
||||
-- TrapOS router state machine: (label -> id) map with TTL.
|
||||
--
|
||||
-- Pure logic so it can be unit-tested without a modem or eventloop.
|
||||
-- The wire glue lives in /programs/router.lua.
|
||||
local DEFAULT_TTL = 90;
|
||||
|
||||
local function createRouter(options)
|
||||
options = options or {};
|
||||
local ttl = options.ttl or DEFAULT_TTL;
|
||||
local nowFn = options.now or os.clock;
|
||||
|
||||
local labelMap = {};
|
||||
|
||||
local function isExpired(entry)
|
||||
return entry.expiresAt < nowFn();
|
||||
end
|
||||
|
||||
local function register(label, id)
|
||||
local existing = labelMap[label];
|
||||
if existing and existing.id ~= id and not isExpired(existing) then
|
||||
return false, 'duplicate label';
|
||||
end
|
||||
labelMap[label] = { id = id, expiresAt = nowFn() + ttl };
|
||||
return true;
|
||||
end
|
||||
|
||||
local function resolve(label)
|
||||
local entry = labelMap[label];
|
||||
if not entry then return nil end
|
||||
if isExpired(entry) then
|
||||
labelMap[label] = nil;
|
||||
return nil;
|
||||
end
|
||||
return entry.id;
|
||||
end
|
||||
|
||||
local function forget(label)
|
||||
labelMap[label] = nil;
|
||||
end
|
||||
|
||||
return {
|
||||
register = register,
|
||||
resolve = resolve,
|
||||
forget = forget,
|
||||
ttl = ttl,
|
||||
};
|
||||
end
|
||||
|
||||
return createRouter;
|
||||
385
apis/net.lua
385
apis/net.lua
@ -1,267 +1,212 @@
|
||||
-- TrapOS networking: service-name bus on a single channel.
|
||||
--
|
||||
-- Servers register handlers on the boot eventloop:
|
||||
-- net.serve('ping', function(msg, reply) reply('pong') end)
|
||||
-- net.listen('events.foo', function(msg, packet) ... end)
|
||||
--
|
||||
-- Clients (CLI programs) call or send without an eventloop:
|
||||
-- local ok, res = net.call('ping', 'ping', { destId = 5, timeout = 0.5 })
|
||||
-- net.send('events.foo', payload, { destId = 'alice' })
|
||||
--
|
||||
-- A router service (servers/router-server.lua) must run on exactly one machine.
|
||||
-- It resolves label-addressed packets, stamps routerId, and rebroadcasts.
|
||||
local createEventLoop = require('/apis/eventloop');
|
||||
|
||||
local DEFAULT_TIMEOUT_WAIT_MESSAGE = 0.5; -- in seconds
|
||||
local DEFAULT_ROUTING_CHANNEL = 10;
|
||||
local BUS_CHANNEL = 10;
|
||||
local DEFAULT_TIMEOUT = 0.5;
|
||||
|
||||
-- Utilitaire pour savoir si un packet nous est destiné.
|
||||
-- le parametre 'packet' est une table avec les champs suivants:
|
||||
-- - sourceId: l'id de la machine qui a envoyé le message
|
||||
-- - destId: l'id du destinataire, si l'id est nil le message est routé a tout le monde
|
||||
-- - routerId: l'id du routeur qui s'est occupé de transmettre le message
|
||||
-- - message: le contenu du message (qui sera le plus souvent une table)
|
||||
-- return un boolean
|
||||
local function isPacketOk(packet)
|
||||
if type(packet) ~= "table" then
|
||||
return false;
|
||||
end
|
||||
|
||||
|
||||
if not packet.routerId or not packet.sourceId then
|
||||
return false;
|
||||
end
|
||||
|
||||
-- if packet.sourceId == os.getComputerID() then
|
||||
-- return false;
|
||||
-- end
|
||||
|
||||
if packet.destId == nil then
|
||||
return true;
|
||||
end
|
||||
|
||||
if type(packet.destId) == 'number' and packet.destId == os.getComputerID() then
|
||||
return true;
|
||||
end
|
||||
|
||||
if type(packet.destId) == 'string' and packet.destId == os.getComputerLabel() then
|
||||
return true;
|
||||
end
|
||||
|
||||
return false;
|
||||
local nextRequestSeq = 1;
|
||||
local function newRequestId()
|
||||
local id = tostring(os.getComputerID()) .. ':' .. tostring(nextRequestSeq) .. ':' .. tostring(os.clock());
|
||||
nextRequestSeq = nextRequestSeq + 1;
|
||||
return id;
|
||||
end
|
||||
|
||||
-- -- Example: implementation simple de ping
|
||||
--
|
||||
--
|
||||
-- local createNet = require('apis/net');
|
||||
-- net = createNet();
|
||||
-- local net = createNet(nil, modem);
|
||||
local function isPacketOk(packet)
|
||||
if type(packet) ~= 'table' then return false end
|
||||
if not packet.routerId or not packet.sourceId then return false end
|
||||
if packet.destId == nil then return true end
|
||||
if type(packet.destId) == 'number' and packet.destId == os.getComputerID() then return true end
|
||||
if type(packet.destId) == 'string' and packet.destId == os.getComputerLabel() then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
-- net.listenRequest(PING_CHANNEL, 'ping', function(message, reply)
|
||||
-- if message == 'ping' then
|
||||
-- reply('pong');
|
||||
-- end
|
||||
-- end)
|
||||
--
|
||||
local function createNetwork(el, modem, routingChannel, timeoutInSec)
|
||||
local function createNetwork(el, modem, modemSide)
|
||||
el = el or createEventLoop();
|
||||
modem = modem or peripheral.find("modem") or error("modem not found");
|
||||
routingChannel = routingChannel or DEFAULT_ROUTING_CHANNEL;
|
||||
timeoutInSec = timeoutInSec or DEFAULT_TIMEOUT_WAIT_MESSAGE;
|
||||
modem = modem or peripheral.find('modem') or error('modem not found');
|
||||
modem.open(BUS_CHANNEL);
|
||||
|
||||
local function openChannel(chan)
|
||||
return modem.open(chan);
|
||||
end
|
||||
local isRouter = false;
|
||||
modemSide = modemSide or peripheral.getName(modem);
|
||||
|
||||
-- net.send function
|
||||
local function sendRaw(channel, message, destId)
|
||||
local sourceId = os.getComputerID()
|
||||
local sourceLabel = os.getComputerLabel();
|
||||
local routerId = nil;
|
||||
|
||||
if _G.isRouterEnabled then
|
||||
routerId = sourceId
|
||||
end
|
||||
|
||||
local packet = {
|
||||
sourceId = sourceId,
|
||||
sourceLabel = sourceLabel,
|
||||
routerId = routerId,
|
||||
local function buildPacket(service, kind, payload, destId, requestId)
|
||||
return {
|
||||
sourceId = os.getComputerID(),
|
||||
sourceLabel = os.getComputerLabel(),
|
||||
destId = tonumber(destId) or destId,
|
||||
message = message
|
||||
}
|
||||
|
||||
if packet.destId ~= nil and packet.destId == sourceId then
|
||||
packet.routerId = packet.sourceId;
|
||||
os.queueEvent('modem_message', peripheral.getName(modem), channel, channel, packet, 0);
|
||||
return nil;
|
||||
service = service,
|
||||
kind = kind,
|
||||
requestId = requestId,
|
||||
payload = payload,
|
||||
routerId = isRouter and os.getComputerID() or nil,
|
||||
};
|
||||
end
|
||||
|
||||
if packet.destId == nil or packet.destId == sourceLabel then
|
||||
os.queueEvent('modem_message', peripheral.getName(modem), channel, channel, packet, 0);
|
||||
local function transmit(packet)
|
||||
local selfId = os.getComputerID();
|
||||
local selfLabel = os.getComputerLabel();
|
||||
|
||||
local destIsSelfId = packet.destId == selfId;
|
||||
local destIsSelfLabel = selfLabel ~= nil and packet.destId == selfLabel;
|
||||
local destIsSelf = destIsSelfId or destIsSelfLabel;
|
||||
local matchesSelf = packet.destId == nil or destIsSelf;
|
||||
|
||||
if matchesSelf then
|
||||
local localPacket = {};
|
||||
for k, v in pairs(packet) do localPacket[k] = v end
|
||||
localPacket.routerId = localPacket.routerId or selfId;
|
||||
os.queueEvent('modem_message', modemSide, BUS_CHANNEL, BUS_CHANNEL, localPacket, 0);
|
||||
end
|
||||
|
||||
if packet.routerId then
|
||||
return modem.transmit(channel, channel, packet);
|
||||
if not destIsSelf then
|
||||
modem.transmit(BUS_CHANNEL, BUS_CHANNEL, packet);
|
||||
end
|
||||
end
|
||||
|
||||
return modem.transmit(routingChannel, channel, packet);
|
||||
end
|
||||
|
||||
local function listenRaw(channel, handler)
|
||||
openChannel(channel);
|
||||
|
||||
local function serve(serviceName, handler)
|
||||
return el.register('modem_message', function(_, _, replyChannel, packet)
|
||||
if isPacketOk(packet) and channel == replyChannel then
|
||||
handler(packet.message, packet);
|
||||
end
|
||||
end)
|
||||
if replyChannel ~= BUS_CHANNEL then return end
|
||||
if not isPacketOk(packet) then return end
|
||||
if packet.service ~= serviceName or packet.kind ~= 'req' then return end
|
||||
|
||||
local function reply(responsePayload)
|
||||
local response = buildPacket(serviceName, 'res', responsePayload, packet.sourceId, packet.requestId);
|
||||
transmit(response);
|
||||
end
|
||||
|
||||
local function send(channel, eventType, payload, destId)
|
||||
local event = { type = eventType, payload = payload };
|
||||
return sendRaw(channel, event, destId);
|
||||
handler(packet.payload, reply, packet);
|
||||
end);
|
||||
end
|
||||
|
||||
local function listen(channel, eventType, handler)
|
||||
return listenRaw(channel, function(event, packet)
|
||||
if event.type == eventType then
|
||||
handler(event.payload, packet)
|
||||
end
|
||||
end)
|
||||
local function listen(serviceName, handler)
|
||||
return el.register('modem_message', function(_, _, replyChannel, packet)
|
||||
if replyChannel ~= BUS_CHANNEL then return end
|
||||
if not isPacketOk(packet) then return end
|
||||
if packet.service ~= serviceName or packet.kind ~= 'evt' then return end
|
||||
|
||||
handler(packet.payload, packet);
|
||||
end);
|
||||
end
|
||||
|
||||
local function listenRequest(channel, eventType, handler)
|
||||
return listen(channel, eventType, function(payload, packet)
|
||||
local reply = function(responsePayload)
|
||||
send(channel, eventType .. "_response", responsePayload, packet.sourceId);
|
||||
local function send(serviceName, payload, opts)
|
||||
opts = opts or {};
|
||||
transmit(buildPacket(serviceName, 'evt', payload, opts.destId, nil));
|
||||
end
|
||||
|
||||
handler(payload, reply, packet);
|
||||
end)
|
||||
end
|
||||
|
||||
local function sendRequest(channel, eventType, payload, destId)
|
||||
local ok = false;
|
||||
local result = nil;
|
||||
local packetResult = nil;
|
||||
|
||||
local privateEventLoop = createEventLoop();
|
||||
local privateNet = createNetwork(privateEventLoop, modem, routingChannel, timeoutInSec);
|
||||
|
||||
privateNet.listen(channel, eventType .. "_response", function(responsePayload, packet)
|
||||
ok = true;
|
||||
result = responsePayload
|
||||
packetResult = packet;
|
||||
privateNet.stop();
|
||||
end)
|
||||
|
||||
privateEventLoop.setTimeout(function()
|
||||
result = "net.sendRequest timeout!"
|
||||
privateNet.stop();
|
||||
end, timeoutInSec);
|
||||
|
||||
privateNet.onStart(function()
|
||||
privateNet.send(channel, eventType, payload, destId);
|
||||
end)
|
||||
|
||||
privateNet.startLoop();
|
||||
|
||||
return ok, result, packetResult;
|
||||
end
|
||||
|
||||
local function sendMultipleRequests(channel, eventType, payload, destId)
|
||||
if destId ~= nil and tonumber(destId) ~= nil then
|
||||
local ok, res, packet = sendRequest(channel, eventType, payload, destId);
|
||||
|
||||
if not ok then
|
||||
return ok, res, packet
|
||||
end
|
||||
|
||||
return ok, { res }, { packet };
|
||||
end
|
||||
|
||||
local ok = false;
|
||||
local function awaitResponse(serviceName, requestId, timeout, collectMultiple)
|
||||
local timerId = os.startTimer(timeout);
|
||||
local results = {};
|
||||
local packetResults = {};
|
||||
local packets = {};
|
||||
|
||||
local privateEventLoop = createEventLoop();
|
||||
local privateNet = createNetwork(privateEventLoop, modem, routingChannel, timeoutInSec);
|
||||
|
||||
privateNet.listen(channel, eventType .. "_response", function(responsePayload, packet)
|
||||
ok = true;
|
||||
table.insert(results, responsePayload)
|
||||
table.insert(packetResults, packet);
|
||||
end)
|
||||
|
||||
privateEventLoop.setTimeout(function()
|
||||
if #results == 0 then
|
||||
results = "net.sendRequest timeout!"
|
||||
while true do
|
||||
local event, p1, _, p3, p4 = os.pullEvent();
|
||||
if event == 'timer' and p1 == timerId then
|
||||
if collectMultiple then
|
||||
if #results == 0 then return false, 'net.call timeout', {} end
|
||||
return true, results, packets;
|
||||
end
|
||||
return false, 'net.call timeout', nil;
|
||||
elseif event == 'modem_message' then
|
||||
local replyChannel = p3;
|
||||
local recvPacket = p4;
|
||||
if replyChannel == BUS_CHANNEL
|
||||
and isPacketOk(recvPacket)
|
||||
and recvPacket.service == serviceName
|
||||
and recvPacket.kind == 'res'
|
||||
and recvPacket.requestId == requestId then
|
||||
if collectMultiple then
|
||||
table.insert(results, recvPacket.payload);
|
||||
table.insert(packets, recvPacket);
|
||||
else
|
||||
os.cancelTimer(timerId);
|
||||
return true, recvPacket.payload, recvPacket;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
privateNet.stop();
|
||||
end, timeoutInSec);
|
||||
|
||||
privateNet.onStart(function()
|
||||
privateNet.send(channel, eventType, payload, destId);
|
||||
end)
|
||||
|
||||
privateNet.startLoop();
|
||||
|
||||
return ok, results, packetResults;
|
||||
end
|
||||
|
||||
local function createRequest(channel, eventType)
|
||||
local requestApi = {};
|
||||
|
||||
function requestApi.send(payload, destId)
|
||||
return sendRequest(channel, eventType, payload, destId);
|
||||
local function call(serviceName, payload, opts)
|
||||
opts = opts or {};
|
||||
local timeout = opts.timeout or DEFAULT_TIMEOUT;
|
||||
local requestId = newRequestId();
|
||||
transmit(buildPacket(serviceName, 'req', payload, opts.destId, requestId));
|
||||
return awaitResponse(serviceName, requestId, timeout, false);
|
||||
end
|
||||
|
||||
function requestApi.sendMultiple(payload, destId)
|
||||
return sendMultipleRequests(channel, eventType, payload, destId);
|
||||
local function callMultiple(serviceName, payload, opts)
|
||||
opts = opts or {};
|
||||
local timeout = opts.timeout or DEFAULT_TIMEOUT;
|
||||
local requestId = newRequestId();
|
||||
transmit(buildPacket(serviceName, 'req', payload, opts.destId, requestId));
|
||||
return awaitResponse(serviceName, requestId, timeout, true);
|
||||
end
|
||||
|
||||
function requestApi.listen(handler)
|
||||
return listenRequest(channel, eventType, handler)
|
||||
local function setRouter(enabled)
|
||||
isRouter = enabled and true or false;
|
||||
end
|
||||
|
||||
return requestApi;
|
||||
local function onUnrouted(handler)
|
||||
return el.register('modem_message', function(_, _, replyChannel, packet)
|
||||
if replyChannel ~= BUS_CHANNEL then return end
|
||||
if type(packet) ~= 'table' then return end
|
||||
if packet.routerId then return end
|
||||
if not packet.sourceId then return end
|
||||
handler(packet);
|
||||
end);
|
||||
end
|
||||
|
||||
local function createEvent(channel, eventType)
|
||||
local eventApi = {}
|
||||
local function rebroadcast(packet)
|
||||
packet.routerId = packet.routerId or os.getComputerID();
|
||||
local selfId = os.getComputerID();
|
||||
local selfLabel = os.getComputerLabel();
|
||||
local destIsSelfId = packet.destId == selfId;
|
||||
local destIsSelfLabel = selfLabel ~= nil and packet.destId == selfLabel;
|
||||
local destIsSelf = destIsSelfId or destIsSelfLabel;
|
||||
local matchesSelf = packet.destId == nil or destIsSelf;
|
||||
|
||||
|
||||
function eventApi.send(payload, destId)
|
||||
return send(channel, eventType, payload, destId);
|
||||
if matchesSelf then
|
||||
os.queueEvent('modem_message', modemSide, BUS_CHANNEL, BUS_CHANNEL, packet, 0);
|
||||
end
|
||||
|
||||
function eventApi.listen(handler)
|
||||
return listen(channel, eventType, handler)
|
||||
if not destIsSelf then
|
||||
modem.transmit(BUS_CHANNEL, BUS_CHANNEL, packet);
|
||||
end
|
||||
|
||||
return eventApi;
|
||||
end
|
||||
|
||||
local function start()
|
||||
return el.startLoop();
|
||||
end
|
||||
|
||||
local function stop()
|
||||
return el.stopLoop();
|
||||
end
|
||||
|
||||
return {
|
||||
sendRaw = sendRaw,
|
||||
listenRaw = listenRaw,
|
||||
send = send,
|
||||
listen = listen,
|
||||
sendRequest = sendRequest,
|
||||
sendMultipleRequests = sendMultipleRequests,
|
||||
listenRequest = listenRequest,
|
||||
createRequest = createRequest,
|
||||
createEvent = createEvent,
|
||||
isPacketOk = isPacketOk,
|
||||
openChannel = openChannel,
|
||||
open = openChannel,
|
||||
events = el,
|
||||
BUS_CHANNEL = BUS_CHANNEL,
|
||||
DEFAULT_TIMEOUT = DEFAULT_TIMEOUT,
|
||||
eventloop = el,
|
||||
start = start,
|
||||
startLoop = start,
|
||||
stop = stop,
|
||||
stopLoop = stop,
|
||||
onStart = el.onStart,
|
||||
onStop = el.onStop,
|
||||
}
|
||||
isPacketOk = isPacketOk,
|
||||
serve = serve,
|
||||
listen = listen,
|
||||
send = send,
|
||||
call = call,
|
||||
callMultiple = callMultiple,
|
||||
setRouter = setRouter,
|
||||
onUnrouted = onUnrouted,
|
||||
rebroadcast = rebroadcast,
|
||||
};
|
||||
end
|
||||
|
||||
return createNetwork;
|
||||
local singleton = nil;
|
||||
|
||||
return function(el, modem, modemSide)
|
||||
if el == nil and modem == nil and _G.bootEventLoop then
|
||||
if not singleton then
|
||||
singleton = createNetwork(_G.bootEventLoop, nil, nil);
|
||||
end
|
||||
return singleton;
|
||||
end
|
||||
return createNetwork(el, modem, modemSide);
|
||||
end
|
||||
|
||||
@ -22,3 +22,4 @@ Future ADRs can reuse the shape of the existing files when it is useful.
|
||||
- [`adr-0012-headless-craftos-pc-as-hypothesis-probe.md`](adr-0012-headless-craftos-pc-as-hypothesis-probe.md) - Headless CraftOS-PC as the canonical hypothesis probe (rename `just craftos` → `just trapos`, add vanilla `just craftos`).
|
||||
- [`adr-0013-markdown-link-syntax-for-cross-references.md`](adr-0013-markdown-link-syntax-for-cross-references.md) - Cross-reference markdown files with `[]()` syntax (so lychee can validate them).
|
||||
- [`adr-0014-prefer-eventloop-settimeout-over-os-sleep.md`](adr-0014-prefer-eventloop-settimeout-over-os-sleep.md) - Prefer `eventloop.setTimeout` over `os.sleep` in application code.
|
||||
- [`adr-0015-unified-boot-eventloop-and-service-bus.md`](adr-0015-unified-boot-eventloop-and-service-bus.md) - Unified boot eventloop and service-name bus (replaces per-server eventloops, channel constants, and `_G.isRouterEnabled`).
|
||||
|
||||
49
docs/adrs/adr-0015-unified-boot-eventloop-and-service-bus.md
Normal file
49
docs/adrs/adr-0015-unified-boot-eventloop-and-service-bus.md
Normal file
@ -0,0 +1,49 @@
|
||||
# ADR 0015: Unified Boot Eventloop and Service-Name Bus
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-06-09
|
||||
|
||||
## Context
|
||||
|
||||
Before this change, the TrapOS networking and boot model had accumulated several issues that were hard to fix incrementally:
|
||||
|
||||
- **Label collision was a silent footgun.** Two machines sharing the same label both accepted and rebroadcast packets addressed to that label, since `programs/router.lua:40` and `apis/net.lua:35` both matched `os.getComputerLabel()` independently. The sender received duplicate responses and the wire carried duplicate retransmits.
|
||||
- **`_G.isRouterEnabled` mutated send behavior across the codebase.** [`apis/net.lua`](../../apis/net.lua) `sendRaw` switched its transmit path based on a global flag set by the router program. This made the same function call mean different things depending on which machine ran it.
|
||||
- **Every autostart server ran its own private eventloop.** Each server file called `net.start()` which delegated to `el.startLoop()`. With N autostart entries, `parallel.waitForAll` ran N coroutines, each pumping an independent `os.pullEventRaw`. Wasteful and conceptually awkward: events were broadcast to every coroutine but only one would have a relevant handler.
|
||||
- **The router lived outside the eventloop entirely.** [`programs/router.lua`](../../programs/router.lua) was a hand-rolled `while true / os.pullEvent` loop, structurally different from every other long-lived process in the repo.
|
||||
- **Channel numbers leaked into every client.** `servers/ping-server.lua` and `programs/ping.lua` both duplicated a `PING_CHANNEL = 9` constant; there was no service registry. Adding a new service meant picking a free integer and replicating it on both ends.
|
||||
|
||||
Net's blast radius was small at this point — only `programs/ping.lua` and `servers/ping-server.lua` consumed it — so a clean break was cheaper than incremental patching.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt three coordinated changes:
|
||||
|
||||
1. **One boot eventloop per machine.** `startup/servers.lua` creates a single `createEventLoop()` instance, stores it at `_G.bootEventLoop`, runs autostart server files (which register handlers and return without blocking), then runs `parallel.waitForAny(shellFn, eventLoopFn)`. The shell and the eventloop are the only two coroutines.
|
||||
|
||||
2. **Service-name addressing on a single bus channel.** [`apis/net.lua`](../../apis/net.lua) exposes `net.serve(name, handler)`, `net.call(name, payload, opts)`, `net.send(name, payload, opts)`, and `net.listen(name, handler)`. All traffic flows on channel `10` and is demultiplexed inside the packet body via a `service` field. Channel numbers stop being a public concept. `require('/apis/net')()` returns a singleton bound to `_G.bootEventLoop` when present, otherwise an ephemeral instance.
|
||||
|
||||
3. **Router as a service on the boot eventloop.** [`programs/router.lua`](../../programs/router.lua) registers handlers on the same boot eventloop everything else uses. It owns a TTL-based label map (extracted into [`apis/librouter.lua`](../../apis/librouter.lua) for testability). Machines with a label autostart [`servers/net-registrar.lua`](../../servers/net-registrar.lua), which periodically broadcasts `(id, label)` so the router can resolve label-addressed packets. Duplicate label registrations are rejected with a printed warning. `_G.isRouterEnabled` is gone; the router service flips a local flag via `net.setRouter(true)` instead.
|
||||
|
||||
CLI programs stay standalone: `net.call` internally uses `os.pullEvent` with a timer, so programs do not need the boot eventloop to receive a response.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Adding a new networked service is now: write a `servers/foo.lua` that calls `net.serve('foo', handler)` and returns, then add it to a package's `autostart`. No channel allocation, no `.start()` blocking call.
|
||||
- The router program returns immediately instead of blocking the shell. Users type `router` once on the chosen machine and continue using the shell.
|
||||
- Label collisions are detected and rejected at registration time, with a clear warning, instead of causing silent duplicate delivery.
|
||||
- The ping API surface changed (`net.sendRequest` → `net.call`, `net.listenRequest` → `net.serve`). Out-of-tree consumers — if any existed — would need to migrate. Inside the repo only ping needed migration.
|
||||
- Programs that need to wait for events still work by direct `os.pullEvent`, but if a program registers a long-lived handler on `_G.bootEventLoop` and exits, the handler keeps firing with a stale closure. Programs should prefer `call`/`send` over `serve`/`listen`. This is documented in [`apis/net.lua`](../../apis/net.lua) but not enforced.
|
||||
- Tests for the router state machine live in [`tests/router.lua`](../../tests/router.lua) and exercise [`apis/librouter.lua`](../../apis/librouter.lua) with an injected clock. Tests for the net packet shape and dispatch live in [`tests/net.lua`](../../tests/net.lua) with a fake modem.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Multi-router topologies. The single-router assumption stays; a network is expected to run `router` on exactly one machine.
|
||||
- Retry and acknowledgement primitives beyond the existing per-call `timeout`.
|
||||
- Unifying `libtui`, `libai`, and `tuidemo` eventloops. They remain private; they are presentation/AI concerns, not network plumbing.
|
||||
- The `ccpm` package manager. It is recent, tested, and not in pain.
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TrapOS",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"branch": "next",
|
||||
"packages": [
|
||||
"trapos"
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
"packages": {
|
||||
"trapos-core": "0.4.0",
|
||||
"trapos-test": "0.2.1",
|
||||
"trapos-boot": "0.2.2",
|
||||
"trapos-net": "0.2.1",
|
||||
"trapos-boot": "0.3.0",
|
||||
"trapos-net": "0.3.0",
|
||||
"trapos-ui": "0.2.2",
|
||||
"trapos-ai": "0.6.0",
|
||||
"trapos-sandbox": "0.1.0",
|
||||
"trapos": "0.7.1"
|
||||
"trapos": "0.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos-boot",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"description": "TrapOS boot: startup MOTD and autostart server launcher",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "trapos-net",
|
||||
"version": "0.2.1",
|
||||
"description": "TrapOS networking: routed modem messaging, router, ping",
|
||||
"version": "0.3.0",
|
||||
"description": "TrapOS networking: service-name bus, router, ping",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
"apis/net.lua",
|
||||
"apis/librouter.lua",
|
||||
"programs/router.lua",
|
||||
"programs/ping.lua",
|
||||
"servers/ping-server.lua"
|
||||
"servers/ping-server.lua",
|
||||
"servers/net-registrar.lua"
|
||||
],
|
||||
"autostart": ["servers/ping-server"]
|
||||
"autostart": ["servers/ping-server", "servers/net-registrar"]
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"description": "TrapOS full install meta-package",
|
||||
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
|
||||
"files": [],
|
||||
|
||||
@ -4,7 +4,10 @@ if firstArg == '-version' or firstArg == '--version' then
|
||||
return;
|
||||
end
|
||||
|
||||
local PING_CHANNEL = 9;
|
||||
if firstArg == '-help' or firstArg == '--help' then
|
||||
print('Usage: ping [<id|label>]');
|
||||
return;
|
||||
end
|
||||
|
||||
local createNet = require('/apis/net');
|
||||
local net = createNet();
|
||||
@ -12,29 +15,21 @@ local net = createNet();
|
||||
local args = table.pack(...);
|
||||
local targetComputerId = tonumber(args[1]) or args[1];
|
||||
|
||||
local sourceId = os.getComputerID()
|
||||
local sourceId = os.getComputerID();
|
||||
local sourceLabel = os.getComputerLabel();
|
||||
|
||||
local ok, results, packets = net.callMultiple('ping', 'ping', { destId = targetComputerId });
|
||||
|
||||
-- envoyer un message sur le canal 9 à la machine cible
|
||||
|
||||
local ok, results, packets = net.sendMultipleRequests(PING_CHANNEL, 'ping', 'ping', targetComputerId);
|
||||
|
||||
if not ok and (targetComputerId ~= sourceId and targetComputerId ~= sourceLabel) then
|
||||
error(results)
|
||||
if not ok and targetComputerId ~= sourceId and targetComputerId ~= sourceLabel then
|
||||
error(results);
|
||||
end
|
||||
|
||||
if not ok then
|
||||
return;
|
||||
end
|
||||
if not ok then return end
|
||||
|
||||
for k, message in ipairs(results) do
|
||||
if message == 'pong' then
|
||||
local packet = packets[k];
|
||||
|
||||
-- if targetComputerId == nil or targetComputerId == packet.sourceId or targetComputerId == packet.sourceLabel then
|
||||
print("=> pong from " .. tostring(packet.sourceId)
|
||||
.. (packet.sourceLabel and " (label=" .. tostring(packet.sourceLabel) .. ")" or ""));
|
||||
-- end
|
||||
end
|
||||
end
|
||||
|
||||
@ -5,52 +5,65 @@ if firstArg == '-version' or firstArg == '--version' then
|
||||
return;
|
||||
end
|
||||
|
||||
local printVerbose = print
|
||||
|
||||
if firstArg == '-silent' or firstArg == '--silent' then
|
||||
printVerbose = function() end
|
||||
if firstArg == '-help' or firstArg == '--help' then
|
||||
print('Usage: router [-silent|--silent]');
|
||||
print('Enables routing on this machine. Registers handlers on the boot eventloop.');
|
||||
return;
|
||||
end
|
||||
|
||||
local ROUTER_CHANNEL = 10;
|
||||
local silent = (firstArg == '-silent' or firstArg == '--silent');
|
||||
local printVerbose = silent and function() end or print;
|
||||
|
||||
local modem = peripheral.find("modem") or error("modem not found");
|
||||
modem.open(ROUTER_CHANNEL);
|
||||
local createEventLoop = require('/apis/eventloop');
|
||||
local createNet = require('/apis/net');
|
||||
local createRouter = require('/apis/librouter');
|
||||
|
||||
printVerbose('started router on port ' .. tostring(ROUTER_CHANNEL) .. '...')
|
||||
local ownsLoop = false;
|
||||
if not _G.bootEventLoop then
|
||||
_G.bootEventLoop = createEventLoop();
|
||||
ownsLoop = true;
|
||||
end
|
||||
|
||||
local routerId = os.getComputerID();
|
||||
local net = createNet();
|
||||
net.setRouter(true);
|
||||
|
||||
_G.isRouterEnabled = true;
|
||||
local router = createRouter();
|
||||
|
||||
while true do
|
||||
local channel, replyChannel, payload, distance;
|
||||
|
||||
repeat
|
||||
_, _, channel, replyChannel, payload, distance = os.pullEvent("modem_message");
|
||||
|
||||
local channelOk = channel == ROUTER_CHANNEL;
|
||||
local payloadOk = type(payload) == 'table' and not payload.routerId;
|
||||
local loopFinished = channelOk and payloadOk;
|
||||
until loopFinished
|
||||
|
||||
|
||||
if payload and not payload.routerId then
|
||||
payload.routerId = routerId;
|
||||
|
||||
if payload.destId == nil or payload.destId == os.getComputerID() or payload.destId == os.getComputerLabel() then
|
||||
os.queueEvent('modem_message', peripheral.getName(modem), replyChannel, replyChannel, payload, distance);
|
||||
end
|
||||
if payload.destId ~= os.getComputerID() then
|
||||
modem.transmit(replyChannel, replyChannel, payload);
|
||||
end
|
||||
end
|
||||
|
||||
if payload.destId then
|
||||
printVerbose("Routed message from " .. tostring(payload.sourceId)
|
||||
.. " to " .. tostring(payload.destId)
|
||||
.. " using channel " .. tostring(replyChannel));
|
||||
net.listen('router.register', function(payload, packet)
|
||||
if type(payload) ~= 'table' or type(payload.label) ~= 'string' then return end
|
||||
local ok = router.register(payload.label, packet.sourceId);
|
||||
if ok then
|
||||
printVerbose("router: registered '" .. payload.label .. "' -> " .. tostring(packet.sourceId));
|
||||
else
|
||||
printVerbose("Broadcasted message from " .. tostring(payload.sourceId)
|
||||
.. " using channel " .. tostring(replyChannel));
|
||||
printVerbose("router: duplicate label '" .. payload.label .. "' from id " .. tostring(packet.sourceId));
|
||||
end
|
||||
end);
|
||||
|
||||
net.onUnrouted(function(packet)
|
||||
if type(packet.destId) == 'string' then
|
||||
local id = router.resolve(packet.destId);
|
||||
if id then
|
||||
packet.destId = id;
|
||||
else
|
||||
printVerbose("router: unknown label '" .. tostring(packet.destId) .. "' (dropping)");
|
||||
return;
|
||||
end
|
||||
end
|
||||
|
||||
if packet.destId then
|
||||
printVerbose("router: " .. tostring(packet.sourceId) .. " -> " .. tostring(packet.destId)
|
||||
.. " [" .. tostring(packet.service) .. "/" .. tostring(packet.kind) .. "]");
|
||||
else
|
||||
printVerbose("router: " .. tostring(packet.sourceId) .. " broadcast"
|
||||
.. " [" .. tostring(packet.service) .. "/" .. tostring(packet.kind) .. "]");
|
||||
end
|
||||
|
||||
net.rebroadcast(packet);
|
||||
end);
|
||||
|
||||
printVerbose('router v' .. require('/apis/libversion')().forSelf()
|
||||
.. ' started on bus channel ' .. tostring(net.BUS_CHANNEL));
|
||||
|
||||
if ownsLoop then
|
||||
_G.bootEventLoop.startLoop();
|
||||
end
|
||||
|
||||
24
servers/net-registrar.lua
Normal file
24
servers/net-registrar.lua
Normal file
@ -0,0 +1,24 @@
|
||||
-- Periodically broadcasts (id, label) to the router so label-addressed
|
||||
-- packets can be resolved network-wide. Skips machines with no label.
|
||||
local createNet = require("/apis/net")
|
||||
|
||||
-- TOFIX: the idea of this file is to dynamically listen the computerLabel so in fact "os.getComputerLabel" should be called inside refresh()
|
||||
local label = os.getComputerLabel()
|
||||
if not label then
|
||||
return
|
||||
end
|
||||
|
||||
local net = createNet()
|
||||
local el = net.eventloop
|
||||
|
||||
-- In the future we might want to consider to have core events like computer-label-changed (at os level)
|
||||
local REFRESH_SECONDS = 30
|
||||
|
||||
local function refresh()
|
||||
-- Note: I don't like "router.register" and net-registrar naming here
|
||||
-- I think what we want here is a sort of hack to get an event computer_label_changed that will be used by the router directly, so maybe move this directly in `startup` dir ?
|
||||
net.send("router.register", { label = label })
|
||||
el.setTimeout(refresh, REFRESH_SECONDS)
|
||||
end
|
||||
|
||||
el.onStart(refresh)
|
||||
@ -1,31 +1,23 @@
|
||||
-- -- Example: implementation simple de ping-server
|
||||
local PING_CHANNEL = 9;
|
||||
local MODEM_DETECTION_TIME = 3; -- in seconds
|
||||
local createNet = require("/apis/net")
|
||||
local createVersion = require("/apis/libversion")
|
||||
|
||||
local createNet = require('/apis/net');
|
||||
local createVersion = require('/apis/libversion');
|
||||
local MODEM_DETECTION_TIME = 3
|
||||
|
||||
local modem = peripheral.find('modem');
|
||||
|
||||
if not modem then
|
||||
print("Warning: modem not found!");
|
||||
if not peripheral.find("modem") then
|
||||
print("Warning: modem not found!")
|
||||
end
|
||||
|
||||
-- on attend le modem
|
||||
while not modem do
|
||||
modem = peripheral.find('modem');
|
||||
os.sleep(MODEM_DETECTION_TIME);
|
||||
-- TOFIX: os.sleep should not be used anymore since we are in the main eventloop here.
|
||||
while not peripheral.find("modem") do
|
||||
os.sleep(MODEM_DETECTION_TIME)
|
||||
end
|
||||
|
||||
local net = createNet(nil, modem);
|
||||
local net = createNet()
|
||||
|
||||
net.listenRequest(PING_CHANNEL, 'ping', function(message, reply)
|
||||
if message == 'ping' then
|
||||
-- print('=======> ping received !');
|
||||
reply('pong');
|
||||
net.serve("ping", function(message, reply)
|
||||
if message == "ping" then
|
||||
reply("pong")
|
||||
end
|
||||
end)
|
||||
|
||||
print('ping-server v' .. createVersion().forSelf() .. ' started.')
|
||||
|
||||
net.start();
|
||||
print("ping-server v" .. createVersion().forSelf() .. " started.")
|
||||
|
||||
@ -1,43 +1,54 @@
|
||||
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
|
||||
local LOCAL_MANIFEST_PATH = "/trapos/manifest.json"
|
||||
|
||||
-- I think this should be moved in `programs`
|
||||
-- then we will run this motd program from startup/boot.lua
|
||||
|
||||
local function readLocalManifest()
|
||||
if not fs.exists(LOCAL_MANIFEST_PATH) then return nil end
|
||||
local f = fs.open(LOCAL_MANIFEST_PATH, 'r');
|
||||
if not f then return nil end
|
||||
local data = f.readAll();
|
||||
f.close();
|
||||
if not data or data == '' then return nil end
|
||||
return textutils.unserializeJSON(data);
|
||||
if not fs.exists(LOCAL_MANIFEST_PATH) then
|
||||
return nil
|
||||
end
|
||||
local f = fs.open(LOCAL_MANIFEST_PATH, "r")
|
||||
if not f then
|
||||
return nil
|
||||
end
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
if not data or data == "" then
|
||||
return nil
|
||||
end
|
||||
return textutils.unserializeJSON(data)
|
||||
end
|
||||
|
||||
local manifest = readLocalManifest();
|
||||
if not manifest then return end
|
||||
local manifest = readLocalManifest()
|
||||
if not manifest then
|
||||
return
|
||||
end
|
||||
|
||||
local name = manifest.name or 'TrapOS';
|
||||
local version = manifest.version or '?';
|
||||
local branch = manifest.branch or 'master';
|
||||
local isBeta = branch == 'next';
|
||||
local name = manifest.name or "TrapOS"
|
||||
local version = manifest.version or "?"
|
||||
local branch = manifest.branch or "master"
|
||||
local isBeta = branch == "next"
|
||||
|
||||
local hasColor = term.isColor and term.isColor();
|
||||
local previousColor;
|
||||
local hasColor = term.isColor and term.isColor()
|
||||
local previousColor
|
||||
|
||||
if hasColor then
|
||||
previousColor = term.getTextColor();
|
||||
previousColor = term.getTextColor()
|
||||
if isBeta then
|
||||
term.setTextColor(colors.orange);
|
||||
term.setTextColor(colors.orange)
|
||||
else
|
||||
term.setTextColor(colors.lime);
|
||||
term.setTextColor(colors.lime)
|
||||
end
|
||||
end
|
||||
|
||||
if isBeta then
|
||||
print(name .. ' v' .. version .. ' [BETA]');
|
||||
print(name .. " v" .. version .. " [BETA]")
|
||||
else
|
||||
print(name .. ' v' .. version);
|
||||
print(name .. " v" .. version)
|
||||
end
|
||||
|
||||
if hasColor and previousColor then
|
||||
term.setTextColor(previousColor);
|
||||
term.setTextColor(previousColor)
|
||||
end
|
||||
|
||||
print();
|
||||
print()
|
||||
|
||||
@ -1,58 +1,60 @@
|
||||
local LOCAL_MANIFEST_PATH = '/trapos/manifest.json';
|
||||
local createEventLoop = require("/apis/eventloop")
|
||||
|
||||
local LOCAL_MANIFEST_PATH = "/trapos/manifest.json"
|
||||
|
||||
-- OK this file should not be named servers.lua we could rename it boot.lua and this could be the only file in this startup directory so the whole startup process will be orchestrated from there.
|
||||
|
||||
local function readLocalManifest()
|
||||
if not fs.exists(LOCAL_MANIFEST_PATH) then return nil end
|
||||
local f = fs.open(LOCAL_MANIFEST_PATH, 'r');
|
||||
if not f then return nil end
|
||||
local data = f.readAll();
|
||||
f.close();
|
||||
if not data or data == '' then return nil end
|
||||
return textutils.unserializeJSON(data);
|
||||
if not fs.exists(LOCAL_MANIFEST_PATH) then
|
||||
return nil
|
||||
end
|
||||
local f = fs.open(LOCAL_MANIFEST_PATH, "r")
|
||||
if not f then
|
||||
return nil
|
||||
end
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
if not data or data == "" then
|
||||
return nil
|
||||
end
|
||||
return textutils.unserializeJSON(data)
|
||||
end
|
||||
|
||||
local function init()
|
||||
shell.setPath(shell.path() .. ':/programs');
|
||||
shell.setPath(shell.path() .. ":/programs")
|
||||
end
|
||||
|
||||
init();
|
||||
init()
|
||||
|
||||
if periphemu then
|
||||
periphemu.create('top', 'modem');
|
||||
periphemu.create("top", "modem")
|
||||
end
|
||||
|
||||
_G.bootEventLoop = createEventLoop()
|
||||
|
||||
local function shellFn()
|
||||
os.sleep(0.1);
|
||||
shell.run("shell");
|
||||
os.sleep(0.1)
|
||||
shell.run("shell")
|
||||
end
|
||||
|
||||
local function getServerFns(serverList)
|
||||
local servers = {};
|
||||
|
||||
for k, v in ipairs(serverList) do
|
||||
local serverName = v;
|
||||
|
||||
servers[k] = function()
|
||||
if serverName then
|
||||
shell.run(serverName);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return servers;
|
||||
local function eventLoopFn()
|
||||
_G.bootEventLoop.runLoop(true)
|
||||
end
|
||||
|
||||
local manifest = readLocalManifest();
|
||||
local SERVERS = (manifest and manifest.autostart) or {};
|
||||
|
||||
local servers = getServerFns(SERVERS);
|
||||
local manifest = readLocalManifest()
|
||||
local SERVERS = (manifest and manifest.autostart) or {}
|
||||
|
||||
if #SERVERS > 0 then
|
||||
print("\nStarting servers...");
|
||||
for _, v in ipairs(SERVERS) do
|
||||
print("\t\t" .. v)
|
||||
print("\nStarting servers...")
|
||||
for _, serverName in ipairs(SERVERS) do
|
||||
print("\t\t" .. serverName)
|
||||
local ok, err = pcall(shell.run, serverName)
|
||||
if not ok then
|
||||
print("server '" .. serverName .. "' failed to start: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(shellFn, table.unpack(servers));
|
||||
parallel.waitForAny(shellFn, eventLoopFn)
|
||||
|
||||
os.shutdown();
|
||||
os.shutdown()
|
||||
|
||||
164
tests/net.lua
Normal file
164
tests/net.lua
Normal file
@ -0,0 +1,164 @@
|
||||
local createLibTest = require('/apis/libtest');
|
||||
local createEventLoop = require('/apis/eventloop');
|
||||
local createNet = require('/apis/net');
|
||||
|
||||
local testlib = createLibTest({ ... });
|
||||
|
||||
local function fakeModem()
|
||||
local transmits = {};
|
||||
return {
|
||||
open = function() end,
|
||||
close = function() end,
|
||||
transmit = function(channel, replyChannel, payload)
|
||||
transmits[#transmits + 1] = {
|
||||
channel = channel,
|
||||
replyChannel = replyChannel,
|
||||
payload = payload,
|
||||
};
|
||||
end,
|
||||
isOpen = function() return true end,
|
||||
_transmits = transmits,
|
||||
};
|
||||
end
|
||||
|
||||
testlib.test('isPacketOk requires sourceId and routerId', function()
|
||||
local modem = fakeModem();
|
||||
local net = createNet(createEventLoop(), modem, 'test_modem');
|
||||
|
||||
testlib.assertEquals(net.isPacketOk(nil), false);
|
||||
testlib.assertEquals(net.isPacketOk({}), false);
|
||||
testlib.assertEquals(net.isPacketOk({ sourceId = 1 }), false);
|
||||
testlib.assertEquals(net.isPacketOk({ sourceId = 1, routerId = 2 }), true);
|
||||
end);
|
||||
|
||||
testlib.test('isPacketOk matches destId by id or label', function()
|
||||
local modem = fakeModem();
|
||||
local net = createNet(createEventLoop(), modem, 'test_modem');
|
||||
|
||||
local selfId = os.getComputerID();
|
||||
|
||||
testlib.assertEquals(
|
||||
net.isPacketOk({ sourceId = 9, routerId = 9, destId = selfId }),
|
||||
true
|
||||
);
|
||||
testlib.assertEquals(
|
||||
net.isPacketOk({ sourceId = 9, routerId = 9, destId = selfId + 100 }),
|
||||
false
|
||||
);
|
||||
end);
|
||||
|
||||
testlib.test('send transmits an evt packet on the bus channel', function()
|
||||
local modem = fakeModem();
|
||||
local net = createNet(createEventLoop(), modem, 'test_modem');
|
||||
|
||||
net.send('myservice', { hello = 'world' }, { destId = 42 });
|
||||
|
||||
testlib.assertEquals(#modem._transmits, 1);
|
||||
local t = modem._transmits[1];
|
||||
testlib.assertEquals(t.channel, net.BUS_CHANNEL);
|
||||
testlib.assertEquals(t.replyChannel, net.BUS_CHANNEL);
|
||||
testlib.assertEquals(t.payload.service, 'myservice');
|
||||
testlib.assertEquals(t.payload.kind, 'evt');
|
||||
testlib.assertEquals(t.payload.destId, 42);
|
||||
testlib.assertEquals(t.payload.payload.hello, 'world');
|
||||
end);
|
||||
|
||||
testlib.test('setRouter stamps routerId on outbound packets', function()
|
||||
local modem = fakeModem();
|
||||
local net = createNet(createEventLoop(), modem, 'test_modem');
|
||||
|
||||
net.send('foo', 'bar', { destId = 42 });
|
||||
testlib.assertEquals(modem._transmits[1].payload.routerId, nil);
|
||||
|
||||
net.setRouter(true);
|
||||
net.send('foo', 'bar', { destId = 42 });
|
||||
testlib.assertEquals(modem._transmits[2].payload.routerId, os.getComputerID());
|
||||
end);
|
||||
|
||||
testlib.test('serve dispatches a request loopback to a registered handler', function()
|
||||
local modem = fakeModem();
|
||||
local el = createEventLoop();
|
||||
local net = createNet(el, modem, 'test_modem');
|
||||
|
||||
local received = nil;
|
||||
|
||||
net.serve('echo', function(payload, reply)
|
||||
received = payload;
|
||||
reply({ echoed = payload });
|
||||
el.stopLoop();
|
||||
end);
|
||||
|
||||
local packet = {
|
||||
sourceId = os.getComputerID(),
|
||||
sourceLabel = os.getComputerLabel(),
|
||||
destId = os.getComputerID(),
|
||||
service = 'echo',
|
||||
kind = 'req',
|
||||
requestId = 'test-req-1',
|
||||
payload = { foo = 'bar' },
|
||||
routerId = os.getComputerID(),
|
||||
};
|
||||
os.queueEvent('modem_message', 'test_modem', net.BUS_CHANNEL, net.BUS_CHANNEL, packet, 0);
|
||||
|
||||
el.runLoop();
|
||||
|
||||
testlib.assertEquals(received.foo, 'bar');
|
||||
end);
|
||||
|
||||
testlib.test('serve ignores packets with a different service', function()
|
||||
local modem = fakeModem();
|
||||
local el = createEventLoop();
|
||||
local net = createNet(el, modem, 'test_modem');
|
||||
|
||||
local hit = false;
|
||||
|
||||
net.serve('echo', function() hit = true end);
|
||||
|
||||
el.setTimeout(function() el.stopLoop() end, 0);
|
||||
|
||||
local packet = {
|
||||
sourceId = os.getComputerID(),
|
||||
sourceLabel = os.getComputerLabel(),
|
||||
destId = os.getComputerID(),
|
||||
service = 'other',
|
||||
kind = 'req',
|
||||
requestId = 'test-req-2',
|
||||
payload = nil,
|
||||
routerId = os.getComputerID(),
|
||||
};
|
||||
os.queueEvent('modem_message', 'test_modem', net.BUS_CHANNEL, net.BUS_CHANNEL, packet, 0);
|
||||
|
||||
el.runLoop();
|
||||
|
||||
testlib.assertEquals(hit, false);
|
||||
end);
|
||||
|
||||
testlib.test('serve ignores unrouted packets (no routerId)', function()
|
||||
local modem = fakeModem();
|
||||
local el = createEventLoop();
|
||||
local net = createNet(el, modem, 'test_modem');
|
||||
|
||||
local hit = false;
|
||||
|
||||
net.serve('echo', function() hit = true end);
|
||||
|
||||
el.setTimeout(function() el.stopLoop() end, 0);
|
||||
|
||||
local packet = {
|
||||
sourceId = os.getComputerID(),
|
||||
sourceLabel = os.getComputerLabel(),
|
||||
destId = os.getComputerID(),
|
||||
service = 'echo',
|
||||
kind = 'req',
|
||||
requestId = 'test-req-3',
|
||||
payload = nil,
|
||||
-- no routerId
|
||||
};
|
||||
os.queueEvent('modem_message', 'test_modem', net.BUS_CHANNEL, net.BUS_CHANNEL, packet, 0);
|
||||
|
||||
el.runLoop();
|
||||
|
||||
testlib.assertEquals(hit, false);
|
||||
end);
|
||||
|
||||
testlib.run();
|
||||
74
tests/router.lua
Normal file
74
tests/router.lua
Normal file
@ -0,0 +1,74 @@
|
||||
local createLibTest = require('/apis/libtest');
|
||||
local createRouter = require('/apis/librouter');
|
||||
|
||||
local testlib = createLibTest({ ... });
|
||||
|
||||
local function makeClock()
|
||||
local now = 0;
|
||||
return function() return now; end, function(delta) now = now + delta; end;
|
||||
end
|
||||
|
||||
testlib.test('register accepts a new label', function()
|
||||
local now, _ = makeClock();
|
||||
local router = createRouter({ now = now, ttl = 10 });
|
||||
|
||||
local ok, err = router.register('alice', 5);
|
||||
testlib.assertEquals(ok, true);
|
||||
testlib.assertEquals(err, nil);
|
||||
testlib.assertEquals(router.resolve('alice'), 5);
|
||||
end);
|
||||
|
||||
testlib.test('register rejects a duplicate label from a different id', function()
|
||||
local now, _ = makeClock();
|
||||
local router = createRouter({ now = now, ttl = 10 });
|
||||
|
||||
router.register('alice', 5);
|
||||
local ok, err = router.register('alice', 7);
|
||||
testlib.assertEquals(ok, false);
|
||||
testlib.assertEquals(err, 'duplicate label');
|
||||
testlib.assertEquals(router.resolve('alice'), 5);
|
||||
end);
|
||||
|
||||
testlib.test('register refreshes a label from the same id', function()
|
||||
local now, advance = makeClock();
|
||||
local router = createRouter({ now = now, ttl = 10 });
|
||||
|
||||
router.register('alice', 5);
|
||||
advance(5);
|
||||
local ok = router.register('alice', 5);
|
||||
testlib.assertEquals(ok, true);
|
||||
|
||||
advance(8);
|
||||
testlib.assertEquals(router.resolve('alice'), 5);
|
||||
end);
|
||||
|
||||
testlib.test('resolve returns nil after TTL expiry', function()
|
||||
local now, advance = makeClock();
|
||||
local router = createRouter({ now = now, ttl = 10 });
|
||||
|
||||
router.register('alice', 5);
|
||||
advance(11);
|
||||
testlib.assertEquals(router.resolve('alice'), nil);
|
||||
end);
|
||||
|
||||
testlib.test('expired entries can be re-registered by a different id', function()
|
||||
local now, advance = makeClock();
|
||||
local router = createRouter({ now = now, ttl = 10 });
|
||||
|
||||
router.register('alice', 5);
|
||||
advance(11);
|
||||
local ok = router.register('alice', 7);
|
||||
testlib.assertEquals(ok, true);
|
||||
testlib.assertEquals(router.resolve('alice'), 7);
|
||||
end);
|
||||
|
||||
testlib.test('forget removes a label', function()
|
||||
local now, _ = makeClock();
|
||||
local router = createRouter({ now = now, ttl = 10 });
|
||||
|
||||
router.register('alice', 5);
|
||||
router.forget('alice');
|
||||
testlib.assertEquals(router.resolve('alice'), nil);
|
||||
end);
|
||||
|
||||
testlib.run();
|
||||
@ -11,6 +11,7 @@ local function runStartupWithStubs()
|
||||
};
|
||||
|
||||
local env = setmetatable({
|
||||
require = require,
|
||||
fs = {
|
||||
exists = function(path)
|
||||
return path ~= '/trapos/manifest.json' and fs.exists(path);
|
||||
@ -44,6 +45,7 @@ local function runStartupWithStubs()
|
||||
setPath = function() end,
|
||||
},
|
||||
}, { __index = _G });
|
||||
env._G = env;
|
||||
|
||||
local chunk, loadErr = loadfile('/startup/servers.lua', 't', env);
|
||||
if not chunk then
|
||||
|
||||
Loading…
Reference in New Issue
Block a user