cc-libs/.plans/cloud-login-interactive-shell-history-fix.md

7.4 KiB

Plan: fix interactive cloud login retry killing the shell

Context

Interactive cloud login now verifies a candidate password by asking the cloud daemon to reconnect with that candidate, then waiting for either trapos_cloud_connected or trapos_cloud_unauthorized.

Observed bug:

  • Run cloud login with no password argument.
  • Enter a wrong password.
  • Instead of printing invalid password, try again and prompting again, the command exits back to the CraftOS shell.
  • The shell's in-memory history is gone, so up-arrow no longer recalls the previous cloud login command.

The interactive branch in packages/trapos-cloud/programs/cloud.lua already looks logically correct: on an unauthorized verdict it calls restore(), prints the retry message, and continues the loop. Existing unit coverage also models wrong-then-right retry successfully. That points to the live boot/runtime topology rather than the branch itself.

Root Cause Hypothesis

The short-lived verifier event loop inside cloud login can accidentally stop the boot event loop.

Relevant flow:

  • packages/trapos-cloud/programs/cloud.lua creates a local event loop inside verifyPassword().
  • When the daemon emits trapos_cloud_unauthorized, the verifier calls el.stopLoop().
  • packages/trapos-core/apis/eventloop.lua implements stopLoop() by globally queueing a synthetic event named @libeventloop/END_OF_LOOP/<id>.
  • Event loop IDs currently come from a module-local counter.
  • If /apis/eventloop.lua is loaded as independent module instances, both the boot event loop and the local verifier loop can use ID 1.
  • The verifier's synthetic stop event can therefore match the boot loop's stop event name.
  • packages/trapos-boot/startup/boot.lua runs the shell and boot event loop with parallel.waitForAny(shellFn, eventLoopFn).
  • If the boot event loop exits, parallel.waitForAny returns and the shell coroutine is killed.
  • Killing/restarting the shell explains losing shell history.

The stale hello messageId issue found during exploration is real hardening work, but it does not directly explain shell history loss. It can cause wrong verdict attribution, not shell teardown.

Goals

  • Wrong passwords in interactive cloud login re-prompt without leaving the command.
  • Stopping a short-lived local event loop must not stop _G.bootEventLoop.
  • Preserve CraftOS shell history after wrong-password retry.
  • Keep the fix minimal and local to the event loop semantics where possible.
  • Add regression tests that reproduce the event-loop collision class.

Non-Goals

  • Do not replace cloud login verification with a program-owned websocket.
  • Do not add a standalone Lua test harness.
  • Do not redesign startup/boot.lua or shell lifecycle unless the event-loop fix proves insufficient.
  • Do not add persisted shell history as a workaround for shell teardown.

Implementation

1. Make event loop IDs process-global

File: packages/trapos-core/apis/eventloop.lua

Replace the module-local next_eventloop_id allocation with a _G-backed counter so independently loaded copies of the module cannot reuse the same synthetic end event name.

Sketch:

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

Then use:

local eventloop_id = nextEventLoopId();

This keeps the existing @libeventloop/END_OF_LOOP/<id> design but removes collisions across module instances.

2. Avoid queued stop events when stopping from inside the same loop

File: packages/trapos-core/apis/eventloop.lua

Add a local stopRequested = false flag.

When stopLoop() is called while the loop is actively dispatching its own handler or timeout, set stopRequested = true instead of queueing a global event. After dispatching the current event, break the loop if stopRequested is set.

Keep queueing the synthetic end event when stopLoop() is called from another coroutine, because the loop may be blocked inside os.pullEventRaw() and needs to be woken.

This is a second layer of protection: the verifier's finish() calls stopLoop() from inside its own event handler, so no global end event should be needed for that path at all.

3. Add an event-loop collision regression test

File: packages/trapos-core/tests/eventloop.lua

Add a test that simulates independent module loads:

  • Load /apis/eventloop.lua twice via loadfile() or an equivalent isolated environment.
  • Create one boot-like loop from the first module and one verifier-like loop from the second module.
  • Run both under parallel.
  • Stop the verifier loop from one of its handlers.
  • Assert the boot-like loop still handles a later probe event.
  • Stop the boot-like loop explicitly at the end so the test cannot hang.

The regression should fail against the current module-local ID allocation and pass after the fix.

4. Add or extend cloud program coverage

File: packages/trapos-cloud/tests/cloud-program.lua

Keep the existing wrong-then-right interactive test, because it verifies the program branch behavior.

Add a small test for prompt-time terminate handling if practical:

  • Fake read('*') raises Terminated.
  • Assert cloud login queues login-restore when needed and re-raises Terminated.

This is not the primary wrong-password bug, but it closes an adjacent interactive cancellation gap.

5. Optional cloud hardening: match hello responses by messageId

File: packages/trapos-cloud/apis/libcloud.lua

Store the active hello messageId, pass frame.messageId through api.onMessage() to onHello, and ignore hello responses that do not match the currently active hello attempt.

This prevents stale hello responses from earlier reconnects being attributed to the current cloud login token. It should be treated as a separate hardening change if the event-loop fix is intended to stay small.

Package Versions

If only eventloop.lua changes:

  • Bump trapos-core in packages/trapos-core/ccpm.json.
  • Mirror the same version in packages/index.json.

If cloud program or cloud daemon behavior also changes:

  • Bump trapos-cloud in packages/trapos-cloud/ccpm.json.
  • Mirror the same version in packages/index.json.

Only bump the full trapos meta-package if the repository's release convention for this change requires it.

Verification

Targeted iteration:

just trapos-exec 'shell.run("/programs/runtest.lua", "--pretty", "/tests/eventloop.lua", "/tests/cloud-program.lua", "/tests/cloud.lua")'

Required after Lua/package edits:

just check
just test --pretty

Manual validation on a running TrapOS computer:

  • Run cloud login --force.
  • Enter a known wrong password.
  • Confirm it prints invalid password, try again (ctrl+t to cancel) and prompts again.
  • Press up-arrow after eventually cancelling or completing, and confirm shell history still contains cloud login --force.

Risks

  • Event loop stop semantics are shared by servers and programs, so regression tests should cover both same-loop stop and cross-coroutine stop.
  • If CraftOS-PC loads require('/apis/eventloop') as a singleton in more cases than expected, the ID collision may be intermittent; the _G counter still makes the behavior deterministic and safer.
  • The optional hello messageId hardening will require updating existing cloud tests that currently send hello responses without message IDs.