Repository navigation
fix: slice 1 robustness follow-ups — record-store re-arm race, login code expiry, control-plane readiness, relay teardown (#426) - #436
Conversation
…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
left a comment
There was a problem hiding this comment.
(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 whenthis.ready === pis the right fix for the microtask window between node-redis's synchronousisOpen = falseand 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 througherrors.ts,openapi.yaml, the client spec table, and the client mapping — no string-matching of GitHub's text anywhere. Re-issue-once bounded byMAX_CODES = 2is a reasonable call; an unattended login still terminates. - #434 (control-plane): the
/readyzPromise.racebound withclearTimeoutinfinallyis clean (the losinggetrejection is absorbed by the race), and exportingredisReconnectStrategy/redisClientOptionsso 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 loggedclose()failures is right. - #434 (
relay.ts): the identity guard (sessions.get(sandboxId) !== own) plusown = undefinedcorrectly 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(() => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 anddeps.close()wait forkeepAliveTimeout. CallingcloseIdleConnections()beforeclose()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>
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). Thesetup.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
harness/src/pool-records.ts:connectOnceholds the attempt in a localp, and its.catchclears the memo only if the memo is stillp. A failed re-arm can no longer wipe a newer attempt.Socket already opened.mocactl loginprints how long the device code is valid, for example(valid for 15 minutes), fromexpiresIn.expired_tokenintounauthorized. It now answers 410device_code_expired.errors.ts,identity.ts,docs/api/openapi.yamland the client spec are updated.mocactl loginagain" and exits 1.redisReconnectStrategy,redisClientOptions), and tests pin it: 100 ms per retry, capped at 2 s, never gives up, and not node-redis's default./readyzboundsindex.getat 500 ms, then answers 503redis_unavailable. A connected but wedged Redis now fails before the kubelet's 1 s default probe timeout.deps.close(), then exits 0. A throwingclose()is logged, and the process still exits.relay.ts): each stream remembers the session its own Hello created.teardownacts 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.'error'. Before the fix, the new session vanished fromparked().For review
device_code_expired(410). A new mocactl talking to an older control plane still seesunauthorized. Its ownexpiresIndeadline 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.shnow printsdevice_code_expired./readyz500 ms bound is a constant.CpDeps.readyzTimeoutMsexists 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.
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