Skip to content

fix: slice 1 robustness follow-ups — record-store re-arm race, login code expiry, control-plane readiness, relay teardown (#426) - #436

Merged
pdettori merged 6 commits into
rossoctl:mainfrom
pdettori:fix/426-slice1-robustness
Oct 5, 2026
Merged

pdettori merged 6 commits into
rossoctl:mainfrom
pdettori:fix/426-slice1-robustness

Conversation

@pdettori

@pdettori pdettori commented Oct 5, 2026

Copy link
Copy Markdown
Member

Summary

Code-only robustness follow-ups from the P6-on-Kubernetes slice 1 reviews (epic #426). It doesn't touch deploy/k8s/ or the manifest tests, so it does not conflict with slice 2 (#424). The setup.sh, manifest and smoke items of #433 and #434 stay open for a follow-up after slice 2 merges.

Closes #428. Closes #431. Part of #434 and #433.

Changes

  • RedisRecordStore re-arm: a failed attempt's .catch can clear a newer memo (microtask race) #428, harness/src/pool-records.ts: connectOnce holds the attempt in a local p, and its .catch clears the memo only if the memo is still p. A failed re-arm can no longer wipe a newer attempt.
    • Test: a hand-settled client interleaves three callers around a failed re-arm. Before the fix, the third caller got Socket already opened.
  • mocactl login: say how long the device code is valid, and handle expired_token #431, mocactl and control plane:
    • mocactl login prints how long the device code is valid, for example (valid for 15 minutes), from expiresIn.
    • The token poll used to collapse GitHub's expired_token into unauthorized. It now answers 410 device_code_expired. errors.ts, identity.ts, docs/api/openapi.yaml and the client spec are updated.
    • On expiry, from the server or from the local deadline, mocactl re-issues the code once and prints the new one. The Login overlay swaps it in with a fresh countdown. A second expiry fails with "run mocactl login again" and exits 1.
  • deploy/k8s, control plane, relay: small robustness follow-ups from the slice 1 reviews #434, control plane:
    • The Redis reconnect strategy is exported (redisReconnectStrategy, redisClientOptions), and tests pin it: 100 ms per retry, capped at 2 s, never gives up, and not node-redis's default.
    • /readyz bounds index.get at 500 ms, then answers 503 redis_unavailable. A connected but wedged Redis now fails before the kubelet's 1 s default probe timeout.
    • SIGTERM closes the HTTP server, then deps.close(), then exits 0. A throwing close() is logged, and the process still exits.
  • deploy/k8s, control plane, relay: small robustness follow-ups from the slice 1 reviews #434, relay (relay.ts): each stream remembers the session its own Hello created. teardown acts only while the map still holds that exact session, and at most once. A stray 'error' after 'end' can therefore no longer evict a same-id reattach or cancel its presence retry.
    • Test: end, reattach with a retry pending, then a late 'error'. Before the fix, the new session vanished from parked().
  • deploy/k8s: test-strength follow-ups from the slice 1 reviews #433, relay test: covers a presence put still in flight at teardown, whether it resolves or rejects: no further put, no timer scheduled, nothing logged. It was checked against a mutation: removing the guard that stops a gone session's retry makes it fail.

For review

  • mocactl login: say how long the device code is valid, and handle expired_token #431 re-issues once instead of failing straight away. The case seen on OpenShift was a user away from the browser: a fresh code costs one request, while a failure costs a re-run. Allowing only one re-issue means an unattended login still ends, after two code lifetimes.
  • New API error device_code_expired (410). A new mocactl talking to an older control plane still sees unauthorized. Its own expiresIn deadline usually catches the expiry, but a poll that lands between GitHub's expiry and mocactl's own deadline still fails as before. deploy/knative/demo-multiuser.sh now prints device_code_expired.
  • The /readyz 500 ms bound is a constant. CpDeps.readyzTimeoutMs exists for tests.

Test plan

  • make lint (exit 0)

  • make typecheck (exit 0)

  • vitest with Redis available, all exit 0. The skipped tests are only the env-gated live ones.

    Package Passed Skipped
    harness 514 5
    control-plane 452 3
    mocactl 557 2
    sandbox-relay 49 0
  • Each new test failed before its fix (deploy/k8s: test-strength follow-ups from the slice 1 reviews #433 is test-only and was mutation-checked).

🤖 Generated with Claude Code

…ctl#428)

connectOnce's .catch cleared `ready` unconditionally. node-redis sets
isOpen false synchronously at the give-up, but the rejection reaches the
.catch microtasks later; a call in that window re-arms with a second
attempt, and the first .catch then wiped that newer memo, so a third
caller ran connect() on an open socket and got `Socket already opened`.

Hold the attempt in a local and clear the memo only while it is still
that attempt's. The test drives a stand-in client by hand: fail the
first connect, call again before its rejection lands, then check a third
caller shares the second attempt (it failed with `Socket already opened`
before the fix).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…red one once (rossoctl#431)

`mocactl login` printed the code with no lifetime and then failed with
`github device authorization failed: expired_token` when the user was
away from the browser too long (seen twice in the rossoctl#423 OpenShift run).

- The control plane's /v1/auth/device relay already carries expiresIn
  (default 900); nothing to fix there. The token poll, though, folded
  GitHub's expired_token into `unauthorized`, so a client could only
  tell expiry from a denial by matching GitHub's text. It now answers
  410 `device_code_expired` (errors.ts, openapi.yaml, spec table).
- mocactl prints "(valid for 15 minutes)" from expiresIn.
- deviceLogin re-issues the code ONCE when the poll says
  device_code_expired or expiresIn passes locally. headless prints
  "That code expired. Open ... and enter the code ..." with the new
  code; the Login overlay swaps it in with a fresh countdown and a note.
  A second expiry ends with "the code expired before it was approved
  — run `mocactl login` again" (exit 1).

Re-issue-once rather than fail: the observed failure is a user who
stepped away, and a fresh code on the terminal they return to costs one
request where a failure costs a re-run. It is clean because deviceLogin
already owns the poll loop and both callers already render whatever
code onCode hands them. Bounded at one so an unattended login still
ends, after two code lifetimes.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…close on SIGTERM (rossoctl#434)

Three small edges from the slice 1 reviews:

- The reconnect strategy (retry forever, 100 ms per retry up to 2 s) is
  exported as redisReconnectStrategy, with redisClientOptions building
  the client's options from it. A test pins the backoff, that it never
  returns anything but a delay, and that the client is built with it,
  so it cannot regress to node-redis's default (which gives up on a
  refused first connect) or to a strategy that gives up.
- /readyz bounds index.get with a 500 ms timeout (CpDeps.readyzTimeoutMs
  as a test seam). A Redis that is connected but wedged now answers 503
  redis_unavailable, below the kubelet's 1 s default probe timeout,
  instead of failing as a probe timeout.
- SIGTERM closes the HTTP server, then calls deps.close() to release
  the Redis client, then exits 0; a close() that throws is logged and
  still exits.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ossoctl#434)

teardown is registered for both 'end' and 'error', and keyed only by
sandboxId. When a stream emitted both with a same-id reattach in
between, the old stream's second teardown found the NEW session: it
cancelled that session's presence retry, failed its in-flight execs,
evicted it from the session map and removed its presence record.

Each stream now remembers the session its own Hello parked, and
teardown runs only while the map still holds that object (and at most
once). The test ends a session, reattaches the same id with a put retry
pending, then fires the old stream's 'error': before the fix the new
session was gone from parked() and its retry cancelled.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…othing (rossoctl#433)

The existing teardown tests catch the presence retry while it waits on
a timer. This one catches the attempt itself still out at Redis when
the stream ends: whether that put then rejects or resolves, there is no
further put, no timer scheduled or left pending, and nothing logged for
the gone session. Checked against a mutant that drops the identity
guard in the put's rejection handler: the `rejects` case fails.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>

@pdettori pdettori left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Review posted as COMMENT — GitHub won't let the PR author's token submit an APPROVE; verdict would be approve.)

Reviewed the four fixes plus the relay test — all look correct, each pinned by a test that fails without it.

  • #428 (pool-records.ts): clearing the memo only when this.ready === p is the right fix for the microtask window between node-redis's synchronous isOpen = false and the rejection reaching .catch. The hand-settled stand-in client makes the interleaving exact rather than a timing race.
  • #431 (identity / mocactl): device_code_expired (410) is consistently wired through errors.ts, openapi.yaml, the client spec table, and the client mapping — no string-matching of GitHub's text anywhere. Re-issue-once bounded by MAX_CODES = 2 is a reasonable call; an unattended login still terminates.
  • #434 (control-plane): the /readyz Promise.race bound with clearTimeout in finally is clean (the losing get rejection is absorbed by the race), and exporting redisReconnectStrategy / redisClientOptions so a test pins both the backoff and the wiring is exactly how this kind of thing should be locked down. SIGTERM ordering (server → deps → exit 0) with logged close() failures is right.
  • #434 (relay.ts): the identity guard (sessions.get(sandboxId) !== own) plus own = undefined correctly stops both the double-teardown from end+error and the same-id reattach eviction. The test covers the exact interleaving.

One suggestion inline; nothing blocking.

🤖 Generated with Claude Code

// keep the process alive past the SIGTERM.
const onSigterm = () => {
server.close(() => process.exit(0));
server.close(() => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: server.close(cb) waits for all connections to end, including idle keep-alive ones from the ingress. In practice k8s SIGKILLs after the grace period so this can't hang forever, but a long-lived idle connection would delay deps.close() (Redis release) and the exit for the full grace period. Consider calling server.closeIdleConnections() (Node ≥ 18.2) right before server.close(...) so only in-flight requests hold the shutdown open.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in bf95af7, but not the way the comment suggests, because I tested it on Node 22 and 25 first:

  • Since Node 19, server.close() already closes connections that are idle when it is called. A socket that was idle at SIGTERM is closed in about 1 ms.
  • The real gap is a keep-alive socket whose request is in flight at SIGTERM. It goes idle after close() runs, and nothing closes it, so the exit and deps.close() wait for keepAliveTimeout. Calling closeIdleConnections() before close() doesn't help there because the socket is busy at that moment. I measured the full keep-alive wait with it in place.

Fix: while draining, every response that finishes calls server.closeIdleConnections() (on setImmediate, so the socket has been marked idle). New test SIGTERM > does not wait out keep-alive on a connection whose request was in flight at SIGTERM sets keepAliveTimeout=60s, reuses one agent socket for /healthz and then a slow /readyz, and checks that the process exits within 2 s. It fails without the fix.

…le after close() (rossoctl#436 review)

server.close() already closes connections idle at call time (Node >= 19), but
a socket whose request was in flight at SIGTERM goes idle afterwards and holds
the exit and the Redis release for keepAliveTimeout. Calling
closeIdleConnections() before close() does not reach that socket either; reap
idle connections after each response that finishes while draining instead.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
@pdettori
pdettori merged commit fc021ba into rossoctl:main Oct 5, 2026
14 checks passed
@pdettori
pdettori deleted the fix/426-slice1-robustness branch October 5, 2026 13:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant