Skip to content

Commit a830da7

Browse files
authored
Bugfix: fix process unknown after agent reconnects (#63)
* Make agent read timeout configurable for testing * Create failing reproducer test for dropped messages on reconnect * Check if websocket is closed before sending * Add retry_exception utility * Re-choose connection if the conn is dead * Format with ruff * Remove outdated comment * Commit Cargo.lock version bump * Fix clippy error
1 parent 4e3548c commit a830da7

8 files changed

Lines changed: 106 additions & 14 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

python/procstar/agent/conn.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,16 @@ async def send(self, msg):
163163
raise WebSocketNotOpen(self.conn_id)
164164

165165
data = serialize_message(msg)
166+
167+
# In rare cases the websocket may appear closed while it's still alive for tasks
168+
# that run on the event loop before the websocket state can be updated. This
169+
# forces it to check if it's closed to avoid quietly sending messages into a
170+
# dead websocket.
171+
try:
172+
await asyncio.wait_for(self.ws.wait_closed(), timeout=0)
173+
except asyncio.TimeoutError:
174+
pass
175+
166176
try:
167177
await self.ws.send(data)
168178
except ConnectionClosedError:

python/procstar/agent/server.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
from . import DEFAULT_PORT
1616
from .conn import Connection, Connections
1717
from .conn import choose_connection, wait_for_connection
18-
from .exc import NoConnectionError
18+
from .exc import NoConnectionError, WebSocketNotOpen
1919
from .proc import Processes, Process, Result
2020
from procstar import proto
21+
from procstar.lib.py import retry_exception
2122
from procstar.lib.time import now
2223

2324
FROM_ENV = object()
@@ -199,7 +200,11 @@ async def _serve_connection(self, access_token, reconnect_timeout, ws):
199200
# Add or re-add the connection.
200201
try:
201202
conn = self.connections._add(
202-
register_msg.conn, register_msg.proc, register_msg.shutdown_state, time, ws
203+
register_msg.conn,
204+
register_msg.proc,
205+
register_msg.shutdown_state,
206+
time,
207+
ws,
203208
)
204209
conn.info.stats.num_received += 1 # the Register message
205210
except RuntimeError as exc:
@@ -311,13 +316,21 @@ async def request_start(
311316
except AttributeError:
312317
pass
313318

314-
conn = await choose_connection(
315-
self.connections,
316-
group_id,
317-
timeout=conn_timeout,
318-
)
319-
320-
await conn.send(proto.ProcStartRequest(specs={proc_id: spec}))
319+
async def choose_and_start():
320+
conn = await choose_connection(
321+
self.connections,
322+
group_id,
323+
timeout=conn_timeout,
324+
)
325+
# raises a WebSocketNotOpen if the conn was closed after choosing the
326+
# connection
327+
await conn.send(proto.ProcStartRequest(specs={proc_id: spec}))
328+
return conn
329+
330+
# We try twice sequentially to avoid an unfortunately common case where an agent
331+
# disconnects directly after the connection is chosen. This is prone to
332+
# happening after the event loop is unintentionally blocked for period of time.
333+
conn = await retry_exception(choose_and_start, (WebSocketNotOpen,), retries=1, interval=0)
321334
return self.processes.create(conn, proc_id)
322335

323336
async def start(self, *args, **kw_args) -> (Process, Result):

python/procstar/lib/py.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
import asyncio
2+
import logging
3+
from typing import Any, Callable, Iterable, Type
4+
5+
6+
logger = logging.getLogger(__name__)
7+
8+
19
class Interval:
210
"""
311
Closed-open interval.
@@ -45,3 +53,23 @@ def format_call(__fn, *args, **kw_args) -> str:
4553

4654
def format_ctor(obj, *args, **kw_args):
4755
return format_call(obj.__class__, *args, **kw_args)
56+
57+
58+
async def retry_exception(
59+
fn: Callable[[], Any],
60+
exc_types: Iterable[Type[BaseException]],
61+
*,
62+
retries,
63+
interval,
64+
):
65+
for retry in range(retries + 1):
66+
try:
67+
return await fn()
68+
except tuple(exc_types) as exc:
69+
if retry == retries:
70+
logger.error(f"failed after {retries} retries")
71+
raise
72+
else:
73+
logger.warning(str(exc))
74+
logger.debug(f"retrying in {interval}s")
75+
await asyncio.sleep(interval)

python/procstar/testing/agent.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ async def wait_for_connect(conns):
208208
for task in tasks:
209209
task.cancel()
210210

211-
def start_procstar(self, *, group_id=proto.DEFAULT_GROUP, args=[]):
211+
def start_procstar(self, *, group_id=proto.DEFAULT_GROUP, args=()):
212212
"""
213213
Starts a single procstar instance.
214214
"""
@@ -246,6 +246,7 @@ async def start(
246246
counts={"default": 1},
247247
access_token=DEFAULT,
248248
reconnect_timeout=None,
249+
args=(),
249250
):
250251
"""
251252
Async context manager for a ready-to-go assembly.
@@ -255,7 +256,7 @@ async def start(
255256
"""
256257
asm = cls(access_token=access_token, reconnect_timeout=reconnect_timeout)
257258
await asm.start_server()
258-
await asm.start_procstars(counts)
259+
await asm.start_procstars(counts, args=args)
259260
try:
260261
yield asm
261262
finally:

src/agent.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ pub struct ConnectConfig {
204204
pub interval_max: Duration,
205205
/// Maximum consecutive failed connection attempts.
206206
pub count_max: u64,
207+
/// Agent websocket read timeout.
208+
pub read_timeout: Duration,
207209
}
208210

209211
impl Default for ConnectConfig {
@@ -219,6 +221,7 @@ impl ConnectConfig {
219221
interval_mult: 2.0,
220222
interval_max: Duration::from_secs(60),
221223
count_max: u64::MAX,
224+
read_timeout: READ_TIMEOUT,
222225
}
223226
}
224227
}
@@ -281,7 +284,7 @@ pub async fn run(
281284
let mut receiver = tokio_stream::StreamExt::timeout_repeating(
282285
receiver,
283286
// Avoid the instant first tick from a standard interval
284-
time::interval_at(time::Instant::now() + READ_TIMEOUT, READ_TIMEOUT),
287+
time::interval_at(time::Instant::now() + cfg.read_timeout, cfg.read_timeout),
285288
);
286289

287290
// Simultaneously wait for an incoming websocket message or a
@@ -300,7 +303,7 @@ pub async fn run(
300303
res = receiver.next() => {
301304
let ws_msg = match res {
302305
None => { warn!("msg stream end"); break },
303-
Some(Err(_)) => {warn!("timeout error after {:?}", READ_TIMEOUT); break; }
306+
Some(Err(_)) => {warn!("timeout error after {:?}", cfg.read_timeout); break; }
304307
Some(Ok(ws_msg)) => ws_msg,
305308
};
306309

src/argv.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ pub struct Args {
8080
/// Maximum number of consecutive agent connection attempts
8181
#[arg(long, value_name = "COUNT")]
8282
pub connect_count_max: Option<u64>,
83+
/// Agent websocket read timeout in seconds; for testing only
84+
#[arg(long, hide = true, value_name = "SECS")]
85+
pub agent_read_timeout: Option<f64>,
8386

8487
/// Process specification file, or "-" for stdin
8588
pub input: Option<String>,
@@ -117,5 +120,8 @@ pub fn get_connect_config(args: &Args) -> agent::ConnectConfig {
117120
.connect_interval_max
118121
.map_or(df.interval_max, Duration::from_secs_f64),
119122
count_max: args.connect_count_max.unwrap_or(df.count_max),
123+
read_timeout: args
124+
.agent_read_timeout
125+
.map_or(df.read_timeout, Duration::from_secs_f64),
120126
}
121127
}

tests/int/agent/test_reconnect_timeout.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import asyncio
22
import shutil
3+
import time
34

45
import pytest
56

67
from procstar import spec
8+
from procstar.agent.exc import ProcessUnknownError
79
from procstar.agent.proc import ConnectionTimeoutError
810
from procstar.agent.server import maybe_set_reconnect_timeout
911
from procstar.testing.agent import Assembly
@@ -45,3 +47,32 @@ async def mock_set_reconnect(*args, **kwargs):
4547

4648
res = await asm.wait(proc)
4749
assert res.status.exit_code == 0
50+
51+
52+
@pytest.mark.asyncio
53+
async def test_dropped_procstart_on_reconnect():
54+
"""
55+
Replicates the bug where ProcStartRequest messages are dropped when
56+
the agent reconnects after a ping timeout due to blocked Python event loop.
57+
58+
This test demonstrates the specific timing issue where messages sent
59+
during the brief window between disconnect and successful reconnect are lost.
60+
"""
61+
# Use short read timeout to make test faster
62+
async with Assembly.start(counts={"default": 1}, args=["--agent-read-timeout", "1"]) as asm:
63+
# block the event loop for longer than agent read timeout
64+
time.sleep(2)
65+
try:
66+
# request a proc start right away before the agent gets a chance to
67+
# reconnect
68+
proc1, _ = await asm.server.start(
69+
"proc1",
70+
spec.make_proc(["/usr/bin/true"]),
71+
conn_timeout=5,
72+
)
73+
await anext(proc1.updates)
74+
except ProcessUnknownError as e:
75+
assert False, f"ProcStartRequest dropped: {e}"
76+
77+
res = await asm.wait(proc1)
78+
assert res.status.exit_code == 0

0 commit comments

Comments
 (0)