mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 12:14:17 +00:00
* feat(packages): backend pip + npm update flow (#900) Extend Phase 1 update infrastructure to pip + npm sources. Register checkers/executors behind edition gate (Lite edition stays github-only). Per-source sentinel errors + stderr classifier; strict package-name validators reject @version suffix. Shared PackageLocker serializes install + update paths. HTTP response surfaces per-source availability from LookPath detection. Closes part of #900 (Phase 2a). * feat(packages): frontend multi-source updates UI (#900) Unified flat updates list with source pill (github/pip/npm) + filter dropdown. Summary bar shows per-source counts, hiding sources whose backend availability=false. 30 i18n keys with full en/vi/zh parity. Mobile-safe table (overflow-x-auto + min-w-[600px]). Part of #900 (Phase 2a). * test(packages): pip + npm integration e2e (#900) Optional real-runtime integration test behind `pipnpm_e2e` build tag. Skipped by default CI; exercises full check + apply cycle with real pip3/npm in Alpine container. Part of #900 (Phase 2a). * docs(packages): document pip + npm update flow (#900) Adds packages-pip-npm.md covering command matrix, exit codes, stderr error classes, pre-release handling, availability detection, runbook for EACCES/ERESOLVE/externally-managed, min versions, fixture regen. Cross-link from packages-github.md. Changelogs updated. Part of #900 (Phase 2a). * fix(packages): set exec bit on testdata npm/pip scripts
124 lines
3.2 KiB
Go
124 lines
3.2 KiB
Go
package skills
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestSharedLocker_InstallAndUpdateSerialize is a P2A-C2 regression guard.
|
|
//
|
|
// It simulates two concurrent paths that must serialize on the same pip package:
|
|
// - Goroutine A: mimics InstallSingleDep acquiring the shared locker for pip "requests"
|
|
// - Goroutine B: mimics PipUpdateExecutor.Update acquiring via UpdateRegistry.Apply
|
|
// for the same source+pkg key
|
|
//
|
|
// Both paths call sharedPackageLocker().Acquire(ctx, "pip", "requests").
|
|
// Asserts: goroutine B blocks until A releases; peak concurrency = 1; no -race.
|
|
func TestSharedLocker_InstallAndUpdateSerialize(t *testing.T) {
|
|
t.Cleanup(func() { sharedLocker.Store(nil) })
|
|
|
|
l := NewPackageLocker()
|
|
SetSharedPackageLocker(l)
|
|
|
|
const source = "pip"
|
|
const pkg = "requests"
|
|
|
|
var inFlight int32
|
|
var maxConcurrent int32
|
|
var order []string
|
|
var orderMu sync.Mutex
|
|
|
|
recordIn := func(label string) {
|
|
cur := atomic.AddInt32(&inFlight, 1)
|
|
for {
|
|
m := atomic.LoadInt32(&maxConcurrent)
|
|
if cur <= m || atomic.CompareAndSwapInt32(&maxConcurrent, m, cur) {
|
|
break
|
|
}
|
|
}
|
|
orderMu.Lock()
|
|
order = append(order, label+":in")
|
|
orderMu.Unlock()
|
|
}
|
|
recordOut := func(label string) {
|
|
atomic.AddInt32(&inFlight, -1)
|
|
orderMu.Lock()
|
|
order = append(order, label+":out")
|
|
orderMu.Unlock()
|
|
}
|
|
|
|
// A acquires first; B must wait until A is done.
|
|
releaseCh := make(chan struct{})
|
|
aHolding := make(chan struct{})
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
// Goroutine A — simulates InstallSingleDep pip path.
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
locker := sharedPackageLocker()
|
|
if locker == nil {
|
|
t.Errorf("A: sharedPackageLocker() is nil")
|
|
return
|
|
}
|
|
release, err := locker.Acquire(context.Background(), source, pkg)
|
|
if err != nil {
|
|
t.Errorf("A: Acquire failed: %v", err)
|
|
return
|
|
}
|
|
recordIn("A")
|
|
close(aHolding) // signal B that A is now holding the lock
|
|
<-releaseCh // hold until test signals
|
|
recordOut("A")
|
|
release()
|
|
}()
|
|
|
|
// Wait until A is holding the lock before starting B.
|
|
<-aHolding
|
|
|
|
// Goroutine B — simulates UpdateRegistry.Apply → PipUpdateExecutor path.
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
// Use a shared locker directly (as the registry would).
|
|
locker := sharedPackageLocker()
|
|
if locker == nil {
|
|
t.Errorf("B: sharedPackageLocker() is nil")
|
|
return
|
|
}
|
|
release, err := locker.Acquire(context.Background(), source, pkg)
|
|
if err != nil {
|
|
t.Errorf("B: Acquire failed: %v", err)
|
|
return
|
|
}
|
|
recordIn("B")
|
|
time.Sleep(2 * time.Millisecond) // simulate work
|
|
recordOut("B")
|
|
release()
|
|
}()
|
|
|
|
// Let A proceed after a brief delay to ensure B is queued.
|
|
time.Sleep(20 * time.Millisecond)
|
|
close(releaseCh)
|
|
|
|
wg.Wait()
|
|
|
|
if maxConcurrent != 1 {
|
|
t.Fatalf("expected max in-flight = 1, got %d — pip install+update are NOT serialized", maxConcurrent)
|
|
}
|
|
|
|
// Verify that A completed before B started (order: A:in, A:out, B:in, B:out).
|
|
orderMu.Lock()
|
|
defer orderMu.Unlock()
|
|
if len(order) != 4 {
|
|
t.Fatalf("expected 4 order events, got %d: %v", len(order), order)
|
|
}
|
|
if order[0] != "A:in" || order[1] != "A:out" || order[2] != "B:in" || order[3] != "B:out" {
|
|
t.Errorf("unexpected order: %v (want [A:in A:out B:in B:out])", order)
|
|
}
|
|
}
|