Skip to content

Commit fef566b

Browse files
committed
changes based on cursor bot review
1 parent 36e1cbc commit fef566b

2 files changed

Lines changed: 221 additions & 13 deletions

File tree

resources/external_control.urscript

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -769,10 +769,16 @@ thread trajectoryThread():
769769
spline_qd = [0, 0, 0, 0, 0, 0]
770770
end
771771
else:
772-
if was_streaming_at_start and not trajectory_streaming:
773-
# Producer signalled STREAM_END between the predicate check and
774-
# the socket read. Drop any pending unread points and exit cleanly
775-
# rather than spin one timeout per missing point.
772+
if was_streaming_at_start and not trajectory_streaming and trajectory_points_left < 0:
773+
# Race: STREAM_END was processed after the consumer had already
774+
# drained every streamed point. The dispatcher's math zeroed
775+
# trajectory_points_left, then this iteration's decrement pushed
776+
# it below zero. No further points are expected; exit cleanly.
777+
# Note: trajectory_points_left >= 0 here means the consumer still
778+
# expected unread points - that is a genuine underrun (either
779+
# producer/STREAM_END count mismatch or a delayed STREAM_END
780+
# arrived during a real underrun) and must be reported as
781+
# FAILURE rather than silently truncated.
776782
trajectory_points_left = 0
777783
else:
778784
textmsg("Receiving trajectory point failed!")
@@ -1157,6 +1163,9 @@ while control_mode > MODE_STOPPED:
11571163
kill thread_trajectory
11581164
request_trajectory_cleanup()
11591165
wait_for_trajectory_cleanup()
1166+
# This is a legacy finite trajectory; ensure trajectory_streaming
1167+
# does not leak in from any prior streaming session.
1168+
trajectory_streaming = False
11601169
trajectory_points_left = params_mult[3]
11611170
thread_trajectory = run trajectoryThread()
11621171
elif params_mult[2] == TRAJECTORY_MODE_STREAM_START:
@@ -1167,21 +1176,32 @@ while control_mode > MODE_STOPPED:
11671176
trajectory_points_left = STREAMING_SENTINEL
11681177
thread_trajectory = run trajectoryThread()
11691178
elif params_mult[2] == TRAJECTORY_MODE_STREAM_END:
1170-
# params_mult[3] is the total count of spline points the producer
1171-
# wrote to the trajectory socket since STREAM_START. The
1172-
# trajectoryThread has decremented trajectory_points_left once per
1173-
# consumed point starting from STREAMING_SENTINEL, so the consumed
1174-
# count is (STREAMING_SENTINEL - trajectory_points_left). The
1175-
# unread remainder still in the OS socket buffer is therefore
1176-
# the producer's total minus the consumer's consumed count.
1177-
trajectory_points_left = params_mult[3] - (STREAMING_SENTINEL - trajectory_points_left)
1178-
trajectory_streaming = False
1179+
# Guard against a stray STREAM_END applied outside an active
1180+
# streaming session (during a finite trajectory, after a cancel,
1181+
# or as a second STREAM_END after the first). The sentinel-based
1182+
# math below is only meaningful while trajectory_points_left is
1183+
# known to be derived from STREAMING_SENTINEL; outside of that,
1184+
# applying it would corrupt trajectory_points_left.
1185+
if trajectory_streaming:
1186+
# params_mult[3] is the total count of spline points the producer
1187+
# wrote to the trajectory socket since STREAM_START. The
1188+
# trajectoryThread has decremented trajectory_points_left once per
1189+
# consumed point starting from STREAMING_SENTINEL, so the consumed
1190+
# count is (STREAMING_SENTINEL - trajectory_points_left). The
1191+
# unread remainder still in the OS socket buffer is therefore
1192+
# the producer's total minus the consumer's consumed count.
1193+
trajectory_points_left = params_mult[3] - (STREAMING_SENTINEL - trajectory_points_left)
1194+
trajectory_streaming = False
1195+
end
11791196
elif params_mult[2] == TRAJECTORY_MODE_CANCEL:
11801197
textmsg("cancel received")
11811198
kill thread_trajectory
11821199
request_trajectory_cleanup()
11831200
stopj(STOPJ_ACCELERATION)
11841201
wait_for_trajectory_cleanup()
1202+
# The streaming session, if any, has been forcibly ended; clear
1203+
# the flag so a subsequent stray STREAM_END is a no-op.
1204+
trajectory_streaming = False
11851205
socket_send_int(TRAJECTORY_RESULT_CANCELED, "trajectory_socket")
11861206
end
11871207
elif control_mode == MODE_SPEEDL:

