Skip to content

Commit 978cf3a

Browse files
committed
fix: NaN-abort in cap_ffi/bytes, cap_fs named-string, generic-struct dunder dispatch; consolidate duplicated float-to-int/div-guard/dial code
Four-way audit (VM/opcode layer, native capability modules, compiler parsing/type-checking, GBC+runtime state) for duplication that had already drifted or was one edit away from drifting. Real bugs fixed: - cap_ffi.zig's extractI64 was missing the isFinite NaN guard cap_net.zig already had; bytes.zig's argAsI64 had the same gap but reachable with no capability at all (std.bytes.u8(1e300) aborted the host process). Consolidated with vm.floatToIntSafe/core.zig's floatToIntChecked into one common.safeI64FromFloat used by all five call sites. - cap_fs_write's content argument used a hand-rolled string-extraction switch with no .named_value case, unlike every other string arg in the file (which use vms.asStringValue) — rejected named string types. - structInstanceLitAfterValue (generic-instantiation/type-alias struct literals) left no ExprPrimInfo on its result, unlike its sibling structInstanceLit — a dunder operator on a generic struct was unreachable from a literal built directly in an expression. Fixing this needed lookupDunderCallee to fall back to a type name stripped of its "[...]" suffix, since generic methods are registered under the template's name but field-compatibility checking needs the concrete instantiated name. - Runtime.init() was missing fs_state.setActive, unlike its sibling initWithConfig() and unlike every other per-runtime global it pins inline in the same function. Also consolidated (no behavior change): vm.zig's four copies of the minInt(i64)/-1 division-overflow guard into one divModOverflows helper; vm_bigint.zig's addBi/subBi into one addSubBi; cap_net.zig's cap_net_dial/cap_net_dial_tls into one dialImpl. Verified under standard, -Dpreset=stress, and -Dgc_stress=true builds.
1 parent 68cb7bd commit 978cf3a

13 files changed

Lines changed: 337 additions & 132 deletions

File tree

CHANGELOG.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,69 @@
22

33
This changelog tracks notable language/runtime changes by implementation date.
44

