The VM's depth guard is real and works — fix eval --strict -E 'let f = n: if n == 0 then [] else [ (f (n - 1)) ]; in f 176000' returns error: stack overflow (possible infinite recursion), and max-call-depth is honoured. But several native recursive walks are not routed through it, and they take the process down instead.
Four sites, all on a ReleaseSafe build:
Parser, src/expr/compiler/driver.zig:95
$ python3 -c 'open("p.nix","w").write("("*200000 + "1" + ")"*200000)'
$ fix eval p.nix
Segmentation fault at address 0x16be2bfe0
$ nix-instantiate --eval p.nix
1
Constant folding, src/expr/compiler/fold.zig:171,260
$ python3 -c 'open("s.nix","w").write("+".join(["1"]*100001))'
$ fix eval s.nix
Segmentation fault at address 0x16a4afe80
$ nix-instantiate --eval s.nix
100001
builtins.fromJSON, src/expr/vm/builtins/serial.zig:490
$ python3 -c 'n=2000000; open("j.nix","w").write("builtins.typeOf (builtins.fromJSON \"" + "["*n + "]"*n + "\")")'
$ fix eval j.nix
Segmentation fault
__functor chain, src/expr/vm/closures.zig:455
$ fix eval --expr 'let g = n: if n == 0 then (x: x) else { __functor = self: g (n - 1); }; in (g 1000000) 1'
Segmentation fault at address 0x137dffff8
$ nix-instantiate --eval -E 'let g = n: if n == 0 then (x: x) else { __functor = self: g (n - 1); }; in (g 100000) 1'
error: ... # clean error at 10x less depth
The fromJSON case is worth separating out, because it does not crash reliably. Same input, same binary, 12 runs each:
| depth |
clean exit |
crash |
| 200,000 |
11 |
1 |
| 800,000 |
6 |
6 |
| 2,000,000 |
4 |
8 |
The crashes arrive as SIGSEGV, SIGABRT and once as SIGTRAP. A stack overrun that sometimes returns a correct answer is not a stack overrun that hits an unmapped page — it is one that lands in live memory and gets away with it.
That points at src/base/fiber.zig:463, which mmaps the fiber stack as one READ|WRITE region:
const stack_raw = std.posix.mmap(
null,
aligned_len,
.{ .READ = true, .WRITE = true },
.{ .TYPE = .PRIVATE, .ANONYMOUS = true },
-1,
0,
) catch return error.OutOfMemory;
There is no PROT_NONE page below the stack. An overrun walks into whatever is mapped next, which at these depths is another fiber's live frames. The observed crash is the lucky outcome; the silent successes in the table above are the unlucky one.
A guard page would not fix the missing depth checks, but it would convert every one of these from "sometimes corrupts a neighbour" into a deterministic fault, which seems worth having on its own.
fix 0.3.0 at 1f1a99cf, built -Doptimize=ReleaseSafe with Zig 0.16.0, darwin/aarch64. Reference implementation is Nix 2.34.8.
The VM's depth guard is real and works —
fix eval --strict -E 'let f = n: if n == 0 then [] else [ (f (n - 1)) ]; in f 176000'returnserror: stack overflow (possible infinite recursion), andmax-call-depthis honoured. But several native recursive walks are not routed through it, and they take the process down instead.Four sites, all on a ReleaseSafe build:
Parser,
src/expr/compiler/driver.zig:95Constant folding,
src/expr/compiler/fold.zig:171,260builtins.fromJSON,src/expr/vm/builtins/serial.zig:490__functorchain,src/expr/vm/closures.zig:455The
fromJSONcase is worth separating out, because it does not crash reliably. Same input, same binary, 12 runs each:The crashes arrive as
SIGSEGV,SIGABRTand once asSIGTRAP. A stack overrun that sometimes returns a correct answer is not a stack overrun that hits an unmapped page — it is one that lands in live memory and gets away with it.That points at
src/base/fiber.zig:463, which mmaps the fiber stack as oneREAD|WRITEregion:There is no
PROT_NONEpage below the stack. An overrun walks into whatever is mapped next, which at these depths is another fiber's live frames. The observed crash is the lucky outcome; the silent successes in the table above are the unlucky one.A guard page would not fix the missing depth checks, but it would convert every one of these from "sometimes corrupts a neighbour" into a deterministic fault, which seems worth having on its own.
fix 0.3.0 at
1f1a99cf, built-Doptimize=ReleaseSafewith Zig 0.16.0, darwin/aarch64. Reference implementation is Nix 2.34.8.