Skip to content

Typed SpawnError rejections; list failures never look empty (#125) - #134

Merged
scttfrdmn merged 1 commit into
mainfrom
fix/125-errors-propagate
Nov 11, 2026
Merged

scttfrdmn merged 1 commit into
mainfrom
fix/125-errors-propagate

Conversation

@scttfrdmn

Copy link
Copy Markdown
Contributor

Fixes #125

Stacked on #133 (#128). This PR targets main so CI runs, which means its diff includes #133's commit until that merges. Review only the top commit, Typed SpawnError rejections…. Merge #133 first. It is stacked because the inline error and the "stopping…" progress need cards that survive the monitor's re-renders, which is what #133 adds.

Client

  • SpawnError (src/core/errors.ts). stop / start / hibernate / terminate / extend / signalComplete / launch / refresh reject with it. Fields: operation, instanceId, code, aws, httpStatus, requestId, cause.
    • With aws: true, code is the AWS code: UnauthorizedOperation, IncorrectInstanceState, UnsupportedHibernationConfiguration, ExpiredToken, and so on. It is read from SDK v3's name when $fault/$metadata are present, or from Code.
    • Otherwise code is one of spawn-ts's own: InvalidInstanceId, InstanceNotFound, AmbiguousName, NotSimulated, NoTtl, InvalidDuration, LaunchRefused, NetworkError, Unknown.
    • The message is unchanged, so existing toThrow(/…/) matches still hold.
  • An action resolves only when its provider call succeeded. The refresh after it no longer turns a successful stop into a rejection; a failed refresh is reported instead.
  • refresh() rejects on a failed DescribeInstances and never resolves []. The new client.listState is { status: "loading" | "ok" | "error", instances, checkedAtMs?, error? }. On error, instances is the last list that loaded.
  • EC2Provider.list follows NextToken. Reading only one page was another way to show a wrong list as if it were current.
  • No unhandled rejections. A failure nobody awaited (a monitor tick, or a post-action refresh) is emitted as { type: "error", error }. Before, void this.tick() left an unhandled rejection.
  • launch types its errors at each throw point rather than wrapping the method. A wrapper adds a microtask per launch, and the fan-out pump's ordering is sensitive to that (one sweep test caught it).
  • The post-launch-refresh-fails → terminate behaviour (Go #1298) is unchanged.

Dashboard

  • A refused action shows on its card (.card-error, role="alert") as one plain sentence naming the action, from describeSpawnError, plus the code and AWS's message, with a dismiss link. It survives re-renders, is cleared by the next successful action, and is logged as an error line.
  • While a call runs, the pressed button reads stopping… / starting… / terminating… and the card's buttons are disabled. Terminate confirms before anything runs.
  • A failed refresh keeps the last cards under a .list-error banner: "…[ExpiredToken] Showing the list from 12:00:00; those instances may still be running."
  • "no instances" appears only after a list actually loaded. Before the first one it says "checking instances…". After a failure with no prior list, it shows the banner and nothing else.
  • .orphan-banner[hidden] (and the new elements) now actually hide. display: flex was overriding the attribute, as the brief noted.

Tests

  • src/core/errors.test.ts:
    • code extraction; toSpawnError keeping the code, status, request id, cause and message; network and local codes; the plain sentences.
    • With a fake EC2: stop / hibernate / terminate rejecting with UnauthorizedOperation, UnsupportedHibernationConfiguration and IncorrectInstanceState. A Name rejects with InvalidInstanceId and makes no AWS call.
    • A successful stop with a failing refresh resolves and sets listState to error.
    • EC2Provider.list rejects instead of returning [], and paginates.
    • refresh rejects, keeps the last good list and marks it stale.
    • 8 monitor ticks over a failing account emit at least 8 error events and leave no unhandled rejection (process.on("unhandledRejection")).
  • dashboard.test.ts:
    • a refused stop shows the reason and code on the card, logs it, re-enables the buttons, and leaves no unhandled rejection;
    • the error survives re-renders, and a later success clears it;
    • progress text shows while the call runs;
    • a failed refresh keeps the cards with the banner and never says "no instances";
    • a first refresh that fails shows the error, not an empty account.

Breaking for existing consumers

Mildly, and noted in the CHANGELOG:

  • errors are SpawnError, though messages are unchanged and instanceof Error still holds;
  • a stop that succeeded no longer rejects because its follow-up refresh failed;
  • before the first refresh, the Dashboard says "checking instances…" instead of "no instances".

Instance actions, launch and refresh reject with SpawnError carrying the
operation, instance id and AWS code. An action resolves only when its
provider call succeeded; a failed refresh after it is reported through
the new client.listState and an "error" event, never an unhandled
rejection. refresh() rejects instead of resolving [] on a failed
DescribeInstances, and EC2Provider.list follows NextToken.

The Dashboard shows a refused action on its card in plain words with the
AWS code, shows "stopping..." while it runs, keeps the last cards with a
"couldn't check" banner when the list fails, and says "no instances" only
after a list loaded.

Fixes #125
@scttfrdmn
scttfrdmn force-pushed the fix/125-errors-propagate branch from 270499f to 7924910 Compare November 11, 2026 05:01
@scttfrdmn
scttfrdmn merged commit cff4e5b into main Nov 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dashboard instance actions swallow errors: stop/start/extend/terminate fail silently

1 participant