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
3 changes: 0 additions & 3 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@
# GitHub configuration and CI/CD
/.github/ @kolkov

# Scripts - Build automation and quality checks
/scripts/ @kolkov

# Tests - Ensure coverage and reliability
*_test.go @kolkov
*_bench_test.go @kolkov
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ go.work
.goda/
docs/dev/

# Temporary files
tmp/

# OS files
.DS_Store
Thumbs.db
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Migration guides
- Best practices documentation

## [0.1.1] - 2026-07-15

### Fixed

- **Goroutine leak in Subscribe** — each `Subscribe` call spawned a goroutine that
blocked for the subscription lifetime. `SubscribeForever` (context.Background)
parked it forever. 10k subscriptions = 10k idle goroutines (20-40MB stack waste).
Replaced with `context.AfterFunc` — zero goroutines per subscription.
([#6](https://github.com/coregx/signals/issues/6), reported by @AnyCPU)

- **Race condition in Unsubscribe** — concurrent `cancel()` + `Unsubscribe()` could
both reach `close(done)` via non-atomic select-default guard, causing
`panic: close of closed channel`. Replaced with `atomic.Bool.CompareAndSwap`
for idempotent cleanup.
([#6](https://github.com/coregx/signals/issues/6))

### Performance

- **Signal_Subscribe**: 745 ns → 134 ns (5.6x faster), 224B → 52B (−77% memory)
- **Effect_Create (1 dep)**: 1560 ns → 352 ns (4.4x faster), 384B → 196B, 10 → 7 allocs
- **Effect_Create (3 deps)**: 4620 ns → 726 ns (6.4x faster), 1016B → 388B, 24 → 13 allocs
- **Effect_Stop**: OOM → 330 ns (goroutine accumulation eliminated)
- **AsReadonly()**: cached after first call (zero allocs on subsequent calls)

### Added

- Allocation regression tests using `testing.AllocsPerRun` — deterministic, breaks
build if heap allocations increase
- Subscribe lifecycle regression tests — goroutine leak detection (10k held subs)
and concurrent cancel+unsub race condition
- Cached `AsReadonly()` — returns same pointer on every call
- Dual-path `Effect` — avoids wrapper closure allocation for effects without cleanup

## [0.1.0] - 2025-10-31

### 🎉 First Stable Release
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ benchstat old.txt new.txt
- **Documentation**: Check [docs/](docs/) and [README.md](README.md)
- **Issues**: Search existing issues first
- **Discussions**: Ask questions in GitHub Discussions
- **Architecture**: Review [Architecture Overview](docs/dev/ARCHITECTURE.md)
- **API Reference**: Review [pkg.go.dev](https://pkg.go.dev/github.com/coregx/signals)

## License

Expand Down
75 changes: 26 additions & 49 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A modern, production-grade reactive programming library for Go 1.25+ that brings
- **Glitch-Free** - Atomic updates prevent intermediate states
- **Lazy Evaluation** - Computed values calculate only when needed
- **Effect Cleanup** - Automatic resource management with cleanup callbacks
- **Production Ready** - 55 tests, 94.5% coverage, 28 benchmarks
- **Production Ready** - 69 tests, 93.8% coverage, 28 benchmarks

---

Expand Down Expand Up @@ -112,16 +112,16 @@ eff.Stop() // Final cleanup runs, effect stops
- **[API Reference](https://pkg.go.dev/github.com/coregx/signals)** - Complete API documentation
- **[Examples](cmd/example/)** - Working code examples

### Advanced
- **[Architecture Overview](docs/dev/ARCHITECTURE.md)** - How it works internally
- **[Implementation Guide](docs/dev/IMPLEMENTATION_GUIDE.md)** - Development guide
- **[Angular Signals Analysis](docs/dev/ANGULAR_SIGNALS_ANALYSIS.md)** - Comparison with Angular
### Design
- **Angular-Inspired** - API design mirrors Angular Signals (see [Compatibility table](#angular-signals-compatibility))
- **Explicit Dependencies** - Go requires explicit dependency passing (no auto-tracking like Angular)
- **Zero Allocation Hot Paths** - Get/Set/Execute paths are allocation-free

---

## Current Status

**Version**: v0.1.0 (Stable - Production-ready!)
**Version**: v0.1.1 (unreleased)

**Production Readiness: Core functionality complete and stable!**

Expand Down Expand Up @@ -161,26 +161,31 @@ eff.Stop() // Final cleanup runs, effect stops

| Package | Tests | Coverage | Benchmarks |
|---------|-------|----------|------------|
| signals | 55 | 94.5% | 28 |
| signals | 69 | 93.8% | 28 |

**Key Metrics**:
- 19 Signal tests
- 17 Computed tests
- 16 Effect tests
- 3 Internal tests (type erasure, reflection fallback)
- 10 Allocation regression tests (`testing.AllocsPerRun`)
- 2 Subscribe lifecycle regression tests (goroutine leak, race condition)
- 2 Readonly cache tests
- Zero allocations in signal read/write hot paths
- Race detector clean (all tests pass with `-race`)
- Race detector clean (all tests pass with `-race` in CI)

### Performance Characteristics

```
BenchmarkSignal_Get 46738254 27.72 ns/op 0 B/op 0 allocs/op
BenchmarkSignal_Set 20975720 52.42 ns/op 0 B/op 0 allocs/op
BenchmarkComputed_Get_Clean 79329402 19.84 ns/op 0 B/op 0 allocs/op
BenchmarkEffect_Execute 8391343 139.6 ns/op 0 B/op 0 allocs/op
BenchmarkSignal_Get 96773490 13.46 ns/op 0 B/op 0 allocs/op
BenchmarkSignal_Set 20975720 52.42 ns/op 0 B/op 0 allocs/op
BenchmarkSignal_Subscribe 17057289 134 ns/op 52 B/op 2 allocs/op
BenchmarkComputed_Get_Clean 96773490 13.46 ns/op 0 B/op 0 allocs/op
BenchmarkEffect_Execute 8426302 136 ns/op 0 B/op 0 allocs/op
BenchmarkEffect_Create 7097109 324 ns/op 196 B/op 7 allocs/op
```

*Zero allocations in hot paths ensure minimal GC pressure*
*Zero allocations in hot paths (Get, Set, Execute). Subscribe uses context.AfterFunc — no goroutine per subscription.*

### Remaining Work

Expand Down Expand Up @@ -214,49 +219,21 @@ See [ROADMAP.md](ROADMAP.md) for detailed timeline.
git clone https://github.com/coregx/signals.git
cd signals

# Run tests
make test

# Run tests with race detector
make test-race

# Run benchmarks
make benchmark

# Run linter
make lint
```

### Testing

```bash
# Run all tests
go test ./...
go test -v ./...

# Run with race detector (Linux/macOS, or CI)
go test -v -race ./...

# Run with coverage
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

# Run with race detector
go test -race ./...

# Run benchmarks
go test -bench=. -benchmem ./...
```

### Code Quality

This project maintains high code quality standards:

```bash
# Format code
make fmt

# Run linter (zero issues required)
make lint

# Run all pre-commit checks
make pre-commit
# Run linter
golangci-lint run
```

---
Expand All @@ -268,7 +245,7 @@ Contributions are welcome! This is an early-stage project and we'd love your hel
**Before contributing**:
1. Read [CONTRIBUTING.md](CONTRIBUTING.md) - Development workflow and guidelines
2. Check [open issues](https://github.com/coregx/signals/issues)
3. Review the [Architecture Overview](docs/dev/ARCHITECTURE.md)
3. Review the [API Reference](https://pkg.go.dev/github.com/coregx/signals)

**Ways to contribute**:
- Report bugs
Expand Down Expand Up @@ -335,7 +312,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
---

**Status**: Stable - Production-ready!
**Version**: v0.1.0
**Version**: v0.1.1

---

Expand Down
145 changes: 145 additions & 0 deletions alloc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package signals

import (
"context"
"testing"
)

// Allocation regression tests.
// These use testing.AllocsPerRun which is deterministic —
// unlike benchmark ns/op, alloc counts don't fluctuate,
// so any increase immediately fails the build.

func TestAllocations_Signal_SubscribeForever(t *testing.T) {
sig := New(0)
allocs := testing.AllocsPerRun(100, func() {
unsub := sig.SubscribeForever(func(int) {})
unsub()
})
// 2 allocs: atomic.Bool escape + remove closure
if allocs > 2 {
t.Errorf("SubscribeForever: %.0f allocs, want <= 2", allocs)
}
}

func TestAllocations_Signal_SubscribeWithContext(t *testing.T) {
sig := New(0)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

allocs := testing.AllocsPerRun(100, func() {
unsub := sig.Subscribe(ctx, func(int) {})
unsub()
})
// 5 allocs: atomic.Bool + remove closure + wrapper closure + AfterFunc internals
if allocs > 5 {
t.Errorf("Subscribe(ctx): %.0f allocs, want <= 5", allocs)
}
}

func TestAllocations_Computed_SubscribeForever(t *testing.T) {
base := New(0)
comp := Computed(func() int { return base.Get() * 2 }, base.AsReadonly())

allocs := testing.AllocsPerRun(100, func() {
unsub := comp.SubscribeForever(func(int) {})
unsub()
})
if allocs > 2 {
t.Errorf("Computed.SubscribeForever: %.0f allocs, want <= 2", allocs)
}
}

func TestAllocations_Signal_Get(t *testing.T) {
sig := New(42)
allocs := testing.AllocsPerRun(100, func() {
_ = sig.Get()
})
if allocs > 0 {
t.Errorf("Get: %.0f allocs, want 0", allocs)
}
}

func TestAllocations_Signal_Set(t *testing.T) {
sig := New(0)
allocs := testing.AllocsPerRun(100, func() {
sig.Set(1)
})
// Set with no subscribers = 0 allocs (no callback slice created)
if allocs > 0 {
t.Errorf("Set (no subscribers): %.0f allocs, want 0", allocs)
}
}

func TestAllocations_Signal_SetWithSubscribers(t *testing.T) {
sig := New(0)
sig.SubscribeForever(func(int) {})

allocs := testing.AllocsPerRun(100, func() {
sig.Set(1)
})
// Set with subscribers: 1 alloc for callback slice copy
if allocs > 1 {
t.Errorf("Set (with subscriber): %.0f allocs, want <= 1", allocs)
}
}

func TestAllocations_Computed_Get_Cached(t *testing.T) {
base := New(42)
comp := Computed(func() int { return base.Get() * 2 }, base.AsReadonly())
_ = comp.Get() // prime cache

allocs := testing.AllocsPerRun(100, func() {
_ = comp.Get()
})
if allocs > 0 {
t.Errorf("Computed.Get (cached): %.0f allocs, want 0", allocs)
}
}

func TestAllocations_AsReadonly(t *testing.T) {
sig := New(42)
_ = sig.AsReadonly() // prime cache

allocs := testing.AllocsPerRun(100, func() {
_ = sig.AsReadonly()
})
// Cached: 0 allocs after first call
if allocs > 0 {
t.Errorf("AsReadonly (cached): %.0f allocs, want 0", allocs)
}
}

func TestAllocations_Effect_Create(t *testing.T) {
base := New(0)
readonly := base.AsReadonly()

allocs := testing.AllocsPerRun(20, func() {
eff := Effect(func() {
_ = base.Get()
}, readonly)
eff.Stop()
})
// 7 allocs: effect struct, pre-alloc slice, method value,
// type-erased callback, atomic.Bool, remove closure, make(slice)
if allocs > 7 {
t.Errorf("Effect (1 dep): %.0f allocs, want <= 7", allocs)
}
}

func TestAllocations_EffectWithCleanup_Create(t *testing.T) {
base := New(0)
readonly := base.AsReadonly()

allocs := testing.AllocsPerRun(20, func() {
eff := EffectWithCleanup(func() func() {
_ = base.Get()
return func() {}
}, readonly)
eff.Stop()
})
// 7 allocs: same as Effect but fn instead of fnSimple
if allocs > 7 {
t.Errorf("EffectWithCleanup (1 dep): %.0f allocs, want <= 7", allocs)
}
}
Loading
Loading