Skip to content

// truncates its result to 32 bits when inlined, but not when passed as a function #383

Open

Description

@gilramir

Summary

The // operator gives two different answers for the same operands depending
on whether the compiler inlined it:

281474976710656 // 2                  --> 0
apply (//) 281474976710656 2          --> 140737488355328     -- correct   

(apply f a b = f a b, so the second forces the kernel function to run.)

The inlined form is generated as (a / b) | 0, which truncates the result
to 32 bits. The kernel function _Basics_idiv uses Math.trunc, which is
correct at any magnitude. The two have disagreed since Math.trunc was
introduced in core in 2023; the code generator was not updated to match.

This is not only a precision limit; it is the same expression producing two
values depending on syntax.

Reproduction

module Main exposing (main)                                                  
             
import Array
import Math                                                              
import Node                        
import Stream
import Task                                                                 

              
main : Node.SimpleProgram a
main =      
    Node.defineSimpleProgram <| \env ->                         
        Node.endSimpleProgram
            (Stream.writeLineAsBytes (String.join "\n" lines) env.stdout
                |> Task.onError (\_ -> Task.succeed env.stdout)
                |> Task.map (\_ -> {})
            )


{-| Applied through a local variable, so the call cannot be inlined and the
kernel function is what runs.
-}
apply : (Int -> Int -> Int) -> Int -> Int -> Int
apply f a b =
    f a b


lines : Array String
lines =
    [ "inlined   2^48 // 2         = " ++ String.fromInt (281474976710656 // 2)
    , "kernel    apply (//) 2^48 2 = " ++ String.fromInt (apply (//) 281474976710656 2)
    , "inlined   2^31 // 1         = " ++ String.fromInt (2147483648 // 1)
    , "kernel    apply (//) 2^31 1 = " ++ String.fromInt (apply (//) 2147483648 1)
    , "partial   map ((//) 2^48)   = " ++ String.fromInt (Maybe.withDefault 0 (Array.first (Array.map ((//) 281474976710656) [ 2 ])))
    , "Math.truncate (2^48 / 2)    = " ++ String.fromInt (Math.truncate (281474976710656 / 2))
    ]

Actual output

inlined   2^48 // 2         = 0
kernel    apply (//) 2^48 2 = 140737488355328
inlined   2^31 // 1         = -2147483648
kernel    apply (//) 2^31 1 = 2147483648
partial   map ((//) 2^48)   = 140737488355328
Math.truncate (2^48 / 2)    = 140737488355328

Actual output

inlined   2^48 // 2         = 0
kernel    apply (//) 2^48 2 = 140737488355328
inlined   2^31 // 1         = -2147483648
kernel    apply (//) 2^31 1 = 2147483648
partial   map ((//) 2^48)   = 140737488355328
Math.truncate (2^48 / 2)    = 140737488355328

Expected output

Every line should read 140737488355328 (or 2147483648 for the 2^31
cases). Int is a double, and both operands and results here are well inside
the exactly-representable range.

Generated JavaScript

From gren make Main, the two paths are visible side by side:

// inlined
String.fromInt((281474976710656 / 2) | 0)

// as a function value
String.fromInt(apply(Basics$idiv, 281474976710656, 2))

Cause

compiler/src/Generate/JavaScript/Expression.hs:436:

"idiv" -> JS.Infix JS.OpBitwiseOr (JS.Infix JS.OpDiv left right) (JS.Int 0)

while core's src/Gren/Kernel/Basics.js has:

var _Basics_idiv = F2(function (a, b) {
  return Math.trunc(a / b);
});

The kernel was changed from | 0 to Math.trunc in core commit 47b5b2d
("Use Math.trunc instead of | 0 to truncate numbers", 2023-07-18). The
inlined code path still emits the old form.

Impact

It is the quotient that must fit in 32 bits, not the operands, so the rule
is not about how large the numbers are. A division is incorrect when its
result lands outside signed 32-bit range:

281474976710656 // 16777216  --> 16777216     -- correct: the answer is 2^24
281474976710656 // 131072    --> -2147483648  -- wrong:   the answer is 2^31
2147483648 // 1              --> -2147483648  -- wrong
4294967296 // 2              --> -2147483648  -- wrong
4294967296 // 4              --> 1073741824   -- correct

Dividing by one is the case worth calling out: n // 1 is wrong for every n
at or above 2^31.

Note that the neighbouring operations are consistent between the two paths:
Math.remainderBy inlines to %, which matches its kernel exactly, and
Math.modBy is not inlined at all. idiv appears to be the only one that
disagrees with its kernel.

Environment

  • Gren 0.6.6
  • compiler at 0.6.6-2-gfd5410c3 (present at the tip of main)
  • core 7.4.2
  • Node 22, Linux

Activity

  1. gilramir commented on Sep 2, 2026

    @gilramir
    ContributorAuthor

    Oh, this is also a Year 2038 issue!

    I hit this again today, going through another route, which was to convert milliseconds to seconds:

    Time.posixToMillis gives milliseconds and a POSIX timestamp is seconds, so
    the conversion is:

    posix =
        Time.posixToMillis now // 1000
    

    The quotient is about 1.79e9 today, comfortably inside signed 32-bit range, so
    this is correct, until 2038-01-19T03:14:08Z, the first
    second whose value does not fit. Then the program starts
    reporting a negative timestamp about 68 years in the past.

    Reproduction

    module Main exposing (main)
    
    import Math
    import Node
    import Stream.Log
    import Time
    
    
    main : Node.SimpleProgram a
    main =
        Node.defineSimpleProgram <| \env ->
            Node.endSimpleProgram
                (Stream.Log.line env.stdout (String.join "\n" lines))
    
    
    apply : (Int -> Int -> Int) -> Int -> Int -> Int
    apply f a b =
        f a b
                                                    
                                                     
    show : String -> Time.Posix -> String           
    show label at =                                 
        let
            ms =
                Time.posixToMillis at                               
        in                                                                      
        label
            ++ "  millis="  
            ++ String.fromInt ms
            ++ "\n    ms // 1000                      = "      
            ++ String.fromInt (ms // 1000)
            ++ "\n    apply (//) ms 1000              = "
            ++ String.fromInt (apply (//) ms 1000)                                   
            ++ "\n    Math.truncate (toFloat ms/1000) = "      
            ++ String.fromInt (Math.truncate (toFloat ms / 1000))  
    
    lines : Array String
    lines =
        [ show "one second before rollover" (Time.millisToPosix 2147483647000)
        , show "at rollover               " (Time.millisToPosix 2147483648000)
        , show "a plausible 2040          " (Time.millisToPosix 2208988800000)
        ]
    

    Actual output

    one second before rollover  millis=2147483647000
        ms // 1000                      = 2147483647
        apply (//) ms 1000              = 2147483647
        Math.truncate (toFloat ms/1000) = 2147483647
    at rollover                 millis=2147483648000
        ms // 1000                      = -2147483648
        apply (//) ms 1000              = 2147483648
        Math.truncate (toFloat ms/1000) = 2147483648
    a plausible 2040            millis=2208988800000
        ms // 1000                      = -2085978496
        apply (//) ms 1000              = 2208988800
        Math.truncate (toFloat ms/1000) = 2208988800
    

    Before the 2038 rollover it's ok, but at rollover, it's wrong (negative)

  2. added this to the 0.7.0 milestone on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggood first issueGood for newcomers

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions