1
use chrono::Utc;
2
use finance::split::Split;
3
use finance::transaction::Transaction;
4
use scripting::executor::ScriptExecutor;
5
use scripting::format::{
6
    ContextType, ENTITY_HEADER_SIZE, EntityType, GLOBAL_HEADER_SIZE, Operation,
7
};
8
use scripting::parser::EntityData;
9
use scripting::serializer::{AccountArgs, EntityHeaderArgs, MemorySerializer, TransactionFromArgs};
10
use uuid::Uuid;
11

            
12
const TEST_WASM: &[u8] = include_bytes!("../../web/static/wasm/groceries_markup.wasm");
13
const TAG_SYNC_WASM: &[u8] = include_bytes!("../../web/static/wasm/tag_sync.wasm");
14

            
15
#[test]
16
1
fn test_groceries_script_tags_splits() {
17
1
    let executor = ScriptExecutor::try_new().expect("baseline engine");
18

            
19
1
    let tx = Transaction {
20
1
        id: Uuid::new_v4(),
21
1
        post_date: Utc::now(),
22
1
        enter_date: Utc::now(),
23
1
    };
24

            
25
1
    let account1_id = Uuid::new_v4();
26
1
    let account2_id = Uuid::new_v4();
27
1
    let commodity_id = Uuid::new_v4();
28

            
29
1
    let split1 = Split {
30
1
        id: Uuid::new_v4(),
31
1
        tx_id: tx.id,
32
1
        account_id: account1_id,
33
1
        commodity_id,
34
1
        value_num: -5000,
35
1
        value_denom: 100,
36
1
        reconcile_state: None,
37
1
        reconcile_date: None,
38
1
        lot_id: None,
39
1
    };
40

            
41
1
    let split2 = Split {
42
1
        id: Uuid::new_v4(),
43
1
        tx_id: tx.id,
44
1
        account_id: account2_id,
45
1
        commodity_id,
46
1
        value_num: 5000,
47
1
        value_denom: 100,
48
1
        reconcile_state: None,
49
1
        reconcile_date: None,
50
1
        lot_id: None,
51
1
    };
52

            
53
1
    let mut serializer = MemorySerializer::new();
54
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
55

            
56
    // Transaction with 2 splits and 1 tag
57
1
    let tx_idx = serializer.add_transaction_from(TransactionFromArgs {
58
1
        transaction: &tx,
59
1
        is_primary: true,
60
1
        split_count: 2,
61
1
        tag_count: 1,
62
1
        is_multi_currency: false,
63
1
    });
64
1
    serializer.set_primary(tx_idx);
65

            
66
1
    let split1_idx = serializer.add_split_from(&split1, tx_idx as i32, "Assets:Checking");
67
1
    let split2_idx = serializer.add_split_from(&split2, tx_idx as i32, "Expenses:Food");
68

            
69
    // Add "note" = "groceries" tag to transaction
70
1
    serializer.add_tag(
71
1
        Uuid::new_v4().into_bytes(),
72
1
        tx_idx as i32,
73
        false,
74
        false,
75
1
        "note",
76
1
        "groceries",
77
    );
78

            
79
1
    let input = serializer.finalize(4096);
80

            
81
1
    println!("Input size: {} bytes", input.len());
82

            
83
1
    let entities = executor
84
1
        .execute(TEST_WASM, &input, Some(4096))
85
1
        .expect("Execution failed");
86

            
87
1
    println!("Output entities: {entities:?}");
88
1
    assert_eq!(
89
1
        entities.len(),
90
        2,
91
        "Expected 2 output entities (category tags for each split)"
92
    );
93

            
94
2
    for (i, entity) in entities.iter().enumerate() {
95
2
        assert_eq!(entity.entity_type, EntityType::Tag);
96
2
        assert_eq!(entity.operation, Operation::Create);
97

            
98
2
        if let EntityData::Tag { name, value } = &entity.data {
99
2
            assert_eq!(name, "category", "Tag {i} name mismatch");
100
2
            assert_eq!(value, "groceries", "Tag {i} value mismatch");
101
        } else {
102
            panic!("Expected Tag entity data, got {:?}", entity.data);
103
        }
104
    }
105

            
106
1
    let parent_indices: Vec<i32> = entities.iter().map(|e| e.parent_idx).collect();
107
1
    assert!(
108
1
        parent_indices.contains(&(split1_idx as i32)),
109
        "Missing tag for split1"
110
    );
111
1
    assert!(
112
1
        parent_indices.contains(&(split2_idx as i32)),
113
        "Missing tag for split2"
114
    );
115
1
}
116

            
117
#[test]
118
1
fn test_groceries_script_skips_non_groceries() {
119
1
    let executor = ScriptExecutor::try_new().expect("baseline engine");
120

            
121
1
    let tx = Transaction {
122
1
        id: Uuid::new_v4(),
123
1
        post_date: Utc::now(),
124
1
        enter_date: Utc::now(),
125
1
    };
126

            
127
1
    let mut serializer = MemorySerializer::new();
128
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
129

            
130
    // Transaction with tag "note" = "other" (not groceries)
131
1
    let tx_idx = serializer.add_transaction_from(TransactionFromArgs {
132
1
        transaction: &tx,
133
1
        is_primary: true,
134
1
        split_count: 0,
135
1
        tag_count: 1,
136
1
        is_multi_currency: false,
137
1
    });
138
1
    serializer.set_primary(tx_idx);
139
1
    serializer.add_tag(
140
1
        Uuid::new_v4().into_bytes(),
141
1
        tx_idx as i32,
142
        false,
143
        false,
144
1
        "note",
145
1
        "other",
146
    );
147

            
148
1
    let input = serializer.finalize(4096);
149

            
150
1
    let entities = executor
151
1
        .execute(TEST_WASM, &input, Some(4096))
152
1
        .expect("Execution failed");
153
1
    assert!(
154
1
        entities.is_empty(),
155
        "Expected 0 output entities for non-groceries transaction"
156
    );
157
1
}
158

            
159
#[test]
160
1
fn test_groceries_script_skips_non_transaction() {
161
1
    let executor = ScriptExecutor::try_new().expect("baseline engine");
162

            
163
1
    let mut serializer = MemorySerializer::new();
164
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Account);
165

            
166
1
    let account_id = [1u8; 16];
