plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


614ddf5 — Peter John 2026-11-03T18:02:43+05:30
Tail calls round 2: closure and error-union tail calls, single-pass TRMC, fast dev GC
Cargo.toml CHANGED
@@ -1,3 +1,12 @@
1
1
  [workspace]
2
2
  members = ["plum-core", "plum-cli", "plum-checker", "plum-wasm-codegen", "plum-lsp"]
3
3
  resolver = "2"
4
+
5
+ [profile.dev.package.wasmtime]
6
+ opt-level = 3
7
+
8
+ [profile.dev.package.cranelift-codegen]
9
+ opt-level = 3
10
+
11
+ [profile.dev.package.wasmtime-internal-cranelift]
12
+ opt-level = 3
docs/plans/2026-11-03-gc-report.md ADDED
@@ -0,0 +1,43 @@
1
+ # GC investigation: why a 1M-cell Link list takes ~35 s under `plum test`
2
+
3
+ Date 2026-11-03. Probes: /private/tmp/claude-501/gc/. Program: `enum Link[T] = Nil | Cons(head: T, tail: Link[T])`,
4
+ `fromTo(a, b)` (tail modulo cons) then `length`; also `build` (accumulator) then `sum`. Wall time of the whole
5
+ `plum test <file>` run (compile included, about 100 ms) unless noted.
6
+
7
+ ## Results
8
+
9
+ | Variant | fromTo+length 100k ms | fromTo+length 1M ms | build+sum 1M ms |
10
+ |---|---|---|---|
11
+ | dev build, wasmtime 47 default (Auto = copying) | 1329 | 36947 | 3480 |
12
+ | dev, collector DRC | - | 12382 | 4329 |
13
+ | dev, collector Copying (explicit) | - | 35973 | 3186 |
14
+ | dev, Null collector (no GC) | - | 198 | 170 |
15
+ | dev, gc_heap_initial_size 64 MiB | - | 31555 | - |
16
+ | dev, gc_heap_initial_size 256 MiB | - | 206 | - |
17
+ | dev, wasmtime crate at opt-level 3 (`--config profile.dev.package.wasmtime.opt-level=3`) | 1830 (cold) | 1238 | 280 |
18
+ | release build of plum-cli | 68 | 448 | 97 |
19
+ | V8 (node 26, same engine as Chrome), `plum compile` output, `main` | - | 128 | - |
20
+
21
+ Chrome itself was not driven: plum-game/web-test only runs `plum game` pages (playwright-rs, needs a
22
+ renderer and frames), so node (V8) running the compiled wasm directly was used. Its only import is `crash`.
23
+
24
+ ## Root cause
25
+
26
+ 1. The collector is the wasmtime 47 semi-space copying collector (Auto resolves to it). `sample` shows nearly all
27
+ time in `copying::` / `GcHeap::index` / `is_i31` helpers, with tiny frames such as `NonZero::get` and
28
+ `Option::is_some` not inlined: wasmtime is compiled at the dev profile (opt-level 0). The same collector is
29
+ about 80x faster optimised (37 s to 0.45 s release; 1.2 s with only the wasmtime crate at opt-level 3).
30
+ 2. Cost is superlinear (100k to 1M is 28x): each collection copies all live cells and the heap grows by doubling
31
+ from 0, so many full-heap copies happen while the list is live. Avoiding GC (Null, or a 256 MiB initial heap)
32
+ removes it; a 64 MiB initial heap is not enough.
33
+ 3. Codegen was not examined in depth (read-only scope); the cost is in the collector, and V8 handles the same
34
+ module in 128 ms, so the layout does not defeat a fast path.
35
+
36
+ ## Recommendation
37
+
38
+ - Not an engine limitation and not a codegen problem: it is the unoptimised dev-profile wasmtime. Add to the
39
+ root Cargo.toml (not owned by T5): `[profile.dev.package.wasmtime] opt-level = 3`. Expected 1M list
40
+ build+length about 1 s; the 200,000-cell limits in depth tests could then go to 1,000,000.
41
+ - `gc_heap_initial_size(256 MiB)` also fixes it (lazily committed) but moved the full sweep only from 39 s to
42
+ 33 s (607 passed), not the 2x bar, and is redundant with the profile fix, so no host config was changed.
43
+ - Collector switch (DRC) is 3x better on this program only and worse on build+sum; not recommended.
docs/plans/2026-11-03-tail-calls-2.progress.md ADDED
@@ -0,0 +1,23 @@
1
+ # Tail calls round 2 run
2
+ - start 184c054
3
+ - Ruling: skip T0 Wave 0 - no Plum/Rust stub generation - cost if wrong: none
4
+ - Ruling: sequential [codegen] T1-T4 share plum-wasm-codegen/src/lib.rs - cost: longer critical path
5
+ - unblocked: dispatched T1 T5 at 17:36:47; T2-T4, T6 follow sequentially
6
+ - checkpoint T1 4333a5e1ef closure tail calls and recursive local closures
7
+ - T1 checkpointed (closure-body tail calls; := closure self-recursion via own table slot; concern: checker does not bind closure's own name for return-type inference)
8
+ - unblocked: dispatched T2 at 17:46:04
9
+ - checkpoint T2 c121cb0f42 error-union tail calls
10
+ - T2 checkpointed c121cb0 (error-union tail calls when result type identical, no ERR_* coercion)
11
+ - unblocked: T3 sent to T2's agent (low context) at 17:49:06
12
+ - Ruling: [scope] T3 worker/wrapper dropped - self calls never need conversion; Int->Number is an AST wrap; remaining case (mutual recursion across T->T?) needs whole-body workers and a function-reservation redesign; review plan listed skip as the alternative - cost if wrong: mutual recursion through a conversion still grows the stack
13
+ - T4 sent to a5ec608 at 17:50:06
14
+ - T5 checkpointed: root cause = wasmtime compiled at dev opt-level 0 (copying GC 30x slow); fix [profile.dev.package.wasmtime] opt-level=3 (37s -> 1.2s for 1M list); V8 128ms. Ruling: apply after T4 (full wasmtime rebuild would collide with T4 builds) - cost: none
15
+ - checkpoint T4 4563fa23ad single-pass tail recursion modulo cons
16
+ - checkpoint T5 a8c6d5e639 GC investigation
17
+ - T4 checkpointed 4563fa2 (single-pass TRMC: Nil placeholder, fill hidden-record field; 2 struct.new per cell = hidden record + union wrapper: representation cost, follow-up). T5 checkpointed
18
+ - Ruling: T6 also owns root Cargo.toml for [profile.dev.package.wasmtime] opt-level=3 (+cranelift) - GC report; 30x dev GC speedup - cost if wrong: longer first rebuild
19
+ - unblocked: dispatched T6 at 17:58:42 (T3 dropped)
20
+ - checkpoint T6 8de6916e9d depth tests round 2
21
+ - T6 checkpointed: Cargo dev opt-level 3 for wasmtime/cranelift-codegen/wasmtime-internal-cranelift; tail_calls 13/13 (1M lists, 2.2s); sweep 611 in 21s (was 38s)
22
+ - tests full passed=80 failed=1 skipped=0 seconds=79.4
23
+ - Final review APPROVED; full suite 1 failure = known generic_funstar_wired (reads untracked repro in main checkout) - Ruling: pre-existing, not ours - cost if wrong: none
docs/plans/2026-11-03-tail-calls-2.report.json ADDED
@@ -0,0 +1,225 @@
1
+ {
2
+ "projection_note": "projection: fixed forecast saved at lint 2026-11-03T17:36:25",
3
+ "headline": {
4
+ "wall_min": {
5
+ "projected": 28.497,
6
+ "actual": 24.167
7
+ },
8
+ "cost_usd_agents": {
9
+ "projected": 0.645714,
10
+ "actual": 1.954344
11
+ },
12
+ "task_min": {
13
+ "projected": 31.399,
14
+ "actual": 36.683
15
+ },
16
+ "agents": {
17
+ "projected": 6,
18
+ "actual": 12
19
+ }
20
+ },
21
+ "feature": null,
22
+ "cost_split": {
23
+ "agents_usd": 1.954344,
24
+ "rework_usd": 0,
25
+ "coordinator_usd": 34.877211,
26
+ "total_usd": 36.831555
27
+ },
28
+ "concurrency": {
29
+ "peak": 2,
30
+ "projected_peak": 2
31
+ },
32
+ "critical_path": {
33
+ "actual": [
34
+ "T6"
35
+ ],
36
+ "projected": [
37
+ "T1",
38
+ "T2",
39
+ "T3",
40
+ "T4",
41
+ "T6"
42
+ ],
43
+ "projected_min": 28.497
44
+ },
45
+ "model_mix": {
46
+ "sonnet": {
47
+ "tasks": 5,
48
+ "cost_usd": 1.9543439999999999
49
+ }
50
+ },
51
+ "coordinator_cost_by_model": {
52
+ "opus": 34.877211
53
+ },
54
+ "quality": {
55
+ "tests": {
56
+ "passed": 80,
57
+ "failed": 1,
58
+ "skipped": 0,
59
+ "seconds": 79.4,
60
+ "mode": "full",
61
+ "failures": [
62
+ "-::generic_funstar_wired: failed"
63
+ ],
64
+ "skipped_tests": []
65
+ },
66
+ "first_time_pass": 0,
67
+ "first_time_n": 0,
68
+ "rework_dispatches": 0,
69
+ "open_incidents": 3
70
+ },
71
+ "tasks": [
72
+ {
73
+ "task": "T5",
74
+ "model": "sonnet",
75
+ "pred_min": 2.902,
76
+ "actual_min": 14.237,
77
+ "delta": {
78
+ "abs": 11.3347,
79
+ "pct": 390.6
80
+ },
81
+ "cost_usd": 0.463526,
82
+ "first_time_pass": null
83
+ },
84
+ {
85
+ "task": "T1",
86
+ "model": "sonnet",
87
+ "pred_min": 7.071,
88
+ "actual_min": 8.822,
89
+ "delta": {
90
+ "abs": 1.7507,
91
+ "pct": 24.8
92
+ },
93
+ "cost_usd": 0.593103,
94
+ "first_time_pass": null
95
+ },
96
+ {
97
+ "task": "T6",
98
+ "model": "sonnet",
99
+ "pred_min": 3.655,
100
+ "actual_min": 2.078,
101
+ "delta": {
102
+ "abs": -1.5767,
103
+ "pct": -43.1
104
+ },
105
+ "cost_usd": 0.120844,
106
+ "first_time_pass": null
107
+ },
108
+ {
109
+ "task": "T3",
110
+ "model": "sonnet",
111
+ "pred_min": 6.292,
112
+ "actual_min": 7.742,
113
+ "delta": {
114
+ "abs": 1.4497,
115
+ "pct": 23.0
116
+ },
117
+ "cost_usd": 0.511579,
118
+ "first_time_pass": null
119
+ },
120
+ {
121
+ "task": "T2",
122
+ "model": "sonnet",
123
+ "pred_min": 4.032,
124
+ "actual_min": 3.805,
125
+ "delta": {
126
+ "abs": -0.227,
127
+ "pct": -5.6
128
+ },
129
+ "cost_usd": 0.265292,
130
+ "first_time_pass": null
131
+ }
132
+ ],
133
+ "cost_table": {
134
+ "source": "est",
135
+ "usd": 2.1147,
136
+ "tokens": 7044653,
137
+ "wall_min": 209266028.3251,
138
+ "rows": [
139
+ {
140
+ "agent": "a0f105af8ea244c3a",
141
+ "model": "opus",
142
+ "wall_min": 0.5564,
143
+ "tokens": 36884,
144
+ "usd": 0.1604
145
+ },
146
+ {
147
+ "agent": "a22bfa5a8592be805",
148
+ "model": "sonnet",
149
+ "wall_min": 29895141.3545,
150
+ "tokens": 0,
151
+ "usd": 0.0
152
+ },
153
+ {
154
+ "agent": "a2815b33cc0ba0797",
155
+ "model": "sonnet",
156
+ "wall_min": 29895136.1811,
157
+ "tokens": 0,
158
+ "usd": 0.0
159
+ },
160
+ {
161
+ "agent": "a3a65d262ba03d8fc",
162
+ "model": "sonnet",
163
+ "wall_min": 2.0893,
164
+ "tokens": 277089,
165
+ "usd": 0.1208
166
+ },
167
+ {
168
+ "agent": "a404dd243c627057d",
169
+ "model": "sonnet",
170
+ "wall_min": 29895149.0868,
171
+ "tokens": 0,
172
+ "usd": 0.0
173
+ },
174
+ {
175
+ "agent": "a4a70cf22b4155982",
176
+ "model": "sonnet",
177
+ "wall_min": 29895134.2054,
178
+ "tokens": 0,
179
+ "usd": 0.0
180
+ },
181
+ {
182
+ "agent": "a5ec60874cf21dddc",
183
+ "model": "sonnet",
184
+ "wall_min": 11.7861,
185
+ "tokens": 2965290,
186
+ "usd": 0.7769
187
+ },
188
+ {
189
+ "agent": "a9452d14b2f6c0237",
190
+ "model": "sonnet",
191
+ "wall_min": 8.8463,
192
+ "tokens": 2032735,
193
+ "usd": 0.5931
194
+ },
195
+ {
196
+ "agent": "ab9989c3eeb17d419",
197
+ "model": "sonnet",
198
+ "wall_min": 29895150.5785,
199
+ "tokens": 0,
200
+ "usd": 0.0
201
+ },
202
+ {
203
+ "agent": "adea4ce43c6426be0",
204
+ "model": "sonnet",
205
+ "wall_min": 29895139.1863,
206
+ "tokens": 0,
207
+ "usd": 0.0
208
+ },
209
+ {
210
+ "agent": "af440f21f781f3db9",
211
+ "model": "sonnet",
212
+ "wall_min": 29895140.2048,
213
+ "tokens": 0,
214
+ "usd": 0.0
215
+ },
216
+ {
217
+ "agent": "afdd594a53598281f",
218
+ "model": "sonnet",
219
+ "wall_min": 14.2496,
220
+ "tokens": 1732655,
221
+ "usd": 0.4635
222
+ }
223
+ ]
224
+ }
225
+ }
docs/plans/2026-11-03-tail-calls-2.report.md ADDED
@@ -0,0 +1,40 @@
1
+ # superlite build report
2
+
3
+ projection: fixed forecast saved at lint 2026-11-03T17:36:25
4
+
5
+ Projected Actual Delta
6
+ wall-clock (min) 28.5 24.2 -4.3 (-15%)
7
+ agent cost (USD, +rework) 0.65 1.95 +1.31 (+203%)
8
+ task-minutes 31.4 36.7 +5.3 (+17%)
9
+ agents 6 12 +6 (+100%)
10
+ cost split: agents $1.95, rework $0.00, coordinator $34.88, total $36.83
11
+ concurrency: peak 2 at once (projected 2); critical path actual T6; projected T1 > T2 > T3 > T4 > T6 (28.5 min)
12
+ model mix: sonnet 5 tasks $1.95, coordinator opus $34.88
13
+ quality: tests 80 passed, 1 failed; first-time pass n/a; rework dispatches 0; open incidents 3
14
+
15
+ task model pred actual delta cost
16
+ T5 sonnet 2.9 14.2 +11.3 (+391%) $0.464
17
+ T1 sonnet 7.1 8.8 +1.8 (+25%) $0.593
18
+ T6 sonnet 3.7 2.1 -1.6 (-43%) $0.121
19
+ T3 sonnet 6.3 7.7 +1.4 (+23%) $0.512
20
+ T2 sonnet 4.0 3.8 -0.2 (-6%) $0.265
21
+
22
+ agent cost table:
23
+ | agent | model | wall_min | tokens | usd |
24
+ | --- | --- | --- | --- | --- |
25
+ | a0f105af8ea244c3a | opus | 0.5564 | 36884 | 0.1604 |
26
+ | a22bfa5a8592be805 | sonnet | 29895141.3545 | 0 | 0.0 |
27
+ | a2815b33cc0ba0797 | sonnet | 29895136.1811 | 0 | 0.0 |
28
+ | a3a65d262ba03d8fc | sonnet | 2.0893 | 277089 | 0.1208 |
29
+ | a404dd243c627057d | sonnet | 29895149.0868 | 0 | 0.0 |
30
+ | a4a70cf22b4155982 | sonnet | 29895134.2054 | 0 | 0.0 |
31
+ | a5ec60874cf21dddc | sonnet | 11.7861 | 2965290 | 0.7769 |
32
+ | a9452d14b2f6c0237 | sonnet | 8.8463 | 2032735 | 0.5931 |
33
+ | ab9989c3eeb17d419 | sonnet | 29895150.5785 | 0 | 0.0 |
34
+ | adea4ce43c6426be0 | sonnet | 29895139.1863 | 0 | 0.0 |
35
+ | af440f21f781f3db9 | sonnet | 29895140.2048 | 0 | 0.0 |
36
+ | afdd594a53598281f | sonnet | 14.2496 | 1732655 | 0.4635 |
37
+ | totals | | 209266028.3251 | 7044653 | 2.1147 |
38
+ cost source: est
39
+
40
+ takeaway: ran 15% faster than projected; cost 203% over; biggest miss T5
plum-core/src/trmc.rs CHANGED
@@ -171,9 +171,10 @@ impl<'a> Cx<'a> {
171
171
  if !self.found {
172
172
  return None;
173
173
  }
174
- let (cons, fields, rec) = self.cons.clone()?;
174
+ self.cons.as_ref()?;
175
175
  let mut stmts = vec![
176
176
  declare("__tc_acc", Some(self.ret.clone()), Expr::TypeName(self.nullary.clone())),
177
+ declare("__tc_res", Some(self.ret.clone()), Expr::TypeName(self.nullary.clone())),
177
178
  declare("__tc_r", Some(self.ret.clone()), Expr::TypeName(self.nullary.clone())),
178
179
  declare("__tc_go", None, Expr::Int(1)),
179
180
  Stmt::While(While {
@@ -185,67 +186,54 @@ impl<'a> Cx<'a> {
185
186
  body: loopBody,
186
187
  }),
187
188
  ];
188
- // Rebuild: for every accumulated cell, last first, wrap `__tc_r` in a fresh one.
189
+ // Fill the last cell's recursive field with the base value (or, when no
189
- let binders: Vec<String> = (0..fields.len()).map(|i| format!("__tc_p{i}")).collect();
190
- let args: Vec<FieldArg> = fields
191
- .iter()
192
- .zip(&binders)
193
- .enumerate()
194
- .map(|(i, (n, b))| FieldArg {
195
- name: n.clone(),
196
- value: if i == rec { var("__tc_r") } else { var(b) },
197
- })
198
- .collect();
199
- let rebuild = Case {
200
- patterns: vec![{
201
- let inner = CasePattern::Class {
202
- name: cons.clone(),
203
- fields: binders.iter().map(|b| CasePattern::Name(b.clone())).collect(),
204
- };
205
- match &self.wrapper {
206
- Some(w) => CasePattern::Class { name: w.clone(), fields: vec![inner] },
207
- None => inner,
208
- }
209
- }],
210
- guard: None,
211
- body: Block {
212
- stmts: vec![
213
- set(
214
- "__tc_r",
215
- wrap(
216
- &self.wrapper,
217
- Expr::ClassCall(ClassCall {
218
- type_name: cons,
219
- fields: args,
220
- generics: self.consGenerics.clone(),
221
- spread: None,
222
- }),
223
- ),
224
- ),
225
- set("__tc_acc", var(&binders[rec])),
190
+ // cell was built, the base value is the result).
226
- ],
227
- },
228
- };
229
- let stop = Case {
230
- patterns: vec![CasePattern::Wildcard],
231
- guard: None,
232
- body: Block { stmts: vec![Stmt::Break] },
233
- };
234
- stmts.push(Stmt::While(While {
235
- condition: Expr::BoolLit(true),
236
- body: Block {
237
- stmts: vec![Stmt::Match(Match {
238
- subjects: vec![var("__tc_acc")],
191
+ stmts.extend(self.fill(var("__tc_r")));
239
- cases: vec![rebuild, stop],
240
- })],
241
- },
242
- }));
243
- stmts.push(Stmt::Return(Some(var("__tc_r"))));
192
+ stmts.push(Stmt::Return(Some(var("__tc_res"))));
244
193
  let mut nf = self.f.clone();
245
194
  nf.body = FnBody::Block(Block { stmts });
246
195
  Some(nf)
247
196
  }
248
197
 
198
+ /// Stores `value` in the recursive field of the last built cell, or in
199
+ /// `__tc_res` when no cell exists yet. Cells are mutated in place through the
200
+ /// record behind the wrapper variant; `__tc_acc` always holds the last cell.
201
+ fn fill(&self, value: Expr) -> Vec<Stmt> {
202
+ let (cons, fields, rec) = self.cons.clone().expect("cons known");
203
+ // Before union lowering a cell is not wrapped (only the unit tests of the
204
+ // pass see that shape); the real pipeline always has the wrapper variant.
205
+ let (w, target) = match &self.wrapper {
206
+ Some(w) => (w.clone(), "__tc_c"),
207
+ None => (cons, "__tc_acc"),
208
+ };
209
+ let patFields = if self.wrapper.is_some() { vec![CasePattern::Name("__tc_c".to_string())] } else { vec![] };
210
+ vec![Stmt::Match(Match {
211
+ subjects: vec![var("__tc_acc")],
212
+ cases: vec![
213
+ Case {
214
+ patterns: vec![CasePattern::Class {
215
+ name: w,
216
+ fields: patFields,
217
+ }],
218
+ guard: None,
219
+ body: Block {
220
+ stmts: vec![Stmt::Assign(Assign {
221
+ targets: vec![AssignTarget::Field(Box::new(var(target)), fields[rec].clone())],
222
+ values: vec![value.clone()],
223
+ declare: false,
224
+ ty: None,
225
+ })],
226
+ },
227
+ },
228
+ Case {
229
+ patterns: vec![CasePattern::Wildcard],
230
+ guard: None,
231
+ body: Block { stmts: vec![set("__tc_res", value)] },
232
+ },
233
+ ],
234
+ })]
235
+ }
236
+
249
237
  // ---- tail-position walk ----
250
238
 
251
239
  fn tailBlock(&mut self, b: Block) -> Option<Block> {
@@ -405,7 +393,7 @@ impl<'a> Cx<'a> {
405
393
  let mut fields = Vec::new();
406
394
  for (i, f) in c.fields.iter().enumerate() {
407
395
  if i == k {
408
- fields.push(FieldArg { name: f.name.clone(), value: var("__tc_acc") });
396
+ fields.push(FieldArg { name: f.name.clone(), value: Expr::TypeName(self.nullary.clone()) });
409
397
  } else if i < k && !pure(&f.value) {
410
398
  let t = self.fresh("f");
411
399
  stmts.push(declare(&t, None, f.value.clone()));
@@ -428,8 +416,10 @@ impl<'a> Cx<'a> {
428
416
  moves.push(set(&p.name, var(&t)));
429
417
  }
430
418
  }
419
+ let cell = self.fresh("cell");
431
- stmts.push(set(
420
+ stmts.push(declare(
432
- "__tc_acc",
421
+ &cell,
422
+ None,
433
423
  wrap(
434
424
  &wrapper,
435
425
  Expr::ClassCall(ClassCall {
@@ -440,6 +430,8 @@ impl<'a> Cx<'a> {
440
430
  }),
441
431
  ),
442
432
  ));
433
+ stmts.extend(self.fill(var(&cell)));
434
+ stmts.push(set("__tc_acc", var(&cell)));
443
435
  stmts.extend(moves);
444
436
  Some(Some(stmts))
445
437
  }
plum-core/tests/trmc_test.rs CHANGED
@@ -258,6 +258,88 @@ fun main() -> Int =
258
258
  assert_eq!(runInt("order2", &p), 192939);
259
259
  }
260
260
 
261
+ /// Number of `struct.new` in the wasm fn whose debug name is `name`.
262
+ fn structNewCount(wasm: &[u8], name: &str) -> usize {
263
+ let mut names = std::collections::HashMap::new();
264
+ let mut imports = 0u32;
265
+ for p in wasmparser::Parser::new(0).parse_all(wasm) {
266
+ match p.unwrap() {
267
+ wasmparser::Payload::ImportSection(r) => imports = r.count(),
268
+ wasmparser::Payload::CustomSection(c) => {
269
+ if let wasmparser::KnownCustom::Name(r) = c.as_known() {
270
+ for n in r {
271
+ if let Ok(wasmparser::Name::Function(m)) = n {
272
+ for e in m.into_iter().flatten() {
273
+ names.insert(e.index, e.name.to_string());
274
+ }
275
+ }
276
+ }
277
+ }
278
+ }
279
+ _ => {}
280
+ }
281
+ }
282
+ let mut idx = imports;
283
+ let mut found = None;
284
+ for p in wasmparser::Parser::new(0).parse_all(wasm) {
285
+ if let wasmparser::Payload::CodeSectionEntry(b) = p.unwrap() {
286
+ if names.get(&idx).is_some_and(|n| n == name || n.starts_with(&format!("{name}["))) {
287
+ let mut r = b.get_operators_reader().unwrap();
288
+ let mut n = 0;
289
+ while !r.eof() {
290
+ if matches!(r.read().unwrap(), wasmparser::Operator::StructNew { .. }) {
291
+ n += 1;
292
+ }
293
+ }
294
+ found = Some(n);
295
+ }
296
+ idx += 1;
297
+ }
298
+ }
299
+ found.unwrap_or_else(|| panic!("no fn {name}: {names:?}"))
300
+ }
301
+
302
+ #[test]
303
+ fn map_single_pass_one_alloc_per_cell() {
304
+ let p = prog("fun main() -> Int = count(map(fromTo(0, 3), |x| x + 1))\n");
305
+ let bytes = compileSource(&load("single", &p)).unwrap();
306
+ // One record + its wrapper variant per cell, in one loop (two loops before).
307
+ assert_eq!(structNewCount(&bytes, "map"), 2);
308
+ assert_eq!(structNewCount(&bytes, "fromTo"), 2);
309
+ }
310
+
311
+ #[test]
312
+ fn user_code_cannot_assign_variant_field() {
313
+ let p = prog("fun poke(xs: Link[Int]) -> Int =\n xs.tail = Nil\n 0\n\nfun main() -> Int = 0\n");
314
+ let source = load("poke", &p);
315
+ let e = plum_checker::checkSource(&source).expect_err("must be rejected");
316
+ assert!(format!("{e:?}").contains("no field"), "{e:?}");
317
+ }
318
+
319
+ #[test]
320
+ fn every_cell_filled_200k() {
321
+ let p = prog(
322
+ "\
323
+ fun main() -> Int =
324
+ big := map(fromTo(0, 200000), |x| x + 1)
325
+ if count(big) == 200000 && sum(big) == 20000100000 then 1 else 0
326
+ ",
327
+ );
328
+ assert_eq!(runInt("fill200k", &p), 1);
329
+ }
330
+
331
+ #[test]
332
+ fn fromTo_single_pass_200k() {
333
+ let p = prog(
334
+ "\
335
+ fun main() -> Int =
336
+ big := fromTo(0, 200000)
337
+ if count(big) == 200000 && sum(big) == 19999900000 then 1 else 0
338
+ ",
339
+ );
340
+ assert_eq!(runInt("ft200k", &p), 1);
341
+ }
342
+
261
343
  #[test]
262
344
  fn pass_reports_names() {
263
345
  let p = prog("fun main() -> Int = 0\n");
plum-examples/functional.plum CHANGED
@@ -18,8 +18,7 @@
18
18
  #
19
19
  # 6. Loops, functionally: the same job as methods, a `for` and a `while`
20
20
  #
21
- # Calls in tail position compile to real tail calls, so a tail-recursive walk
22
- # over a very long list runs in constant stack (see "deep list tail calls").
21
+ # Tail calls and constructor-wrapped self calls run in constant stack; other recursion is limited to about 260,000 deep.
23
22
  # Scene trees are shallow, so the rest recurses plainly.
24
23
  module functional
25
24
  import std/Str
plum-examples/tail_calls.plum CHANGED
@@ -2,9 +2,7 @@
2
2
  #
3
3
  # Self tail calls compile to loops, other tail calls use `return_call`, and a
4
4
  # constructor wrapped self call (`Cons(head: f(h), tail: map(t, f))`) is rewritten
5
- # to a loop (tail recursion modulo cons). Building a long list is slow under
5
+ # to a loop (tail recursion modulo cons). All deep tests use 1,000,000 steps.
6
- # wasmtime GC, so list building tests use 200,000 cells; allocation free loops
7
- # use 1,000,000.
8
6
  module tail_calls
9
7
  import std/Int
10
8
  import std/Float
@@ -52,29 +50,36 @@ fun spin(own c: Counter) -> Counter =
52
50
  fun depth(n: Int) -> Int =
53
51
  if n == 0 then 0 else 1 + depth(n - 1)
54
52
 
53
+ fun down(n: Int) -> error{Neg}!Int =
54
+ if n < 0
55
+ return error.Neg
56
+ if n == 0
57
+ return 5
58
+ down(n - 1)
59
+
55
60
  test "tail_sum_million"
56
61
  assert sumTo(1000000, 0) == 500000500000
57
62
 
58
- test "tail_build_200k"
63
+ test "tail_build_million"
59
- assert length(build(200000, Nil), 0) == 200000
64
+ assert length(build(1000000, Nil), 0) == 1000000
60
65
 
61
- test "fold_200k"
66
+ test "fold_million"
62
- xs := fromTo(0, 200000)
67
+ xs := fromTo(0, 1000000)
63
- assert foldl(xs, 0, |a, x| a + x) == 19999900000
68
+ assert foldl(xs, 0, |a, x| a + x) == 499999500000
64
69
 
65
70
  test "mutual_even_odd_million"
66
71
  assert isEven(1000000)
67
72
  assert !isOdd(1000000)
68
73
  assert isOdd(999999)
69
74
 
70
- test "trmc_map_200k"
75
+ test "trmc_map_million"
71
- ys := map(fromTo(0, 200000), |x| x + 1)
76
+ ys := map(fromTo(0, 1000000), |x| x + 1)
72
- assert length(ys, 0) == 200000
77
+ assert length(ys, 0) == 1000000
73
- assert foldl(ys, 0, |a, x| a + x) == 20000100000
78
+ assert foldl(ys, 0, |a, x| a + x) == 500000500000
74
79
 
75
- test "trmc_fromTo_200k"
80
+ test "trmc_fromTo_million"
76
- xs := fromTo(0, 200000)
81
+ xs := fromTo(0, 1000000)
77
- assert length(xs, 0) == 200000
82
+ assert length(xs, 0) == 1000000
78
83
 
79
84
  test "closure_tail_million"
80
85
  assert dec(1000000) == 0
@@ -84,6 +89,21 @@ test "own_record_loop_100k"
84
89
  assert c.n == 0
85
90
  assert c.total == 200000
86
91
 
92
+ test "recursive_closure_million"
93
+ go := |n, acc| if n == 0 then acc else go(n - 1, acc + n)
94
+ assert go(1000000, 0) == 500000500000
95
+
96
+ test "closure_body_tail_million"
97
+ fin := |n: Int| sumTo(n, 0)
98
+ assert fin(1000000) == 500000500000
99
+
100
+ test "error_union_loop_million"
101
+ assert { down(1000000) catch 99 } == 5
102
+
103
+ test "map_single_pass_million"
104
+ ys := map(fromTo(0, 1000000), |x| x * 2)
105
+ assert foldl(ys, 0, |a, x| a + x) == 999999000000
106
+
87
107
  # Non-tail recursion still uses the wasm stack. Measured limit under the 4 MiB
88
108
  # host stack: about 265,000 frames (260,000 passes, 270,000 traps) of `1 + depth(n - 1)`; tested well below it.
89
109
  test "tree_recursion_depth_recorded"
plum-wasm-codegen/src/lib.rs CHANGED
@@ -388,6 +388,9 @@ pub struct ClosureInfo {
388
388
  /// Free variables captured by value, in a stable (first-appearance) order; the
389
389
  /// index into this vec IS the variable's field index in the env struct.
390
390
  pub free_vars: Vec<(String, PlumType)>,
391
+ /// The local this literal is bound to (`name := |..| ..`): inside the body that
392
+ /// name refers to the closure itself.
393
+ pub self_name: Option<String>,
391
394
  }
392
395
 
393
396
  /// Closure ABI: every closure compiles to the wasm function type
@@ -520,6 +523,9 @@ struct LocalCtx<'a> {
520
523
  tail_depth: Cell<u32>,
521
524
  /// Imported function count: calls to imports are never tail calls.
522
525
  import_count: u32,
526
+ /// In a closure body bound as `name := |..| ..`: `name` and the closure's table
527
+ /// index. The name resolves to the closure itself (env is wasm local 0).
528
+ self_closure: Option<(String, u32)>,
523
529
  }
524
530
 
525
531
  /// What tail-call emission needs to know about the fn being compiled.
@@ -2797,6 +2803,7 @@ fn compileSourceInner(
2797
2803
  locals,
2798
2804
  named_fn_refs: HashSet::new(),
2799
2805
  fn_locals,
2806
+ pending_self: None,
2800
2807
  };
2801
2808
  match &f.body {
2802
2809
  ast::FnBody::Block(block) => walker.walkBlock(block),
@@ -2868,6 +2875,7 @@ fn compileSourceInner(
2868
2875
  param_ptypes: rc.param_ptypes.clone(),
2869
2876
  ret_ptype: rc.ret_ptype.clone(),
2870
2877
  free_vars: rc.free_vars.clone(),
2878
+ self_name: rc.self_name.clone(),
2871
2879
  },
2872
2880
  );
2873
2881
  closure_asts.insert((*ui, rc.ptr), rc.closure);
@@ -3746,6 +3754,7 @@ struct RawClosure<'a> {
3746
3754
  param_ptypes: Vec<PlumType>,
3747
3755
  ret_ptype: PlumType,
3748
3756
  free_vars: Vec<(String, PlumType)>,
3757
+ self_name: Option<String>,
3749
3758
  }
3750
3759
 
3751
3760
  /// The `fn(...)` type a bare reference to top-level fn `name` is used at.
@@ -3793,6 +3802,8 @@ struct ClosureWalker<'a, 't> {
3793
3802
  /// The in-scope locals/params whose type is `fn(...)` — a closure body that
3794
3803
  /// CALLS one (`|x| cb(x)`) captures it (see `fvCollectRefsExpr`'s `FnCall` arm).
3795
3804
  fn_locals: HashMap<String, PlumType>,
3805
+ /// The name a closure literal about to be recorded is bound to.
3806
+ pending_self: Option<String>,
3796
3807
  }
3797
3808
 
3798
3809
  impl<'a, 't> ClosureWalker<'a, 't> {
@@ -3807,7 +3818,13 @@ impl<'a, 't> ClosureWalker<'a, 't> {
3807
3818
  ast::Stmt::ForLoop(_) => {}
3808
3819
  ast::Stmt::Assign(a) => {
3809
3820
  for (target, value) in a.targets.iter().zip(a.values.iter()) {
3821
+ if let (ast::Expr::Closure(_), ast::AssignTarget::Var(name), 1) =
3822
+ (value, target, a.targets.len())
3823
+ {
3824
+ self.pending_self = Some(name.clone());
3825
+ }
3810
3826
  self.walkExpr(value);
3827
+ self.pending_self = None;
3811
3828
  match target {
3812
3829
  ast::AssignTarget::Var(name) if name == "_" => {}
3813
3830
  ast::AssignTarget::Var(name) => {
@@ -3972,13 +3989,16 @@ impl<'a, 't> ClosureWalker<'a, 't> {
3972
3989
  // every param as an Int and the result as Unit.
3973
3990
  _ => (vec![PlumType::TInt; cl.params.len()], PlumType::TUnit),
3974
3991
  };
3992
+ let self_name = self.pending_self.take();
3993
+ let free_vars =
3975
- let free_vars = collectFreeVars(cl, self.types, self.fn_decls, &self.fn_locals);
3994
+ collectFreeVars(cl, self_name.as_deref(), self.types, self.fn_decls, &self.fn_locals);
3976
3995
  self.found.push(RawClosure {
3977
3996
  ptr: cl as *const ast::Closure as usize,
3978
3997
  closure: cl,
3979
3998
  param_ptypes,
3980
3999
  ret_ptype,
3981
4000
  free_vars,
4001
+ self_name,
3982
4002
  });
3983
4003
 
3984
4004
  // Recurse into this closure's own body to find any closure literals nested
@@ -4008,12 +4028,15 @@ impl<'a, 't> ClosureWalker<'a, 't> {
4008
4028
  /// scope's type env.
4009
4029
  fn collectFreeVars(
4010
4030
  cl: &ast::Closure,
4031
+ self_name: Option<&str>,
4011
4032
  env: &HashMap<usize, PlumType>,
4012
4033
  fn_decls: &HashMap<String, &ast::Fn>,
4013
4034
  fn_locals: &HashMap<String, PlumType>,
4014
4035
  ) -> Vec<(String, PlumType)> {
4015
4036
  let mut bound: std::collections::HashSet<String> = cl.params.iter().cloned().collect();
4016
4037
  fvCollectBoundBlock(&cl.body, &mut bound);
4038
+ // The closure's own name is not captured: it resolves to the closure itself.
4039
+ bound.extend(self_name.map(str::to_string));
4017
4040
 
4018
4041
  let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
4019
4042
  let mut free: Vec<(String, PlumType)> = Vec::new();
@@ -4667,7 +4690,7 @@ fn compileFnBodyImpl(
4667
4690
  }
4668
4691
 
4669
4692
  // Tail calls: only real fns with a plain (non error-union) result.
4670
- let self_func = if const_init.is_some() || matches!(ret_decl, PlumType::TErr(..)) {
4693
+ let self_func = if const_init.is_some() {
4671
4694
  None
4672
4695
  } else {
4673
4696
  let own = match unit.inst {
@@ -4804,6 +4827,7 @@ fn compileFnBodyImpl(
4804
4827
  self_func,
4805
4828
  tail_depth: Cell::new(0),
4806
4829
  import_count: ctx.import_count,
4830
+ self_closure: None,
4807
4831
  };
4808
4832
 
4809
4833
  let result_vt = if f.returns.is_none() || matches!(ret_decl, PlumType::TUnit) {
@@ -7227,6 +7251,14 @@ fn compileExprCore(
7227
7251
  // Not a local: a bare reference to a top-level function used as a
7228
7252
  // value (e.g. `each(double)`) — push its zero-capture trampoline
7229
7253
  // closure's (pre-built by the `start` function) global.
7254
+ None if ctx.self_closure.as_ref().is_some_and(|(n, _)| n == name) => {
7255
+ // The closure's own name used as a value: rebuild its closure
7256
+ // struct from the same table index and the running env.
7257
+ let (_, table_idx) = ctx.self_closure.as_ref().expect("just checked");
7258
+ Instruction::I32Const(*table_idx as i32).encode(body);
7259
+ Instruction::LocalGet(0).encode(body);
7260
+ Instruction::StructNew(ctx.gc_types.closure_type_idx).encode(body);
7261
+ }
7230
7262
  None => {
7231
7263
  let fty = namedRefType(name, expr, ctx.types, ctx.func_sigs);
7232
7264
  let global_idx = *ctx
@@ -7477,6 +7509,12 @@ fn compileExprCore(
7477
7509
  }
7478
7510
  } else if is_closure_call {
7479
7511
  compileClosureCall(call, expr, body, ctx, state)?;
7512
+ } else if ctx
7513
+ .self_closure
7514
+ .as_ref()
7515
+ .is_some_and(|(n, _)| *n == call.name && !ctx.locals.contains_key(n))
7516
+ {
7517
+ compileSelfClosureCall(call, body, ctx, state)?;
7480
7518
  } else if isValueTy(&PlumType::TNamed(call.name.clone()))
7481
7519
  && ctx
7482
7520
  .enum_variants
@@ -7823,7 +7861,7 @@ fn tailMode(
7823
7861
  return TailMode::Plain;
7824
7862
  }
7825
7863
  let want = ctx.ret_type.clone().unwrap_or(PlumType::TUnit);
7826
- if matches!(want, PlumType::TErr(..)) || &want != result_ty {
7864
+ if &want != result_ty {
7827
7865
  return TailMode::Plain;
7828
7866
  }
7829
7867
  match func_idx {
@@ -7845,6 +7883,16 @@ fn emitCall(
7845
7883
  ctx: &LocalCtx,
7846
7884
  ) {
7847
7885
  let ty = inferLocalType(result_expr, ctx);
7886
+ let ptr = result_expr as *const ast::Expr as usize;
7887
+ let converted = [
7888
+ plum_checker::ERR_WRAP,
7889
+ plum_checker::ERR_WRAP2,
7890
+ plum_checker::ERR_NULL_NESTED,
7891
+ plum_checker::ERR_FROM_NAME,
7892
+ ]
7893
+ .iter()
7894
+ .any(|k| ctx.types.contains_key(&(ptr + k)));
7895
+ let tail = if converted { ast::TailKind::NotTail } else { tail };
7848
7896
  match tailMode(tail, Some(func_idx), &sig.ret, &ty, ctx) {
7849
7897
  TailMode::Plain => Instruction::Call(func_idx).encode(body),
7850
7898
  TailMode::Return => Instruction::ReturnCall(func_idx).encode(body),
@@ -8592,6 +8640,38 @@ fn compileClosureCallArgs(
8592
8640
  Ok(())
8593
8641
  }
8594
8642
 
8643
+ /// A closure calling itself by name: the running env (wasm local 0) is passed
8644
+ /// straight through, the callee is this closure's own table slot.
8645
+ fn compileSelfClosureCall(
8646
+ call: &ast::FnCall,
8647
+ body: &mut Vec<u8>,
8648
+ ctx: &LocalCtx,
8649
+ state: &mut ModuleState,
8650
+ ) -> Result<(), String> {
8651
+ let (_, table_idx) = ctx.self_closure.as_ref().expect("self closure call");
8652
+ let Some(PlumType::TFun(ps, r)) = ctx.local_decl_types.get(&call.name) else {
8653
+ return Err(format!("codegen: '{}' is not a function-typed local", call.name));
8654
+ };
8655
+ let key = closureSigKey(ps, r, ctx.gc_types);
8656
+ let type_index = *ctx.closure_call_types.get(&key).ok_or_else(|| {
8657
+ format!("internal codegen error: no call_indirect type for closure '{}'", call.name)
8658
+ })?;
8659
+ Instruction::LocalGet(0).encode(body);
8660
+ for (i, arg) in call.args.iter().enumerate() {
8661
+ match ps.get(i) {
8662
+ Some(p) => compileExprInto(argExprOf(arg), p, body, ctx, state)?,
8663
+ None => compileExpr(argExprOf(arg), body, ctx, state)?,
8664
+ }
8665
+ }
8666
+ Instruction::I32Const(*table_idx as i32).encode(body);
8667
+ if tailMode(call.tail, None, &key.1, r, ctx) == TailMode::Plain {
8668
+ Instruction::CallIndirect { type_index, table_index: 0 }.encode(body);
8669
+ } else {
8670
+ Instruction::ReturnCallIndirect { type_index, table_index: 0 }.encode(body);
8671
+ }
8672
+ Ok(())
8673
+ }
8674
+
8595
8675
  /// Whether the closure-typed local `name` is declared to return `Unit`.
8596
8676
  fn closureCallReturnsUnit(name: &str, ctx: &LocalCtx) -> bool {
8597
8677
  match ctx.local_decl_types.get(name) {
@@ -8623,6 +8703,11 @@ fn compileClosureBody(
8623
8703
  base_env.insert(name.clone(), TypeScheme::mono(ty.clone()));
8624
8704
  decl_types.insert(name.clone(), ty.clone());
8625
8705
  }
8706
+ if let Some(n) = &info.self_name {
8707
+ let fty = PlumType::TFun(info.param_ptypes.clone(), Box::new(info.ret_ptype.clone()));
8708
+ base_env.insert(n.clone(), TypeScheme::mono(fty.clone()));
8709
+ decl_types.insert(n.clone(), fty);
8710
+ }
8626
8711
  for (name, pty) in cl.params.iter().zip(info.param_ptypes.iter()) {
8627
8712
  base_env.insert(name.clone(), TypeScheme::mono(pty.clone()));
8628
8713
  decl_types.insert(name.clone(), pty.clone());
@@ -8740,9 +8825,15 @@ fn compileClosureBody(
8740
8825
  ret_type: Some(info.ret_ptype.clone()),
8741
8826
  match_subject_types: RefCell::new(HashMap::new()),
8742
8827
  subject_ptypes: RefCell::new(HashMap::new()),
8828
+ // Tail calls inside the closure body are relative to the closure's own wasm fn.
8743
- self_func: None,
8829
+ self_func: Some(SelfFunc {
8830
+ idx: u32::MAX,
8831
+ ret: closureSigKey(&info.param_ptypes, &info.ret_ptype, &ctx.gc_types).1,
8832
+ looped: false,
8833
+ }),
8744
8834
  tail_depth: Cell::new(0),
8745
8835
  import_count: ctx.import_count,
8836
+ self_closure: info.self_name.clone().map(|n| (n, info.table_idx)),
8746
8837
  };
8747
8838
 
8748
8839
  if matches!(info.ret_ptype, PlumType::TUnit) {
plum-wasm-codegen/tests/closure_tail_e2e_test.rs ADDED
@@ -0,0 +1,83 @@
1
+ #![allow(non_snake_case)]
2
+ use plum_wasm_codegen::compileSource;
3
+
4
+ fn gcEngine() -> wasmtime::Engine {
5
+ let mut config = wasmtime::Config::new();
6
+ config.wasm_gc(true);
7
+ config.wasm_function_references(true);
8
+ config.wasm_tail_call(true);
9
+ wasmtime::Engine::new(&config).expect("engine with GC config should construct")
10
+ }
11
+
12
+ fn repoRoot() -> std::path::PathBuf {
13
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..")
14
+ }
15
+
16
+ fn compile(name: &str, src: &str) -> Vec<u8> {
17
+ let dir = std::env::temp_dir().join(format!("plum_tail_{}_{}", name, std::process::id()));
18
+ std::fs::create_dir_all(&dir).unwrap();
19
+ let path = dir.join("main.plum");
20
+ std::fs::write(&path, src).unwrap();
21
+ let r = plum_core::loadAndMerge(&path, &repoRoot());
22
+ let _ = std::fs::remove_dir_all(&dir);
23
+ let source = r.unwrap_or_else(|e| panic!("load: {e}"));
24
+ let bytes = compileSource(&source).unwrap_or_else(|e| panic!("compile failed: {e}"));
25
+ wasmparser::validate(&bytes).expect("valid wasm");
26
+ bytes
27
+ }
28
+
29
+ fn runInt(name: &str, src: &str) -> i64 {
30
+ let bytes = compile(name, src);
31
+ let engine = gcEngine();
32
+ let module = wasmtime::Module::new(&engine, &bytes).expect("module");
33
+ let mut store = wasmtime::Store::new(&engine, ());
34
+ let mut linker = wasmtime::Linker::new(&engine);
35
+ linker.define_unknown_imports_as_traps(&module).unwrap();
36
+ let inst = linker.instantiate(&mut store, &module).expect("instantiate");
37
+ let main = inst.get_typed_func::<(), i64>(&mut store, "main").expect("main");
38
+ main.call(&mut store, ()).expect("run")
39
+ }
40
+
41
+ const HEAD: &str = "import std/Int\n\n";
42
+
43
+ #[test]
44
+ fn closure_body_tail_call_million() {
45
+ let src = format!("{HEAD}fun countDown(n: Int) -> Int = if n == 0 then 0 else countDown(n - 1)\n\nfun main() -> Int =\n dec := |n: Int| if n == 0 then 7 else countDown(n - 1) + 0\n fin := |n: Int| countDown(n)\n fin(1000000) + dec(0)\n");
46
+ assert_eq!(runInt("cbody", &src), 7);
47
+ }
48
+
49
+ #[test]
50
+ fn recursive_local_closure_million() {
51
+ let src = format!("{HEAD}fun main() -> Int =\n go := |n, acc| if n == 0 then acc else go(n - 1, acc + n)\n go(1000000, 0)\n");
52
+ assert_eq!(runInt("rec", &src), 500000500000);
53
+ }
54
+
55
+ #[test]
56
+ fn recursive_closure_captures_outer() {
57
+ let src = format!("{HEAD}fun main() -> Int =\n k := 3\n go := |n, acc| if n == 0 then acc else go(n - 1, acc + k)\n go(1000000, 0)\n");
58
+ assert_eq!(runInt("reccap", &src), 3000000);
59
+ }
60
+
61
+ #[test]
62
+ fn closure_not_recursive_unchanged() {
63
+ let src = format!("{HEAD}fun main() -> Int =\n k := 10\n f := |x| x + k\n f(5)\n");
64
+ assert_eq!(runInt("plain", &src), 15);
65
+ }
66
+
67
+ #[test]
68
+ fn wasm_closure_has_return_call() {
69
+ let src = format!("{HEAD}fun main() -> Int =\n go := |n, acc| if n == 0 then acc else go(n - 1, acc + n)\n go(10, 0)\n");
70
+ let bytes = compile("wasmrc", &src);
71
+ let mut found = false;
72
+ for payload in wasmparser::Parser::new(0).parse_all(&bytes) {
73
+ if let wasmparser::Payload::CodeSectionEntry(body) = payload.unwrap() {
74
+ let mut r = body.get_operators_reader().unwrap();
75
+ while !r.eof() {
76
+ if matches!(r.read().unwrap(), wasmparser::Operator::ReturnCallIndirect { .. }) {
77
+ found = true;
78
+ }
79
+ }
80
+ }
81
+ }
82
+ assert!(found, "expected return_call_indirect in a closure body");
83
+ }
plum-wasm-codegen/tests/error_tail_e2e_test.rs ADDED
@@ -0,0 +1,91 @@
1
+ #![allow(non_snake_case)]
2
+ use plum_wasm_codegen::compileSource;
3
+
4
+ fn gcEngine() -> wasmtime::Engine {
5
+ let mut config = wasmtime::Config::new();
6
+ config.wasm_gc(true);
7
+ config.wasm_function_references(true);
8
+ config.wasm_tail_call(true);
9
+ wasmtime::Engine::new(&config).expect("engine with GC config should construct")
10
+ }
11
+
12
+ fn repoRoot() -> std::path::PathBuf {
13
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..")
14
+ }
15
+
16
+ fn compile(name: &str, src: &str) -> Vec<u8> {
17
+ let dir = std::env::temp_dir().join(format!("plum_tail_{}_{}", name, std::process::id()));
18
+ std::fs::create_dir_all(&dir).unwrap();
19
+ let path = dir.join("main.plum");
20
+ std::fs::write(&path, src).unwrap();
21
+ let r = plum_core::loadAndMerge(&path, &repoRoot());
22
+ let _ = std::fs::remove_dir_all(&dir);
23
+ let source = r.unwrap_or_else(|e| panic!("load: {e}"));
24
+ let bytes = compileSource(&source).unwrap_or_else(|e| panic!("compile failed: {e}"));
25
+ wasmparser::validate(&bytes).expect("valid wasm");
26
+ bytes
27
+ }
28
+
29
+ fn runInt(name: &str, src: &str) -> i64 {
30
+ let bytes = compile(name, src);
31
+ let engine = gcEngine();
32
+ let module = wasmtime::Module::new(&engine, &bytes).expect("module");
33
+ let mut store = wasmtime::Store::new(&engine, ());
34
+ let mut linker = wasmtime::Linker::new(&engine);
35
+ linker.define_unknown_imports_as_traps(&module).unwrap();
36
+ let inst = linker.instantiate(&mut store, &module).expect("instantiate");
37
+ let main = inst.get_typed_func::<(), i64>(&mut store, "main").expect("main");
38
+ main.call(&mut store, ()).expect("run")
39
+ }
40
+
41
+ fn countReturnCalls(bytes: &[u8]) -> usize {
42
+ let mut n = 0;
43
+ for p in wasmparser::Parser::new(0).parse_all(bytes) {
44
+ if let Ok(wasmparser::Payload::CodeSectionEntry(b)) = p {
45
+ let mut r = b.get_operators_reader().unwrap();
46
+ while !r.eof() {
47
+ if let Ok(wasmparser::Operator::ReturnCall { .. } | wasmparser::Operator::ReturnCallIndirect { .. }) = r.read() {
48
+ n += 1;
49
+ }
50
+ }
51
+ }
52
+ }
53
+ n
54
+ }
55
+
56
+ const HEAD: &str = "import std/Int\n\n";
57
+
58
+ #[test]
59
+ fn error_union_self_loop_million() {
60
+ let src = format!("{HEAD}fun down(n: Int) -> error{{Neg}}!Int =\n if n < 0\n return error.Neg\n if n == 0\n return 5\n down(n - 1)\n\nfun main() -> Int = down(1000000) catch 99\n");
61
+ assert_eq!(runInt("error_union_self_loop_million", &src), 5);
62
+ }
63
+
64
+ #[test]
65
+ fn error_union_self_loop_returns_error() {
66
+ let src = format!("{HEAD}fun down(n: Int) -> error{{Neg}}!Int =\n if n == 0\n return error.Neg\n down(n - 1)\n\nfun main() -> Int = down(1000000) catch 42\n");
67
+ assert_eq!(runInt("error_union_self_loop_returns_error", &src), 42);
68
+ }
69
+
70
+ #[test]
71
+ fn error_union_mutual_million() {
72
+ let src = format!("{HEAD}fun ping(n: Int) -> error{{Neg}}!Int =\n if n == 0\n return 1\n pong(n - 1)\n\nfun pong(n: Int) -> error{{Neg}}!Int =\n if n == 0\n return 2\n ping(n - 1)\n\nfun main() -> Int = ping(1000000) catch 99\n");
73
+ assert_eq!(runInt("error_union_mutual_million", &src), 1);
74
+ }
75
+
76
+ #[test]
77
+ fn try_in_tail_not_tail_call() {
78
+ let src = format!("{HEAD}fun down(n: Int) -> error{{Neg}}!Int =\n if n == 0\n return 5\n try down(n - 1)\n\nfun main() -> Int = down(100) catch 99\n");
79
+ let base = countReturnCalls(&compile("ebase", &format!("{HEAD}fun main() -> Int = 5\n")));
80
+ assert_eq!(countReturnCalls(&compile("etry", &src)), base);
81
+ assert_eq!(runInt("try_in_tail_not_tail_call", &src), 5);
82
+ }
83
+
84
+ #[test]
85
+ fn different_error_sets_normal_call() {
86
+ let src = format!("{HEAD}fun inner(n: Int) -> error{{A}}!Int =\n if n == 0\n return error.A\n 1\n\nfun outer(n: Int) -> error{{A, B}}!Int =\n if n == 9\n return error.B\n inner(n)\n\nfun main() -> Int = outer(0) catch 7\n");
87
+ let base = countReturnCalls(&compile("ebase2", &format!("{HEAD}fun main() -> Int = 5\n")));
88
+ assert_eq!(countReturnCalls(&compile("eset", &src)), base);
89
+ assert_eq!(runInt("different_error_sets_normal_call", &src), 7);
90
+ }
91
+