cc-libs/.plans/cloud-login-session-crash-fix-plan.md

6.2 KiB

Fix: interactive cloud login tears down the whole trapos session on a wrong password

Context

cloud login (interactive) was just rewritten to verify the password before saving (commit 1d4789b). When testing it at the local in-game terminal, typing a wrong password produces two bugs:

  1. It prints invalid password, try again (ctrl+t to cancel) and then quits to the shell instead of re-prompting.
  2. The CraftOS shell command history is gone (up-arrow no longer recalls cloud login).

Why this happens (root cause — confirmed by code, not guessed)

The interactive loop itself is correct: verified three ways (logic trace, a faithful real-eventloop+daemon simulation, and a real-CC probe on 8:trap2) that a wrong password returns 'unauthorized', runs the re-prompt branch (which is why the user sees the "invalid password" line), and loops back to readPassword(). So the program is not the thing that exits — the whole trapos session is being torn down underneath it.

The teardown chain, all confirmed in the code:

  • A wrong password triggers a rapid reconnect storm on the daemon: login-verify (close live socket → open with candidate → rejected) immediately followed by login-restore (close → open with persisted secret), plus any reconnectDelay retries.
  • connect() opens the socket with httpLike.websocketAsync(url) un-pcall'd (packages/trapos-cloud/apis/libcloud.lua:256). On rapid open/close this can throw (CC's per-computer websocket limit / "already open" races).
  • The shared boot event loop dispatches handlers un-pcall'd (packages/trapos-core/apis/eventloop.lua:294handler(table.unpack(packed))).
  • The boot loop runs under parallel.waitForAny(shellFn, eventLoopFn) with no isolation (packages/trapos-boot/startup/boot.lua:80). A throw in eventLoopFn propagates out of waitForAny, the startup script dies, and CraftOS drops to its default shell with a fresh history — i.e. "quit to shell" + "history lost". It fires right after the "invalid password" line because the throw happens while the daemon processes the login-restore reconnect.

The connection bouncing is harmless on a local terminal; the session crash is the bug.

Fix

Two small, complementary changes. The eventloop change is the real fix (it makes the shared boot loop fault-isolated so no server hiccup can ever kill the session); the connect() change removes the specific throw and keeps the daemon retrying correctly.

packages/trapos-core/apis/eventloop.lua, in runLoop (~line 293):

Wrap the handler call so a throwing handler can never escape runLoop (and thus never crash parallel.waitForAny / the trapos session). Keep the existing api.STOP unregister behaviour for the success case; on error, report and continue.

-- before:
local result_handler = handler(table.unpack(packed))
if result_handler == api.STOP then
  api.unregister(eventName, handler)
end
-- after:
local ok, result_handler = pcall(handler, table.unpack(packed))
if not ok then
  -- one bad handler must not tear down the shared boot loop / session
  printError('eventloop handler error ('..eventName..'): '..tostring(result_handler))
elseif result_handler == api.STOP then
  api.unregister(eventName, handler)
end

(Use whatever error sink fits the file's style — printError if available, else a no-op / existing logger. The point is: catch, don't propagate.)

2. (Cause-specific) Make the daemon's socket-open crash-safe

packages/trapos-cloud/apis/libcloud.lua, connect() (~line 246-257):

pcall the websocketAsync call and, on failure, fall back to scheduleReconnect() so a throw becomes a normal retry instead of a fatal error. scheduleReconnect is defined after connect, so add a forward declaration local scheduleReconnect before connect and drop the local on its later definition.

local function connect()
  if clearReconnectTimeout then clearReconnectTimeout(); clearReconnectTimeout = nil; end
  secret = secretOverride or getSecret();
  state = 'connecting';
  if not reconnecting then log('info', 'connecting', { url = url }); end
  local ok = pcall(function() httpLike.websocketAsync(url); end)
  if not ok then
    log('warn', 'websocket open failed', { url = url });
    scheduleReconnect();
  end
end

After these, the wrong-password flow is: verify → unauthorized → print "invalid password" → login-restore (now crash-safe) → readPassword() re-prompts. Both symptoms are fixed: the session survives and the prompt re-appears.

Note (not the cause, but flagged by the grilling plans)

The interactive terminate branch still does error('Terminated', 0) (packages/trapos-cloud/programs/cloud.lua:182). With the eventloop hardened this no longer risks the session, but a clean print('login cancelled'); return; for the Ctrl+T cancel is a reasonable follow-up (optional, out of scope for the crash fix).

Tests

  • packages/trapos-core/tests/eventloop.lua — add: a handler that throws does not propagate out of runLoop; sibling handlers and subsequent events still fire.
  • packages/trapos-cloud/tests/cloud.lua — add (uses the existing fakes.fakeHttp() / ctx.el.fire harness, ~line 186-205): configure http.websocketAsync to error(), fire trapos_cloud_reconnect, assert no error propagates and the daemon schedules a retry rather than dying.
  • Re-run the existing cloud suites (tests/cloud.lua, tests/cloud-program.lua) — the fake-eventloop program tests are unaffected.

Verification (end to end)

On a disposable/test CC computer (do not use the shared 8/10 machines — driving a wrong-password verify bounces their live gateway connection):

  1. Deploy the updated trapos-core + trapos-cloud, set cloud.url, reboot so the daemon runs.
  2. cloud login --force, type a wrong password, Enter.
    • Expect: invalid password, try again (ctrl+t to cancel) and the prompt returns — the shell/session stays alive and command history is intact.
  3. Type the correct password → logged in.
  4. Sanity: cloud status still works (daemon recovered after the verify bounce).