10 KiB
Final Plan: fix interactive cloud login session teardown
Goal
Fix the user-visible bug where interactive cloud login with a wrong password prints the retry
message, then drops back to a fresh CraftOS shell with lost in-memory command history instead of
prompting again.
One end-to-end fix, split into small independently testable changes. Before building all of it, we disambiguate the actual root cause (see Step 0) so we know which fix is curing the bug and which are hardening — the prior merged plan shipped three fixes across two competing theories without ever confirming which one mattered.
Confirmed code facts (verified against source)
These are grounded in the current tree, not hypotheses:
eventloop.lua:19—next_eventloop_idis module-local.eventloop.lua:174/186—END_OF_LOOPis keyed to that id, andstopLoop()stops a loop by queueing that global event. Boot loop is created atboot.lua:48; thecloud loginverifier loop atcloud.lua:117.eventloop.lua:294— handlers are dispatched un-pcall'd. The boot loop runs this underparallel.waitForAny(shellFn, eventLoopFn)(boot.lua:63,80), so a throwing handler ends the startup session and drops to a fresh shell.libcloud.lua:256—connect()callshttpLike.websocketAsync(url)un-pcall'd. The daemon runs on_G.bootEventLoop(servers/cloud.lua:45), andtrapos_cloud_reconnect→reconnectNow→connect()(libcloud.lua:415→425). So a throw here escapes into the boot loop and out throughwaitForAny.
Two candidate root causes predict different symptoms
This is the key insight that drives Step 0.
- A. Event-loop ID collision (clean exit). Only possible if
require('/apis/eventloop')hands the cloud program a separate module instance from boot's. If so, both loops can take id1; the verifier'sstopLoop()queuesEND_OF_LOOP/1, the boot loop also matches it and exits cleanly →waitForAnyreturns →startupends with no error printed → fresh shell. Ifrequireis a singleton (normal CraftOS module cache), boot=id1 / verifier=id2+, names never collide, and this is a non-cause. - B. Thrown handler (crash exit). A throw in a boot-loop handler (e.g. websocket open) propagates
through
waitForAny; bios prints astartup:80:-style traceback before the fresh shell.
The original report — "invalid password, then dropped to shell", no traceback mentioned — leans toward A (silent clean exit). Step 0 confirms which it is so the after-test is meaningful.
Step 0: Disambiguate and reproduce the failure FIRST
On a disposable TrapOS computer (never the shared 8/10 machines — a wrong-password verify bounces
their live gateway connection):
-
Deploy the current (unfixed)
trapos-core+trapos-cloud, setcloud.url, reboot so the daemon runs. -
cloud login --force, enter a wrong password. -
Record exactly what happens:
- Was a Lua traceback printed before the shell returned? → cause is B (a throw); fix #3 is load-bearing, fix #1 is unrelated hardening.
- Did it drop to the shell silently (only the "invalid password" line, then a fresh prompt)? → cause is A (collision); fix #1 is load-bearing.
-
Confirm the runtime instance question directly. Probe whether the cloud program actually gets a separate eventloop module instance from boot:
# from a shell.run program context, compare against boot's loader identity / next id just trapos-exec 'print(tostring(require("/apis/eventloop")))'Compare the loader identity (and observed allocated ids) seen from boot vs. from a
shell.runprogram. If identical → singleton → collision (A) is impossible and the bug must be B.
Do not skip this. If the failure does not reproduce on demand it may be instance-identity dependent and therefore intermittent; a post-fix "it re-prompts once" result would prove nothing.
Bugs / Fix Areas
1. Event-loop stop can leak across loops
packages/trapos-core/apis/eventloop.lua.
Primary fix — same-loop stopRequested (cleaner, more local). The verifier's finish() always
calls stopLoop() from inside its own handler. So when stopLoop() is invoked while the loop is
actively dispatching its own handler/timeout, set an internal stopRequested = true and break after
the current dispatch — never queue a global event at all. This makes the verifier→boot leak
impossible regardless of ids.
Belt-and-suspenders — process-global id counter. Keep queueing the synthetic END_OF_LOOP/<id>
for external (cross-coroutine) stop calls, where the loop may be blocked in os.pullEventRaw() and
must be woken. To make those ids collision-proof across independently loaded module instances,
allocate the id from a _G-backed counter:
local COUNTER_KEY = '__trapos_next_eventloop_id';
local function nextEventLoopId()
local id = tonumber(_G[COUNTER_KEY]) or 1;
_G[COUNTER_KEY] = id + 1;
return id;
end
Preserve existing contracts: api.STOP unregister on successful handler return, duplicate-register
error, and "stopping an already stopped loop" error.
2. Boot event-loop handler errors can tear down the session
packages/trapos-core/apis/eventloop.lua + packages/trapos-boot/startup/boot.lua.
- Add protected handler dispatch as an explicit, opt-in event-loop option, e.g.
createEventLoop({ onError = function(eventName, err) ... end }). - Use protected dispatch for
_G.bootEventLoopinboot.lua; the error handler reports viaprintError(elseprint) and the loop continues after a handler error. - Keep dispatch unprotected by default for short-lived program-local loops so dev/test failures still fail loudly unless explicitly isolated.
- Interaction to verify: after a handler throws under protected dispatch, the
api.STOPunregister (:295) and theEND_OF_LOOP/terminatebreak (:303, which sits outside the handler loop) must still run.
3. Cloud daemon websocket open can throw during reconnect
packages/trapos-cloud/apis/libcloud.lua.
- Forward-declare
scheduleReconnectsoconnect()can call it. - Wrap
httpLike.websocketAsync(url)inpcall. - On failure, log
websocket open failedand callscheduleReconnect()instead of throwing. - Avoid duplicate noisy logs while already in reconnect-loop mode.
Out of scope (state the reasoning so it isn't re-litigated)
error('Terminated', 0)atcloud.lua:182/205(ctrl+t path). Raised in the cloud program (shellFn side), whichshell.runalreadypcalls — it never reacheswaitForAnyand is not fatal to the session. Protected boot dispatch (#2) does not and need not cover it. Leave as-is.- Stale hello
messageIdmatching. Useful cloud-login hardening, but it causes wrong-verdict attribution, not session teardown. Follow-up only if Step 0 / manual testing still shows wrong verdicts after the main fixes. - Persisted shell history. Not a workaround for session teardown. Do not add.
Implementation Steps
Step 1: Regression coverage
packages/trapos-core/tests/eventloop.lua:
- Same-loop stop: a handler calls
stopLoop()on its own loop; assert the loop stops after the current dispatch and that no globalEND_OF_LOOPevent is required (i.e. a second sibling loop sharing the id is unaffected). This is the test that actually corresponds to the real failure mode — frame it that way, not as the artificial double-loadfile. - Cross-instance id: simulate independent module loads (
loadfile), create a boot-like and a verifier-like loop, stop the verifier from inside its handler, assert the boot-like loop still handles a later probe event. (Belt-and-suspenders for the_Gcounter.) Explicitly stop the boot-like loop at the end so the test cannot hang. - Protected dispatch: one handler throws, a sibling/later handler still runs, and the configured
onErrorsink receives the error.
packages/trapos-cloud/tests/cloud.lua:
http.websocketAsyncthrows duringstartSession()/reconnect; assert the error does not propagate and a reconnect timer is scheduled.
Keep existing cloud-program.lua wrong-then-right interactive tests.
Step 2: Fix eventloop.lua
- Add same-loop
stopRequestedbreak (primary). - Replace module-local id allocation with the
_Gcounter (secondary). - Add opt-in protected dispatch (
onError) for handlers (and timeout callbacks if practical). - Preserve
api.STOPunregister on success and the duplicate-register / already-stopped contracts.
Step 3: Use protected boot event loop
packages/trapos-boot/startup/boot.lua — construct _G.bootEventLoop with an onError that reports
via printError/print and continues.
Step 4: Harden cloud reconnect
packages/trapos-cloud/apis/libcloud.lua — forward-declare scheduleReconnect, pcall the
websocketAsync open, log + reschedule on failure, no duplicate noise while reconnecting.
Step 5: Package versions
Bump owning packages and mirror in packages/index.json:
trapos-core(apis/eventloop.lua)trapos-boot(startup/boot.lua)trapos-cloud(apis/libcloud.lua)
Do not bump the trapos meta-package unless release convention requires it.
Verification
Targeted iteration:
just trapos-exec 'shell.run("/programs/runtest.lua", "--pretty", "/tests/eventloop.lua", "/tests/cloud.lua", "/tests/cloud-program.lua")'
Required after Lua/package edits:
just check
just test --pretty
Manual validation on the same disposable computer used in Step 0 (so before/after is comparable):
- Deploy updated
trapos-core,trapos-boot,trapos-cloud; reboot. cloud login --force, enter a wrong password → retry message prints and the prompt returns.- Cancel or complete login, then up-arrow → shell history still contains the command.
- Enter the correct password →
logged in. cloud status→ daemon recovered after the verify bounce.
Tie it back to Step 0: the symptom recorded there (silent vs. traceback) must be gone, and the fix that addresses that cause must be the one demonstrated, not just "all three are in and it passes."
Recommended split (three reviewable chunks)
- Event-loop same-loop stop + cross-instance id safety.
- Boot-loop protected dispatch.
- Cloud reconnect crash-safety.