Skip to content

Commit c2958c5

Browse files
authored
Fix frozen command_id default causing concurrent execution stdout corruption (#757)
1 parent f13916a commit c2958c5

2 files changed

Lines changed: 110 additions & 8 deletions

File tree

src/runloop_api_client/resources/devboxes/devboxes.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -867,7 +867,7 @@ def execute(
867867
id: str,
868868
*,
869869
command: str,
870-
command_id: str = str(uuid7()),
870+
command_id: str | None = None,
871871
last_n: str | Omit = omit,
872872
optimistic_timeout: Optional[int] | Omit = omit,
873873
shell_name: Optional[str] | Omit = omit,
@@ -892,7 +892,8 @@ def execute(
892892
specified the command is run from the directory based on the recent state of the
893893
persistent shell.
894894
895-
command_id: The command ID in UUIDv7 string format for idempotency and tracking
895+
command_id: The command ID in UUIDv7 string format for idempotency and tracking.
896+
A fresh UUID is generated per call if not provided.
896897
897898
last_n: Last n lines of standard error / standard out to return (default: 100)
898899
@@ -915,6 +916,8 @@ def execute(
915916
"""
916917
if not id:
917918
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
919+
if command_id is None:
920+
command_id = str(uuid7())
918921
if not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT:
919922
timeout = 600
920923
return self._post(
@@ -944,7 +947,7 @@ def execute_and_await_completion(
944947
devbox_id: str,
945948
*,
946949
command: str,
947-
command_id: str = str(uuid7()),
950+
command_id: str | None = None,
948951
last_n: str | Omit = omit,
949952
optimistic_timeout: Optional[int] | Omit = omit,
950953
shell_name: Optional[str] | Omit = omit,
@@ -963,9 +966,11 @@ def execute_and_await_completion(
963966
return the result within the initial request's timeout. If the execution is not yet
964967
complete, it switches to using wait_for_command to minimize latency while waiting.
965968
966-
A command_id (UUIDv7) is automatically generated for idempotency and tracking.
969+
A command_id (UUIDv7) is automatically generated per call for idempotency and tracking.
967970
You can provide your own command_id to enable custom retry logic or external tracking.
968971
"""
972+
if command_id is None:
973+
command_id = str(uuid7())
969974
execution = self.execute(
970975
devbox_id,
971976
command=command,
@@ -2543,7 +2548,7 @@ async def execute(
25432548
id: str,
25442549
*,
25452550
command: str,
2546-
command_id: str = str(uuid7()),
2551+
command_id: str | None = None,
25472552
last_n: str | Omit = omit,
25482553
optimistic_timeout: Optional[int] | Omit = omit,
25492554
shell_name: Optional[str] | Omit = omit,
@@ -2568,7 +2573,8 @@ async def execute(
25682573
specified the command is run from the directory based on the recent state of the
25692574
persistent shell.
25702575
2571-
command_id: The command ID in UUIDv7 string format for idempotency and tracking
2576+
command_id: The command ID in UUIDv7 string format for idempotency and tracking.
2577+
A fresh UUID is generated per call if not provided.
25722578
25732579
last_n: Last n lines of standard error / standard out to return (default: 100)
25742580
@@ -2591,6 +2597,8 @@ async def execute(
25912597
"""
25922598
if not id:
25932599
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
2600+
if command_id is None:
2601+
command_id = str(uuid7())
25942602
if not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT:
25952603
timeout = 600
25962604
return await self._post(
@@ -2620,7 +2628,7 @@ async def execute_and_await_completion(
26202628
devbox_id: str,
26212629
*,
26222630
command: str,
2623-
command_id: str = str(uuid7()),
2631+
command_id: str | None = None,
26242632
last_n: str | Omit = omit,
26252633
optimistic_timeout: Optional[int] | Omit = omit,
26262634
shell_name: Optional[str] | Omit = omit,
@@ -2639,7 +2647,7 @@ async def execute_and_await_completion(
26392647
return the result within the initial request's timeout. If the execution is not yet
26402648
complete, it switches to using wait_for_command to minimize latency while waiting.
26412649
2642-
A command_id (UUIDv7) is automatically generated for idempotency and tracking.
2650+
A command_id (UUIDv7) is automatically generated per call for idempotency and tracking.
26432651
You can provide your own command_id to enable custom retry logic or external tracking.
26442652
"""
26452653

tests/test_command_id.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Tests for command_id default generation in execute / execute_and_await_completion.
2+
3+
Verifies that each call generates a fresh UUIDv7 rather than reusing a frozen
4+
default (the bug fixed in this change).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
from typing import cast
11+
12+
import httpx
13+
import pytest
14+
from respx import Route, MockRouter
15+
16+
from runloop_api_client import Runloop, AsyncRunloop
17+
18+
base_url = "http://127.0.0.1:4010"
19+
EXECUTE_PATH = "/v1/devboxes/dbx_test/execute"
20+
21+
STUB_RESPONSE = {
22+
"execution_id": "exec_1",
23+
"command_id": "ignored",
24+
"devbox_id": "dbx_test",
25+
"status": "completed",
26+
"exit_status": 0,
27+
"stdout": "",
28+
"stderr": "",
29+
}
30+
31+
32+
def _get_command_ids(route: Route) -> list[str]:
33+
return [
34+
json.loads(cast(bytes, call.request.content))["command_id"] # type: ignore[union-attr]
35+
for call in route.calls # type: ignore[union-attr]
36+
]
37+
38+
39+
def _get_request_body(route: Route, index: int = 0) -> dict[str, object]:
40+
return json.loads(cast(bytes, route.calls[index].request.content)) # type: ignore[union-attr]
41+
42+
43+
class TestCommandIdGeneration:
44+
"""command_id must be a fresh UUIDv7 per call when not explicitly provided."""
45+
46+
@pytest.mark.respx(base_url=base_url)
47+
def test_execute_generates_unique_command_ids(self, respx_mock: MockRouter) -> None:
48+
route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE))
49+
client = Runloop(base_url=base_url, bearer_token="test")
50+
51+
for _ in range(5):
52+
client.devboxes.execute(id="dbx_test", command="echo hi")
53+
54+
assert route.call_count == 5
55+
ids = _get_command_ids(route)
56+
assert len(set(ids)) == 5, f"command_ids should all be unique, got: {ids}"
57+
58+
@pytest.mark.respx(base_url=base_url)
59+
def test_execute_preserves_explicit_command_id(self, respx_mock: MockRouter) -> None:
60+
route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE))
61+
client = Runloop(base_url=base_url, bearer_token="test")
62+
63+
client.devboxes.execute(id="dbx_test", command="echo hi", command_id="my-custom-id")
64+
65+
body = _get_request_body(route)
66+
assert body["command_id"] == "my-custom-id"
67+
68+
69+
class TestAsyncCommandIdGeneration:
70+
"""Async variant: command_id must be a fresh UUIDv7 per call when not explicitly provided."""
71+
72+
@pytest.mark.respx(base_url=base_url)
73+
@pytest.mark.asyncio
74+
async def test_execute_generates_unique_command_ids(self, respx_mock: MockRouter) -> None:
75+
route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE))
76+
client = AsyncRunloop(base_url=base_url, bearer_token="test")
77+
78+
for _ in range(5):
79+
await client.devboxes.execute(id="dbx_test", command="echo hi")
80+
81+
assert route.call_count == 5
82+
ids = _get_command_ids(route)
83+
assert len(set(ids)) == 5, f"command_ids should all be unique, got: {ids}"
84+
85+
@pytest.mark.respx(base_url=base_url)
86+
@pytest.mark.asyncio
87+
async def test_execute_preserves_explicit_command_id(self, respx_mock: MockRouter) -> None:
88+
route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE))
89+
client = AsyncRunloop(base_url=base_url, bearer_token="test")
90+
91+
await client.devboxes.execute(id="dbx_test", command="echo hi", command_id="my-custom-id")
92+
93+
body = _get_request_body(route)
94+
assert body["command_id"] == "my-custom-id"

0 commit comments

Comments
 (0)