tests/test_trajectory_streaming.cpp

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
#include <chrono>
3232
#include <condition_variable>
33+
#include <functional>
3334
#include <memory>
3435
#include <mutex>
3536
#include <string>
@@ -120,6 +121,41 @@ class TrajectoryStreamingTest : public ::testing::Test
120121
}
121122
return control::TrajectoryResult::TRAJECTORY_RESULT_UNKNOWN;
122123
}
124+
125+
// Variant that fires a single user-supplied action exactly once after
126+
// `inject_at` has elapsed since the call started. The action takes the
127+
// place of one NOOP-pump iteration. Used to inject a stray trajectory
128+
// control message mid-flight without spinning up a second thread.
129+
control::TrajectoryResult waitForTrajectoryResultPumpingNoopsWithInjection(
130+
std::chrono::milliseconds total_timeout, std::chrono::milliseconds inject_at,
131+
std::function<void()> inject_action)
132+
{
133+
const auto start = std::chrono::steady_clock::now();
134+
const auto deadline = start + total_timeout;
135+
const auto inject_deadline = start + inject_at;
136+
bool injected = false;
137+
while (std::chrono::steady_clock::now() < deadline)
138+
{
139+
std::unique_lock<std::mutex> lk(g_trajectory_result_mutex);
140+
if (g_trajectory_result_cv.wait_for(lk, std::chrono::milliseconds(50),
141+
[] { return g_trajectory_result_received; }))
142+
{
143+
return g_trajectory_result;
144+
}
145+
lk.unlock();
146+
if (!injected && std::chrono::steady_clock::now() >= inject_deadline)
147+
{
148+
inject_action();
149+
injected = true;
150+
}
151+
else
152+
{
153+
g_my_robot->getUrDriver()->writeTrajectoryControlMessage(
154+
control::TrajectoryControlMessage::TRAJECTORY_NOOP);
155+
}
156+
}
157+
return control::TrajectoryResult::TRAJECTORY_RESULT_UNKNOWN;
158+
}
123159
};
124160

125161
// Clean end-to-end stream: STREAM_START, N spline points at a held pose,
@@ -197,6 +233,158 @@ TEST_F(TrajectoryStreamingTest, stream_cancel_yields_canceled)
197233
waitForTrajectoryResultPumpingNoops(std::chrono::seconds(5)));
198234
}
199235

