Skip to content

feat(cli): reconnect instead of dying when the app side goes away - #246

Merged
arzafran merged 2 commits into
mainfrom
feat/cli-reconnect-hardening
Aug 4, 2026
Merged

feat(cli): reconnect instead of dying when the app side goes away#246
arzafran merged 2 commits into
mainfrom
feat/cli-reconnect-hardening

Conversation

@arzafran

@arzafran arzafran commented Aug 4, 2026

Copy link
Copy Markdown
Member

What this does

Hardens the CLI against the 'sockets are unstable, losing connection constantly' report. The drop pattern (systemic, all at once, reconnect heals) matches the app process restarting — we ship a rolling build on every green commit, so update relaunches are frequent — plus watch-events hard-exiting after one hour of event silence.

  • watch-events reconnects indefinitely on EOF, transport error, or the 1h idle timeout: capped exponential backoff, full re-auth + resubscribe, one stderr notice per drop including elapsed seconds (so the next field report tells us which case fired). stdout stays events-only for piping. --no-reconnect keeps the old exit-on-drop for scripts that want it.
  • one-shot commands retry the initial connect 3 times over ~1.5s on ECONNREFUSED/ENOENT (the app-is-restarting window). Only the connect is retried, never post-connect work, so nothing can double-execute.
  • connect errors carry errno instead of message strings so transient vs permanent is classified without parsing.

Deliberately no server protocol changes: a server-side keepalive/ping event would leak into older CLIs' piped event streams (version skew during rolling updates).

Test plan

  • CI green (programa-cli and app schemes both build)
  • Manual: start watch-events, quit + relaunch the app — stream prints a reconnect notice and resumes instead of exiting
  • Manual: programa during an app restart succeeds after a brief retry instead of erroring

No automated test: bouncing the app's own listener mid-test has no tests_v2 harness (existing reconnect tests bounce docker sshd / the browser proxy, never the app), stated per test policy rather than faking one.

Field report: socket drops are systemic (all connections at once) and
reconnecting heals them — which matches the app process restarting
(rolling auto-update or crash) plus watch-events' hard exit after one
hour of silence (the 3600s idle read timeout was fatal).

Three CLI-side changes, no server protocol changes (a server ping would
break older CLIs piping the event stream):

- watch-events now reconnects indefinitely on EOF, transport error, or
  the idle timeout: one stderr notice per drop (with elapsed seconds, so
  field reports distinguish the 1h idle case from an app restart),
  exponential backoff 0.5s to a 5s cap with jitter, full
  connect + re-auth + resubscribe on the same path as the initial
  connect, and a stderr notice on success. stdout stays events-only.
  --no-reconnect preserves the old exit-on-drop behavior.
- one-shot commands retry the initial connect up to 3 times over ~1.5s
  when it fails with ECONNREFUSED/ENOENT (the app-is-restarting window).
  Only the connect syscall is retried, never anything after a successful
  connection, so non-idempotent commands cannot double-execute.
- connect failures now carry errno (SocketConnectError) instead of
  message strings, so the retry logic classifies transient vs permanent
  without parsing.

No automated test: bouncing the app's own socket listener mid-test has
no harness in tests_v2 (existing reconnect tests bounce docker sshd or
the browser proxy, never the app process), and the repo policy keeps the
app under test a single tagged instance. Stated per test policy instead
of faking it.
…etrying permanent failures

watch-events's subscribeAndAck() now parses the subscribe ack and requires ok:true
before treating a (re)connect as subscribed -- a rejected resubscription (bad
password, unsafe-socket rejection, invalid params) no longer prints
'reconnected' and the raw ack line is never echoed to stdout (stdout is
events-only). The reconnect retry loop now writes a stderr line for every
failed attempt instead of failing silently, and classifies auth/protocol
rejections as permanent: after 3 consecutive permanent failures it exits with
the underlying error instead of looping forever. Transient failures
(ECONNREFUSED/ENOENT/EOF/timeout) keep retrying indefinitely as before.
@arzafran
arzafran merged commit 323c965 into main Aug 4, 2026
10 checks passed
@arzafran
arzafran deleted the feat/cli-reconnect-hardening branch August 4, 2026 17:07
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.

1 participant