Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions lib/atom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The constructors are the module's only top-level members; all mutation happens t

## Types

The cells created above are custom Starlark values. Each is truthy when its value is non-zero / non-empty, hashable (usable as a dict key), and ordered (`==`, `!=`, `<`, `<=`, `>`, `>=` compare by current value against another cell of the same type). `str()` renders as `<atom_int:N>`, `<atom_float:N>`, `<atom_string:"S">`.
The cells created above are custom Starlark values. Each is truthy when its value is non-zero / non-empty, and ordered (`==`, `!=`, `<`, `<=`, `>`, `>=` compare by current value against another cell of the same type). Cells are **not hashable** — a mutable cell cannot be a dict/set key (a live-value hash would go stale on mutation and strand the entry); key by `x.get()` when a snapshot key is what you mean. `str()` renders as `<atom_int:N>`, `<atom_float:N>`, `<atom_string:"S">`.

### `atom_int`

Expand Down Expand Up @@ -112,6 +112,7 @@ print(x.get())

- **Type names.** Script-visible type names are `atom_int`, `atom_float`, `atom_string` (as returned by `type()`); the underlying Go types are `AtomicInt`, `AtomicFloat`, `AtomicString`.
- **Truthiness.** A cell is falsy when its value is `0` / `0.0` / `""` and truthy otherwise (`bool(new_int(0))` is `False`).
- **Comparison and hashing.** Cells are comparable only against the same cell type and are hashable by current value, so they work as dict keys. Mutating a cell after using it as a key leaves the existing entry under the old hash — treat keyed cells as you would any mutable key.
- **Comparison and hashing.** Cells are comparable only against the same cell type. They are **not hashable**: using a cell as a dict/set key errors with `unhashable type: atom_int` (or `atom_float` / `atom_string`) — a live-value hash goes stale when the cell mutates and the keyed entry becomes unreachable. Key by `x.get()` instead.
- **Freezing.** Once a cell is frozen (Starlark freezes values that cross a module boundary), every mutating method — `set`, `cas`, `add`, `sub`, `inc`, `dec` — errors with `cannot <method> frozen atom_int` (or `atom_float` / `atom_string`); `get`, truthiness, and comparisons still work.
- **Range.** `atom_int` is a 64-bit signed integer; `atom_float` is IEEE-754 `float64`. `add`/`sub` wrap or lose precision exactly as the underlying 64-bit types do.
- **Float widening.** `atom_float` accepts ints for `set`/`cas`/`add`/`sub` and stores them as floats; `cas` compares with float equality, so seed `old` with the exact stored float.
24 changes: 14 additions & 10 deletions lib/atom/atom.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func newInt(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, k

type AtomicInt struct {
val *atomic.Int64
frozen bool
frozen atomic.Bool
}

func (a *AtomicInt) String() string {
Expand All @@ -69,16 +69,18 @@ func (a *AtomicInt) Type() string {
}

func (a *AtomicInt) Freeze() {
a.frozen = true
a.frozen.Store(true)
}

func (a *AtomicInt) Truth() starlark.Bool {
return a.val.Load() != 0
}

// Hash rejects using a cell as a dict/set key: a cell is mutable and its
// hash tracked the live value, so mutating a keyed cell silently stranded
// the entry under the stale hash (a in d became False after a.set).
func (a *AtomicInt) Hash() (uint32, error) {
//return 0, fmt.Errorf("unhashable: %s", a.Type())
return hashInt64(a.val.Load()), nil
return 0, fmt.Errorf("unhashable type: %s", a.Type())
}

func (a *AtomicInt) Attr(name string) (starlark.Value, error) {
Expand Down Expand Up @@ -115,7 +117,7 @@ var (

type AtomicFloat struct {
val *atomic.Float64
frozen bool
frozen atomic.Bool
}

func newFloat(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
Expand All @@ -135,15 +137,16 @@ func (a *AtomicFloat) Type() string {
}

func (a *AtomicFloat) Freeze() {
a.frozen = true
a.frozen.Store(true)
}

func (a *AtomicFloat) Truth() starlark.Bool {
return a.val.Load() != 0
}

// Hash rejects keying; see AtomicInt.Hash.
func (a *AtomicFloat) Hash() (uint32, error) {
return hashFloat64(a.val.Load()), nil
return 0, fmt.Errorf("unhashable type: %s", a.Type())
}

func (a *AtomicFloat) Attr(name string) (starlark.Value, error) {
Expand Down Expand Up @@ -180,7 +183,7 @@ var (

type AtomicString struct {
val *atomic.String
frozen bool
frozen atomic.Bool
}

func newString(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
Expand All @@ -200,15 +203,16 @@ func (a *AtomicString) Type() string {
}

func (a *AtomicString) Freeze() {
a.frozen = true
a.frozen.Store(true)
}

func (a *AtomicString) Truth() starlark.Bool {
return a.val.Load() != ""
}

// Hash rejects keying; see AtomicInt.Hash.
func (a *AtomicString) Hash() (uint32, error) {
return hashString(a.val.Load()), nil
return 0, fmt.Errorf("unhashable type: %s", a.Type())
}

func (a *AtomicString) Attr(name string) (starlark.Value, error) {
Expand Down
203 changes: 191 additions & 12 deletions lib/atom/atom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@ func TestLoadModule_Atom(t *testing.T) {
assert.eq(str(x), '<atom_int:0>')
assert.eq(dir(x), ["add", "cas", "dec", "get", "inc", "set", "sub"])
assert.true(not bool(x))

m = {}
m[x] = 1
# assert.eq(m[x], 1)
`),
},
{
Expand Down Expand Up @@ -189,10 +185,6 @@ func TestLoadModule_Atom(t *testing.T) {
assert.eq(str(x), '<atom_float:0>')
assert.eq(dir(x), ["add", "cas", "get", "set", "sub"])
assert.true(not bool(x))

m = {}
m[x] = 1
# assert.eq(m[x], 1)
`),
},
{
Expand Down Expand Up @@ -327,10 +319,6 @@ func TestLoadModule_Atom(t *testing.T) {
assert.eq(str(x), '<atom_string:"">')
assert.eq(dir(x), ["cas", "get", "set"])
assert.true(not bool(x))

m = {}
m[x] = 1
# assert.eq(m[x], 1)
`),
},
{
Expand Down Expand Up @@ -423,6 +411,197 @@ func TestLoadModule_Atom(t *testing.T) {
assert.eq(x.get(), "!!!!!!!!!!")
`),
},
// unhashable: a mutable cell must not be usable as a dict/set key —
// its live-value hash goes stale on mutation and the entry is lost
{
name: `int: unhashable as dict key`,
script: itn.HereDoc(`
load('atom', 'new_int')
x = new_int(1)
d = {x: 'a'}
`),
wantErr: "unhashable type: atom_int",
},
{
name: `float: unhashable as dict key`,
script: itn.HereDoc(`
load('atom', 'new_float')
x = new_float(1.5)
d = {x: 'a'}
`),
wantErr: "unhashable type: atom_float",
},
{
name: `string: unhashable as dict key`,
script: itn.HereDoc(`
load('atom', 'new_string')
x = new_string("k")
d = {x: 'a'}
`),
wantErr: "unhashable type: atom_string",
},
{
name: `int: unhashable as set element`,
script: itn.HereDoc(`
load('atom', 'new_int')
s = set([new_int(1)])
`),
wantErr: "unhashable type: atom_int",
},
// frozen: every mutating method must reject a frozen cell
{
name: `int: frozen set fails`,
script: itn.HereDoc(`
load('atom', 'new_int')
load('freeze.star', 'freeze')
x = new_int(1)
freeze(x)
x.set(2)
`),
wantErr: "cannot set frozen atom_int",
},
{
name: `int: frozen cas fails`,
script: itn.HereDoc(`
load('atom', 'new_int')
load('freeze.star', 'freeze')
x = new_int(1)
freeze(x)
x.cas(1, 2)
`),
wantErr: "cannot cas frozen atom_int",
},
{
name: `int: frozen add fails`,
script: itn.HereDoc(`
load('atom', 'new_int')
load('freeze.star', 'freeze')
x = new_int(1)
freeze(x)
x.add(1)
`),
wantErr: "cannot add frozen atom_int",
},
{
name: `int: frozen sub fails`,
script: itn.HereDoc(`
load('atom', 'new_int')
load('freeze.star', 'freeze')
x = new_int(1)
freeze(x)
x.sub(1)
`),
wantErr: "cannot sub frozen atom_int",
},
{
name: `int: frozen inc fails`,
script: itn.HereDoc(`
load('atom', 'new_int')
load('freeze.star', 'freeze')
x = new_int(1)
freeze(x)
x.inc()
`),
wantErr: "cannot inc frozen atom_int",
},
{
name: `int: frozen dec fails`,
script: itn.HereDoc(`
load('atom', 'new_int')
load('freeze.star', 'freeze')
x = new_int(1)
freeze(x)
x.dec()
`),
wantErr: "cannot dec frozen atom_int",
},
{
name: `float: frozen set fails`,
script: itn.HereDoc(`
load('atom', 'new_float')
load('freeze.star', 'freeze')
x = new_float(1.5)
freeze(x)
x.set(2.5)
`),
wantErr: "cannot set frozen atom_float",
},
{
name: `float: frozen cas fails`,
script: itn.HereDoc(`
load('atom', 'new_float')
load('freeze.star', 'freeze')
x = new_float(1.5)
freeze(x)
x.cas(1.5, 2.5)
`),
wantErr: "cannot cas frozen atom_float",
},
{
name: `float: frozen add fails`,
script: itn.HereDoc(`
load('atom', 'new_float')
load('freeze.star', 'freeze')
x = new_float(1.5)
freeze(x)
x.add(1.0)
`),
wantErr: "cannot add frozen atom_float",
},
{
name: `float: frozen sub fails`,
script: itn.HereDoc(`
load('atom', 'new_float')
load('freeze.star', 'freeze')
x = new_float(1.5)
freeze(x)
x.sub(1.0)
`),
wantErr: "cannot sub frozen atom_float",
},
{
name: `string: frozen set fails`,
script: itn.HereDoc(`
load('atom', 'new_string')
load('freeze.star', 'freeze')
x = new_string("a")
freeze(x)
x.set("b")
`),
wantErr: "cannot set frozen atom_string",
},
{
name: `string: frozen cas fails`,
script: itn.HereDoc(`
load('atom', 'new_string')
load('freeze.star', 'freeze')
x = new_string("a")
freeze(x)
x.cas("a", "b")
`),
wantErr: "cannot cas frozen atom_string",
},
// frozen cells stay readable and comparable
{
name: `frozen cells still read and compare`,
script: itn.HereDoc(`
load('atom', 'new_int', 'new_float', 'new_string')
load('freeze.star', 'freeze')
i = new_int(7)
f = new_float(2.5)
s = new_string("hi")
freeze(i)
freeze(f)
freeze(s)
assert.eq(i.get(), 7)
assert.eq(f.get(), 2.5)
assert.eq(s.get(), "hi")
assert.true(i == new_int(7))
assert.true(f < new_float(3.0))
assert.true(s != new_string("yo"))
assert.true(bool(i))
`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
43 changes: 0 additions & 43 deletions lib/atom/helper.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
package atom

import (
"encoding/binary"
"fmt"
"hash/fnv"
"math"
"sort"

"go.starlark.net/starlark"
Expand All @@ -28,46 +25,6 @@ func builtinAttrNames(methods map[string]*starlark.Builtin) []string {
return names
}

// hashInt64 hashes an int64 value to a uint32 hash value using little-endian byte order
func hashInt64(value int64) uint32 {
// Allocate a byte slice
bytes := make([]byte, 8)
// Convert the int64 value into bytes using little-endian encoding
binary.LittleEndian.PutUint64(bytes, uint64(value))
// Initialize a new 32-bit FNV-1a hash
h := fnv.New32a()
// Write the bytes to the hasher, and ignore the error returned by Write, as hashing can't really fail here
_, _ = h.Write(bytes)
// Calculate the hash and return it
return h.Sum32()
}

// hashFloat64 hashes a float64 value to a uint32 hash value
func hashFloat64(value float64) uint32 {
// Convert the float64 value into its binary representation as uint64
bits := math.Float64bits(value)
// Allocate a byte slice
bytes := make([]byte, 8)
// Convert the uint64 bits into bytes using little-endian encoding
binary.LittleEndian.PutUint64(bytes, bits)
// Initialize a new 32-bit FNV-1a hash
h := fnv.New32a()
// Write the bytes to the hasher, and ignore the error returned by Write, as hashing can't really fail here
_, _ = h.Write(bytes)
// Calculate the hash and return it
return h.Sum32()
}

// hashString hashes a string value to a uint32 hash value
func hashString(value string) uint32 {
// Initialize a new 32-bit FNV-1a hash
h := fnv.New32a()
// Write the string to the hasher, and ignore the error returned by Write, as hashing can't really fail here
_, _ = h.Write([]byte(value))
// Calculate the hash and return it
return h.Sum32()
}

// threewayCompare interprets a three-way comparison value cmp (-1, 0, +1)
// as a boolean comparison (e.g. x < y).
func threewayCompare(op syntax.Token, cmp int) (bool, error) {
Expand Down
Loading
Loading