5+
## 2026-08-21
6+
7+
### Audit — duplication/DRY sweep across native modules, VM arithmetic, and the compiler
8+
9+
Four parallel audits (VM/opcode layer, native capability modules, compiler
10+
parsing/type-checking, GBC+runtime state) went looking for copy-pasted code
11+
whose copies had already drifted, or were one edit away from drifting, plus
12+
opportunities to consolidate safely. Confirmed and fixed:
13+
14+
- **cap_ffi.zig's `extractI64`** was missing the `isFinite` NaN guard its
15+
sibling `cap_net.zig` copy already had fixed — a NaN passed as an FFI
16+
call's int argument reached `@intFromFloat` unchecked (safety-checked
17+
illegal behavior, process abort).
18+
- **bytes.zig's `argAsI64`** had the same gap, and unlike the FFI case is
19+
reachable with **no capability required at all**`std.bytes.u8(1e300)`
20+
aborted the whole host process from any script.
21+
- Both, plus `vm.floatToIntSafe` and `core.zig`'s separate
22+
`floatToIntChecked`, were four independent hand-written copies of the
23+
same NaN/Infinity/out-of-i64-range check. Consolidated into one
24+
`common.safeI64FromFloat`, called from all four sites (`vm.zig`,
25+
`core.zig`, `cap_net.zig`, `cap_ffi.zig`, `bytes.zig`).
26+
- **cap_fs.zig's `cap_fs_write`** extracted its content argument through a
27+
hand-rolled `.string`/`.dyn_string`/`.string_view` switch with no
28+
`.named_value` case, unlike every other string argument in the file
29+
(which already go through `vms.asStringValue`, which does unwrap
30+
`.named_value`) — a value of a named string type (`type Path string`)
31+
passed as `fs.write`'s content raised a spurious `TypeError`. Now calls
32+
`vms.asStringValue` directly, like its neighbors.
33+
- **`structInstanceLitAfterValue`** (the struct-literal path used for
34+
generic instantiations and type aliases of them, e.g. `Box[int]{...}`)
35+
left no `ExprPrimInfo` on its result at all, unlike its sibling
36+
`structInstanceLit` (plain `Name{...}`) — so a dunder operator
37+
(`__add__` etc.) declared on a generic struct was unreachable when the
38+
literal was built directly in an expression rather than read back out of
39+
a variable first. Fixing this surfaced a second, subtler issue:
40+
`lookupDunderCallee` only ever tried the literal's *concrete* qualified
41+
name (e.g. `"Box[int]"`), but a generic struct's methods are registered
42+
once, type-erased, under the *template's* qualified name (`"Box"`) —
43+
while `checkFieldValueCompatibility`'s struct_t case needs the concrete
44+
form to validate a field of generic type. `lookupDunderCallee` now falls
45+
back to the name stripped of its `[...]` suffix when the direct lookup
46+
misses, so both consumers are satisfied from the same value.
47+
- **`Runtime.init()`** was missing `fs_state.setActive(&rt.fs_mounts)`
48+
every other per-runtime process-global (chunk/globals/heap/vm/tasks/net/
49+
http) was pinned inline in this function, just not this one.
50+
`initWithConfig()` (its heap-init sibling) already had it.
51+
- Minor consolidations with no behavior change: `vm.zig`'s four independent
52+
copies of the `minInt(i64)/-1` division-overflow guard (already the exact
53+
bug class that caused a prior `.mod` bug) now share one `divModOverflows`
54+
helper; `vm_bigint.zig`'s `addBi`/`subBi` (identical but for `r.add` vs
55+
`r.sub`) now share one `addSubBi` body; `cap_net.zig`'s `cap_net_dial`/
56+
`cap_net_dial_tls` (identical but for the scope-check message and which
57+
`net_state` function connects) now share one `dialImpl`.
58+
59+
Flagged but deliberately not acted on (real duplication, but perf-critical
60+
or disproportionate to fix): `fusion_pass.zig`/`vm_defuse.zig`'s mirrored
61+
per-opcode width tables (encode vs. decode, ~30 opcodes, nothing enforces
62+
agreement but tests); `gbc_writer.zig`/`gbc_reader.zig`'s hand-rolled
63+
symmetric section read/write pairs (inherent to a hand-rolled binary
64+
format); `vm.zig`'s `.div`/`.int_div`/`.rem`/`.mod` int/int fast-path
65+
guards (already correct, but a function-call indirection in the hottest
66+
per-instruction dispatch path isn't worth the risk for a one-line check).
67+
568
## 2026-08-20
669

770
### Fix — REPL: a string global (bare, or nested in an array/map) silently corrupted on the next line

src/compiler_test.zig

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3177,6 +3177,28 @@ test "compiler: std.bytes decode family matches the native-call path byte for by
31773177
// catchable RangeError like every other out-of-bounds offset — decodeAt's
31783178
// multi-byte variants (everything but byte_at) cast straight to usize
31793179
// without checking the sign first. Fixed with offsetToUsize (bytes.zig).
3180+
// bytes.zig's argAsI64 (backing std.bytes.u8/u16be/pack/slice/etc.) fed a
3181+
// NaN or out-of-i64-range float straight into @intFromFloat, which is
3182+
// safety-checked illegal behavior (process abort) for either — reachable
3183+
// from any script with no capability required, unlike vm.zig/core.zig's
3184+
// analogous conversions which already guarded against this.
3185+
test "compiler: std.bytes.u8 raises RangeError (not a crash) on NaN/Inf/huge float" {
3186+
var rt = try setup();
3187+
defer rt.deinit();
3188+
try runSrc(&rt,
3189+
\\std := import("std")
3190+
\\func viaNan() string { return std.bytes.u8(std.math.nan()) }
3191+
\\func viaInf() string { return std.bytes.u8(std.math.inf) }
3192+
\\func viaHuge() string { return std.bytes.u8(1e300) }
3193+
\\func viaOk() string { return std.bytes.u8(65) }
3194+
);
3195+
try std.testing.expectError(error.RangeError, rt.callGlobal("viaNan", &.{}));
3196+
try std.testing.expectError(error.RangeError, rt.callGlobal("viaInf", &.{}));
3197+
try std.testing.expectError(error.RangeError, rt.callGlobal("viaHuge", &.{}));
3198+
const ok = try rt.callGlobal("viaOk", &.{});
3199+
try std.testing.expectEqualStrings("A", try vms.asStringValue(ok));
3200+
}
3201+
31803202
test "compiler: std.bytes decode family raises RangeError (not a crash) on a negative offset" {
31813203
var rt = try setup();
31823204
defer rt.deinit();
@@ -4325,6 +4347,27 @@ test "compiler: multiple methods on the same generic struct receiver" {
43254347
try std.testing.expectEqual(@as(i64, 3), result.int);
43264348
}
43274349

4350+
// structInstanceLitAfterValue (the struct-literal path used for generic
4351+
// instantiations, e.g. Box[int]{...}, and type aliases of them) used to
4352+
// leave no ExprPrimInfo on its result at all, unlike its sibling
4353+
// structInstanceLit (plain Name{...}) — so a dunder operator declared on a
4354+
// generic struct was unreachable when the literal was used directly in an
4355+
// expression (not read back out of a variable first, whose static type is
4356+
// tracked separately from ExprPrimInfo).
4357+
test "compiler: dunder operator dispatches on a generic struct literal built directly in an expression" {
4358+
var rt = try setup();
4359+
defer rt.deinit();
4360+
try runSrc(&rt,
4361+
\\type Box[T] struct { v T }
4362+
\\func (a Box[T]) __add__(b Box[T]) Box[T] { return Box[T]{ v: a.v + b.v } }
4363+
\\func direct() int {
4364+
\\ return (Box[int]{ v: 1 } + Box[int]{ v: 2 }).v
4365+
\\}
4366+
);
4367+
const result = try rt.callGlobal("direct", &.{});
4368+
try std.testing.expectEqual(@as(i64, 3), result.int);
4369+
}
4370+
43284371
test "compiler: generic receiver method works for multiple instantiations of same type" {
43294372
var rt = try setup();
43304373
defer rt.deinit();

src/lang/common.zig

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,25 @@ pub fn hashBytes(s: []const u8) u64 {
1313

1414
const std = @import("std");
1515

16+
// @intFromFloat is safety-checked illegal behavior (process abort) for NaN,
17+
// +/-Infinity, or any magnitude outside i64's range, so every float-to-int
18+
// conversion anywhere in the engine must reject those first. This exact
19+
// check was independently reimplemented in vm.zig, core.zig, cap_net.zig,
20+
// cap_ffi.zig, and bytes.zig — one of those copies (bytes.zig's argAsI64)
21+
// was missing the `isFinite` guard entirely, letting `bytes.u8(1e300)`
22+
// abort the whole host process with no capability required. Kept here as
23+
// the single shared implementation so a future fix (or bounds change)
24+
// lands once instead of needing to be copied to every call site again.
25+
pub fn safeI64FromFloat(n: f64) !i64 {
26+
if (!std.math.isFinite(n) or
27+
n < @as(f64, @floatFromInt(std.math.minInt(i64))) or
28+
n >= @as(f64, @floatFromInt(std.math.maxInt(i64)))) return error.RangeError;
29+
const t = @trunc(n);
30+
const as_i64: i64 = @intFromFloat(t);
31+
if (@as(f64, @floatFromInt(as_i64)) != t) return error.RangeError;
32+
return as_i64;
33+
}
34+
1635
pub fn parseFloat(s: []const u8) ?f64 {
1736
if (s.len == 0) return null;
1837
var i: usize = 0;

src/lang/compiler.zig

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1205,7 +1205,21 @@ pub const Compiler = struct {
12051205
const dname = dunderMethodName(op);
12061206
if (is_struct) {
12071207
const candidate = std.fmt.bufPrint(buf, "{s}.{s}", .{ type_name, dname }) catch return null;
1208-
return if (self.registry.hasGlobalFunc(candidate)) candidate else null;
1208+
if (self.registry.hasGlobalFunc(candidate)) return candidate;
1209+
// type_name here is a struct literal's ExprPrimInfo.struct_type,
1210+
// which for a generic instantiation is the CONCRETE qualified
1211+
// name (e.g. "Stack[int]") — checkFieldValueCompatibility's own
1212+
// generic-args fallback (this file, struct_t case) needs that
1213+
// exact form to validate a field assignment. But a generic
1214+
// struct's methods are registered once, type-erased, under the
1215+
// bare template name (methodDecl's qrecv_type, computed before
1216+
// the receiver's `[T]` is parsed) — so retry stripped down to
1217+
// the part before '[' when the direct lookup misses.
1218+
if (std.mem.indexOfScalar(u8, type_name, '[')) |bi| {
1219+
const base_candidate = std.fmt.bufPrint(buf, "{s}.{s}", .{ type_name[0..bi], dname }) catch return null;
1220+
return if (self.registry.hasGlobalFunc(base_candidate)) base_candidate else null;
1221+
}
1222+
return null;
12091223
}
12101224
var lookup: ?[]const u8 = type_name;
12111225
while (lookup) |cur| {

src/lang/compiler_expr.zig

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ pub fn infixExpr(c: anytype, tt: TT) anyerror!void {
462462
if (c.check(.lbrace) and looksLikeStructLiteral(
463463
c,
464464
)) {
465-
try structInstanceLitAfterValue(c, prop.line, null);
465+
try structInstanceLitAfterValue(c, prop.line, null, null);
466466
}
467467
// Propagate named_type through field access (e.g. enum member reads).
468468
if (receiver_named_type) |nt| {
@@ -1094,7 +1094,26 @@ pub fn structInstanceLit(c: anytype, type_name: Token) !void {
10941094
// through a cross-module field access), in which case this behaves exactly
10951095
// as before: the runtime check in vm.zig's build_struct_instance handler
10961096
// is the only one that runs.
1097-
pub fn structInstanceLitAfterValue(c: anytype, line: u32, resolved_type: ?*value_mod.Object) !void {
1097+
//
1098+
// dunder_type_name: the qualified name to tag onto this literal's
1099+
// ExprPrimInfo so a dunder method (__add__ etc.) declared on the type is
1100+
// reachable when the literal is used directly in an expression (not read
1101+
// back out of a variable first) — AND so checkFieldValueCompatibility can
1102+
// validate this literal against a generic-typed struct field elsewhere
1103+
// (its struct_t case matches a concrete instantiated name like "Box[int]"
1104+
// against a field's bare-template-plus-generic_args spec). Always the
1105+
// concrete instantiated qualified_name (same as resolved_type's own, when
1106+
// present) — never the bare generic template name — because that
1107+
// compatibility check needs the concrete form. A generic struct's methods
1108+
// are nonetheless registered once, type-erased, under the *template's*
1109+
// qualified name (e.g. "Box", from methodDecl's `qualifyTypeName(recv_type)`
1110+
// where recv_type is the bare receiver name before its `[T]` is parsed), so
1111+
// lookupDunderCallee (compiler.zig) has its own fallback that strips a
1112+
// "[...]" suffix off this same name when the direct lookup misses — keeping
1113+
// both consumers satisfied from one value instead of two conflicting ones.
1114+
// Pass null when the caller has no dunder-eligible name at hand (e.g. the
1115+
// cross-module field-access case) — same as before this parameter existed.
1116+
pub fn structInstanceLitAfterValue(c: anytype, line: u32, resolved_type: ?*value_mod.Object, dunder_type_name: ?[]const u8) !void {
10981117
try c.consume(.lbrace);
10991118
var count: u8 = 0;
11001119
var key_toks: [255]Token = undefined;
@@ -1127,11 +1146,20 @@ pub fn structInstanceLitAfterValue(c: anytype, line: u32, resolved_type: ?*value
11271146
}
11281147
}
11291148
try c.consume(.rbrace);
1149+
// See structInstanceLit's identical comment above: without this, a
1150+
// dunder method declared on the struct is unreachable from a literal
1151+
// built directly through this path (generic instantiation, or a type
1152+
// alias of one) — this sibling function was missing it entirely.
11301153
if (resolved_type) |t| {
11311154
if (t.* == .struct_type) {
11321155
try validateStructLiteralFieldNames(c, t.struct_type.fields, key_toks[0..count], val_infos[0..count], t.struct_type.name);
11331156
}
11341157
}
1158+
if (dunder_type_name) |dtn| {
1159+
c.setCurrentExprPrimInfo(.{ .struct_type = dtn });
1160+
} else {
1161+
c.clearCurrentExprPrimInfo();
1162+
}
11351163
try c.cs.emit2(@intFromEnum(Op.build_struct_instance), count, line);
11361164
}
11371165

@@ -1270,7 +1298,16 @@ pub fn varExpr(c: anytype, name: Token) !void {
12701298
c,
12711299
)) {
12721300
const resolved_obj = if (c.registry.getCachedInstByQname(qname)) |e| e.obj else null;
1273-
try structInstanceLitAfterValue(c, name.line, resolved_obj);
1301+
// Use the CONCRETE instantiated qname (e.g. "Box[int]"), not the
1302+
// bare template name: checkFieldValueCompatibility's struct_t
1303+
// case needs this exact form (it strips the "[...]" itself when
1304+
// checking a field of generic type), and lookupDunderCallee
1305+
// (compiler.zig) now has its own fallback that strips this same
1306+
// suffix if the direct lookup misses, so both consumers of this
1307+
// ExprPrimInfo are satisfied without a second, differently-named
1308+
// value. See structInstanceLitAfterValue's doc comment.
1309+
const dunder_type_name: ?[]const u8 = if (resolved_obj != null and resolved_obj.?.* == .struct_type) qname else null;
1310+
try structInstanceLitAfterValue(c, name.line, resolved_obj, dunder_type_name);
12741311
}
12751312
return;
12761313
}
@@ -1308,7 +1345,17 @@ pub fn varExpr(c: anytype, name: Token) !void {
13081345
// so build_struct_instance gets the type with the correct field definitions.
13091346
try c.cs.emitGetGlobal(alias.target_qname, name.line);
13101347
const resolved_obj = if (c.registry.getCachedInstByQname(alias.target_qname)) |e| e.obj else null;
1311-
try structInstanceLitAfterValue(c, name.line, resolved_obj);
1348+
// Unlike the bare-generic-instantiation case above, a type
1349+
// alias's methods ARE registered under the concrete
1350+
// instantiation's own qualified name (methodDecl resolves
1351+
// the alias receiver to ta.target_qname), so the resolved
1352+
// object's qualified_name is the correct dunder lookup key
1353+
// here. See structInstanceLitAfterValue's doc comment.
1354+
const dunder_type_name: ?[]const u8 = if (resolved_obj != null and resolved_obj.?.* == .struct_type)
1355+
alias.target_qname
1356+
else
1357+
null;
1358+
try structInstanceLitAfterValue(c, name.line, resolved_obj, dunder_type_name);
13121359
} else {
13131360
try structInstanceLit(c, name);
13141361
}

src/lang/native/bytes.zig

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const vmgc = @import("../vm_gc.zig");
55
const Value = @import("../value.zig").Value;
66
const NativeFnId = @import("native_ids.zig").NativeFnId;
77
const NativeFuncObj = @import("../value.zig").NativeFuncObj;
8+
const common = @import("../common.zig");
89

910
fn makeBinaryString(ctx: VMContext, bytes: []const u8) !Value {
1011
const obj = try vmgc.allocTempRooted(ctx, .{ .dyn_string = &[_]u8{} });
@@ -20,10 +21,13 @@ fn makeBinaryString(ctx: VMContext, bytes: []const u8) !Value {
2021
return .{ .object = obj };
2122
}
2223

24+
// found via `bytes.u8(1e300)`, reachable from any script with no capability
25+
// required, aborting the whole host process before the isFinite/bounds
26+
// check below was added — see common.safeI64FromFloat's doc comment.
2327
fn argAsI64(v: Value) !i64 {
2428
return switch (v) {
2529
.int => |n| n,
26-
.float => |n| @as(i64, @intFromFloat(n)),
30+
.float => |n| common.safeI64FromFloat(n) catch return error.RangeError,
2731
else => error.TypeError,
2832
};
2933
}

src/lang/native/cap_ffi.zig

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const Object = vmod.Object;
2323
const MapEntry = vmod.MapEntry;
2424
const NativeFnId = @import("native_ids.zig").NativeFnId;
2525
const NativeFuncObj = @import("../value.zig").NativeFuncObj;
26+
const common = @import("../common.zig");
2627

2728
pub const LibQualifiedName = "@cap_type:ffi.Lib";
2829
pub const CallableQualifiedName = "@cap_type:ffi.Callable";
@@ -213,10 +214,7 @@ fn callTrampoline(frame: *FfiCall) void {
213214
fn extractI64(v: Value) !i64 {
214215
return switch (v) {
215216
.int => |n| n,
216-
.float => |n| blk: {
217-
if (n < @as(f64, @floatFromInt(std.math.minInt(i64))) or n >= std.math.pow(f64, 2.0, 63.0)) return error.TypeError;
218-
break :blk @as(i64, @intFromFloat(n));
219-
},
217+
.float => |n| common.safeI64FromFloat(n) catch return error.TypeError,
220218
else => return error.TypeError,
221219
};
222220
}

src/lang/native/cap_fs.zig

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,7 @@ pub fn dispatch(ctx: VMContext, nf: NativeFuncObj, argc: u8) !void {
135135
.cap_fs_write => {
136136
if (argc != 2) return error.ArityMismatch;
137137
const path = vms.asStringValue(try ctx.vs.vmPeek(1)) catch return error.TypeError;
138-
const arg1 = try ctx.vs.vmPeek(0);
139-
const content: []const u8 = switch (arg1) {
140-
.string => |s| s.bytes,
141-
.object => |o| if (o.* == .dyn_string) o.dyn_string else if (o.* == .string_view) o.string_view.bytes else return error.TypeError,
142-
else => return error.TypeError,
143-
};
138+
const content = vms.asStringValue(try ctx.vs.vmPeek(0)) catch return error.TypeError;
144139

145140
const lr = try fs_state.lookup(ctx.vs.fs_es, path);
146141
if (lr.mount.kind == .driver) {
@@ -323,3 +318,36 @@ test "cap_fs path extraction accepts string and dyn_string" {
323318
const ds = vms.asStringValue(dyn) catch return error.TestFailed;
324319
try std.testing.expectEqualStrings("test.txt", ds);
325320
}
321+
322+
// Regression: cap_fs_write's content argument used to be extracted through a
323+
// hand-rolled .string/.dyn_string/.string_view switch with no .named_value
324+
// case, unlike every other string-accepting argument in this file (which go
325+
// through vms.asStringValue, which does unwrap .named_value). A value of a
326+
// named string type (e.g. `type Path string`) passed as fs.write's content
327+
// raised a spurious TypeError. cap_fs_write now calls vms.asStringValue
328+
// directly, so this exercises the same unwrap the fix relies on.
329+
test "cap_fs write content accepts a named string-type value" {
330+
const Runtime = @import("../../runtime/runtime.zig").Runtime;
331+
var rt: Runtime = undefined;
332+
rt.initWithPolicy(.{ .allow_io = false }) catch return error.TestFailed;
333+
defer rt.deinit();
334+
335+
const ctx = vms.VMContext.fromActive();
336+
const vmtyp = @import("../vm_types.zig");
337+
338+
const typ_obj = try vmgc.vmAllocObject(ctx);
339+
typ_obj.* = .{ .named_type = .{ .name = "Content", .qualified_name = "Content", .base = .string } };
340+
// Root typ_obj before the further allocations below (makeNamedValue's
341+
// own vmAllocObject, internStr): under -Dgc_stress=true every
342+
// allocation can trigger a GC pass, and typ_obj isn't reachable from
343+
// any root yet (no global, no VM stack slot) until it's wrapped into
344+
// the named value it backs.
345+
try ctx.vs.pushTempRoot(.{ .object = typ_obj });
346+
defer ctx.vs.popTempRoot();
347+
348+
const inner = try ctx.cs.internStr("hello named content");
349+
const named = vmtyp.makeNamedValue(ctx, typ_obj, .{ .string = inner }) catch return error.TestFailed;
350+
351+
const content = vms.asStringValue(named) catch return error.TestFailed;
352+
try std.testing.expectEqualStrings("hello named content", content);
353+
}

0 commit comments

Comments
 (0)