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
//! Integration tests proving that after the registry-unification refactor:
//!
//! - C2: `disable`/`enable` through the `system.control.toggle_feature`
//! pipeline is observed by the executor's module-lookup step.
//! - C3: `APCore` constructors work inside a tokio runtime (no more
//! `blocking_lock` panics, no more `Arc::get_mut` hack, no more
//! parallel registries).
//!
//! These tests fail on the pre-refactor codebase because sys modules were
//! registered into a separate `Arc<Mutex<Registry>>` from the one the
//! executor's pipeline inspected.
use std::sync::Arc;
use apcore::config::Config;
use apcore::context::Context;
use apcore::errors::{ErrorCode, ModuleError};
use apcore::module::Module;
use apcore::APCore;
use async_trait::async_trait;
use serde_json::{json, Value};
// ---------------------------------------------------------------------------
// Test module
// ---------------------------------------------------------------------------
struct Dummy;
#[async_trait]
impl Module for Dummy {
fn description(&self) -> &'static str {
"dummy"
}
fn input_schema(&self) -> Value {
json!({})
}
fn output_schema(&self) -> Value {
json!({})
}
async fn execute(&self, _inputs: Value, _ctx: &Context<Value>) -> Result<Value, ModuleError> {
Ok(json!({"ok": true}))
}
}
fn enable_events_config() -> Config {
let mut config = Config::default();
config.set("sys_modules.events.enabled", json!(true));
config
}
// ---------------------------------------------------------------------------
// C3: constructor runs fine inside a tokio runtime, and sys modules are
// registered into the SAME registry that the executor pipeline uses.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn apcore_constructor_works_inside_tokio_runtime() {
let apcore = APCore::new();
let registered = apcore.list_modules(None, None);
// Health, manifest, and usage modules are always registered when
// sys_modules.enabled=true. These used to be absent when the constructor
// was invoked inside a tokio runtime (the pre-refactor guard bailed out
// to avoid a blocking_lock panic).
assert!(
registered.contains(&"system.health.summary".to_string()),
"system.health.summary should be auto-registered; got: {registered:?}"
);
assert!(
registered.contains(&"system.manifest.full".to_string()),
"system.manifest.full should be auto-registered; got: {registered:?}"
);
assert!(
registered.contains(&"system.usage.summary".to_string()),
"system.usage.summary should be auto-registered; got: {registered:?}"
);
}
#[tokio::test]
async fn apcore_toggle_feature_is_reachable_from_executor_when_events_enabled() {
// When events are enabled, control modules including
// `system.control.toggle_feature` should be registered into the same
// registry the executor consults.
let apcore = APCore::with_config(enable_events_config());
let registered = apcore.list_modules(None, None);
assert!(
registered.contains(&"system.control.toggle_feature".to_string()),
"system.control.toggle_feature should be registered in the executor's registry; got: {registered:?}"
);
// And calling it through the executor pipeline must succeed.
apcore
.register("demo.module", Box::new(Dummy))
.expect("register should succeed");
let result = apcore
.call(
"system.control.toggle_feature",
json!({
"module_id": "demo.module",
"enabled": false,
"reason": "test",
}),
None,
None,
)
.await;
assert!(
result.is_ok(),
"toggle_feature should be callable through executor: {result:?}"
);
}
// ---------------------------------------------------------------------------
// C2: disable via pipeline is observed by subsequent calls.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn disable_through_pipeline_is_observed_by_executor() {
let apcore = APCore::with_config(enable_events_config());
apcore
.register("demo.module", Box::new(Dummy))
.expect("register should succeed");
// Disable via the pipeline.
let res = apcore.disable("demo.module", Some("integration")).await;
assert!(res.is_ok(), "disable should succeed: {res:?}");
// Subsequent call must be blocked by the pipeline's module_lookup step.
let err = apcore
.call("demo.module", json!({}), None, None)
.await
.expect_err("call on disabled module must fail");
assert_eq!(err.code, ErrorCode::ModuleDisabled);
}
#[tokio::test]
async fn enable_through_pipeline_restores_execution() {
let apcore = APCore::with_config(enable_events_config());
apcore
.register("demo.module", Box::new(Dummy))
.expect("register should succeed");
apcore
.disable("demo.module", Some("first"))
.await
.expect("disable should succeed");
apcore
.enable("demo.module", Some("second"))
.await
.expect("enable should succeed");
let ok = apcore
.call("demo.module", json!({}), None, None)
.await
.expect("call after re-enable should succeed");
assert_eq!(ok["ok"], true);
}
// ---------------------------------------------------------------------------
// Concurrency: interior mutability must allow concurrent register + read.
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_register_and_list_no_deadlock() {
let apcore = Arc::new(APCore::new());
let mut handles = Vec::new();
// 100 writers (register) with yield points to force task interleaving.
for i in 0..100 {
let ac = Arc::clone(&apcore);
handles.push(tokio::spawn(async move {
if i % 10 == 0 {
tokio::task::yield_now().await;
}
let id = format!("demo.module_{i}");
ac.register(&id, Box::new(Dummy)).expect("register");
}));
}
// 200 readers (list) with a yield up front to widen the interleaving window.
for _ in 0..200 {
let ac = Arc::clone(&apcore);
handles.push(tokio::spawn(async move {
tokio::task::yield_now().await;
let _ = ac.list_modules(None, None);
}));
}
// Wait for all tasks with a generous timeout — a deadlock manifests as a
// timeout here on the multi-threaded runtime.
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
futures_util::future::join_all(handles),
)
.await;
let joined = result.expect("concurrent operations timed out — possible deadlock");
for h in joined {
h.expect("task must not panic");
}
let modules = apcore.list_modules(None, Some("demo."));
assert_eq!(
modules.len(),
100,
"all 100 demo modules should be registered"
);
}
// ---------------------------------------------------------------------------
// Issue 2: ToggleFeatureModule TOCTOU — attempting to toggle a non-existent
// module must fail without polluting ToggleState so subsequent registration
// of the same ID is not silently disabled.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn toggle_feature_on_nonexistent_module_does_not_pollute_toggle_state() {
let apcore = APCore::with_config(enable_events_config());
// Attempt to disable a non-existent module — must fail.
let result = apcore.disable("does.not.exist", Some("testing")).await;
assert!(result.is_err(), "disable of nonexistent module should fail");
// Now register a module with the same ID and verify it executes
// successfully (i.e. ToggleState is not holding a stale `disabled` entry).
apcore
.register("does.not.exist", Box::new(Dummy))
.expect("register after failed disable should succeed");
let ok = apcore
.call("does.not.exist", json!({}), None, None)
.await
.expect("call should succeed — module must not be stuck in disabled state");
assert_eq!(ok["ok"], true);
}