Skip to content

Conversation

@fmguerreiro
Copy link

Summary

Fixes a circular dependency issue in Sema.analyzeAs() where @as coercion with nested cast builtins (@intCast, @floatCast, @ptrCast, @truncate) causes infinite recursion/timeout during compilation in specific control flow patterns.

Problematic Pattern

The bug manifests when the following elements are combined:

  • Loop constructs
  • Short-circuit boolean operations (OR/AND)
  • Optional unwrapping
  • @as(DestType, @intCast(value)) pattern

Example from the wild (from Bun's codebase):

while (lockfile.buffers.resolutions.items.len > i) {
    const pkg_id: u32 = @truncate(lockfile.buffers.resolutions.items[i].tag.to);
    if (workspace_package_ids.items.len > 0 and
        !workspace_package_ids.items[@as(usize, @intCast(pkg_id))])
    {
        break;
    }
    i += 1;
}

This pattern causes compilation to timeout indefinitely.

Minimal reproduction:

fn testPattern() void {
    comptime {
        var pid: u32 = 1;
        const arr = [_]bool{ true, false, true };
        var opt: ?[]const bool = &arr;

        var i: usize = 0;
        while (i < 3) : (i += 1) {
            if (opt == null or (opt.?)[@as(usize, @intCast(pid))] == false) {
                break;
            }
        }
    }
}

Root Cause

The original implementation in Sema.analyzeAs() resolves the operand before the destination type:

const operand = try sema.resolveInst(zir_operand);
const dest_ty = try sema.resolveTypeOrPoison(block, src, zir_dest_type) orelse {
    return operand;
};

When the operand is a type-directed cast builtin like @intCast, this creates a circular dependency:

  1. @as tries to resolve @intCast(pid) without knowing the destination type
  2. @intCast recursively analyzes itself without type context
  3. In complex control flow (loops + short-circuits + optionals), this triggers infinite recursion

Fix

The fix adds an optimization path that detects when the operand is a cast builtin and resolves the destination type FIRST:

if (zir_operand.toIndex()) |operand_index| {
    const operand_tag = sema.code.instructions.items(.tag)[@intFromEnum(operand_index)];
    switch (operand_tag) {
        .int_cast, .float_cast, .ptr_cast, .truncate => {
            // Resolve dest_ty FIRST so inner cast has type context
            const dest_ty = try sema.resolveTypeOrPoison(block, src, zir_dest_type) orelse {
                return sema.resolveInst(zir_operand);
            };

            // ... validation ...

            // Now analyze inner cast with dest_ty already resolved
            const operand = try sema.resolveInst(zir_operand);

            // Skip redundant coercion if types already match
            if (sema.typeOf(operand).eql(dest_ty, zcu)) {
                return operand;
            }

            // Perform outer coercion for edge cases
            return sema.coerceExtra(block, dest_ty, operand, src, ...);
        },
        else => {},
    }
}

This breaks the circular dependency while preserving correct type coercion semantics.

Testing

Verified the fix by:

  1. Building the Zig compiler from source (bootstrap build)
  2. Successfully compiling Bun with the fixed compiler
    • The compilation previously timed out indefinitely at updatePackageJSONAndInstall.zig:722
    • With the fix, Bun compiles successfully
  3. Tested minimal reproduction case compiles without timeout

Related

  • Affects: Bun and potentially other codebases using similar patterns
  • Related to: Type system circular dependency resolution

@fmguerreiro fmguerreiro force-pushed the fix-as-intcast-timeout branch 2 times, most recently from aa74eaf to b4ee164 Compare October 24, 2025 11:11
Resolves a circular dependency issue where `@as` coercion with nested
cast builtins (`@intCast`, `@floatCast`, `@ptrCast`, `@truncate`) would
cause infinite recursion in complex control flow contexts.

The bug occurs when the pattern `@as(DestType, @intcast(value))` appears
in code with:
- Loop constructs
- Short-circuit boolean operations (OR/AND)
- Optional unwrapping

Example problematic pattern:
```zig
while (condition) {
    if (opt == null or (opt.?)[@as(usize, @intcast(pid))] == false) {
        break;
    }
}
```

Root cause: The original code would resolve the operand before the
destination type, causing the inner cast builtin to recursively analyze
without type context, leading to circular dependencies in the type
resolution system.

Fix: When the operand is a cast builtin, resolve the destination type
FIRST, then analyze the inner cast with proper type context. This breaks
the circular dependency while maintaining correct type coercion semantics.

The fix adds an optimization path that:
1. Detects when operand is a type-directed cast builtin
2. Resolves destination type before analyzing the operand
3. Skips redundant outer coercion if types already match
4. Preserves existing behavior for non-cast operands

A helper function `validateCastDestType` was extracted to eliminate
code duplication and improve maintainability.

Tested with Bun codebase which previously timed out during compilation.
The pattern appears in src/install/updatePackageJSONAndInstall.zig:722.

Related: oven-sh/bun
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant