Skip to content

host-service never re-registers after the relay answers 4403 not_registered, so a running host stays invisible until restarted #8445

Open

Description

@David-Banquet

What happens

A Linux host-service started on 2026-09-29 (CLI 1.31.0) ran fine until 2026-10-08. That day its host row disappeared from the cloud: superset hosts list no longer showed the machine, from any client. The process kept running and kept dialing the relay, which refused it every time:

2026-10-08T15:39:05Z relay closed control (4401 auth-expired: mint a fresh token and reconnect)
...
2026-10-08T15:39:27Z relay closed control (4403 forbidden: access denied, retry slowly): Forbidden: host not registered to this account - run `superset start` on it with this org

The 4403 repeated 2,095 times over five days (about every 5 seconds while the machine was awake). Same user and same organization on both sides (superset auth whoami, organization list). superset stop && superset start --daemon fixed it at once: the new process logged registered as host <same id>, the host came back online, and the 4403s stopped.

I don't know what removed the row. The only paths I can find are an explicit delete by an owner (packages/trpc/src/router/host/management.ts), a member removal and an account purge. Whatever the cause, the host should recover by itself.

Why it does not recover

  • Registration (host.ensure) runs once per process, in connectRelay (connect.ts#L56-L69). Once it has succeeded, nothing calls it again.
  • On close, the tunnel client only logs the event and lets partysocket reconnect (tunnel-client.ts#L154-L167). A 4403 is treated like any other drop.
  • The relay's own message tells the user to run superset start (access.ts#L38-L39), which is exactly the step the running host-service could take itself.

Same family as #6415 (a failed boot registration left the host locally healthy but cloud-invisible), fixed by retrying host.ensure at boot. This is the same state, reached after boot.

Suggested fix

When the control socket closes with 4403 and the reason is not_registered, run host.ensure again (with the existing backoff) before reconnecting. If the row was deleted on purpose, host.ensure either recreates it, which matches what a manual restart does today, or fails and gets reported through health.check like a failed boot registration. In both cases superset status could say "not registered" instead of "running".

Activity

  1. github-actions commented on Oct 13, 2026

    @github-actions
    Contributor

    Summary

    The reporter's host-service registered fine at boot and then lost its cloud access row while it was still running. After that, the relay refused every control connect with 4403 forbidden … host not registered to this account. The process kept reconnecting every ~5 s for five days and never called host.ensure again. A manual superset stop && superset start re-registered it at once. The report holds up against the code: registration runs once per process, and nothing on the reconnect path treats a 4403 differently from any other drop. Whatever removed the row, the host has no way to recover without a restart, and superset status keeps reporting it as registered the whole time.

    Affected code

    • packages/host-service/src/tunnel/connect.ts:58-92: host.ensure runs in a retry loop only until the first success. It then builds the TunnelClient, returns it, and is never called again for the life of the process. The comment at connect.ts:65-66 states this design ("Registration runs once per process and a restart re-registers").
    • packages/host-service/src/tunnel/tunnel-client.ts:154-168: the close listener only logs, and partysocket reconnects with maxReconnectionDelay: 5_000 (tunnel-client.ts:124-129). A 4403 is treated like any transport drop. That gives the steady ~5 s 4403 loop in the logs, even though RELAY_CLOSE.forbidden is documented as "retry slowly" (packages/shared/src/tunnel-protocol.ts:41-42). TunnelClientOptions (tunnel-client.ts:51-62) has no API client and no callback, so the client cannot re-register even if it wanted to.
    • packages/host-service/src/tunnel/registration-state.ts:26-30: recordRegistrationSuccess() sets registered = true at boot. Nothing ever resets it, so health.check / superset status keep reporting the host as registered while the relay rejects it.
    • Relay side: apps/relay/src/index.ts:125-137 checks access on every control connect through checkHostAccessForRegister (apps/relay/src/access.ts:75-89). That calls host.checkAccess (packages/trpc/src/router/host/host.ts:240-278), which only looks for a v2_users_hosts row for (user, org, machine). If the row is missing, the result is not_registered, and the 4403 reason is the text from accessDenialMessage (access.ts:38-39). The reason travels only as free text; 4403 is also used for not_in_org and invalid_host.

    What could have removed the row

    checkAccess and host.list both depend on v2_users_hosts, so the trigger was the loss of that row (or of the v2_hosts row it belongs to). I found these deletion paths:

    • An explicit host delete by a host owner or org owner: packages/trpc/src/router/host/management.ts:238-246.
    • Removal of one user's access to a host: management.ts:~370 (allowed only when another owner remains).
    • Member removal: packages/db/src/utils/member-removal.ts:95-113. This deletes all of the member's v2_users_hosts rows in the org, plus any hosts left without an owner.
    • Account purge: packages/trpc/src/lib/account-purge/purgeAccount.ts.

    The reporter is still a member, so member removal is unlikely unless they were removed and then re-invited. I can't tell which path ran from the code alone. Server logs or the nudge(…, "hosts") events around 2026-10-08 15:39Z would show it. The 4401 just before it is probably unrelated: the relay sends 4401 for an expired JWT, and the next connect with a fresh token reached the access check.

    Reproduction

    1. Start a host-service (superset start --daemon) and confirm it appears in superset hosts list.
    2. Delete the host's access row, for example with the host delete action in Settings → Hosts or by removing your own v2_users_hosts row in a dev DB.
    3. Watch the host-service log. You get repeated control closed { code: 4403, reason: "Forbidden: host not registered…" } about every 5 s, no registered as host line, and superset status still reports the host as registered.

    A bun:test reproduction could start a local Bun.serve WebSocket server that accepts the upgrade and closes with 4403 and the not_registered reason. Point a TunnelClient at it, then assert that a new onNotRegistered/reRegister callback (see below) is called once, before the next connect attempt. Today the test fails because no such hook exists. Pass the delays in rather than waiting on real timers, per the test guidelines.

    Suggested fix direction

    1. Add an option to TunnelClientOptions, for example onForbidden?: (reason: string) => Promise<void>. Call it from the close listener when event.code === RELAY_CLOSE.forbidden.
    2. In connect.ts, move the host.ensure call into a helper that the boot loop and that callback both use. On a not_registered close, call recordRegistrationFailure(...) so health.check reports the truth, then retry ensure with the existing REGISTER_RETRY_* backoff. Guard it with a single in-flight promise so repeated closes don't stack calls. The machineId/org don't change, so the existing tunnel and routing key can stay. It only needs control.reconnect() after ensure succeeds.
    3. Make the relay send a structured denial reason (a short fixed token, or a separate close code for not_registered) so the host doesn't have to string-match accessDenialMessage. A stale JWT not_in_org should probably refresh the token rather than call ensure.
    4. Separately, apply "retry slowly" to 4403: a longer backoff while forbidden would have cut the 2,095 refused connects.

    Design question for maintainers: automatic re-registration means a host that an owner (especially an org owner) deleted on purpose comes back by itself while its process is still running. That already happens on the next restart, but it would now be immediate. If that's unwanted, host.ensure would need a tombstone or "deleted" marker to refuse, and the host-service would then report registered: false instead of recreating the row.

  2. added a commit that references this issue on Oct 26, 2026
    14956eb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions