diff --git a/env.sample b/env.sample index de0abc5..113d18f 100644 --- a/env.sample +++ b/env.sample @@ -1,5 +1,5 @@ # 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. TRAPOS_TEST_TIMEOUT_WATCHDOG_SECONDS=1 diff --git a/manifest.json b/manifest.json index 4a5b84c..ee0ec06 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.12.3", + "version": "0.12.4", "branch": "master", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index fec5aaf..4b19f32 100644 --- a/packages/index.json +++ b/packages/index.json @@ -1,13 +1,13 @@ { "packages": { - "trapos-core": "0.9.3", + "trapos-core": "0.9.4", "trapos-test": "0.2.3", - "trapos-boot": "0.4.2", + "trapos-boot": "0.4.3", "trapos-net": "0.3.2", "trapos-ui": "0.2.4", "trapos-ai": "0.8.0", - "trapos-cloud": "0.4.3", + "trapos-cloud": "0.4.4", "trapos-sandbox-legacy": "0.3.2", - "trapos": "0.12.3" + "trapos": "0.12.4" } } diff --git a/packages/trapos-boot/ccpm.json b/packages/trapos-boot/ccpm.json index d8f8a78..89db1c1 100644 --- a/packages/trapos-boot/ccpm.json +++ b/packages/trapos-boot/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-boot", - "version": "0.4.2", + "version": "0.4.3", "description": "TrapOS boot: startup MOTD and autostart server launcher", "dependencies": ["trapos-core"], "files": ["programs/motd.lua", "startup/boot.lua"], diff --git a/packages/trapos-boot/startup/boot.lua b/packages/trapos-boot/startup/boot.lua index b303586..2d9320c 100644 --- a/packages/trapos-boot/startup/boot.lua +++ b/packages/trapos-boot/startup/boot.lua @@ -45,7 +45,17 @@ if periphemu then periphemu.create("top", "modem") 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 action = "shutdown" diff --git a/packages/trapos-cloud/apis/libcloud.lua b/packages/trapos-cloud/apis/libcloud.lua index ef43f0d..7852305 100644 --- a/packages/trapos-cloud/apis/libcloud.lua +++ b/packages/trapos-cloud/apis/libcloud.lua @@ -228,6 +228,7 @@ local function createCloud() -- Inbound frames of an advertised type are re-emitted as trapos_cloud_request -- events; unadvertised (non-ping) types get an immediate unknown_type reply. local servedTypes = {}; + local scheduleReconnect = nil; local function sendFrame(frame) if not activeWs then @@ -253,10 +254,16 @@ local function createCloud() if not reconnecting then log('info', 'connecting', { url = url }); 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 - local function scheduleReconnect() + function scheduleReconnect() activeWs = nil; if state == 'unauthorized' or state == 'displaced' then return; diff --git a/packages/trapos-cloud/ccpm.json b/packages/trapos-cloud/ccpm.json index 856dc2e..4dd1190 100644 --- a/packages/trapos-cloud/ccpm.json +++ b/packages/trapos-cloud/ccpm.json @@ -1,6 +1,6 @@ { "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", "dependencies": ["trapos-core"], "files": [ diff --git a/packages/trapos-cloud/tests/cloud.lua b/packages/trapos-cloud/tests/cloud.lua index afb82c9..1e44ee9 100644 --- a/packages/trapos-cloud/tests/cloud.lua +++ b/packages/trapos-cloud/tests/cloud.lua @@ -209,6 +209,27 @@ testlib.test('startSession connects to the /gateway path', function() testlib.assertEquals(ctx.url, 'ws://host:4444/gateway'); 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() local ctx = startSession({ secret = 'hunter2' }); local ws = fakes.fakeWs(); diff --git a/packages/trapos-core/apis/eventloop.lua b/packages/trapos-core/apis/eventloop.lua index 5377d19..9c2cd2a 100644 --- a/packages/trapos-core/apis/eventloop.lua +++ b/packages/trapos-core/apis/eventloop.lua @@ -16,19 +16,26 @@ -- end) -- -- 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() end local STOP = '@libeventloop/STOP_HANDLER_SUBSCRIPTION'; -local function createEventLoop() +local function createEventLoop(opts) + opts = opts or {}; local api = {} local runningLoop = false; - local eventloop_id = next_eventloop_id; - next_eventloop_id = next_eventloop_id + 1; + local eventloop_id = nextEventLoopId(); + local stopRequested = false; local shouldCheckHandlers = true; @@ -58,6 +65,25 @@ local function createEventLoop() local nextOnStartHandlerId = 1; 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() nextOnStartHandlerId = 1; onStartHandlers = {}; @@ -139,6 +165,7 @@ local function createEventLoop() local function resetAll() runningLoop = false; + stopRequested = false; handlersCounter = 0; allHandlers = {}; runningHandlers = nil; @@ -183,7 +210,11 @@ local function createEventLoop() -- stopLoop function api.stopLoop() if api.isRunningLoop() then - os.queueEvent(END_OF_LOOP) + if runningHandlers or runningTimeoutHandler then + stopRequested = true; + else + os.queueEvent(END_OF_LOOP) + end else error("libeventloop error: loop is already stopped") end @@ -274,7 +305,7 @@ local function createEventLoop() for k, h in pairs(timeoutHandlers) do if k == timerId then removeTimeoutHandler(k) - h(); + dispatchHandler('timer', h); end end runningTimeoutHandler = false @@ -291,8 +322,8 @@ local function createEventLoop() if handlers then runningHandlers = eventName for _, handler in pairs(handlers) do - local result_handler = handler(table.unpack(packed)) - if result_handler == api.STOP then + local ok, result_handler = dispatchHandler(eventName, handler, table.unpack(packed)) + if ok and result_handler == api.STOP then api.unregister(eventName, handler) end end @@ -300,11 +331,12 @@ local function createEventLoop() flushUnregisterQueue() end - if eventName == END_OF_LOOP or (shouldCheckHandlers and eventName == 'terminate') then - execOnStopHandlers() - resetAll() - break - end + end + + if stopRequested or eventName == END_OF_LOOP or (shouldCheckHandlers and eventName == 'terminate') then + execOnStopHandlers() + resetAll() + break end end end diff --git a/packages/trapos-core/ccpm.json b/packages/trapos-core/ccpm.json index c118540..f1a8e79 100644 --- a/packages/trapos-core/ccpm.json +++ b/packages/trapos-core/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-core", - "version": "0.9.3", + "version": "0.9.4", "description": "TrapOS base: package manager, event loop, upgrade and event tools", "dependencies": [], "files": [ diff --git a/packages/trapos-core/tests/eventloop.lua b/packages/trapos-core/tests/eventloop.lua index 84715ad..24ad6db 100644 --- a/packages/trapos-core/tests/eventloop.lua +++ b/packages/trapos-core/tests/eventloop.lua @@ -138,6 +138,127 @@ testlib.test('onStart and onStop run around loop', function() testlib.assertEquals(order, 'start>timeout>stop'); 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() local events = createEventLoop(); local stopped = false; diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index fb60333..7831f6f 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.12.3", + "version": "0.12.4", "description": "TrapOS full install meta-package", "dependencies": [ "trapos-boot",