Skip to content

Commit 7c7cd8e

Browse files
committed
fix(exec): fall back to queue and poll when the relay has no run endpoint (2026.8.28.0-6244)
handoff exec went straight to the one-shot run endpoint, so it failed against any relay that had not been updated yet. It now retries through the queue and result endpoints, which have been there all along, and reshapes the reply into the same envelope so callers see one contract either way. An unknown session token also answers 404, so that case is told apart from a missing route and reported as-is rather than being retried.
1 parent cdf5c59 commit 7c7cd8e

2 files changed

Lines changed: 198 additions & 2 deletions

File tree

‎cmd/exec.go‎

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,7 @@ func Exec(args []string) int {
7878
return execExitUsage
7979
}
8080

81-
url := fmt.Sprintf("%s/api/sessions/%s/run?wait_ms=%d", opts.Relay, token, timeout.Milliseconds())
82-
body, status, err := postJSON(ctx, url, payload)
81+
body, status, err := runCommand(ctx, opts.Relay, token, payload, timeout)
8382
if err != nil {
8483
fmt.Fprintln(os.Stderr, "request failed:", err)
8584
return 1
@@ -237,3 +236,124 @@ func viewTokenFrom(s string) string {
237236
}
238237
return strings.Trim(s, "/")
239238
}
239+
240+
// runCommand prefers the relay's one-shot endpoint and falls back to queueing
241+
// plus a result poll. A relay that predates /run answers 404, and a client that
242+
// gave up there would be useless against any relay not yet carrying it.
243+
func runCommand(ctx context.Context, relayBase, token string, payload map[string]interface{}, timeout time.Duration) ([]byte, int, error) {
244+
runURL := fmt.Sprintf("%s/api/sessions/%s/run?wait_ms=%d", relayBase, token, timeout.Milliseconds())
245+
body, status, err := postJSON(ctx, runURL, payload)
246+
if err != nil {
247+
return nil, 0, err
248+
}
249+
if status != http.StatusNotFound {
250+
return body, status, nil
251+
}
252+
253+
// A 404 from an unknown session names the token; a 404 from a relay with no
254+
// /run route does not, and that is the case worth retrying.
255+
var probe map[string]interface{}
256+
if json.Unmarshal(body, &probe) == nil {
257+
if msg, _ := probe["error"].(string); strings.Contains(msg, "view token") {
258+
return body, status, nil
259+
}
260+
}
261+
return queueAndAwait(ctx, relayBase, token, payload, timeout)
262+
}
263+
264+
func queueAndAwait(ctx context.Context, relayBase, token string, payload map[string]interface{}, timeout time.Duration) ([]byte, int, error) {
265+
ackBody, ackStatus, err := postJSON(ctx, fmt.Sprintf("%s/api/sessions/%s/cmd", relayBase, token), payload)
266+
if err != nil {
267+
return nil, 0, err
268+
}
269+
if ackStatus != http.StatusOK {
270+
return ackBody, ackStatus, nil
271+
}
272+
273+
var ack struct {
274+
CommandID string `json:"command_id"`
275+
}
276+
if err := json.Unmarshal(ackBody, &ack); err != nil || ack.CommandID == "" {
277+
return ackBody, ackStatus, nil
278+
}
279+
280+
kind, _ := payload["kind"].(string)
281+
deadline := time.Now().Add(timeout)
282+
resultURL := fmt.Sprintf("%s/api/sessions/%s/cmd/%s", relayBase, token, ack.CommandID)
283+
284+
for time.Now().Before(deadline) {
285+
wait := time.Until(deadline)
286+
if wait > 25*time.Second {
287+
wait = 25 * time.Second
288+
}
289+
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
290+
fmt.Sprintf("%s?wait_ms=%d", resultURL, wait.Milliseconds()), nil)
291+
if err != nil {
292+
return nil, 0, err
293+
}
294+
resp, err := http.DefaultClient.Do(req)
295+
if err != nil {
296+
return nil, 0, err
297+
}
298+
raw, readErr := io.ReadAll(resp.Body)
299+
resp.Body.Close()
300+
if readErr != nil {
301+
return nil, 0, readErr
302+
}
303+
304+
var got map[string]interface{}
305+
_ = json.Unmarshal(raw, &got)
306+
307+
switch resp.StatusCode {
308+
case http.StatusOK:
309+
// An older relay returns the raw terminal event; reshape it into
310+
// the same envelope /run would have produced.
311+
if inner, isMap := got["payload"].(map[string]interface{}); isMap {
312+
return reshapeResult(inner, kind, ack.CommandID), http.StatusOK, nil
313+
}
314+
if got["status"] == "pending" {
315+
continue
316+
}
317+
return raw, http.StatusOK, nil
318+
case http.StatusGone:
319+
return raw, http.StatusGone, nil
320+
case http.StatusNotFound:
321+
continue
322+
default:
323+
return raw, resp.StatusCode, nil
324+
}
325+
}
326+
327+
pending, _ := json.Marshal(map[string]interface{}{
328+
"command_id": ack.CommandID,
329+
"kind": kind,
330+
"status": "pending",
331+
"ok": false,
332+
"result_url": fmt.Sprintf("/api/sessions/%s/cmd/%s", token, ack.CommandID),
333+
})
334+
return pending, http.StatusAccepted, nil
335+
}
336+
337+
func reshapeResult(inner map[string]interface{}, kind, commandID string) []byte {
338+
ok, _ := inner["ok"].(bool)
339+
out := map[string]interface{}{
340+
"command_id": commandID,
341+
"kind": kind,
342+
"ok": ok,
343+
"status": "error",
344+
"result": inner["result"],
345+
"error": inner["error"],
346+
"elapsed_ms": inner["elapsed_ms"],
347+
}
348+
if ok {
349+
out["status"] = "ok"
350+
}
351+
if detail, present := inner["detail"]; present {
352+
out["detail"] = detail
353+
}
354+
enc, err := json.Marshal(out)
355+
if err != nil {
356+
return []byte(`{"ok":false,"error":"could not read the relay's result"}`)
357+
}
358+
return enc
359+
}

