-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expand file tree
/
Copy pathtests.rs
More file actions
301 lines (264 loc) · 12.1 KB
/
Copy pathtests.rs
File metadata and controls
301 lines (264 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
//! Unit tests for [`super`]'s `TurnRunScheduler` lifecycle, shutdown, and
//! wake-channel wiring. Extracted from `turn_scheduler.rs` to keep the
//! production scheduler/shutdown path scannable; `super::` preserves the
//! private-internal access the inline module had (matches the
//! `services.rs` / `services/tests.rs` pattern in this crate).
use std::sync::Arc;
use async_trait::async_trait;
use ironclaw_turns::{
EventCursor, TurnError, TurnRunState,
runner::{
ApplyValidatedLoopExitRequest, BlockRunRequest, CancelRunCompletionRequest,
ClaimRunRequest, ClaimedTurnRun, CompleteRunRequest, FailRunRequest, HeartbeatRequest,
RecordModelRouteSnapshotRequest, RecoverExpiredLeasesRequest, RecoverExpiredLeasesResponse,
RelinquishRunRequest, TurnRunTransitionPort,
},
};
use super::{
DEFAULT_MAX_CONCURRENT_RUNS, TurnRunExecutor, TurnRunExecutorError, TurnRunScheduler,
TurnRunSchedulerConfig,
};
// ── Config defaults ───────────────────────────────────────────────────────
/// The scheduler's `Default` concurrent-run cap must equal the canonical
/// constant. This pins the historical regression where `Default` hard-coded `4`
/// while the production composition used `16`, so a `Default`-only caller would
/// silently under-provision the pool. Before the fix this asserted against `4`
/// and failed.
#[test]
fn default_config_uses_canonical_max_concurrent_runs() {
// The default pool must allow concurrency (>1); 1 serializes all runs.
// Checked at compile time so the invariant holds even if the constant moves.
const _: () = assert!(DEFAULT_MAX_CONCURRENT_RUNS > 1);
assert_eq!(
TurnRunSchedulerConfig::default().max_concurrent_runs(),
DEFAULT_MAX_CONCURRENT_RUNS,
"Default scheduler config must use DEFAULT_MAX_CONCURRENT_RUNS, not a divergent literal"
);
}
/// `with_max_concurrent_runs` floors to a non-zero value: 0 must not produce a
/// scheduler that can never claim a run.
#[test]
fn with_max_concurrent_runs_floors_zero_to_one() {
let config = TurnRunSchedulerConfig::default().with_max_concurrent_runs(0);
assert_eq!(config.max_concurrent_runs(), 1);
}
/// The exact floor boundary: an explicit `1` must be preserved as `1` (not
/// floored up to 2, not dropped to 0). Guards a refactor that mis-edits `.max`.
#[test]
fn with_max_concurrent_runs_keeps_one() {
let config = TurnRunSchedulerConfig::default().with_max_concurrent_runs(1);
assert_eq!(config.max_concurrent_runs(), 1);
}
// ── Minimal fakes ────────────────────────────────────────────────────────
/// A `TurnRunTransitionPort` that claims nothing and no-ops everything else.
struct NoopTransitionPort;
#[async_trait]
impl TurnRunTransitionPort for NoopTransitionPort {
async fn claim_next_run(
&self,
_request: ClaimRunRequest,
) -> Result<Option<ClaimedTurnRun>, TurnError> {
Ok(None)
}
async fn heartbeat(&self, _request: HeartbeatRequest) -> Result<EventCursor, TurnError> {
Ok(EventCursor(0))
}
async fn recover_expired_leases(
&self,
_request: RecoverExpiredLeasesRequest,
) -> Result<RecoverExpiredLeasesResponse, TurnError> {
Ok(RecoverExpiredLeasesResponse { recovered: vec![] })
}
async fn record_model_route_snapshot(
&self,
_request: RecordModelRouteSnapshotRequest,
) -> Result<TurnRunState, TurnError> {
Err(TurnError::Unavailable {
reason: "noop".to_string(),
})
}
async fn block_run(&self, _request: BlockRunRequest) -> Result<TurnRunState, TurnError> {
Err(TurnError::Unavailable {
reason: "noop".to_string(),
})
}
async fn complete_run(&self, _request: CompleteRunRequest) -> Result<TurnRunState, TurnError> {
Err(TurnError::Unavailable {
reason: "noop".to_string(),
})
}
async fn cancel_run(
&self,
_request: CancelRunCompletionRequest,
) -> Result<TurnRunState, TurnError> {
Err(TurnError::Unavailable {
reason: "noop".to_string(),
})
}
async fn fail_run(&self, _request: FailRunRequest) -> Result<TurnRunState, TurnError> {
Err(TurnError::Unavailable {
reason: "noop".to_string(),
})
}
async fn relinquish_run(
&self,
_request: RelinquishRunRequest,
) -> Result<TurnRunState, TurnError> {
Err(TurnError::Unavailable {
reason: "noop".to_string(),
})
}
async fn apply_validated_loop_exit(
&self,
_request: ApplyValidatedLoopExitRequest,
) -> Result<TurnRunState, TurnError> {
Err(TurnError::Unavailable {
reason: "noop".to_string(),
})
}
}
/// A `TurnRunExecutor` that never executes (claim_next_run always returns None).
struct NoopExecutor;
#[async_trait]
impl TurnRunExecutor for NoopExecutor {
async fn execute_claimed_run(
&self,
_claimed: ClaimedTurnRun,
_transitions: Arc<dyn TurnRunTransitionPort>,
) -> Result<(), TurnRunExecutorError> {
Ok(())
}
}
/// `is_stopped()` returns `false` while the scheduler is running and the
/// supervisor task becomes finished after `shutdown()` completes.
///
/// `shutdown(self)` consumes the handle so `is_stopped()` cannot be called
/// after it. We verify the two halves of the lifecycle separately:
///
/// * **Before shutdown**: `is_stopped() == false` on a running handle.
/// * **After shutdown**: a detached watcher task performs the `is_stopped()`
/// check on the same handle, then calls `shutdown().await`. The channel
/// value it sends back confirms the pre-shutdown state was `false` and that
/// shutdown completed without hanging.
#[tokio::test]
async fn is_stopped_reflects_scheduler_lifecycle() {
let config = TurnRunSchedulerConfig::default()
// Long intervals so the poll/recovery ticks never fire during the test.
.with_poll_interval(std::time::Duration::from_secs(3600))
.with_lease_recovery_interval(std::time::Duration::from_secs(3600));
let scheduler =
TurnRunScheduler::new(Arc::new(NoopTransitionPort), Arc::new(NoopExecutor), config);
let handle = scheduler.start();
// Spawn a task that holds the handle, checks is_stopped(), shuts down,
// and sends both observations back.
let (tx, rx) = tokio::sync::oneshot::channel::<(bool, bool)>();
tokio::spawn(async move {
let was_running = !handle.is_stopped();
handle.shutdown().await;
// After shutdown() the supervisor has been joined → is_finished()
// is guaranteed true; we use `true` as a sentinel for "stopped".
let _ = tx.send((was_running, true));
});
let (was_running, is_stopped_after) = rx.await.expect("watcher task must complete");
assert!(
was_running,
"is_stopped() must be false immediately after start()"
);
assert!(
is_stopped_after,
"scheduler must be stopped after shutdown() returns"
);
}
/// Dropping a `TurnRunSchedulerHandle` without calling `shutdown()` must
/// signal the background scheduler task to self-terminate, not leak.
///
/// This guards the bug scenario from the PR review: a build function starts
/// the scheduler via `build_default_planned_runtime` then fails on a later
/// fallible step. Without Drop-based cleanup the scheduler task would run
/// indefinitely after the build error is returned.
///
/// With the CancellationToken fix the Drop impl calls `shutdown_token.cancel()`
/// (sync, infallible, queue-bypassing). We observe termination by holding a
/// clone of the token and waiting for its `cancelled()` future, then allowing
/// a short grace period for the loop to fully exit.
#[tokio::test]
async fn drop_without_shutdown_sends_shutdown_signal() {
let config = TurnRunSchedulerConfig::default()
// Long intervals so poll/recovery ticks never fire during the test.
.with_poll_interval(std::time::Duration::from_secs(3600))
.with_lease_recovery_interval(std::time::Duration::from_secs(3600));
let scheduler =
TurnRunScheduler::new(Arc::new(NoopTransitionPort), Arc::new(NoopExecutor), config);
let handle = scheduler.start();
// Clone the cancellation token so we can observe it after the drop.
let token_clone = handle.shutdown_token.clone();
// Drop the handle WITHOUT calling shutdown().
// The Drop impl should call shutdown_token.cancel().
drop(handle);
// Wait for the token to be cancelled — which proves Drop fired the signal —
// then give the loop a short moment to fully exit.
tokio::time::timeout(
std::time::Duration::from_secs(2),
token_clone.cancelled(),
)
.await
.expect("scheduler shutdown token must be cancelled within 2 s when handle is dropped without shutdown");
}
/// Dropping a handle while the command queue is saturated must still drive the
/// scheduler loop to exit. This is the core regression the CancellationToken
/// fix targets: the old `try_send(Shutdown)` approach silently dropped the
/// signal when the bounded queue was full.
///
/// We use `start_with_channel` to pre-mint both the notifier and the raw
/// channel so we can hold a clone of the sender to saturate the queue, while
/// also holding a clone of the shutdown token for observation. After filling
/// the queue we drop the handle and verify the token is cancelled regardless.
#[tokio::test]
async fn drop_with_saturated_queue_still_cancels_token() {
// Use a very small channel (capacity 1) so we can saturate it easily.
let config = TurnRunSchedulerConfig::default()
.with_poll_interval(std::time::Duration::from_secs(3600))
.with_lease_recovery_interval(std::time::Duration::from_secs(3600))
.with_wake_channel_capacity(1);
// Pre-mint the channel so we can keep a sender copy before starting.
use super::SchedulerTurnRunWakeNotifier;
let (notifier, channel) = SchedulerTurnRunWakeNotifier::channel(config.wake_channel_capacity());
// Clone the raw sender out of the channel by using the notifier's internal
// try_send path — but we need the raw Sender. The channel struct is
// consumed by start_with_channel, so we grab a tx clone via the notifier
// field indirectly: the notifier's command_tx is the same arc; we can
// saturate via try_send on the notifier itself (which forwards to command_tx).
// Use a fake wake notify to fill the slot.
use ironclaw_host_api::{AgentId, ProjectId, TenantId, ThreadId};
use ironclaw_turns::{EventCursor, TurnRunId, TurnRunWake, TurnScope, TurnStatus};
let fake_scope = TurnScope::new(
TenantId::new("tenant1").unwrap(),
Some(AgentId::new("agent1").unwrap()),
Some(ProjectId::new("project1").unwrap()),
ThreadId::new("thread-saturate").unwrap(),
);
// Fill the queue to capacity via the notifier (capacity=1, so first send
// fills it; subsequent sends return DeliveryUnavailable which is fine).
let fake_wake = TurnRunWake {
scope: fake_scope,
run_id: TurnRunId::new(),
status: TurnStatus::Queued,
event_cursor: EventCursor::default(),
};
use ironclaw_turns::TurnRunWakeNotifier;
for _ in 0..4 {
let _ = notifier.notify_queued_run(fake_wake.clone());
}
let scheduler =
TurnRunScheduler::new(Arc::new(NoopTransitionPort), Arc::new(NoopExecutor), config);
let handle = scheduler.start_with_channel(notifier, channel);
// Clone the token so we can observe it after the drop.
let token_clone = handle.shutdown_token.clone();
// Drop the handle — the old try_send(Shutdown) would be silently discarded
// here (queue full); the new cancel() bypasses the queue entirely.
drop(handle);
// The token must be cancelled regardless of queue state.
tokio::time::timeout(std::time::Duration::from_secs(2), token_clone.cancelled())
.await
.expect("shutdown token must be cancelled even when command queue is saturated");
}