Skip to main content

runmat_runtime/builtins/timing/
timer.rs

1//! MATLAB-compatible timer handle objects.
2//!
3//! Timer objects are handle objects with MATLAB timer properties. RunMat keeps
4//! timers in a per-thread registry so `timerfind` can discover them after the
5//! constructor returns. Lifecycle methods execute callbacks on the current VM
6//! path rather than a background event thread; this keeps callback execution
7//! deterministic across native and wasm hosts.
8
9use std::cell::{Cell, RefCell};
10use std::collections::HashMap;
11#[cfg(not(target_arch = "wasm32"))]
12use std::time::Duration;
13
14#[cfg(test)]
15use once_cell::sync::Lazy;
16#[cfg(test)]
17use std::sync::Mutex;
18
19use runmat_builtins::{
20    Access, BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
21    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
22    CellArray, ClassDef, HandleRef, MethodDef, ObjectInstance, PropertyDef, StructValue, Value,
23};
24use runmat_macros::runtime_builtin;
25
26use crate::builtins::common::spec::{
27    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
28    ReductionNaN, ResidencyPolicy, ShapeRequirements,
29};
30use crate::{build_runtime_error, BuiltinResult, RuntimeError};
31
32const TIMER_CLASS: &str = "timer";
33const BUILTIN_TIMER: &str = "timer";
34const TIMER_METHOD_START: &str = "__runmat_timer_start";
35const TIMER_METHOD_STARTAT: &str = "__runmat_timer_startat";
36const TIMER_METHOD_STOP: &str = "__runmat_timer_stop";
37const TIMER_METHOD_WAIT: &str = "__runmat_timer_wait";
38const TIMER_METHOD_DELETE: &str = "__runmat_timer_delete";
39
40const CALLBACK_PROPS: [&str; 4] = ["TimerFcn", "StartFcn", "StopFcn", "ErrorFcn"];
41const STRING_PROPS: [&str; 5] = [
42    "BusyMode",
43    "ExecutionMode",
44    "Name",
45    "Tag",
46    "ObjectVisibility",
47];
48const NUMERIC_PROPS: [&str; 3] = ["Period", "StartDelay", "TasksToExecute"];
49const READONLY_PROPS: [&str; 5] = [
50    "AveragePeriod",
51    "InstantPeriod",
52    "Running",
53    "TasksExecuted",
54    "Type",
55];
56
57thread_local! {
58    static TIMER_REGISTRY: RefCell<Vec<HandleRef>> = const { RefCell::new(Vec::new()) };
59    static TIMER_COUNTER: Cell<usize> = const { Cell::new(0) };
60}
61
62#[cfg(test)]
63static TIMER_TEST_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
64
65const TIMER_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
66    name: "t",
67    ty: BuiltinParamType::Any,
68    arity: BuiltinParamArity::Required,
69    default: None,
70    description: "Timer handle object.",
71}];
72
73const TIMER_INPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
74    name: "Name,Value",
75    ty: BuiltinParamType::Any,
76    arity: BuiltinParamArity::Variadic,
77    default: None,
78    description: "Timer properties such as TimerFcn, StartDelay, Period, ExecutionMode, Name, Tag, and UserData.",
79}];
80
81const TIMER_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
82    BuiltinSignatureDescriptor {
83        label: "t = timer",
84        inputs: &[],
85        outputs: &TIMER_OUTPUT,
86    },
87    BuiltinSignatureDescriptor {
88        label: "t = timer(Name, Value, ...)",
89        inputs: &TIMER_INPUTS,
90        outputs: &TIMER_OUTPUT,
91    },
92];
93
94const TIMERFIND_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
95    name: "timers",
96    ty: BuiltinParamType::Any,
97    arity: BuiltinParamArity::Required,
98    default: None,
99    description: "Matching timer handle, empty cell row, or cell row of timer handles.",
100}];
101
102const TIMERFIND_INPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
103    name: "Name,Value",
104    ty: BuiltinParamType::Any,
105    arity: BuiltinParamArity::Variadic,
106    default: None,
107    description: "Property filters matched by exact value.",
108}];
109
110const TIMERFIND_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
111    BuiltinSignatureDescriptor {
112        label: "timers = timerfind",
113        inputs: &[],
114        outputs: &TIMERFIND_OUTPUT,
115    },
116    BuiltinSignatureDescriptor {
117        label: "timers = timerfind(Name, Value, ...)",
118        inputs: &TIMERFIND_INPUTS,
119        outputs: &TIMERFIND_OUTPUT,
120    },
121];
122
123const TIMERFINDALL_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
124    BuiltinSignatureDescriptor {
125        label: "timers = timerfindall",
126        inputs: &[],
127        outputs: &TIMERFIND_OUTPUT,
128    },
129    BuiltinSignatureDescriptor {
130        label: "timers = timerfindall(Name, Value, ...)",
131        inputs: &TIMERFIND_INPUTS,
132        outputs: &TIMERFIND_OUTPUT,
133    },
134];
135
136const TIMER_METHOD_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
137    name: "status",
138    ty: BuiltinParamType::NumericScalar,
139    arity: BuiltinParamArity::Optional,
140    default: None,
141    description: "Zero on success.",
142}];
143
144const TIMER_METHOD_INPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
145    name: "t",
146    ty: BuiltinParamType::Any,
147    arity: BuiltinParamArity::Required,
148    default: None,
149    description: "Timer handle or cell array of timer handles.",
150}];
151
152const TIMER_METHOD_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
153    label: "status = method(t)",
154    inputs: &TIMER_METHOD_INPUTS,
155    outputs: &TIMER_METHOD_OUTPUT,
156}];
157
158const TIMER_SETTER_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
159    name: "obj",
160    ty: BuiltinParamType::Any,
161    arity: BuiltinParamArity::Required,
162    default: None,
163    description: "Updated timer object.",
164}];
165
166const TIMER_SETTER_INPUTS: [BuiltinParamDescriptor; 2] = [
167    BuiltinParamDescriptor {
168        name: "obj",
169        ty: BuiltinParamType::Any,
170        arity: BuiltinParamArity::Required,
171        default: None,
172        description: "Timer object receiver.",
173    },
174    BuiltinParamDescriptor {
175        name: "value",
176        ty: BuiltinParamType::Any,
177        arity: BuiltinParamArity::Required,
178        default: None,
179        description: "Property value to validate and assign.",
180    },
181];
182
183const TIMER_SETTER_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
184    label: "obj = set.Property(obj, value)",
185    inputs: &TIMER_SETTER_INPUTS,
186    outputs: &TIMER_SETTER_OUTPUT,
187}];
188
189const TIMER_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
190    code: "RM.TIMER.INVALID_INPUT",
191    identifier: Some("RunMat:timer:InvalidInput"),
192    when: "A timer argument, property, or callback value has an unsupported type.",
193    message: "timer: invalid input",
194};
195
196const TIMER_ERROR_INVALID_PROPERTY: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
197    code: "RM.TIMER.INVALID_PROPERTY",
198    identifier: Some("RunMat:timer:InvalidProperty"),
199    when: "A timer property name is unknown, read-only, or has an invalid value.",
200    message: "timer: invalid property",
201};
202
203const TIMER_ERROR_INVALID_HANDLE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
204    code: "RM.TIMER.INVALID_HANDLE",
205    identifier: Some("RunMat:timer:InvalidHandle"),
206    when: "A lifecycle method receives a non-timer or invalid timer handle.",
207    message: "timer: invalid timer handle",
208};
209
210const TIMER_ERROR_CALLBACK: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
211    code: "RM.TIMER.CALLBACK",
212    identifier: Some("RunMat:timer:CallbackFailed"),
213    when: "A timer callback fails.",
214    message: "timer: callback failed",
215};
216
217const TIMER_ERROR_GC: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
218    code: "RM.TIMER.GC",
219    identifier: Some("RunMat:timer:GcFailure"),
220    when: "A timer object cannot be allocated, rooted, read, or mutated.",
221    message: "timer: object storage failed",
222};
223
224const TIMER_ERRORS: [BuiltinErrorDescriptor; 5] = [
225    TIMER_ERROR_INVALID_INPUT,
226    TIMER_ERROR_INVALID_PROPERTY,
227    TIMER_ERROR_INVALID_HANDLE,
228    TIMER_ERROR_CALLBACK,
229    TIMER_ERROR_GC,
230];
231
232pub const TIMER_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
233    signatures: &TIMER_SIGNATURES,
234    output_mode: BuiltinOutputMode::Fixed,
235    completion_policy: BuiltinCompletionPolicy::Public,
236    errors: &TIMER_ERRORS,
237};
238
239pub const TIMERFIND_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
240    signatures: &TIMERFIND_SIGNATURES,
241    output_mode: BuiltinOutputMode::Fixed,
242    completion_policy: BuiltinCompletionPolicy::Public,
243    errors: &TIMER_ERRORS,
244};
245
246pub const TIMERFINDALL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
247    signatures: &TIMERFINDALL_SIGNATURES,
248    output_mode: BuiltinOutputMode::Fixed,
249    completion_policy: BuiltinCompletionPolicy::Public,
250    errors: &TIMER_ERRORS,
251};
252
253pub const TIMER_METHOD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
254    signatures: &TIMER_METHOD_SIGNATURES,
255    output_mode: BuiltinOutputMode::Fixed,
256    completion_policy: BuiltinCompletionPolicy::HiddenInternal,
257    errors: &TIMER_ERRORS,
258};
259
260pub const TIMER_SETTER_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
261    signatures: &TIMER_SETTER_SIGNATURES,
262    output_mode: BuiltinOutputMode::Fixed,
263    completion_policy: BuiltinCompletionPolicy::MethodOnly,
264    errors: &TIMER_ERRORS,
265};
266
267#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::timing::timer")]
268pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
269    name: "timer",
270    op_kind: GpuOpKind::Custom("timer"),
271    supported_precisions: &[],
272    broadcast: BroadcastSemantics::None,
273    provider_hooks: &[],
274    constant_strategy: ConstantStrategy::InlineLiteral,
275    residency: ResidencyPolicy::GatherImmediately,
276    nan_mode: ReductionNaN::Include,
277    two_pass_threshold: None,
278    workgroup_size: None,
279    accepts_nan_mode: false,
280    notes: "Timer handles are host control-flow objects. Timer callbacks may call GPU-capable builtins, but timer itself has no provider kernel.",
281};
282
283#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::timing::timer")]
284pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
285    name: "timer",
286    shape: ShapeRequirements::Any,
287    constant_strategy: ConstantStrategy::InlineLiteral,
288    elementwise: None,
289    reduction: None,
290    emits_nan: false,
291    notes: "Timer object creation, discovery, and callback execution are side-effect boundaries and are excluded from fusion.",
292};
293
294#[runtime_builtin(
295    name = "timer",
296    category = "timing",
297    summary = "Create a MATLAB-compatible timer handle object.",
298    keywords = "timer,callback,start,stop,wait,timerfind",
299    descriptor(crate::builtins::timing::timer::TIMER_DESCRIPTOR),
300    builtin_path = "crate::builtins::timing::timer"
301)]
302pub async fn timer_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
303    ensure_timer_class_registered();
304    let mut object = default_timer_object(next_timer_name());
305    apply_name_value_pairs(&mut object, &args, true)?;
306    let target = runmat_gc::gc_allocate(Value::Object(object))
307        .map_err(|err| timer_error(&TIMER_ERROR_GC, format!("timer: {err}")))?;
308    runmat_gc::gc_add_root(target)
309        .map_err(|err| timer_error(&TIMER_ERROR_GC, format!("timer: {err}")))?;
310    let handle = HandleRef {
311        class_name: TIMER_CLASS.to_string(),
312        target,
313        valid: true,
314    };
315    TIMER_REGISTRY.with(|registry| registry.borrow_mut().push(handle.clone()));
316    Ok(Value::HandleObject(handle))
317}
318
319#[runtime_builtin(
320    name = "timerfind",
321    category = "timing",
322    summary = "Find visible timer objects by property value.",
323    keywords = "timerfind,timer,callback,handle",
324    descriptor(crate::builtins::timing::timer::TIMERFIND_DESCRIPTOR),
325    builtin_path = "crate::builtins::timing::timer"
326)]
327pub fn timerfind_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
328    find_timers(args, false)
329}
330
331#[runtime_builtin(
332    name = "timerfindall",
333    category = "timing",
334    summary = "Find all timer objects by property value.",
335    keywords = "timerfindall,timer,callback,handle",
336    descriptor(crate::builtins::timing::timer::TIMERFINDALL_DESCRIPTOR),
337    builtin_path = "crate::builtins::timing::timer"
338)]
339pub fn timerfindall_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
340    find_timers(args, true)
341}
342
343#[runtime_builtin(
344    name = "__runmat_timer_start",
345    category = "timing",
346    summary = "Start a timer object.",
347    keywords = "timer,start",
348    sink = true,
349    suppress_auto_output = true,
350    descriptor(crate::builtins::timing::timer::TIMER_METHOD_DESCRIPTOR),
351    builtin_path = "crate::builtins::timing::timer"
352)]
353pub async fn timer_start_builtin(value: Value) -> BuiltinResult<Value> {
354    start_timer_values(&value, None).await?;
355    Ok(Value::Num(0.0))
356}
357
358#[runtime_builtin(
359    name = "__runmat_timer_startat",
360    category = "timing",
361    summary = "Start a timer object at a requested time.",
362    keywords = "timer,startat",
363    sink = true,
364    suppress_auto_output = true,
365    descriptor(crate::builtins::timing::timer::TIMER_METHOD_DESCRIPTOR),
366    builtin_path = "crate::builtins::timing::timer"
367)]
368pub async fn timer_startat_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
369    let delay = rest
370        .first()
371        .map(|value| numeric_scalar(value, "startat"))
372        .transpose()?
373        .filter(|seconds| *seconds > 0.0);
374    start_timer_values(&value, delay).await?;
375    Ok(Value::Num(0.0))
376}
377
378#[runtime_builtin(
379    name = "__runmat_timer_stop",
380    category = "timing",
381    summary = "Stop a timer object.",
382    keywords = "timer,stop",
383    sink = true,
384    suppress_auto_output = true,
385    descriptor(crate::builtins::timing::timer::TIMER_METHOD_DESCRIPTOR),
386    builtin_path = "crate::builtins::timing::timer"
387)]
388pub async fn timer_stop_builtin(value: Value) -> BuiltinResult<Value> {
389    stop_timer_values(&value, true).await?;
390    Ok(Value::Num(0.0))
391}
392
393#[runtime_builtin(
394    name = "__runmat_timer_wait",
395    category = "timing",
396    summary = "Wait until timer objects finish.",
397    keywords = "timer,wait",
398    sink = true,
399    suppress_auto_output = true,
400    descriptor(crate::builtins::timing::timer::TIMER_METHOD_DESCRIPTOR),
401    builtin_path = "crate::builtins::timing::timer"
402)]
403pub async fn timer_wait_builtin(value: Value) -> BuiltinResult<Value> {
404    wait_timer_values(&value).await?;
405    Ok(Value::Num(0.0))
406}
407
408#[runtime_builtin(
409    name = "__runmat_timer_delete",
410    category = "timing",
411    summary = "Delete a timer object.",
412    keywords = "timer,delete",
413    sink = true,
414    suppress_auto_output = true,
415    descriptor(crate::builtins::timing::timer::TIMER_METHOD_DESCRIPTOR),
416    builtin_path = "crate::builtins::timing::timer"
417)]
418pub async fn timer_delete_builtin(value: Value) -> BuiltinResult<Value> {
419    delete_timer_values(&value).await?;
420    Ok(Value::Num(0.0))
421}
422
423macro_rules! timer_setter_builtin {
424    ($fn_name:ident, $builtin_name:literal, $property:literal) => {
425        #[runtime_builtin(
426                            name = $builtin_name,
427                            category = "timing",
428                            summary = "Validate and assign a timer property.",
429                            keywords = "timer,set,property",
430                            descriptor(crate::builtins::timing::timer::TIMER_SETTER_DESCRIPTOR),
431                            builtin_path = "crate::builtins::timing::timer"
432                        )]
433        pub async fn $fn_name(obj: Value, value: Value) -> BuiltinResult<Value> {
434            set_timer_property_object(obj, $property, value).await
435        }
436    };
437}
438
439timer_setter_builtin!(timer_set_timer_fcn_builtin, "set.TimerFcn", "TimerFcn");
440timer_setter_builtin!(timer_set_start_fcn_builtin, "set.StartFcn", "StartFcn");
441timer_setter_builtin!(timer_set_stop_fcn_builtin, "set.StopFcn", "StopFcn");
442timer_setter_builtin!(timer_set_error_fcn_builtin, "set.ErrorFcn", "ErrorFcn");
443timer_setter_builtin!(timer_set_period_builtin, "set.Period", "Period");
444timer_setter_builtin!(
445    timer_set_start_delay_builtin,
446    "set.StartDelay",
447    "StartDelay"
448);
449timer_setter_builtin!(
450    timer_set_tasks_to_execute_builtin,
451    "set.TasksToExecute",
452    "TasksToExecute"
453);
454timer_setter_builtin!(timer_set_busy_mode_builtin, "set.BusyMode", "BusyMode");
455timer_setter_builtin!(
456    timer_set_execution_mode_builtin,
457    "set.ExecutionMode",
458    "ExecutionMode"
459);
460timer_setter_builtin!(timer_set_name_builtin, "set.Name", "Name");
461timer_setter_builtin!(timer_set_tag_builtin, "set.Tag", "Tag");
462timer_setter_builtin!(
463    timer_set_object_visibility_builtin,
464    "set.ObjectVisibility",
465    "ObjectVisibility"
466);
467timer_setter_builtin!(timer_set_user_data_builtin, "set.UserData", "UserData");
468
469fn ensure_timer_class_registered() {
470    if runmat_builtins::get_class(TIMER_CLASS).is_some() {
471        return;
472    }
473
474    let mut methods = HashMap::new();
475    for (name, function_name) in [
476        ("start", TIMER_METHOD_START),
477        ("startat", TIMER_METHOD_STARTAT),
478        ("stop", TIMER_METHOD_STOP),
479        ("wait", TIMER_METHOD_WAIT),
480        ("delete", TIMER_METHOD_DELETE),
481    ] {
482        methods.insert(
483            name.to_string(),
484            MethodDef {
485                name: name.to_string(),
486                is_static: false,
487                is_abstract: false,
488                is_sealed: false,
489                access: Access::Public,
490                function_name: function_name.to_string(),
491                implicit_class_argument: None,
492            },
493        );
494    }
495
496    let mut properties = HashMap::new();
497    for name in CALLBACK_PROPS
498        .into_iter()
499        .chain(STRING_PROPS)
500        .chain(NUMERIC_PROPS)
501        .chain(READONLY_PROPS)
502        .chain(["UserData"])
503    {
504        properties.insert(
505            name.to_string(),
506            PropertyDef {
507                name: name.to_string(),
508                is_static: false,
509                is_constant: false,
510                is_dependent: is_timer_mutable_property(name),
511                get_access: Access::Public,
512                set_access: if READONLY_PROPS.contains(&name) {
513                    Access::Private
514                } else {
515                    Access::Public
516                },
517                default_value: None,
518            },
519        );
520    }
521
522    runmat_builtins::register_class(ClassDef {
523        name: TIMER_CLASS.to_string(),
524        parent: Some("handle".to_string()),
525        properties,
526        methods,
527    });
528}
529
530fn is_timer_mutable_property(name: &str) -> bool {
531    CALLBACK_PROPS.contains(&name)
532        || STRING_PROPS.contains(&name)
533        || NUMERIC_PROPS.contains(&name)
534        || name == "UserData"
535}
536
537fn default_timer_object(name: String) -> ObjectInstance {
538    let mut object = ObjectInstance::new(TIMER_CLASS.to_string());
539    for prop in CALLBACK_PROPS {
540        object
541            .properties
542            .insert(prop.to_string(), Value::String(String::new()));
543    }
544    object
545        .properties
546        .insert("Period".to_string(), Value::Num(1.0));
547    object
548        .properties
549        .insert("StartDelay".to_string(), Value::Num(0.0));
550    object
551        .properties
552        .insert("TasksToExecute".to_string(), Value::Num(1.0));
553    object
554        .properties
555        .insert("BusyMode".to_string(), Value::String("drop".to_string()));
556    object.properties.insert(
557        "ExecutionMode".to_string(),
558        Value::String("singleShot".to_string()),
559    );
560    object
561        .properties
562        .insert("Name".to_string(), Value::String(name));
563    object
564        .properties
565        .insert("Tag".to_string(), Value::String(String::new()));
566    object.properties.insert(
567        "ObjectVisibility".to_string(),
568        Value::String("on".to_string()),
569    );
570    object
571        .properties
572        .insert("UserData".to_string(), Value::Num(0.0));
573    object
574        .properties
575        .insert("AveragePeriod".to_string(), Value::Num(f64::NAN));
576    object
577        .properties
578        .insert("InstantPeriod".to_string(), Value::Num(f64::NAN));
579    object
580        .properties
581        .insert("Running".to_string(), Value::String("off".to_string()));
582    object
583        .properties
584        .insert("TasksExecuted".to_string(), Value::Num(0.0));
585    object
586        .properties
587        .insert("Type".to_string(), Value::String(TIMER_CLASS.to_string()));
588    object.properties.insert(
589        crate::HANDLE_VALID_FLAG_PROPERTY.to_string(),
590        Value::Bool(true),
591    );
592    object
593}
594
595fn next_timer_name() -> String {
596    TIMER_COUNTER.with(|counter| {
597        let next = counter.get() + 1;
598        counter.set(next);
599        format!("timer-{next}")
600    })
601}
602
603fn apply_name_value_pairs(
604    object: &mut ObjectInstance,
605    args: &[Value],
606    constructor: bool,
607) -> BuiltinResult<()> {
608    if !args.len().is_multiple_of(2) {
609        return Err(timer_error(
610            &TIMER_ERROR_INVALID_INPUT,
611            "timer: name-value arguments must appear in pairs",
612        ));
613    }
614    let mut idx = 0usize;
615    while idx < args.len() {
616        let name = canonical_property_name(&value_to_string(&args[idx])?)?;
617        if READONLY_PROPS.contains(&name.as_str()) {
618            return Err(timer_error(
619                &TIMER_ERROR_INVALID_PROPERTY,
620                format!("timer: property '{name}' is read-only"),
621            ));
622        }
623        let value = normalize_property_value(&name, args[idx + 1].clone())?;
624        if !constructor && running_is_on(object) && is_running_readonly_property(&name) {
625            return Err(timer_error(
626                &TIMER_ERROR_INVALID_PROPERTY,
627                format!("timer: property '{name}' cannot be changed while Running is on"),
628            ));
629        }
630        object.properties.insert(name, value);
631        idx += 2;
632    }
633    Ok(())
634}
635
636async fn set_timer_property_object(
637    obj: Value,
638    property_name: &'static str,
639    value: Value,
640) -> BuiltinResult<Value> {
641    let Value::Object(mut object) = obj else {
642        return Err(timer_error(
643            &TIMER_ERROR_INVALID_HANDLE,
644            format!("timer: set.{property_name} requires timer object receiver"),
645        ));
646    };
647    if object.class_name != TIMER_CLASS {
648        return Err(timer_error(
649            &TIMER_ERROR_INVALID_HANDLE,
650            format!(
651                "timer: set.{property_name} requires timer object receiver, got '{}'",
652                object.class_name
653            ),
654        ));
655    }
656    apply_name_value_pairs(
657        &mut object,
658        &[Value::String(property_name.to_string()), value],
659        false,
660    )?;
661    Ok(Value::Object(object))
662}
663
664fn canonical_property_name(name: &str) -> BuiltinResult<String> {
665    let trimmed = name.trim();
666    for prop in CALLBACK_PROPS
667        .into_iter()
668        .chain(STRING_PROPS)
669        .chain(NUMERIC_PROPS)
670        .chain(READONLY_PROPS)
671        .chain(["UserData"])
672    {
673        if prop.eq_ignore_ascii_case(trimmed) {
674            return Ok(prop.to_string());
675        }
676    }
677    Err(timer_error(
678        &TIMER_ERROR_INVALID_PROPERTY,
679        format!("timer: unknown property '{trimmed}'"),
680    ))
681}
682
683fn normalize_property_value(name: &str, value: Value) -> BuiltinResult<Value> {
684    match name {
685        "Period" => {
686            let seconds = numeric_scalar(&value, name)?;
687            if !seconds.is_finite() || seconds <= 0.001 {
688                return Err(timer_error(
689                    &TIMER_ERROR_INVALID_PROPERTY,
690                    "timer: Period must be a finite scalar greater than 0.001",
691                ));
692            }
693            Ok(Value::Num(seconds))
694        }
695        "StartDelay" => {
696            let seconds = numeric_scalar(&value, name)?;
697            if !seconds.is_finite() || seconds < 0.0 {
698                return Err(timer_error(
699                    &TIMER_ERROR_INVALID_PROPERTY,
700                    "timer: StartDelay must be a finite non-negative scalar",
701                ));
702            }
703            Ok(Value::Num(seconds))
704        }
705        "TasksToExecute" => {
706            let count = numeric_scalar(&value, name)?;
707            if !count.is_finite() || count < 1.0 || count.fract() != 0.0 {
708                return Err(timer_error(
709                    &TIMER_ERROR_INVALID_PROPERTY,
710                    "timer: TasksToExecute must be a positive integer scalar",
711                ));
712            }
713            Ok(Value::Num(count))
714        }
715        "BusyMode" => match_string_choice(&value, name, &["drop", "error", "queue"]),
716        "ExecutionMode" => match_string_choice(
717            &value,
718            name,
719            &["singleShot", "fixedRate", "fixedDelay", "fixedSpacing"],
720        ),
721        "ObjectVisibility" => match_string_choice(&value, name, &["on", "off"]),
722        "Name" | "Tag" => Ok(Value::String(value_to_string(&value)?)),
723        "TimerFcn" | "StartFcn" | "StopFcn" | "ErrorFcn" => normalize_callback(value, name),
724        "UserData" => Ok(value),
725        _ => Err(timer_error(
726            &TIMER_ERROR_INVALID_PROPERTY,
727            format!("timer: property '{name}' cannot be assigned"),
728        )),
729    }
730}
731
732fn is_running_readonly_property(name: &str) -> bool {
733    matches!(name, "BusyMode" | "ExecutionMode" | "StartDelay")
734}
735
736fn normalize_callback(value: Value, name: &str) -> BuiltinResult<Value> {
737    match value {
738        Value::String(_)
739        | Value::CharArray(_)
740        | Value::FunctionHandle(_)
741        | Value::ExternalFunctionHandle(_)
742        | Value::MethodFunctionHandle(_)
743        | Value::BoundFunctionHandle { .. }
744        | Value::Closure(_)
745        | Value::Cell(_) => Ok(value),
746        other => Err(timer_error(
747            &TIMER_ERROR_INVALID_PROPERTY,
748            format!("timer: {name} must be text, a function handle, or a callback cell array, got {other:?}"),
749        )),
750    }
751}
752
753fn match_string_choice(value: &Value, name: &str, choices: &[&str]) -> BuiltinResult<Value> {
754    let text = value_to_string(value)?;
755    choices
756        .iter()
757        .find(|choice| choice.eq_ignore_ascii_case(text.trim()))
758        .map(|choice| Value::String((*choice).to_string()))
759        .ok_or_else(|| {
760            timer_error(
761                &TIMER_ERROR_INVALID_PROPERTY,
762                format!("timer: invalid {name} value '{text}'"),
763            )
764        })
765}
766
767fn value_to_string(value: &Value) -> BuiltinResult<String> {
768    match value {
769        Value::String(text) => Ok(text.clone()),
770        Value::CharArray(chars) if chars.rows == 1 => Ok(chars.data.iter().collect()),
771        other => Err(timer_error(
772            &TIMER_ERROR_INVALID_INPUT,
773            format!("timer: expected string scalar or character row, got {other:?}"),
774        )),
775    }
776}
777
778fn numeric_scalar(value: &Value, name: &str) -> BuiltinResult<f64> {
779    match value {
780        Value::Num(value) => Ok(*value),
781        Value::Int(value) => Ok(value.to_f64()),
782        Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(tensor.data[0]),
783        other => Err(timer_error(
784            &TIMER_ERROR_INVALID_INPUT,
785            format!("timer: {name} must be a numeric scalar, got {other:?}"),
786        )),
787    }
788}
789
790async fn start_timer_values(value: &Value, override_delay: Option<f64>) -> BuiltinResult<()> {
791    match value {
792        Value::HandleObject(handle) if is_timer_handle(handle) => {
793            start_one_timer(handle.clone(), override_delay).await
794        }
795        Value::Cell(cell) => {
796            for item in &cell.data {
797                Box::pin(start_timer_values(item, override_delay)).await?;
798            }
799            Ok(())
800        }
801        other => Err(timer_error(
802            &TIMER_ERROR_INVALID_HANDLE,
803            format!("timer: expected timer handle, got {other:?}"),
804        )),
805    }
806}
807
808async fn stop_timer_values(value: &Value, call_stop: bool) -> BuiltinResult<()> {
809    match value {
810        Value::HandleObject(handle) if is_timer_handle(handle) => {
811            stop_one_timer(handle, call_stop).await
812        }
813        Value::Cell(cell) => {
814            for item in &cell.data {
815                Box::pin(stop_timer_values(item, call_stop)).await?;
816            }
817            Ok(())
818        }
819        other => Err(timer_error(
820            &TIMER_ERROR_INVALID_HANDLE,
821            format!("timer: expected timer handle, got {other:?}"),
822        )),
823    }
824}
825
826async fn wait_timer_values(value: &Value) -> BuiltinResult<()> {
827    match value {
828        Value::HandleObject(handle) if is_timer_handle(handle) => {
829            if running_property(handle)? {
830                stop_one_timer(handle, true).await?;
831            }
832            Ok(())
833        }
834        Value::Cell(cell) => {
835            for item in &cell.data {
836                Box::pin(wait_timer_values(item)).await?;
837            }
838            Ok(())
839        }
840        other => Err(timer_error(
841            &TIMER_ERROR_INVALID_HANDLE,
842            format!("timer: expected timer handle, got {other:?}"),
843        )),
844    }
845}
846
847async fn delete_timer_values(value: &Value) -> BuiltinResult<()> {
848    match value {
849        Value::HandleObject(handle) if is_timer_handle(handle) => {
850            stop_one_timer(handle, false).await?;
851            crate::set_handle_valid(handle, false);
852            TIMER_REGISTRY.with(|registry| registry.borrow_mut().retain(|h| h != handle));
853            let _ = runmat_gc::gc_remove_root(handle.target);
854            Ok(())
855        }
856        Value::Cell(cell) => {
857            for item in &cell.data {
858                Box::pin(delete_timer_values(item)).await?;
859            }
860            Ok(())
861        }
862        other => Err(timer_error(
863            &TIMER_ERROR_INVALID_HANDLE,
864            format!("timer: expected timer handle, got {other:?}"),
865        )),
866    }
867}
868
869async fn start_one_timer(handle: HandleRef, override_delay: Option<f64>) -> BuiltinResult<()> {
870    ensure_valid_timer(&handle)?;
871    let timer_fcn = property(&handle, "TimerFcn")?;
872    if callback_is_empty(&timer_fcn) {
873        return Err(timer_error(
874            &TIMER_ERROR_INVALID_PROPERTY,
875            "timer: TimerFcn must be set before starting a timer",
876        ));
877    }
878    set_property(&handle, "Running", Value::String("on".to_string()))?;
879    set_property(&handle, "TasksExecuted", Value::Num(0.0))?;
880    let start_delay = override_delay.unwrap_or(numeric_property(&handle, "StartDelay")?);
881    sleep_seconds(start_delay);
882    if let Err(err) = run_callback(&handle, "StartFcn", "StartFcn").await {
883        let _ = stop_one_timer(&handle, false).await;
884        return Err(err);
885    }
886
887    let execution_mode = string_property(&handle, "ExecutionMode")?;
888    let tasks = if execution_mode.eq_ignore_ascii_case("singleShot") {
889        1usize
890    } else {
891        numeric_property(&handle, "TasksToExecute")?.max(1.0) as usize
892    };
893    let period = numeric_property(&handle, "Period")?;
894    let mut last_fire: Option<runmat_time::Instant> = None;
895    for idx in 0..tasks {
896        if idx > 0 {
897            sleep_seconds(period);
898        }
899        let now = runmat_time::Instant::now();
900        if let Some(last) = last_fire {
901            let instant_period = now.duration_since(last).as_secs_f64();
902            set_property(&handle, "InstantPeriod", Value::Num(instant_period))?;
903            update_average_period(&handle, instant_period, idx)?;
904        }
905        last_fire = Some(now);
906        match run_callback(&handle, "TimerFcn", "TimerFcn").await {
907            Ok(()) => {
908                let executed = numeric_property(&handle, "TasksExecuted")? + 1.0;
909                set_property(&handle, "TasksExecuted", Value::Num(executed))?;
910            }
911            Err(err) => {
912                let _ = run_callback(&handle, "ErrorFcn", "ErrorFcn").await;
913                let _ = stop_one_timer(&handle, true).await;
914                return Err(timer_error(&TIMER_ERROR_CALLBACK, err.message()));
915            }
916        }
917    }
918    stop_one_timer(&handle, true).await
919}
920
921async fn stop_one_timer(handle: &HandleRef, call_stop: bool) -> BuiltinResult<()> {
922    ensure_valid_timer(handle)?;
923    let was_running = running_property(handle)?;
924    set_property(handle, "Running", Value::String("off".to_string()))?;
925    if call_stop && was_running {
926        run_callback(handle, "StopFcn", "StopFcn").await?;
927    }
928    Ok(())
929}
930
931fn update_average_period(handle: &HandleRef, instant_period: f64, idx: usize) -> BuiltinResult<()> {
932    let current = numeric_property(handle, "AveragePeriod").unwrap_or(f64::NAN);
933    let average = if current.is_finite() {
934        (current * (idx as f64 - 1.0) + instant_period) / idx as f64
935    } else {
936        instant_period
937    };
938    set_property(handle, "AveragePeriod", Value::Num(average))
939}
940
941async fn run_callback(
942    handle: &HandleRef,
943    property_name: &str,
944    event_type: &str,
945) -> BuiltinResult<()> {
946    let callback = property(handle, property_name)?;
947    if callback_is_empty(&callback) {
948        return Ok(());
949    }
950    match callback {
951        Value::String(text) if !text.trim().is_empty() => {
952            return Err(timer_error(
953                &TIMER_ERROR_CALLBACK,
954                "timer: text callback execution requires dynamic-eval callback infrastructure; use a function handle callback",
955            ));
956        }
957        Value::CharArray(chars) if !chars.data.is_empty() => {
958            let text: String = chars.data.iter().collect();
959            if !text.trim().is_empty() {
960                return Err(timer_error(
961                    &TIMER_ERROR_CALLBACK,
962                    "timer: text callback execution requires dynamic-eval callback infrastructure; use a function handle callback",
963                ));
964            }
965        }
966        Value::Cell(cell) => {
967            let Some(function) = cell.data.first().cloned() else {
968                return Ok(());
969            };
970            let mut args = callback_base_args(handle, event_type);
971            args.extend(cell.data.iter().skip(1).cloned());
972            crate::call_feval_async_with_outputs(function, &args, 0).await?;
973        }
974        function @ (Value::FunctionHandle(_)
975        | Value::ExternalFunctionHandle(_)
976        | Value::MethodFunctionHandle(_)
977        | Value::BoundFunctionHandle { .. }
978        | Value::Closure(_)) => {
979            let args = callback_base_args(handle, event_type);
980            crate::call_feval_async_with_outputs(function, &args, 0).await?;
981        }
982        _ => {}
983    }
984    Ok(())
985}
986
987fn callback_base_args(handle: &HandleRef, event_type: &str) -> Vec<Value> {
988    vec![Value::HandleObject(handle.clone()), timer_event(event_type)]
989}
990
991fn timer_event(event_type: &str) -> Value {
992    let mut data = StructValue::new();
993    data.insert(
994        "time",
995        Value::Num(runmat_time::duration_since_epoch().as_secs_f64()),
996    );
997    let mut event = StructValue::new();
998    event.insert("Type", Value::String(event_type.to_string()));
999    event.insert("Data", Value::Struct(data));
1000    Value::Struct(event)
1001}
1002
1003fn callback_is_empty(value: &Value) -> bool {
1004    match value {
1005        Value::String(text) => text.trim().is_empty(),
1006        Value::CharArray(chars) => chars.data.iter().collect::<String>().trim().is_empty(),
1007        Value::Cell(cell) => cell.data.is_empty(),
1008        _ => false,
1009    }
1010}
1011
1012fn sleep_seconds(seconds: f64) {
1013    if seconds <= 0.0 || !seconds.is_finite() {
1014        return;
1015    }
1016    #[cfg(not(target_arch = "wasm32"))]
1017    {
1018        std::thread::sleep(Duration::from_secs_f64(seconds));
1019    }
1020    #[cfg(target_arch = "wasm32")]
1021    {
1022        let _ = seconds;
1023    }
1024}
1025
1026fn find_timers(args: Vec<Value>, include_invisible: bool) -> BuiltinResult<Value> {
1027    ensure_timer_class_registered();
1028    let filters = parse_filters(&args)?;
1029    let mut matches = Vec::new();
1030    TIMER_REGISTRY.with(|registry| {
1031        let mut registry = registry.borrow_mut();
1032        registry.retain(crate::is_handle_valid);
1033        for handle in registry.iter() {
1034            if !include_invisible
1035                && !string_property(handle, "ObjectVisibility")
1036                    .map(|value| value.eq_ignore_ascii_case("on"))
1037                    .unwrap_or(false)
1038            {
1039                continue;
1040            }
1041            if filters_match(handle, &filters).unwrap_or(false) {
1042                matches.push(Value::HandleObject(handle.clone()));
1043            }
1044        }
1045    });
1046    handles_to_result(matches)
1047}
1048
1049fn parse_filters(args: &[Value]) -> BuiltinResult<Vec<(String, Value)>> {
1050    if !args.len().is_multiple_of(2) {
1051        return Err(timer_error(
1052            &TIMER_ERROR_INVALID_INPUT,
1053            "timerfind: name-value arguments must appear in pairs",
1054        ));
1055    }
1056    let mut out = Vec::new();
1057    let mut idx = 0usize;
1058    while idx < args.len() {
1059        let name = canonical_property_name(&value_to_string(&args[idx])?)?;
1060        out.push((name, normalize_find_value(args[idx + 1].clone())));
1061        idx += 2;
1062    }
1063    Ok(out)
1064}
1065
1066fn normalize_find_value(value: Value) -> Value {
1067    match value {
1068        Value::CharArray(chars) if chars.rows == 1 => Value::String(chars.data.iter().collect()),
1069        other => other,
1070    }
1071}
1072
1073fn filters_match(handle: &HandleRef, filters: &[(String, Value)]) -> BuiltinResult<bool> {
1074    for (name, expected) in filters {
1075        let actual = normalize_find_value(property(handle, name)?);
1076        if !timer_values_equal(&actual, expected) {
1077            return Ok(false);
1078        }
1079    }
1080    Ok(true)
1081}
1082
1083fn timer_values_equal(lhs: &Value, rhs: &Value) -> bool {
1084    match (lhs, rhs) {
1085        (Value::String(a), Value::String(b)) => a == b,
1086        (Value::Num(a), Value::Num(b)) if a.is_nan() && b.is_nan() => true,
1087        (Value::Num(a), Value::Num(b)) => a == b,
1088        (Value::Bool(a), Value::Bool(b)) => a == b,
1089        _ => lhs == rhs,
1090    }
1091}
1092
1093fn handles_to_result(handles: Vec<Value>) -> BuiltinResult<Value> {
1094    if handles.len() == 1 {
1095        Ok(handles.into_iter().next().unwrap())
1096    } else {
1097        let len = handles.len();
1098        CellArray::new(handles, 1, len)
1099            .map(Value::Cell)
1100            .map_err(|err| timer_error(&TIMER_ERROR_INVALID_INPUT, err))
1101    }
1102}
1103
1104fn property(handle: &HandleRef, name: &str) -> BuiltinResult<Value> {
1105    ensure_valid_timer(handle)?;
1106    runmat_gc::gc_with_value(&handle.target, |target| {
1107        let Value::Object(object) = target else {
1108            return None;
1109        };
1110        object.properties.get(name).cloned()
1111    })
1112    .map_err(|err| timer_error(&TIMER_ERROR_GC, format!("timer: {err}")))?
1113    .ok_or_else(|| {
1114        timer_error(
1115            &TIMER_ERROR_INVALID_PROPERTY,
1116            format!("timer: missing property '{name}'"),
1117        )
1118    })
1119}
1120
1121fn set_property(handle: &HandleRef, name: &str, value: Value) -> BuiltinResult<()> {
1122    ensure_valid_timer(handle)?;
1123    let updated = runmat_gc::gc_with_value_mut(&handle.target, |target| {
1124        let Value::Object(object) = target else {
1125            return false;
1126        };
1127        object.properties.insert(name.to_string(), value);
1128        true
1129    })
1130    .map_err(|err| timer_error(&TIMER_ERROR_GC, format!("timer: {err}")))?;
1131    if updated {
1132        Ok(())
1133    } else {
1134        Err(timer_error(
1135            &TIMER_ERROR_INVALID_HANDLE,
1136            "timer: handle target is not an object",
1137        ))
1138    }
1139}
1140
1141fn string_property(handle: &HandleRef, name: &str) -> BuiltinResult<String> {
1142    match property(handle, name)? {
1143        Value::String(text) => Ok(text),
1144        Value::CharArray(chars) if chars.rows == 1 => Ok(chars.data.iter().collect()),
1145        other => Err(timer_error(
1146            &TIMER_ERROR_INVALID_PROPERTY,
1147            format!("timer: property '{name}' must be text, got {other:?}"),
1148        )),
1149    }
1150}
1151
1152fn numeric_property(handle: &HandleRef, name: &str) -> BuiltinResult<f64> {
1153    numeric_scalar(&property(handle, name)?, name)
1154}
1155
1156fn running_property(handle: &HandleRef) -> BuiltinResult<bool> {
1157    Ok(string_property(handle, "Running")?.eq_ignore_ascii_case("on"))
1158}
1159
1160fn running_is_on(object: &ObjectInstance) -> bool {
1161    matches!(
1162        object.properties.get("Running"),
1163        Some(Value::String(text)) if text.eq_ignore_ascii_case("on")
1164    )
1165}
1166
1167fn is_timer_handle(handle: &HandleRef) -> bool {
1168    handle.class_name == TIMER_CLASS
1169}
1170
1171fn ensure_valid_timer(handle: &HandleRef) -> BuiltinResult<()> {
1172    if is_timer_handle(handle) && crate::is_handle_valid(handle) {
1173        Ok(())
1174    } else {
1175        Err(timer_error(
1176            &TIMER_ERROR_INVALID_HANDLE,
1177            "timer: invalid or deleted timer handle",
1178        ))
1179    }
1180}
1181
1182fn timer_error(error: &'static BuiltinErrorDescriptor, detail: impl AsRef<str>) -> RuntimeError {
1183    let detail = detail.as_ref();
1184    let message = if detail.is_empty() {
1185        error.message.to_string()
1186    } else {
1187        detail.to_string()
1188    };
1189    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_TIMER);
1190    if let Some(identifier) = error.identifier {
1191        builder = builder.with_identifier(identifier);
1192    }
1193    builder.build()
1194}
1195
1196#[cfg(test)]
1197pub(crate) fn reset_timer_state_for_tests() {
1198    TIMER_REGISTRY.with(|registry| {
1199        for handle in registry.borrow().iter() {
1200            let _ = runmat_gc::gc_remove_root(handle.target);
1201        }
1202        registry.borrow_mut().clear();
1203    });
1204    TIMER_COUNTER.with(|counter| counter.set(0));
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209    use super::*;
1210    use futures::executor::block_on;
1211    use runmat_builtins::CharArray;
1212    use std::sync::{Arc, Mutex};
1213
1214    #[test]
1215    fn timer_constructor_sets_defaults_and_name_values() {
1216        let _lock = TIMER_TEST_LOCK.lock().unwrap();
1217        reset_timer_state_for_tests();
1218        let value = block_on(timer_builtin(vec![
1219            Value::String("Name".into()),
1220            Value::String("solver".into()),
1221            Value::String("Tag".into()),
1222            Value::String("client".into()),
1223            Value::String("StartDelay".into()),
1224            Value::Num(0.25),
1225        ]))
1226        .expect("timer");
1227        let Value::HandleObject(handle) = value else {
1228            panic!("expected timer handle");
1229        };
1230        assert_eq!(string_property(&handle, "Name").unwrap(), "solver");
1231        assert_eq!(string_property(&handle, "Tag").unwrap(), "client");
1232        assert_eq!(numeric_property(&handle, "StartDelay").unwrap(), 0.25);
1233        assert_eq!(string_property(&handle, "Running").unwrap(), "off");
1234        assert!(crate::is_handle_valid(&handle));
1235    }
1236
1237    #[test]
1238    fn timerfind_filters_visible_timers() {
1239        let _lock = TIMER_TEST_LOCK.lock().unwrap();
1240        reset_timer_state_for_tests();
1241        let _hidden = block_on(timer_builtin(vec![
1242            Value::String("Name".into()),
1243            Value::String("hidden".into()),
1244            Value::String("ObjectVisibility".into()),
1245            Value::String("off".into()),
1246        ]))
1247        .expect("hidden timer");
1248        let visible = block_on(timer_builtin(vec![
1249            Value::String("Name".into()),
1250            Value::String("visible".into()),
1251            Value::String("Tag".into()),
1252            Value::String("batch".into()),
1253        ]))
1254        .expect("visible timer");
1255        let found = timerfind_builtin(vec![
1256            Value::String("Tag".into()),
1257            Value::String("batch".into()),
1258        ])
1259        .expect("timerfind");
1260        assert_eq!(found, visible);
1261        let all = timerfindall_builtin(vec![]).expect("timerfindall");
1262        let Value::Cell(cell) = all else {
1263            panic!("expected cell row of all timers");
1264        };
1265        assert_eq!((cell.rows, cell.cols), (1, 2));
1266    }
1267
1268    #[test]
1269    fn timer_start_runs_function_callback_and_updates_state() {
1270        let _lock = TIMER_TEST_LOCK.lock().unwrap();
1271        reset_timer_state_for_tests();
1272        let calls = Arc::new(Mutex::new(0usize));
1273        let invoker_calls = calls.clone();
1274        let _guard = crate::user_functions::install_semantic_function_invoker(Some(Arc::new(
1275            move |_function, args, requested_outputs| {
1276                assert_eq!(requested_outputs, 0);
1277                let args = args.to_vec();
1278                let invoker_calls = Arc::clone(&invoker_calls);
1279                Box::pin(async move {
1280                    assert_eq!(args.len(), 2);
1281                    let Value::HandleObject(handle) = &args[0] else {
1282                        panic!("expected timer handle");
1283                    };
1284                    assert!(is_timer_handle(handle));
1285                    let Value::Struct(event) = &args[1] else {
1286                        panic!("expected event struct");
1287                    };
1288                    assert!(event.fields.contains_key("Type"));
1289                    *invoker_calls.lock().unwrap() += 1;
1290                    Ok(Value::OutputList(Vec::new()))
1291                })
1292            },
1293        )));
1294        let timer = block_on(timer_builtin(vec![
1295            Value::String("TimerFcn".into()),
1296            Value::BoundFunctionHandle {
1297                name: "tick".into(),
1298                function: 1,
1299            },
1300            Value::String("ExecutionMode".into()),
1301            Value::String("fixedSpacing".into()),
1302            Value::String("TasksToExecute".into()),
1303            Value::Num(2.0),
1304            Value::String("Period".into()),
1305            Value::Num(0.002),
1306        ]))
1307        .expect("timer");
1308        block_on(timer_start_builtin(timer.clone())).expect("start");
1309        let Value::HandleObject(handle) = timer else {
1310            panic!("expected timer handle");
1311        };
1312        assert_eq!(*calls.lock().unwrap(), 2);
1313        assert_eq!(numeric_property(&handle, "TasksExecuted").unwrap(), 2.0);
1314        assert_eq!(string_property(&handle, "Running").unwrap(), "off");
1315    }
1316
1317    #[test]
1318    fn timer_delete_invalidates_and_removes_from_find_results() {
1319        let _lock = TIMER_TEST_LOCK.lock().unwrap();
1320        reset_timer_state_for_tests();
1321        let timer = block_on(timer_builtin(vec![
1322            Value::String("TimerFcn".into()),
1323            Value::String("disp(1)".into()),
1324        ]))
1325        .expect("timer");
1326        let Value::HandleObject(handle) = timer.clone() else {
1327            panic!("expected timer handle");
1328        };
1329        block_on(timer_delete_builtin(timer)).expect("delete");
1330        assert!(!crate::is_handle_valid(&handle));
1331        let found = timerfindall_builtin(vec![]).expect("timerfindall");
1332        let Value::Cell(cell) = found else {
1333            panic!("expected empty cell");
1334        };
1335        assert_eq!((cell.rows, cell.cols), (1, 0));
1336    }
1337
1338    #[test]
1339    fn timer_rejects_invalid_property_values() {
1340        let _lock = TIMER_TEST_LOCK.lock().unwrap();
1341        reset_timer_state_for_tests();
1342        let err = block_on(timer_builtin(vec![
1343            Value::CharArray(CharArray::new_row("Period")),
1344            Value::Num(0.0),
1345        ]))
1346        .unwrap_err();
1347        assert_eq!(
1348            err.identifier().map(str::to_string),
1349            Some("RunMat:timer:InvalidProperty".to_string())
1350        );
1351    }
1352
1353    #[test]
1354    fn timer_dependent_setters_validate_post_construction_values() {
1355        let _lock = TIMER_TEST_LOCK.lock().unwrap();
1356        reset_timer_state_for_tests();
1357        let timer = block_on(timer_builtin(vec![])).expect("timer");
1358        let Value::HandleObject(handle) = timer else {
1359            panic!("expected timer handle");
1360        };
1361        let object = runmat_gc::gc_clone_value(&handle.target).expect("timer object");
1362        let err = block_on(set_timer_property_object(
1363            object.clone(),
1364            "Period",
1365            Value::Num(0.0),
1366        ))
1367        .unwrap_err();
1368        assert_eq!(
1369            err.identifier().map(str::to_string),
1370            Some("RunMat:timer:InvalidProperty".to_string())
1371        );
1372
1373        let updated = block_on(set_timer_property_object(
1374            object,
1375            "Tag",
1376            Value::CharArray(CharArray::new_row("batch")),
1377        ))
1378        .expect("valid tag update");
1379        let Value::Object(updated) = updated else {
1380            panic!("expected updated timer object");
1381        };
1382        assert_eq!(
1383            updated.properties.get("Tag"),
1384            Some(&Value::String("batch".to_string()))
1385        );
1386    }
1387}