feat(net): unify boot eventloop and service bus

This commit is contained in:
Guillaume ARM 2026-06-09 21:48:56 +02:00
parent db02eef960
commit e66fb87bde
19 changed files with 704 additions and 380 deletions

View File

@ -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
View 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;

View File

@ -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;
local nextRequestSeq = 1;
local function newRequestId()
local id = tostring(os.getComputerID()) .. ':' .. tostring(nextRequestSeq) .. ':' .. tostring(os.clock());
nextRequestSeq = nextRequestSeq + 1;
return id;
end
-- 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;
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
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;
end
-- -- Example: implementation simple de ping
--
--
-- local createNet = require('apis/net');
-- net = createNet();
-- local net = createNet(nil, modem);
-- 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

View File

@ -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`).

View 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.

View File

@ -1,6 +1,6 @@
{
"name": "TrapOS",
"version": "0.7.1",
"version": "0.8.0",
"branch": "next",
"packages": [
"trapos"

View File

@ -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"
}
}

View File

@ -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": [

View File

@ -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"]
}

View File

@ -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": [],

View File

@ -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

View File

@ -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 routerId = os.getComputerID();
_G.isRouterEnabled = true;
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
local ownsLoop = false;
if not _G.bootEventLoop then
_G.bootEventLoop = createEventLoop();
ownsLoop = true;
end
if payload.destId then
printVerbose("Routed message from " .. tostring(payload.sourceId)
.. " to " .. tostring(payload.destId)
.. " using channel " .. tostring(replyChannel));
local net = createNet();
net.setRouter(true);
local router = createRouter();
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
View 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)

View File

@ -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.")

View File

@ -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()

View File

@ -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
local function eventLoopFn()
_G.bootEventLoop.runLoop(true)
end
return servers;
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
View 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
View 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();

View File

@ -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