Skip to content

feat(pod,serverless): add --wait and --wait-timeout to create (CON-689) - #317

Draft
justinwlin wants to merge 6 commits into
mainfrom
justinlin/con-689-wait-flag
Draft

feat(pod,serverless): add --wait and --wait-timeout to create (CON-689)#317
justinwlin wants to merge 6 commits into
mainfrom
justinlin/con-689-wait-flag

Conversation

@justinwlin

@justinwlin justinwlin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

adds an opt-in --wait to pod create and serverless create so a create returns when the resource is usable, not when it is scheduled — today a pod reports RUNNING minutes before sshd answers, so agents sit in a poll loop guessing.

https://linear.app/runpod/issue/CON-689

what changed

  • pod create --wait returns when the pod's public port 22 accepts a tcp connection and answers with an ssh banner, then prints the pod get shape (so the payload carries the live ssh block).
  • serverless create --wait returns when the endpoint's /health reports a ready or running worker.
  • --wait-timeout (default 10m, reuses the existing 7d-aware duration parser, now shared in internal/duration).
  • new internal/waitfor: one bounded poll loop with an injectable clock, throttled stderr progress, and a typed error carrying the last known state. progress is a caller-supplied io.Writer, so the loop structurally cannot touch stdout.
  • the legacy PodSSHConnection loop (cmd/project/ssh.go) now calls the shared helper, dropping its re-poll-inside-the-condition bug and its post-loop timeout check that could fire on a poll that had just succeeded. its stdout line, wording and 1s/5m timings are unchanged (proven by a base-vs-head diff below).
  • error objects gained an optional id field, so a wait_timeout names the resource you now own as data, not prose.
  • two new codes: wait_timeout, wait_interrupted (added to the client.go vocabulary block, README's error table and AGENTS.md).

deviations from the ticket

everything below departs from CON-689's wording. reviewer 2 re-derived each justification from ai-api / runpod-backend / host source or a free live probe rather than taking this PR's word for it; their ruling is stated verbatim, including where it was not valid.

1. "pod create --wait → returns when ssh is reachable"

  • ticket asked for: "returns when ssh is reachable", and its implementation note says "PodSSHConnection (cmd/project/ssh.go:257) already polls pod runtime ports until ssh is up; extract that loop and reuse it."
  • shipped: the loop was extracted and reused (that half of the note is honoured, legacy included), but the readiness signal was replaced: reachability is a tcp connect plus the RFC-4253 SSH- banner, not the presence of port 22 in runtime.ports. no handshake and no key, so --wait can report success on an image whose sshd never got your key.
  • why: the note's premise is false. graphql runtime.ports is proxied from the host daemon (runpod-backend/node/graphql/schema/podRuntime.ts:38-43model/src/hapiGateway/pod.ts:21-29), which builds it from docker's published-port table with IsIpPublic set purely from HostIP == 0.0.0.0 (host/pkg/docker/container.go:392-417). a docker port binding exists from container start and says nothing about anything listening inside. measured: an alpine:3.20 cpu pod had a public 22 in ~25s while nc -vz was refused. a handshake was rejected as an alternative because --wait must work before runpodctl doctor has ever run.
  • verified by review: valid. reviewer 2 confirmed the mechanism at the source rather than accepting the measurement. they could not re-measure the ~25s figure without buying a pod (unverifiable-without-spend; one cpu alpine:3.20 pod plus one nc -vz would settle it). the reachable-but-unauthenticated gap is disclosed in the flag help, the README table and the code.

2. "serverless create --wait → returns when at least one worker is ready"

  • ticket asked for: "returns when at least one worker is ready".
  • shipped: ready means /health reports ready > 0 || running > 0. neither counter is as strong as the ticket's wording, and /health exposes no third one.
  • why: ready counts a flashboot-cached worker whose record reads desiredStatus: EXITED (ai-api/pkg/api/health.go:109-117 + pkg/workerstate/ready.go:119-130), so the first request resumes it. running counts desiredStatus: RUNNING, which the control plane writes when the worker row is created — at scheduling time, before any container exists (runpod-backend/model/src/pod/rentPod.ts:401). running still has to count: a --workers-min worker stays RUNNING for its whole life and never enters ready, so dropping it would make the flag's main case wait forever.
  • verified by review: reviewer 2 filed this as an unlisted deviation, severity major — it was not disclosed at all in the previous body, and the progress/last-known-state line omitted running, the one counter that can decide readiness, so the success case was unattributable. fixed in this round: running is now in the detail string, and the success line carries the detail too (ready after 25s: workers ready 0, running 1, ...), so a run says which clause fired. the weaker-than-asked predicate itself remains, disclosed here and in the README's ready-means table — there is no stronger live signal to switch to.

3. serverless create --wait used to require --workers-min >= 1

  • ticket asked for: the flag unconditionally; the ticket's own example passes no --workers-min.
  • shipped, previously: a hard refusal before anything was created, on the stated grounds that "at 0 min workers runpod provisions nothing until a request arrives, so 'a worker is ready' is unsatisfiable by construction".
  • why that was wrong: reviewer 2 refuted the premise at the source, and I re-derived it independently. finalEndpoint floors workersStandby to 5 whenever workersMax > 1 regardless of workersMin (ai-api/pkg/graphql/aiapi.go:435-450; the CLI's own --workers-max default is 3). worker.Sync then runs startMinPods + launchWarmPods for any endpoint with standby > 0 (pkg/worker/sync.go:115-121, 175-199), and every /health read triggers a Sync (pkg/api/health.go:36pkg/loader/aiapi.go:74) — so the CLI's own polling drives the provisioning. those cache workers land as EXITED + cached, which /health counts as ready. live probe on this account, six endpoints, all with workersMin unset: ready 3/5/1/2/1/5 with running 0.
  • verified by review: reviewer 2 ruled this deviation invalid — not merely understated. fixed in this round rather than argued: the refusal is gone. --workers-min 0 now emits a stderr note: (the same treatment already given to --compute-type CPU and community-without---public-ip) and the wait runs. the refuted claim was also removed from the error string, the README and AGENTS.md, where it was becoming durable guidance for the next agent.

4. endpoint readiness is read from /health, not GetEndpoint(includeWorkers)

  • ticket asked for: nothing specific — "at least one worker is ready" leaves the source open.
  • shipped: the invoke service's /health (so it follows RUNPOD_INVOKE_URL, not RUNPOD_API_URL).
  • why: includeWorkers returns historical records. live: serverless get mr4lkli7h69gwh --include-workers → 6 workers, every one desiredStatus: EXITED, while the same endpoint's /health reported ready: 5. ai-api/pkg/api/health.go:109-117 explains it: an EXITED pod is bucketed ready when the machine has it cached.
  • verified by review: valid — reviewer 2 reproduced the same endpoint live and confirmed the bucketing at the source.

5. a transient api failure does not end a wait

  • ticket asked for: "on timeout, exit non-zero with the last known state in the error" — silent on mid-wait failures.
  • shipped: a poll error is reported as the current state and polling continues to the deadline. only these stop it early: http 400/401/403 (or the codes unauthorized/forbidden/no_credentials/bad_request), a terminal pod status (conflict), and a resource absent from two consecutive reads (not_found).
  • why: the resource already exists and bills, so giving up early is the expensive answer, and reporting a transport code would tell an agent to retry the create and buy a second one. /health legitimately 404s an endpoint id it has not propagated yet (ai-api/pkg/api/userAuth.go:184-191, pkg/api/health.go:36-47, 20s dataloader cache in pkg/loader/aiapi.go:24-56).
  • verified by review: valid-but-understated. reviewer 2 confirmed the tradeoff and the 404 story at the source, but flagged that the endpoint poller had no fatal case at all, so an endpoint deleted out of band burned the whole 10m while only the pod path failed fast — an asymmetry nothing mentioned. fixed in this round: a /health 404 after a successful read is now fatal not_found (10.5s instead of 90s in a fake-plane run); 404 before the first successful read, and any 5xx, stay transient. reviewer 1 separately found the auth fail-fast was unreachable on the pod path (every graphql failure carries the constant code graphql_error), so a bad key burned the full budget and reported wait_timeout; the fatal check now also consults the http status — 0.4s instead of 15s in the repro below.

6. pod create --wait prints the pod get shape, not the create response

  • ticket asked for: "stdout stays a single clean JSON object of the final resource" — it does not say which shape.
  • shipped: a rest re-read enriched with the live ssh block, i.e. --wait changes the stdout schema versus a plain create.
  • why: neither create response carries an ssh command, and handing back a connectable pod is the flag's whole purpose.
  • verified by review: valid-but-understated. reviewer 2 pointed out the field-level loss nobody had mentioned: the graphql create response selects machine { gpuDisplayName location }, which the re-read dropped. partly fixed in this round: the re-read now passes includeMachine=true. what still differs and is not fixed: rest encodes env as a map and ports as []string, where graphql returns ["KEY=VALUE"] and a comma-separated string. reviewer 2 could not diff the two payloads live without buying a pod.

7. --compute-type CPU and community-without---public-ip warn instead of failing

  • ticket asked for: nothing — the ticket is silent on flag combinations.
  • shipped: both warn on stderr; only --ssh=false is refused up front.
  • why: both are satisfiable but often are not, so refusing would remove a working invocation. the rest create path has no startSsh field at all (cmd/pod/create.go:427-449 vs the graphql path's StartSsh), and --public-ip maps to supportPublicIp, which is a machine filter in the backend (model/src/pod/bidPod.ts:260, model/src/pod/cpu/deployPod/index.ts:128). --ssh=false is genuinely unsatisfiable.
  • verified by review: valid — reviewer 2 confirmed both mechanics at the source. they also noted this is the same "satisfiable but often not" class as deviation 3, which is exactly why the old hard refusal there read as inconsistent; deviation 3 is now resolved the same way.

8. the shared error object gained an optional id field

  • ticket asked for: "exit non-zero with the last known state in the error".
  • shipped: that, plus an omitempty id field on the error object and two new codes (wait_timeout, wait_interrupted) — a change to the output contract of every command.
  • why: a --wait that times out has already bought a billed resource, and a caller must not have to regex a message to find it. no existing error gains the field.
  • verified by review: valid — additive, and documented in all three places the repo requires (client.go vocabulary, README error table, AGENTS.md).

9. the resource is never deleted on timeout or ctrl-c

  • ticket asked for: "on timeout, exit non-zero with the last known state in the error" — silent on cleanup.
  • shipped: the pod/endpoint is kept. the exit code is non-zero and the error carries the id, the last known state and the delete command.
  • why: you paid for it and need the id to debug or clean up; deleting a resource the user did not ask to delete is worse than leaking one you were told about.
  • verified by review: not challenged by either reviewer.

10. one legacy behaviour change

  • ticket asked for: extracting and reusing the existing loop, which the repo laws additionally require to preserve legacy behaviour exactly.
  • shipped: cmd/project/ssh.go's stdout line, wording, %w wrapping and 1s/5m timings are byte-identical (diff below). one thing changed: a poll that succeeds at or after the 5m deadline now returns success instead of the old spurious "timeout waiting for pod" error.
  • verified by review: valid — reviewer 2 diffed the strings and timings against origin/main and confirmed it is a fix, on an error path, and disclosed.

the ticket carries no scope note offering a smaller cut, so there is no narrower version that was declined.

review fixes

two review rounds. every substantive finding was reproduced through the real binary against a fake control plane before fixing, and re-run after.

round 2 (this round)

finding verdict
--workers-min >= 1 refusal rests on a false premise (deviation 3) real, fixed. refusal → stderr note; claim removed from the error string, README and AGENTS.md. re-derived independently from ai-api source + a six-endpoint live probe
auth fail-fast is unreachable on the pod path: graphql failures all carry the constant code graphql_error real, fixed. a graphql 401 mid-wait went from a 15.05s wait_timeout to 0.4s with {"code":"graphql_error","status":401,"id":...}. the fatal check now also consults http status (400/401/403); 404/429/5xx stay transient
the endpoint poller has no fatal case, so a deleted endpoint burns the full budget (deviation 5) real, fixed. /health 404 after a successful read → fatal not_found in 10.5s instead of 90s. 404 before the first read, and any 5xx, still tolerated
running is invisible in progress and in the timeout error, so nobody can tell which clause fired (deviation 2) real, fixed. running is in the detail string, and the success line now carries the detail
FindPodConnection nil-derefs on a null entry in graphql's pods array, right after --wait reported success real, fixed. reproduced: panic: SIGSEGV, exit 2, 0 bytes on stdout, no json error object at all. one-line nil guard in FindPodConnection and ListConnections
duration.Parse silently returns a negative duration at >= 106752d, so an out-of-range --wait-timeout was replaced by the 10m default real, fixed. the product is now range-checked (106752dout of range (max 106751d)). also fixes pod list --since 200000d, which returned []
one transient empty myself{pods} read ended the wait with not_found asserting the pod "was terminated" real, fixed. it now takes two consecutive misses; a single blip reads "pod not listed in the last read". the genuine-deletion path still fires (10.0s in the repro)
the re-read dropped the machine block the graphql create response carried (deviation 6) real, partly fixed. includeMachine=true; the env/ports type difference remains and is disclosed above
two fail-fast tests set both interval and timeout to an hour, so a regression hangs for 10m instead of failing real, fixed. both now pass a 300ms budget with the huge interval; calls == 1 still proves the fail-fast
State.Err is unused; DefaultTimeout/DefaultProgressEvery/the EqualFold status compare survive mutation skipped (nits). every production caller passes explicit options, so the surviving mutations are on values the CLI never reads
pod create --wait polls the account-wide GetPods() every 5s; the post-wait re-read's 2s backoff ignores the wait context skipped (nits). both inherited from the legacy loop; worst case is ~4s past the deadline
round 1 (previous round, 17 findings)
finding verdict
one bad poll ends the whole wait, with a transport code real, fixed. /health 404s an endpoint id it has not propagated yet → the wait exited 0.04s after create with {"code":"not_found","status":404} for an endpoint that exists and bills a warm worker. one graphql blip did the same with network_error — the one code README defines as "transient, retry", so an agent would have bought a second pod
a doomed pod burns the full budget real, fixed. a terminal desiredStatus polled the entire 20s test budget (10m by default); now 0.75s with code conflict. a pod terminated out of band read "pod not listed yet" for the rest of the budget while the error claimed it was "still billing"; now not_found, and the wording no longer asserts it is running
pod create --wait can exit 0 with "ssh": {"error": ...} real, fixed. the post-wait re-read swallowed a graphql failure into that blob, losing the one field the flag exists to produce, with a success exit code. now retries, then fails loudly naming the address that did answer
community cloud without --public-ip is unsatisfiable and unwarned real, fixed (warn, not refuse), plus flag help, README and regenerated docs
the id is only in the message prose real, fixed. new id field on the error object; the e2e cleanup reads obj["id"] instead of slicing prose
legacy project/exec failure text changed and stopped wrapping the error real, fixed. both legacy messages restored verbatim and %w wrapping restored, so errors.Is/As reaches the typed no_credentials sentinel again
e2e cleanup registered after t.Fatalf paths could orphan a billed resource real, fixed. a name-based sweep is registered before each create
TestUntilWithoutProgressWriterStaysSilent asserted nothing about silence real, fixed. it now captures os.Stdout/os.Stderr and asserts zero bytes
ctrl-c / SIGINT unevidenced and untested real, fixed. signal registration is injectable and asserted in both packages, plus a live SIGINT run and an e2e test
the finalization branch had no unit coverage (two mutations survived) real, fixed. the re-read is injectable and covered four ways
"blocker": a < 1< 0 mutation of the workers-min guard rejected. the reviewer says themselves the committed tree read < 1; a concurrent process left that edit in the shared worktree mid-review and it reverted itself. (moot now — the guard is gone, see deviation 3)

testing

gofmt -l . clean · go vet ./... clean · go vet -tags e2e ./e2e/... clean · go test ./... all 16 packages ok · go run ./docs/docs-gen.go regenerated (committed). coverage: internal/waitfor 95%+, internal/duration 100%, internal/output 86.5%.

round-2 reproductions, before and after (real binary, fake control plane)
# graphql answers 401 during a pod wait  (--wait-timeout 15s)
BEFORE 5 polls, 15.05s -> {"error":"timed out after 15s ... last known state: graphql error: Unauthorized ...","code":"wait_timeout","id":"fakepod123"}   EXIT=1
AFTER  1 poll,   0.41s -> {"error":"waiting for ssh on pod fakepod123: graphql error: Unauthorized; ...","code":"graphql_error","status":401,"id":"fakepod123"}   EXIT=1

# null entry in graphql's pods array, wait succeeds, then the re-read runs
BEFORE panic: SIGSEGV at internal/sshconnect/sshconnect.go:127  EXIT=2  stdout 0 bytes, no json error object
AFTER  ssh on pod fakepod123 ready after 0s: ssh reachable at 127.0.0.1:58245  EXIT=0  stdout parses as one json doc

# one transient empty myself{pods} read, pod reachable on poll 3  (--wait-timeout 30s)
BEFORE 5.02s -> {"error":"... pod fakepod123 is no longer listed, so it was terminated or deleted ...","code":"not_found"}   EXIT=1
AFTER  10.07s -> ssh on pod fakepod123 ready after 10s   EXIT=0
# and the genuine deletion still fails fast: 10.02s, code not_found (two consecutive misses)

# endpoint deleted out of band mid-wait  (--wait-timeout 90s)
BEFORE would poll the full 90s (a /health 404 is treated as propagation lag)
AFTER  10.48s -> {"error":"... endpoint fakeep999 is no longer known to the invoke service ...","code":"not_found","id":"fakeep999"}   EXIT=1
# /health 404 on the first two polls (propagation lag) still succeeds: ready after 10s
# /health 500 on polls 2-4 still succeeds: ready after 20s

# --wait at --workers-min 0
BEFORE {"error":"--wait needs --workers-min 1 or more: at 0 min workers runpod starts no worker ...","code":"cli_error"}   EXIT=1, nothing created
AFTER  note: at --workers-min 0 no worker is guaranteed to start; ... --workers-min 1 starts (and bills) a worker immediately
       ... then the wait actually runs

# which counter satisfied the wait is now visible
BEFORE a ready worker on endpoint fakeep999 ready after 0s
AFTER  a ready worker on endpoint fakeep999 ready after 0s: workers ready 0, running 1, initializing 0, throttled 0, unhealthy 0

# --wait-timeout range check
BEFORE 200000d -> accepted, silently waits the 10m default
AFTER  200000d -> {"error":"invalid --wait-timeout: invalid duration \"200000d\": out of range (max 106751d)","code":"cli_error"}
       106751d -> accepted;  0d / -5m / abc -> rejected as before

in every failure path stdout was 0 bytes and the flat json error object was the last stderr line.

live prod e2e, with proof of cleanup (round 1; the paid paths were not re-bought)
$ pod create --compute-type cpu --image alpine:3.20 ... --wait --wait-timeout 25s      # timeout path
EXIT=1   stdout bytes: 0
note: cpu pods are created through the rest api, which cannot request runpod-managed ssh; ...
waiting for ssh on pod kasgak1hp6sluk (timeout 25s)
still waiting for ssh on pod kasgak1hp6sluk (16s elapsed): ssh port not allocated yet
{"error":"timed out after 25s waiting for ssh on pod kasgak1hp6sluk; last known state: ssh port
 216.81.151.47:15868 allocated but not reachable: dial tcp ...: connect: connection refused; pod
 kasgak1hp6sluk was created: ...","code":"wait_timeout","id":"kasgak1hp6sluk"}
-> deleted using the error object's id field (no prose parsing); pod list -a -> []

$ pod create --compute-type cpu ... --wait --wait-timeout 10m   then kill -INT                 # ctrl-c path
EXIT=1   stdout bytes: 0
{"error":"interrupted after 4s waiting for ssh on pod ekchrd6mlssk98; ...","code":"wait_interrupted","id":"ekchrd6mlssk98"}
-> deleted; pod list -a -> []

$ pod create --template-id runpod-torch-v21 --gpu-id "NVIDIA RTX A4000" --cloud-type SECURE \
    --volume-in-gb 0 --wait --wait-timeout 8m                                                  # success path
EXIT=0   ssh on pod u7o5sl42j668ld ready after 12s
stdout | python3 -m json.tool -> JSON_TOOL_EXIT=0   (exactly one parseable object)
  ssh_command "ssh -i /Users/justin/.runpod/ssh/runpodctl-ssh-key root@157.157.221.29 -p 20847"
-> deleted; pod list -a -> []

$ serverless create --template-id <tpl> --gpu-id "NVIDIA RTX A5000" --workers-min 1 --workers-max 1 \
    --wait --wait-timeout 7m                                                                   # success path
EXIT=0   a ready worker on endpoint mfbxwbyfrwnz1h ready after 25s
-> endpoint + template deleted; serverless list back to the same 6 pre-existing endpoints

$ final leak check:  pod list -a -> []   serverless list -> the same 6   template list -> unchanged

the full go test -tags e2e ./e2e/... was not run: it creates many billed resources and asserts on account-wide list output, which would race with sibling agents on the same prod account.

not re-verified in round 2, deliberately: the shared prod account is billed by the second and round 1's paid evidence stands. re-buying it would settle the ~25s alpine port-allocation measurement (deviation 1) and the create-vs---wait payload diff (deviation 6). round 2's verification was free: ai-api / runpod-backend / host source, read-only prod probes (six /health reads, serverless get --include-workers, a bogus-key graphql 401), and the real binary against a fake control plane.

legacy project/exec behaviour: base vs head, byte-identical

both binaries run against an unreachable graphql (RUNPOD_GRAPHQL_URL=http://127.0.0.1:9), each polling the full legacy 5m budget:

$ rpctl exec python --pod_id fakepod123 noop.py
origin/main : Error executing Python over SSH: getting SSH connection: failed to get SSH info for pod fakepod123: getting pods: Post "http://127.0.0.1:9": dial tcp 127.0.0.1:9: connect: connection refused
this branch : (identical, byte for byte)
stdout      : identical ("Running remote Python shell...\nWaiting for Pod to come online... ")

re-run after the round-2 changes: still byte-identical on both streams.

follow-ups

  • update the runpodctl agent skill (github.com/runpod/skills/tree/main/runpodctl) — --wait / --wait-timeout semantics, what ready actually proves, the pod get output shape, and the wait_timeout / wait_interrupted codes + the new id field
  • ask the backend for a readiness signal that means "a container is up and can take a job". neither /health counter does (deviation 2): ready is a flashboot-cached EXITED worker, running is a scheduled row. today's predicate is the strongest thing a client can observe
  • internal/api.GetEndpointHealth may collide with CON-688 (serverless health); whichever lands second should reuse the other

… read (CON-689)

create returns when a resource is scheduled, not when it is usable. add the
machinery for an opt-in wait:

- internal/waitfor: one bounded poll loop with injectable clock, throttled
  stderr progress and a typed error carrying the resource id, the last known
  state and a stable code (wait_timeout / wait_interrupted).
- internal/waitfor.ProbeSSH: tcp connect plus ssh banner. verified against prod
  that a cpu pod running alpine has a public port 22 listed in runtime.ports
  while the connection is refused, so port allocation is not readiness. no
  handshake, so --wait works without a configured ssh key.
- internal/api.GetEndpointHealth: the invoke service's live worker counts, which
  is the only readiness signal for an endpoint (includeWorkers is historical).
- internal/sshconnect.PublicSSHPort: the port-22 lookup BuildConnection already
  did, now shared.
- internal/duration: the pod list --since parser, moved so --wait-timeout reuses
  it instead of adding a third duration parser.

also runs gofmt over api/endpoint.go and cmd/pod/list.go, which were already
unformatted on main.
pod create --wait blocks until the pod's public port 22 answers with an ssh
banner, then prints the same payload as 'pod get' (the create response has no
ssh info, and a pod you can connect to is the point of waiting).

serverless create --wait blocks until /health reports a ready or running worker.
it requires --workers-min >= 1: at 0 runpod starts no worker until a request
arrives, so the wait could only ever time out. refuse up front rather than
silently billing a warm worker the user did not ask for.

--wait-timeout defaults to 10m. on timeout or ctrl-c the resource is kept, the
exit code is non-zero and the error names the id, the last known state and the
delete command. progress goes to stderr on a 15s cadence; stdout stays a single
json object.

--wait cannot be combined with --ssh=false, and warns on cpu pods, which are
created over rest and so never get runpod-managed ssh.

the legacy project ssh loop now calls the shared wait too, dropping its
re-poll-inside-the-condition bug and its post-loop timeout check that could fire
on a poll that had just succeeded. its stdout line and 1s/5m timings are
unchanged.
four cases: the cpu timeout path (an image with no sshd, which prod still gives
a public port 22 — the exact state the wait must not read as ready), the gpu
success path asserting one json object with a live ssh command, the free
workers-min refusal, and a warm-worker endpoint wait.

every paid resource is torn down in t.Cleanup, endpoint before template.

runCLI now honours RUNPODCTL_BIN so a run can target a 'go build' output instead
of overwriting the installed ~/go/bin/runpodctl.
… resources (CON-689)

review found three ways --wait misbehaved once it was actually waiting.

a single bad poll ended the whole wait, and it surfaced with the underlying
transport code. verified against a fake control plane: /health 404s an endpoint
id the invoke service has not propagated yet, so `serverless create --wait`
could exit ~0.04s after create with `{"code":"not_found","status":404}` for an
endpoint that exists and is billing a warm worker; one graphql blip did the same
to `pod create --wait` with `network_error`, the one code readme documents as
"transient, retry" — an agent following that would buy a second pod. poll errors
are now the current state, not the end of the wait; only failures that cannot
resolve (unauthorized, forbidden, no_credentials, bad_request) stop it.

a pod that can never become ready burned the whole budget: a terminal
desiredStatus polled for the full 10m default, and a pod terminated out of band
mid-wait read as "pod not listed yet" for the rest of it while the error claimed
it was "still billing". both now end the wait at once (conflict / not_found).

`pod create --wait` could exit 0 with `"ssh": {"error": "ssh info unavailable"}`:
the post-wait re-read swallows a graphql failure into that blob, so the one field
the flag exists to produce went missing with a success exit code. the re-read now
retries and then fails loudly, naming the address that did answer.

also: --wait on community cloud without --public-ip is warned about the way the
cpu path already was (no publicly mapped port 22 to probe, so it could only time
out), and errors that leave a resource behind carry its id in the error object's
new `id` field instead of only in prose.
…the workers-min claim (CON-689)

second review round. all of these were reproduced through the real binary
against a fake control plane before the fix and re-run after.

- isFatalPollError now also consults the http status (400/401/403). the pod
  wait's only api call is graphql GetPods(), and every graphql failure is an
  *api.GraphQLError whose ErrorCode() is the constant "graphql_error", so none
  of fatalPollCodes was reachable there: a bad key burned the whole budget
  while the pod billed and reported wait_timeout. 15.05s -> 0.41s.
- serverless create --wait no longer refuses --workers-min 0. the premise was
  wrong: ai-api floors workersStandby to 5 whenever workersMax > 1 regardless
  of workersMin (pkg/graphql/aiapi.go finalEndpoint), worker.Sync fills it with
  cache workers (pkg/worker/sync.go) and every /health read triggers a Sync
  (pkg/loader/aiapi.go), and /health counts a cached worker as ready. six prod
  endpoints with workersMin unset report ready 1-5. it now warns, like the
  other satisfiable-but-often-not combinations, and the refuted claim is out of
  the error string, README and AGENTS.md.
- the endpoint poller had no fatal case, so an endpoint deleted out of band
  burned the full budget while the pod path failed fast. a /health 404 after a
  successful read is now fatal not_found; a 404 before the first read
  (propagation lag) and any 5xx stay transient.
- the endpoint detail string now reports `running`, and the success line carries
  the detail, so a run says which counter satisfied it. running is written at
  scheduling time (runpod-backend rentPod.ts), so an unattributed "ready after"
  was not evidence of anything.
- the pod poller takes two consecutive missing reads before declaring a pod
  deleted. one short list read is an unknown state like every other tolerated
  anomaly, and the error asserted the pod "was terminated".
- FindPodConnection/ListConnections skip nil entries. graphql lists are
  nullable, and --wait re-reads that list right after reporting success: a null
  entry panicked with SIGSEGV and exit 2, replacing the json error object with
  a stack trace.
- duration.Parse range-checks the product, not just the operand. 106752d and up
  wrapped negative, so an out-of-range --wait-timeout was silently replaced by
  the 10m default (and pod list --since 200000d returned []).
- the post-wait re-read passes includeMachine=true, so --wait no longer hands
  back less than a plain create (graphql selected machine { gpuDisplayName
  location }).
- two fail-fast tests set interval and timeout both to an hour, so a regression
  hung until go test panicked at 10m; they now pass a 300ms budget.
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