fix(eventloop): isolate handler failures

This commit is contained in:
Guillaume ARM 2026-06-18 01:04:39 +02:00
parent 4f7967513f
commit 4f2c64ef1d
12 changed files with 217 additions and 26 deletions

View File

@ -1,5 +1,5 @@
# Host-side watchdog for the normal `just test` CraftOS-PC process. # Host-side watchdog for the normal `just test` CraftOS-PC process.
TRAPOS_TEST_TIMEOUT_SECONDS=3 TRAPOS_TEST_TIMEOUT_SECONDS=4
# Dedicated `just test-timeout` fixture timings. # Dedicated `just test-timeout` fixture timings.
TRAPOS_TEST_TIMEOUT_WATCHDOG_SECONDS=1 TRAPOS_TEST_TIMEOUT_WATCHDOG_SECONDS=1

View File

@ -1,6 +1,6 @@
{ {
"name": "TrapOS", "name": "TrapOS",
"version": "0.12.3", "version": "0.12.4",
"branch": "master", "branch": "master",
"packages": [ "packages": [
"trapos" "trapos"

View File

@ -1,13 +1,13 @@
{ {
"packages": { "packages": {
"trapos-core": "0.9.3", "trapos-core": "0.9.4",
"trapos-test": "0.2.3", "trapos-test": "0.2.3",
"trapos-boot": "0.4.2", "trapos-boot": "0.4.3",
"trapos-net": "0.3.2", "trapos-net": "0.3.2",
"trapos-ui": "0.2.4", "trapos-ui": "0.2.4",
"trapos-ai": "0.8.0", "trapos-ai": "0.8.0",
"trapos-cloud": "0.4.3", "trapos-cloud": "0.4.4",
"trapos-sandbox-legacy": "0.3.2", "trapos-sandbox-legacy": "0.3.2",
"trapos": "0.12.3" "trapos": "0.12.4"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "trapos-boot", "name": "trapos-boot",
"version": "0.4.2", "version": "0.4.3",
"description": "TrapOS boot: startup MOTD and autostart server launcher", "description": "TrapOS boot: startup MOTD and autostart server launcher",
"dependencies": ["trapos-core"], "dependencies": ["trapos-core"],
"files": ["programs/motd.lua", "startup/boot.lua"], "files": ["programs/motd.lua", "startup/boot.lua"],

View File

@ -45,7 +45,17 @@ if periphemu then
periphemu.create("top", "modem") periphemu.create("top", "modem")
end end
_G.bootEventLoop = createEventLoop() local function reportBootEventLoopError(eventName, err)
local message = "boot eventloop handler error (" .. tostring(eventName) .. "): " .. tostring(err)
local printErrorFn = _G.printError
if type(printErrorFn) == "function" then
printErrorFn(message)
else
print(message)
end
end
_G.bootEventLoop = createEventLoop({ onError = reportBootEventLoopError })
local shellExited = false local shellExited = false
local action = "shutdown" local action = "shutdown"

View File

@ -228,6 +228,7 @@ local function createCloud()
-- Inbound frames of an advertised type are re-emitted as trapos_cloud_request -- Inbound frames of an advertised type are re-emitted as trapos_cloud_request
-- events; unadvertised (non-ping) types get an immediate unknown_type reply. -- events; unadvertised (non-ping) types get an immediate unknown_type reply.
local servedTypes = {}; local servedTypes = {};
local scheduleReconnect = nil;
local function sendFrame(frame) local function sendFrame(frame)
if not activeWs then if not activeWs then
@ -253,10 +254,16 @@ local function createCloud()
if not reconnecting then if not reconnecting then
log('info', 'connecting', { url = url }); log('info', 'connecting', { url = url });
end end
httpLike.websocketAsync(url); local ok, err = pcall(function() httpLike.websocketAsync(url); end);
if not ok then
if not reconnecting then
log('warn', 'websocket open failed', { url = url, error = tostring(err) });
end
scheduleReconnect();
end
end end
local function scheduleReconnect() function scheduleReconnect()
activeWs = nil; activeWs = nil;
if state == 'unauthorized' or state == 'displaced' then if state == 'unauthorized' or state == 'displaced' then
return; return;

View File

@ -1,6 +1,6 @@
{ {
"name": "trapos-cloud", "name": "trapos-cloud",
"version": "0.4.3", "version": "0.4.4",
"description": "TrapOS cloud gateway client: WS daemon (servers/cloud) + cloud command + MCP exec/write server", "description": "TrapOS cloud gateway client: WS daemon (servers/cloud) + cloud command + MCP exec/write server",
"dependencies": ["trapos-core"], "dependencies": ["trapos-core"],
"files": [ "files": [

View File

@ -209,6 +209,27 @@ testlib.test('startSession connects to the /gateway path', function()
testlib.assertEquals(ctx.url, 'ws://host:4444/gateway'); testlib.assertEquals(ctx.url, 'ws://host:4444/gateway');
end); end);
testlib.test('startSession retries when websocketAsync throws', function()
local http = { connects = {} };
function http.websocketAsync(url)
http.connects[#http.connects + 1] = url;
error('open boom', 0);
end
function http.websocket()
return nil;
end
local ctx = startSession({ http = http });
testlib.assertEquals(ctx.session.isReady(), false);
testlib.assertEquals(#http.connects, 1);
testlib.assertEquals(#ctx.el.timers, 1);
ctx.el.fireTimer(#ctx.el.timers);
testlib.assertEquals(#http.connects, 2);
testlib.assertEquals(#ctx.el.timers, 2);
end);
testlib.test('startSession sends hello on open and goes ready on ack', function() testlib.test('startSession sends hello on open and goes ready on ack', function()
local ctx = startSession({ secret = 'hunter2' }); local ctx = startSession({ secret = 'hunter2' });
local ws = fakes.fakeWs(); local ws = fakes.fakeWs();

View File

@ -16,19 +16,26 @@
-- end) -- end)
-- --
-- events.runLoop(); -- events.runLoop();
local next_eventloop_id = 1; local EVENTLOOP_COUNTER_KEY = '__trapos_next_eventloop_id';
local function nextEventLoopId()
local id = tonumber(_G[EVENTLOOP_COUNTER_KEY]) or 1;
_G[EVENTLOOP_COUNTER_KEY] = id + 1;
return id;
end
local function noop() local function noop()
end end
local STOP = '@libeventloop/STOP_HANDLER_SUBSCRIPTION'; local STOP = '@libeventloop/STOP_HANDLER_SUBSCRIPTION';
local function createEventLoop() local function createEventLoop(opts)
opts = opts or {};
local api = {} local api = {}
local runningLoop = false; local runningLoop = false;
local eventloop_id = next_eventloop_id; local eventloop_id = nextEventLoopId();
next_eventloop_id = next_eventloop_id + 1; local stopRequested = false;
local shouldCheckHandlers = true; local shouldCheckHandlers = true;
@ -58,6 +65,25 @@ local function createEventLoop()
local nextOnStartHandlerId = 1; local nextOnStartHandlerId = 1;
local onStartHandlers = {}; local onStartHandlers = {};
local function reportError(eventName, err)
if type(opts.onError) == 'function' then
pcall(opts.onError, eventName, err);
end
end
local function dispatchHandler(eventName, handler, ...)
if type(opts.onError) ~= 'function' then
return true, handler(...);
end
local ok, result = pcall(handler, ...);
if not ok then
reportError(eventName, result);
return false, nil;
end
return true, result;
end
local function resetOnStartHandlers() local function resetOnStartHandlers()
nextOnStartHandlerId = 1; nextOnStartHandlerId = 1;
onStartHandlers = {}; onStartHandlers = {};
@ -139,6 +165,7 @@ local function createEventLoop()
local function resetAll() local function resetAll()
runningLoop = false; runningLoop = false;
stopRequested = false;
handlersCounter = 0; handlersCounter = 0;
allHandlers = {}; allHandlers = {};
runningHandlers = nil; runningHandlers = nil;
@ -183,7 +210,11 @@ local function createEventLoop()
-- stopLoop -- stopLoop
function api.stopLoop() function api.stopLoop()
if api.isRunningLoop() then if api.isRunningLoop() then
if runningHandlers or runningTimeoutHandler then
stopRequested = true;
else
os.queueEvent(END_OF_LOOP) os.queueEvent(END_OF_LOOP)
end
else else
error("libeventloop error: loop is already stopped") error("libeventloop error: loop is already stopped")
end end
@ -274,7 +305,7 @@ local function createEventLoop()
for k, h in pairs(timeoutHandlers) do for k, h in pairs(timeoutHandlers) do
if k == timerId then if k == timerId then
removeTimeoutHandler(k) removeTimeoutHandler(k)
h(); dispatchHandler('timer', h);
end end
end end
runningTimeoutHandler = false runningTimeoutHandler = false
@ -291,8 +322,8 @@ local function createEventLoop()
if handlers then if handlers then
runningHandlers = eventName runningHandlers = eventName
for _, handler in pairs(handlers) do for _, handler in pairs(handlers) do
local result_handler = handler(table.unpack(packed)) local ok, result_handler = dispatchHandler(eventName, handler, table.unpack(packed))
if result_handler == api.STOP then if ok and result_handler == api.STOP then
api.unregister(eventName, handler) api.unregister(eventName, handler)
end end
end end
@ -300,14 +331,15 @@ local function createEventLoop()
flushUnregisterQueue() flushUnregisterQueue()
end end
if eventName == END_OF_LOOP or (shouldCheckHandlers and eventName == 'terminate') then end
if stopRequested or eventName == END_OF_LOOP or (shouldCheckHandlers and eventName == 'terminate') then
execOnStopHandlers() execOnStopHandlers()
resetAll() resetAll()
break break
end end
end end
end end
end
-- startLoop -- startLoop
api.startLoop = api.runLoop api.startLoop = api.runLoop

View File

@ -1,6 +1,6 @@
{ {
"name": "trapos-core", "name": "trapos-core",
"version": "0.9.3", "version": "0.9.4",
"description": "TrapOS base: package manager, event loop, upgrade and event tools", "description": "TrapOS base: package manager, event loop, upgrade and event tools",
"dependencies": [], "dependencies": [],
"files": [ "files": [

View File

@ -138,6 +138,127 @@ testlib.test('onStart and onStop run around loop', function()
testlib.assertEquals(order, 'start>timeout>stop'); testlib.assertEquals(order, 'start>timeout>stop');
end); end);
testlib.test('same-loop stop does not leak to a sibling loop with the same id', function()
local createEventLoopA = assert(loadfile('/apis/eventloop.lua'))();
local createEventLoopB = assert(loadfile('/apis/eventloop.lua'))();
local bootEvents = createEventLoopA();
local verifierEvents = createEventLoopB();
local bootProbeHandled = false;
bootEvents.register('eventloop_test_same_loop_probe', function()
bootProbeHandled = true;
bootEvents.stopLoop();
end);
verifierEvents.register('eventloop_test_same_loop_stop', function()
verifierEvents.stopLoop();
os.queueEvent('eventloop_test_same_loop_probe');
end);
os.queueEvent('eventloop_test_same_loop_stop');
parallel.waitForAll(function() bootEvents.runLoop(true); end, function() verifierEvents.runLoop(); end);
testlib.assertTrue(bootProbeHandled);
end);
testlib.test('external stop events use ids unique across module instances', function()
local createEventLoopA = assert(loadfile('/apis/eventloop.lua'))();
local createEventLoopB = assert(loadfile('/apis/eventloop.lua'))();
local bootEvents = createEventLoopA();
local verifierEvents = createEventLoopB();
local bootProbeHandled = false;
bootEvents.register('eventloop_test_external_stop', function()
verifierEvents.stopLoop();
os.queueEvent('eventloop_test_external_probe');
end);
bootEvents.register('eventloop_test_external_probe', function()
bootProbeHandled = true;
bootEvents.stopLoop();
end);
os.queueEvent('eventloop_test_external_stop');
parallel.waitForAll(function() bootEvents.runLoop(true); end, function() verifierEvents.runLoop(true); end);
testlib.assertTrue(bootProbeHandled);
end);
testlib.test('protected dispatch reports errors and keeps the loop alive', function()
local errors = {};
local events = createEventLoop({
onError = function(eventName, err)
errors[#errors + 1] = { eventName = eventName, err = err };
end,
});
local siblingCalled = false;
local laterCalled = false;
events.register('eventloop_test_protected', function()
error('handler boom', 0);
end);
events.register('eventloop_test_protected', function()
siblingCalled = true;
os.queueEvent('eventloop_test_protected_later');
end);
events.register('eventloop_test_protected_later', function()
laterCalled = true;
events.stopLoop();
end);
os.queueEvent('eventloop_test_protected');
events.runLoop();
testlib.assertEquals(#errors, 1);
testlib.assertEquals(errors[1].eventName, 'eventloop_test_protected');
testlib.assertTrue(string.find(tostring(errors[1].err), 'handler boom', 1, true) ~= nil);
testlib.assertTrue(siblingCalled);
testlib.assertTrue(laterCalled);
end);
testlib.test('protected timeout dispatch reports errors and keeps the loop alive', function()
local errors = {};
local events = createEventLoop({
onError = function(eventName, err)
errors[#errors + 1] = { eventName = eventName, err = err };
end,
});
local laterCalled = false;
events.setTimeout(function()
error('timeout boom', 0);
end, 0);
events.setTimeout(function()
laterCalled = true;
events.stopLoop();
end, 0);
events.runLoop();
testlib.assertEquals(#errors, 1);
testlib.assertEquals(errors[1].eventName, 'timer');
testlib.assertTrue(string.find(tostring(errors[1].err), 'timeout boom', 1, true) ~= nil);
testlib.assertTrue(laterCalled);
end);
testlib.test('timeout handler can stop a loop with persistent handlers', function()
local events = createEventLoop();
events.register('eventloop_test_never_fired', function()
end);
events.setTimeout(function()
events.stopLoop();
end, 0);
events.runLoop();
testlib.assertTrue(not events.isRunningLoop());
end);
testlib.test('empty loop returns and runs onStop', function() testlib.test('empty loop returns and runs onStop', function()
local events = createEventLoop(); local events = createEventLoop();
local stopped = false; local stopped = false;

View File

@ -1,6 +1,6 @@
{ {
"name": "trapos", "name": "trapos",
"version": "0.12.3", "version": "0.12.4",
"description": "TrapOS full install meta-package", "description": "TrapOS full install meta-package",
"dependencies": [ "dependencies": [
"trapos-boot", "trapos-boot",