‎cmd/exec_test.go‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"net/http"
77
"net/http/httptest"
8+
"strings"
89
"testing"
910
)
1011

@@ -139,3 +140,78 @@ func TestExecRequiresAKind(t *testing.T) {
139140
t.Fatalf("exit = %d, want a usage error", code)
140141
}
141142
}
143+
144+
func TestExecFallsBackWhenRelayHasNoRunEndpoint(t *testing.T) {
145+
// A relay that predates /run answers 404. Giving up there would make a new
146+
// client useless against a relay that has not been deployed yet.
147+
var sawCmd, sawResult bool
148+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
149+
w.Header().Set("Content-Type", "application/json")
150+
switch {
151+
case strings.HasSuffix(r.URL.Path, "/run"):
152+
w.WriteHeader(http.StatusNotFound)
153+
_, _ = w.Write([]byte(`{"error":"Not Found"}`))
154+
case strings.HasSuffix(r.URL.Path, "/cmd"):
155+
sawCmd = true
156+
_, _ = w.Write([]byte(`{"command_id":"c_fallback"}`))
157+
case strings.Contains(r.URL.Path, "/cmd/c_fallback"):
158+
sawResult = true
159+
_, _ = w.Write([]byte(`{"id":"c_fallback","payload":{"id":"c_fallback","ok":true,"result":{"up":1},"elapsed_ms":7}}`))
160+
default:
161+
w.WriteHeader(http.StatusNotFound)
162+
}
163+
}))
164+
defer srv.Close()
165+
166+
t.Setenv("HANDOFF_CONFIG", "")
167+
if code := Exec([]string{"--relay", srv.URL, "--json", "n1_token", "sys.uptime"}); code != 0 {
168+
t.Fatalf("Exec exit = %d, want 0 via the fallback", code)
169+
}
170+
if !sawCmd || !sawResult {
171+
t.Fatalf("fallback path not used: cmd=%v result=%v", sawCmd, sawResult)
172+
}
173+
}
174+
175+
func TestExecDoesNotFallBackForAnUnknownSession(t *testing.T) {
176+
// An unknown token also 404s. Retrying that against /cmd would turn a clear
177+
// error into a second confusing one.
178+
var cmdCalls int
179+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180+
if strings.HasSuffix(r.URL.Path, "/cmd") {
181+
cmdCalls++
182+
}
183+
w.Header().Set("Content-Type", "application/json")
184+
w.WriteHeader(http.StatusNotFound)
185+
_, _ = w.Write([]byte(`{"error":"unknown view token"}`))
186+
}))
187+
defer srv.Close()
188+
189+
t.Setenv("HANDOFF_CONFIG", "")
190+
if code := Exec([]string{"--relay", srv.URL, "--json", "n1_token", "sys.uptime"}); code != 1 {
191+
t.Fatalf("Exec exit = %d, want 1", code)
192+
}
193+
if cmdCalls != 0 {
194+
t.Fatalf("fell back %d times for an unknown session", cmdCalls)
195+
}
196+
}
197+
198+
func TestExecFallbackReportsHostFailure(t *testing.T) {
199+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
200+
w.Header().Set("Content-Type", "application/json")
201+
switch {
202+
case strings.HasSuffix(r.URL.Path, "/run"):
203+
w.WriteHeader(http.StatusNotFound)
204+
_, _ = w.Write([]byte(`{"error":"Not Found"}`))
205+
case strings.HasSuffix(r.URL.Path, "/cmd"):
206+
_, _ = w.Write([]byte(`{"command_id":"c_bad"}`))
207+
default:
208+
_, _ = w.Write([]byte(`{"id":"c_bad","payload":{"id":"c_bad","ok":false,"error":"boom"}}`))
209+
}
210+
}))
211+
defer srv.Close()
212+
213+
t.Setenv("HANDOFF_CONFIG", "")
214+
if code := Exec([]string{"--relay", srv.URL, "--json", "n1_token", "ps.exec"}); code != execExitFailed {
215+
t.Fatalf("Exec exit = %d, want %d", code, execExitFailed)
216+
}
217+
}

0 commit comments

Comments
 (0)