236+
// Regression test for PR #528 cursor-bot review comment 1a.
237+
// A stray TRAJECTORY_STREAM_END dispatched during a legacy finite
238+
// TRAJECTORY_START trajectory must not corrupt trajectory_points_left.
239+
// Without the streaming-guard fix, the dispatcher's STREAM_END math
240+
// (which assumes trajectory_points_left is a sentinel-based credit
241+
// budget) computes a huge negative value, the trajectoryThread's
242+
// "trajectory_points_left > 0" predicate fails on the next iteration,
243+
// the loop exits while trajectory_result is still SUCCESS, and the
244+
// callback fires SUCCESS even though motion was truncated.
245+
TEST_F(TrajectoryStreamingTest, stray_stream_end_during_finite_trajectory_does_not_truncate)
246+
{
247+
const vector6d_t pose_a = { 0.0, -1.57, 0.0, -1.57, 0.0, 0.0 };
248+
const vector6d_t pose_b = { 0.0, -1.40, 0.0, -1.57, 0.0, 0.0 };
249+
const vector6d_t pose_c = { 0.0, -1.20, 0.0, -1.57, 0.0, 0.0 };
250+
const vector6d_t zero = { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 };
251+
252+
// Pre-position
253+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectoryControlMessage(
254+
control::TrajectoryControlMessage::TRAJECTORY_START, 1));
255+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectorySplinePoint(pose_a, zero, zero, 2.0f));
256+
ASSERT_EQ(control::TrajectoryResult::TRAJECTORY_RESULT_SUCCESS,
257+
waitForTrajectoryResultPumpingNoops(std::chrono::seconds(5)));
258+
resetTrajectoryResultState();
259+
260+
// Send a 3-point finite trajectory, each segment ~1 second.
261+
const float k_segment_time = 1.0f;
262+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectoryControlMessage(
263+
control::TrajectoryControlMessage::TRAJECTORY_START, 3));
264+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectorySplinePoint(pose_b, zero, zero, k_segment_time));
265+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectorySplinePoint(pose_c, zero, zero, k_segment_time));
266+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectorySplinePoint(pose_a, zero, zero, k_segment_time));
267+
268+
const auto motion_start = std::chrono::steady_clock::now();
269+
const auto result = waitForTrajectoryResultPumpingNoopsWithInjection(
270+
std::chrono::seconds(6), std::chrono::milliseconds(500), [] {
271+
g_my_robot->getUrDriver()->writeTrajectoryControlMessage(
272+
control::TrajectoryControlMessage::TRAJECTORY_STREAM_END, 0);
273+
});
274+
const auto elapsed = std::chrono::steady_clock::now() - motion_start;
275+
276+
EXPECT_EQ(control::TrajectoryResult::TRAJECTORY_RESULT_SUCCESS, result);
277+
// Three 1-second segments expect ~3 s of motion. Without the fix, the
278+
// stray STREAM_END truncates the loop after segment 1, callback fires
279+
// in ~1 s.
280+
EXPECT_GE(elapsed, std::chrono::milliseconds(2500))
281+
<< "Expected ~3 s of motion; stray STREAM_END appears to have truncated the trajectory.";
282+
}
283+
284+
// Regression test for PR #528 cursor-bot review comment 1c.
285+
// A second TRAJECTORY_STREAM_END dispatched during the drain phase
286+
// re-applies the sentinel-based math to a non-sentinel
287+
// trajectory_points_left value, producing a huge negative value and
288+
// truncating the drain while reporting SUCCESS. Without the streaming-
289+
// guard fix the second STREAM_END is destructive; with the fix it is
290+
// a no-op because trajectory_streaming was cleared by the first.
291+
TEST_F(TrajectoryStreamingTest, double_stream_end_does_not_truncate)
292+
{
293+
const vector6d_t held_pose = { 0.0, -1.57, 0.0, -1.57, 0.0, 0.0 };
294+
const vector6d_t zero = { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 };
295+
296+
// Pre-position
297+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectoryControlMessage(
298+
control::TrajectoryControlMessage::TRAJECTORY_START, 1));
299+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectorySplinePoint(held_pose, zero, zero, 2.0f));
300+
ASSERT_EQ(control::TrajectoryResult::TRAJECTORY_RESULT_SUCCESS,
301+
waitForTrajectoryResultPumpingNoops(std::chrono::seconds(5)));
302+
resetTrajectoryResultState();
303+
304+
// Stream 20 points with tmptime=0.1 s each. Nominal consumer-side
305+
// execution is ~2 s.
306+
const int k_num_points = 20;
307+
const float k_step_time = 0.1f;
308+
309+
const auto motion_start = std::chrono::steady_clock::now();
310+
311+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectoryControlMessage(
312+
control::TrajectoryControlMessage::TRAJECTORY_STREAM_START));
313+
for (int i = 0; i < k_num_points; ++i)
314+
{
315+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectorySplinePoint(held_pose, zero, zero, k_step_time));
316+
}
317+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectoryControlMessage(
318+
control::TrajectoryControlMessage::TRAJECTORY_STREAM_END, k_num_points));
319+
// Second STREAM_END immediately after the first. Without the guard fix
320+
// this re-applies the sentinel-based math to the already-recomputed
321+
// counter and truncates the drain.
322+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectoryControlMessage(
323+
control::TrajectoryControlMessage::TRAJECTORY_STREAM_END, k_num_points));
324+
325+
const auto result = waitForTrajectoryResultPumpingNoops(std::chrono::seconds(5));
326+
const auto elapsed = std::chrono::steady_clock::now() - motion_start;
327+
328+
EXPECT_EQ(control::TrajectoryResult::TRAJECTORY_RESULT_SUCCESS, result);
329+
EXPECT_GE(elapsed, std::chrono::milliseconds(1500))
330+
<< "Expected ~2 s of consumer-side execution; double STREAM_END appears to have truncated the drain.";
331+
}
332+
333+
// Regression test for PR #528 cursor-bot review comment 2.
334+
// A post-STREAM_END timeout-on-read with trajectory_points_left still
335+
// positive must be reported as TRAJECTORY_RESULT_FAILURE, not silently
336+
// swallowed as SUCCESS. The current clean-end shortcut fires whenever
337+
// trajectory_streaming was just cleared, regardless of whether the
338+
// consumer still expects more points. The fix narrows the shortcut to
339+
// the only legitimate case (count_after_decrement < 0), which can only
340+
// arise via a race where STREAM_END landed during the read block after
341+
// the consumer had already drained the buffer.
342+
TEST_F(TrajectoryStreamingTest, stream_end_with_overcount_yields_failure)
343+
{
344+
const vector6d_t held_pose = { 0.0, -1.57, 0.0, -1.57, 0.0, 0.0 };
345+
const vector6d_t zero = { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 };
346+
347+
// Pre-position
348+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectoryControlMessage(
349+
control::TrajectoryControlMessage::TRAJECTORY_START, 1));
350+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectorySplinePoint(held_pose, zero, zero, 2.0f));
351+
ASSERT_EQ(control::TrajectoryResult::TRAJECTORY_RESULT_SUCCESS,
352+
waitForTrajectoryResultPumpingNoops(std::chrono::seconds(5)));
353+
resetTrajectoryResultState();
354+
355+
// Send 50 real points but tell STREAM_END the producer wrote 100.
356+
const int k_actually_sent = 50;
357+
const int k_announced = 100;
358+
const float k_step_time = 0.01f;
359+
360+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectoryControlMessage(
361+
control::TrajectoryControlMessage::TRAJECTORY_STREAM_START));
362+
for (int i = 0; i < k_actually_sent; ++i)
363+
{
364+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectorySplinePoint(held_pose, zero, zero, k_step_time));
365+
}
366+
// The STREAM_END must arrive after the trajectoryThread has executed
367+
// its `was_streaming_at_start = trajectory_streaming` assignment with
368+
// the streaming flag still True. Otherwise a race - URScript
369+
// scheduling the dispatcher's next iteration before the new thread's
370+
// first statement runs - causes was_streaming_at_start to be captured
371+
// as False, which sends any underrun straight down the legacy FAILURE
372+
// arm regardless of the bug under test. Pump NOOPs for ~250 ms to
373+
// give the thread plenty of time to start and consume some points
374+
// before STREAM_END is dispatched.
375+
for (int i = 0; i < 5; ++i)
376+
{
377+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
378+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectoryControlMessage(
379+
control::TrajectoryControlMessage::TRAJECTORY_NOOP));
380+
}
381+
ASSERT_TRUE(g_my_robot->getUrDriver()->writeTrajectoryControlMessage(
382+
control::TrajectoryControlMessage::TRAJECTORY_STREAM_END, k_announced));
383+
384+
EXPECT_EQ(control::TrajectoryResult::TRAJECTORY_RESULT_FAILURE,
385+
waitForTrajectoryResultPumpingNoops(std::chrono::seconds(5)));
386+
}
387+
200388
int main(int argc, char* argv[])
201389
{
202390
::testing::InitGoogleTest(&argc, argv);

0 commit comments

Comments
 (0)