167
1
    let parent_account_id = [0u8; 16];
168
1
    let account_idx = serializer.add_account(
169
1
        EntityHeaderArgs {
170
1
            id: account_id,
171
1
            parent_idx: -1,
172
1
            is_primary: true,
173
1
            is_context: false,
174
1
        },
175
1
        AccountArgs {
176
1
            parent_account_id,
177
1
            name: "Test Account",
178
1
            path: "Assets:Test Account",
179
1
            tag_count: 0,
180
1
        },
181
    );
182
1
    serializer.set_primary(account_idx);
183

            
184
1
    let input = serializer.finalize(4096);
185

            
186
1
    let entities = executor
187
1
        .execute(TEST_WASM, &input, Some(4096))
188
1
        .expect("Execution failed");
189
1
    assert!(
190
1
        entities.is_empty(),
191
        "Expected 0 output entities for Account"
192
    );
193
1
}
194

            
195
#[test]
196
1
fn test_groceries_script_skips_no_note_tag() {
197
1
    let executor = ScriptExecutor::try_new().expect("baseline engine");
198

            
199
1
    let tx = Transaction {
200
1
        id: Uuid::new_v4(),
201
1
        post_date: Utc::now(),
202
1
        enter_date: Utc::now(),
203
1
    };
204

            
205
1
    let mut serializer = MemorySerializer::new();
206
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
207

            
208
    // Transaction without any tags
209
1
    let tx_idx = serializer.add_transaction_from(TransactionFromArgs {
210
1
        transaction: &tx,
211
1
        is_primary: true,
212
1
        split_count: 0,
213
1
        tag_count: 0,
214
1
        is_multi_currency: false,
215
1
    });
216
1
    serializer.set_primary(tx_idx);
217

            
218
1
    let input = serializer.finalize(4096);
219

            
220
1
    let entities = executor
221
1
        .execute(TEST_WASM, &input, Some(4096))
222
1
        .expect("Execution failed");
223
1
    assert!(
224
1
        entities.is_empty(),
225
        "Expected 0 output entities for transaction without note tag"
226
    );
227
1
}
228

            
229
#[test]
230
1
fn test_tag_sync_copies_user_tags_to_splits() {
231
1
    let executor = ScriptExecutor::try_new().expect("baseline engine");
232

            
233
1
    let tx = Transaction {
234
1
        id: Uuid::new_v4(),
235
1
        post_date: Utc::now(),
236
1
        enter_date: Utc::now(),
237
1
    };
238

            
239
1
    let commodity_id = Uuid::new_v4();
240

            
241
1
    let split1 = Split {
242
1
        id: Uuid::new_v4(),
243
1
        tx_id: tx.id,
244
1
        account_id: Uuid::new_v4(),
245
1
        commodity_id,
246
1
        value_num: -5000,
247
1
        value_denom: 100,
248
1
        reconcile_state: None,
249
1
        reconcile_date: None,
250
1
        lot_id: None,
251
1
    };
252

            
253
1
    let split2 = Split {
254
1
        id: Uuid::new_v4(),
255
1
        tx_id: tx.id,
256
1
        account_id: Uuid::new_v4(),
257
1
        commodity_id,
258
1
        value_num: 5000,
259
1
        value_denom: 100,
260
1
        reconcile_state: None,
261
1
        reconcile_date: None,
262
1
        lot_id: None,
263
1
    };
264

            
265
1
    let mut serializer = MemorySerializer::new();
266
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
267

            
268
1
    let tx_idx = serializer.add_transaction_from(TransactionFromArgs {
269
1
        transaction: &tx,
270
1
        is_primary: true,
271
1
        split_count: 2,
272
1
        tag_count: 2,
273
1
        is_multi_currency: false,
274
1
    });
275
1
    serializer.set_primary(tx_idx);
276

            
277
1
    let split1_idx = serializer.add_split_from(&split1, tx_idx as i32, "Assets:Checking");
278
1
    let split2_idx = serializer.add_split_from(&split2, tx_idx as i32, "Expenses:Food");
279

            
280
    // "note" is a system tag — excluded from user tag count
281
1
    serializer.add_tag(
282
1
        Uuid::new_v4().into_bytes(),
283
1
        tx_idx as i32,
284
        false,
285
        false,
286
1
        "note",
287
1
        "groceries",
288
    );
289
    // "category" is a user tag
290
1
    serializer.add_tag(
291
1
        Uuid::new_v4().into_bytes(),
292
1
        tx_idx as i32,
293
        false,
294
        false,
295
1
        "category",
296
1
        "food",
297
    );
298

            
299
1
    let input = serializer.finalize(4096);
300

            
301
1
    let entities = executor
302
1
        .execute(TAG_SYNC_WASM, &input, Some(4096))
303
1
        .expect("tag_sync execution failed");
304

            
305
1
    assert_eq!(
306
1
        entities.len(),
307
        2,
308
        "Expected 2 output entities (category tag copied to each split)"
309
    );
310

            
311
2
    for entity in &entities {
312
2
        assert_eq!(entity.entity_type, EntityType::Tag);
313
2
        assert_eq!(entity.operation, Operation::Create);
314

            
315
2
        if let EntityData::Tag { name, value } = &entity.data {
316
2
            assert_eq!(name, "category");
317
2
            assert_eq!(value, "food");
318
        } else {
319
            panic!("Expected Tag entity data, got {:?}", entity.data);
320
        }
321
    }
322

            
323
1
    let parent_indices: Vec<i32> = entities.iter().map(|e| e.parent_idx).collect();
324
1
    assert!(
325
1
        parent_indices.contains(&(split1_idx as i32)),
326
        "Missing tag for split1"
327
    );
328
1
    assert!(
329
1
        parent_indices.contains(&(split2_idx as i32)),
330
        "Missing tag for split2"
331
    );
332
1
}
333

            
334
#[test]
335
1
fn test_tag_sync_copies_split_tags_to_transaction() {
336
1
    let executor = ScriptExecutor::try_new().expect("baseline engine");
337

            
338
1
    let tx = Transaction {
339
1
        id: Uuid::new_v4(),
340
1
        post_date: Utc::now(),
341
1
        enter_date: Utc::now(),
342
1
    };
343

            
344
1
    let commodity_id = Uuid::new_v4();
345

            
346
1
    let split1 = Split {
347
1
        id: Uuid::new_v4(),
348
1
        tx_id: tx.id,
349
1
        account_id: Uuid::new_v4(),
350
1
        commodity_id,
351
1
        value_num: -5000,
352
1
        value_denom: 100,
353
1
        reconcile_state: None,
354
1
        reconcile_date: None,
355
1
        lot_id: None,
356
1
    };
357

            
358
1
    let split2 = Split {
359
1
        id: Uuid::new_v4(),
360
1
        tx_id: tx.id,
361
1
        account_id: Uuid::new_v4(),
362
1
        commodity_id,
363
1
        value_num: 5000,
364
1
        value_denom: 100,
365
1
        reconcile_state: None,
366
1
        reconcile_date: None,
367
1
        lot_id: None,
368
1
    };
369

            
370
1
    let mut serializer = MemorySerializer::new();
371
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
372

            
373
    // Transaction with only a "note" tag (no user tags)
374
1
    let tx_idx = serializer.add_transaction_from(TransactionFromArgs {
375
1
        transaction: &tx,
376
1
        is_primary: true,
377
1
        split_count: 2,
378
1
        tag_count: 1,
379
1
        is_multi_currency: false,
380
1
    });
381
1
    serializer.set_primary(tx_idx);
382

            
383
1
    let split1_idx = serializer.add_split_from(&split1, tx_idx as i32, "Assets:Checking");
384
1
    serializer.add_split_from(&split2, tx_idx as i32, "Expenses:Food");
385

            
386
1
    serializer.add_tag(
387
1
        Uuid::new_v4().into_bytes(),
388
1
        tx_idx as i32,
389
        false,
390
        false,
391
1
        "note",
392
1
        "groceries",
393
    );
394
    // Split1 has a user tag
395
1
    serializer.add_tag(
396
1
        Uuid::new_v4().into_bytes(),
397
1
        split1_idx as i32,
398
        false,
399
        false,
400
1
        "category",
401
1
        "food",
402
    );
403

            
404
1
    let input = serializer.finalize(4096);
405

            
406
1
    let entities = executor
407
1
        .execute(TAG_SYNC_WASM, &input, Some(4096))
408
1
        .expect("tag_sync execution failed");
409

            
410
1
    assert_eq!(
411
1
        entities.len(),
412
        1,
413
        "Expected 1 output entity (category tag copied to transaction)"
414
    );
415

            
416
1
    let entity = &entities[0];
417
1
    assert_eq!(entity.entity_type, EntityType::Tag);
418
1
    assert_eq!(entity.operation, Operation::Create);
419
1
    assert_eq!(entity.parent_idx, tx_idx as i32);
420

            
421
1
    if let EntityData::Tag { name, value } = &entity.data {
422
1
        assert_eq!(name, "category");
423
1
        assert_eq!(value, "food");
424
    } else {
425
        panic!("Expected Tag entity data, got {:?}", entity.data);
426
    }
427
1
}
428

            
429
#[test]
430
1
fn test_tag_sync_noop_when_note_only_no_split_tags() {
431
1
    let executor = ScriptExecutor::try_new().expect("baseline engine");
432

            
433
1
    let tx = Transaction {
434
1
        id: Uuid::new_v4(),
435
1
        post_date: Utc::now(),
436
1
        enter_date: Utc::now(),
437
1
    };
438

            
439
1
    let commodity_id = Uuid::new_v4();
440

            
441
1
    let split1 = Split {
442
1
        id: Uuid::new_v4(),
443
1
        tx_id: tx.id,
444
1
        account_id: Uuid::new_v4(),
445
1
        commodity_id,
446
1
        value_num: -5000,
447
1
        value_denom: 100,
448
1
        reconcile_state: None,
449
1
        reconcile_date: None,
450
1
        lot_id: None,
451
1
    };
452

            
453
1
    let split2 = Split {
454
1
        id: Uuid::new_v4(),
455
1
        tx_id: tx.id,
456
1
        account_id: Uuid::new_v4(),
457
1
        commodity_id,
458
1
        value_num: 5000,
459
1
        value_denom: 100,
460
1
        reconcile_state: None,
461
1
        reconcile_date: None,
462
1
        lot_id: None,
463
1
    };
464

            
465
1
    let mut serializer = MemorySerializer::new();
466
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
467

            
468
1
    let tx_idx = serializer.add_transaction_from(TransactionFromArgs {
469
1
        transaction: &tx,
470
1
        is_primary: true,
471
1
        split_count: 2,
472
1
        tag_count: 1,
473
1
        is_multi_currency: false,
474
1
    });
475
1
    serializer.set_primary(tx_idx);
476

            
477
1
    serializer.add_split_from(&split1, tx_idx as i32, "Assets:Checking");
478
1
    serializer.add_split_from(&split2, tx_idx as i32, "Expenses:Food");
479

            
480
    // Only "note" on tx, no tags on splits — nothing to sync
481
1
    serializer.add_tag(
482
1
        Uuid::new_v4().into_bytes(),
483
1
        tx_idx as i32,
484
        false,
485
        false,
486
1
        "note",
487
1
        "groceries",
488
    );
489

            
490
1
    let input = serializer.finalize(4096);
491

            
492
1
    let entities = executor
493
1
        .execute(TAG_SYNC_WASM, &input, Some(4096))
494
1
        .expect("tag_sync execution failed");
495

            
496
1
    assert!(
497
1
        entities.is_empty(),
498
        "Expected 0 output entities when only note tag on tx and no split tags"
499
    );
500
1
}
501

            
502
5
fn build_valid_transaction_input() -> Vec<u8> {
503
5
    let tx = Transaction {
504
5
        id: Uuid::new_v4(),
505
5
        post_date: Utc::now(),
506
5
        enter_date: Utc::now(),
507
5
    };
508

            
509
5
    let commodity_id = Uuid::new_v4();
510

            
511
5
    let split1 = Split {
512
5
        id: Uuid::new_v4(),
513
5
        tx_id: tx.id,
514
5
        account_id: Uuid::new_v4(),
515
5
        commodity_id,
516
5
        value_num: -5000,
517
5
        value_denom: 100,
518
5
        reconcile_state: None,
519
5
        reconcile_date: None,
520
5
        lot_id: None,
521
5
    };
522

            
523
5
    let split2 = Split {
524
5
        id: Uuid::new_v4(),
525
5
        tx_id: tx.id,
526
5
        account_id: Uuid::new_v4(),
527
5
        commodity_id,
528
5
        value_num: 5000,
529
5
        value_denom: 100,
530
5
        reconcile_state: None,
531
5
        reconcile_date: None,
532
5
        lot_id: None,
533
5
    };
534

            
535
5
    let mut serializer = MemorySerializer::new();
536
5
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
537

            
538
5
    let tx_idx = serializer.add_transaction_from(TransactionFromArgs {
539
5
        transaction: &tx,
540
5
        is_primary: true,
541
5
        split_count: 2,
542
5
        tag_count: 1,
543
5
        is_multi_currency: false,
544
5
    });
545
5
    serializer.set_primary(tx_idx);
546
5
    serializer.add_split_from(&split1, tx_idx as i32, "Assets:Checking");
547
5
    serializer.add_split_from(&split2, tx_idx as i32, "Expenses:Food");
548
5
    serializer.add_tag(
549
5
        Uuid::new_v4().into_bytes(),
550
5
        tx_idx as i32,
551
        false,
552
        false,
553
5
        "note",
554
5
        "groceries",
555
    );
556

            
557
5
    serializer.finalize(4096)
558
5
}
559

            
560
#[test]
561
1
fn test_script_handles_truncated_input_without_panic() {
562
1
    let executor = ScriptExecutor::try_new().expect("baseline engine");
563
1
    let input = build_valid_transaction_input();
564

            
565
1
    let truncated = &input[..GLOBAL_HEADER_SIZE + ENTITY_HEADER_SIZE];
566
1
    let result = executor.execute(TEST_WASM, truncated, Some(4096));
567
1
    assert!(
568
1
        result.is_ok() || result.is_err(),
569
        "Must return a result, not panic"
570
    );
571
1
}
572

            
573
#[test]
574
1
fn test_script_handles_corrupted_entity_data_offset_without_panic() {
575
1
    let executor = ScriptExecutor::try_new().expect("baseline engine");
576
1
    let mut input = build_valid_transaction_input();
577

            
578
1
    let entity_start = GLOBAL_HEADER_SIZE;
579
1
    let data_offset_field = entity_start + 24;
580
1
    if data_offset_field + 4 <= input.len() {
581
1
        input[data_offset_field..data_offset_field + 4].copy_from_slice(&u32::MAX.to_le_bytes());
582
1
    }
583

            
584
1
    let result = executor.execute(TEST_WASM, &input, Some(4096));
585
1
    assert!(
586
1
        result.is_ok() || result.is_err(),
587
        "Must return a result, not panic"
588
    );
589
1
}
590

            
591
#[test]
592
1
fn test_script_handles_corrupted_entity_data_size_without_panic() {
593
1
    let executor = ScriptExecutor::try_new().expect("baseline engine");
594
1
    let mut input = build_valid_transaction_input();
595

            
596
1
    let entity_start = GLOBAL_HEADER_SIZE;
597
1
    let data_size_field = entity_start + 28;
598
1
    if data_size_field + 4 <= input.len() {
599
1
        input[data_size_field..data_size_field + 4].copy_from_slice(&u32::MAX.to_le_bytes());
600
1
    }
601

            
602
1
    let result = executor.execute(TEST_WASM, &input, Some(4096));
603
1
    assert!(
604
1
        result.is_ok() || result.is_err(),
605
        "Must return a result, not panic"
606
    );
607
1
}
608

            
609
#[test]
610
1
fn test_tag_sync_script_handles_truncated_input_without_panic() {
611
1
    let executor = ScriptExecutor::try_new().expect("baseline engine");
612
1
    let input = build_valid_transaction_input();
613

            
614
1
    let truncated = &input[..GLOBAL_HEADER_SIZE + ENTITY_HEADER_SIZE];
615
1
    let result = executor.execute(TAG_SYNC_WASM, truncated, Some(4096));
616
1
    assert!(
617
1
        result.is_ok() || result.is_err(),
618
        "Must return a result, not panic"
619
    );
620
1
}
621

            
622
#[test]
623
1
fn test_tag_sync_script_handles_corrupted_entity_offset_without_panic() {
624
1
    let executor = ScriptExecutor::try_new().expect("baseline engine");
625
1
    let mut input = build_valid_transaction_input();
626

            
627
1
    let entity_start = GLOBAL_HEADER_SIZE;
628
1
    let data_offset_field = entity_start + 24;
629
1
    if data_offset_field + 4 <= input.len() {
630
1
        input[data_offset_field..data_offset_field + 4].copy_from_slice(&u32::MAX.to_le_bytes());
631
1
    }
632

            
633
1
    let result = executor.execute(TAG_SYNC_WASM, &input, Some(4096));
634
1
    assert!(
635
1
        result.is_ok() || result.is_err(),
636
        "Must return a result, not panic"
637
    );
638
1
}
639

            
640
/// The epoch ticker must not outlive the execution that started it.
641
///
642
/// The epoch counter is shared by every execution on the same engine, so a
643
/// ticker that keeps running after its call spends the budget of whatever runs
644
/// next — and executors are reused (the account-script batch path drives one
645
/// across many transactions). A `JoinHandle` does not stop a thread when
646
/// dropped, it detaches it, so the previous `drop(ticker)` left it ticking, and
647
/// every `?` between the spawn and the call skipped even that.
648
///
649
/// This asserts the fix's actual contract — no ticker thread survives the call —
650
/// by counting live threads, rather than trying to catch the downstream symptom.
651
/// A symptom test was tried first and rejected: a stray bump only does damage if
652
/// it lands inside a later execution's call window, and with the fast fixtures
653
/// available here it lands between runs instead, so the test passed with the bug
654
/// deliberately reintroduced. Thread count fails against that same reintroduction.
655
#[test]
656
1
fn epoch_ticker_does_not_outlive_the_execution() {
657
2
    fn live_threads() -> usize {
658
2
        std::fs::read_dir("/proc/self/task")
659
2
            .expect("Linux /proc/self/task — this test is Linux-only, as is CI")
660
2
            .count()
661
2
    }
662

            
663
    // A long budget: with the ticker detached, these threads linger for a full
664
    // minute each, so the count after the loop is unambiguous.
665
1
    let executor = ScriptExecutor::try_new()
666
1
        .expect("baseline engine")
667
1
        .with_epoch_deadline_ticks(60);
668
1
    let mut serializer = MemorySerializer::new();
669
1
    serializer.set_context(ContextType::EntityCreate, EntityType::Transaction);
670
1
    let input = serializer.finalize(4096);
671

            
672
    // One warm-up run first: wasmtime brings up a parallel-compilation pool
673
    // sized to the host's cores on first use, and counting that as a leak would
674
    // make this test a core-count assertion.
675
1
    executor
676
1
        .execute(TEST_WASM, &input, Some(4096))
677
1
        .expect("warm-up execution failed");
678

            
679
1
    let before = live_threads();
680
8
    for _ in 0..8 {
681
8
        executor
682
8
            .execute(TEST_WASM, &input, Some(4096))
683
8
            .expect("execution failed");
684
8
    }
685
1
    let after = live_threads();
686

            
687
1
    assert!(
688
1
        after <= before + 1,
689
        "ticker threads outlived their executions: {before} live threads before 8 runs, \
690
         {after} after — a detached ticker keeps bumping the shared engine epoch"
691
    );
692
1
}