test: 8 shutdown tests — EXIT, AtExit, WorkArea, panic-safe, statics
Tests verify: - EXIT PROCEDURE auto-execution on shutdown - Reverse module order for EXIT - AtExit LIFO callback order - WorkArea CloseAll on exit - Panic in cleanup doesn't crash (safeCall) - Shutdown runs exactly once (sync.Once) - Static variables cleared - Full sequence order: EXIT → AtExit → WA:Close → onExit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"lastUpdated": "2026-03-31T01:06:49.182Z",
|
||||
"lastUpdated": "2026-03-31T01:11:37.627Z",
|
||||
"activeFeatures": [
|
||||
"hbrt",
|
||||
"hbrtl",
|
||||
@@ -32,9 +32,9 @@
|
||||
"documents": {},
|
||||
"timestamps": {
|
||||
"started": "2026-03-27T09:33:04.512Z",
|
||||
"lastUpdated": "2026-03-31T01:05:45.128Z"
|
||||
"lastUpdated": "2026-03-31T01:11:37.627Z"
|
||||
},
|
||||
"lastFile": "/mnt/d/charles/five/hbrt/vm.go"
|
||||
"lastFile": "/mnt/d/charles/five/hbrt/shutdown_test.go"
|
||||
},
|
||||
"hbrtl": {
|
||||
"phase": "do",
|
||||
@@ -266,7 +266,7 @@
|
||||
"session": {
|
||||
"startedAt": "2026-03-27T06:06:49.620Z",
|
||||
"onboardingCompleted": false,
|
||||
"lastActivity": "2026-03-31T01:06:49.182Z"
|
||||
"lastActivity": "2026-03-31T01:11:37.627Z"
|
||||
},
|
||||
"history": [
|
||||
{
|
||||
@@ -5326,6 +5326,48 @@
|
||||
"feature": "hbrtl",
|
||||
"phase": "do",
|
||||
"action": "updated"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-03-31T01:09:06.413Z",
|
||||
"feature": "hbrt",
|
||||
"phase": "do",
|
||||
"action": "updated"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-03-31T01:09:32.137Z",
|
||||
"feature": "hbrt",
|
||||
"phase": "do",
|
||||
"action": "updated"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-03-31T01:09:42.530Z",
|
||||
"feature": "hbrt",
|
||||
"phase": "do",
|
||||
"action": "updated"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-03-31T01:10:44.898Z",
|
||||
"feature": "hbrt",
|
||||
"phase": "do",
|
||||
"action": "updated"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-03-31T01:10:57.257Z",
|
||||
"feature": "hbrt",
|
||||
"phase": "do",
|
||||
"action": "updated"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-03-31T01:11:25.249Z",
|
||||
"feature": "hbrt",
|
||||
"phase": "do",
|
||||
"action": "updated"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-03-31T01:11:37.627Z",
|
||||
"feature": "hbrt",
|
||||
"phase": "do",
|
||||
"action": "updated"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -132,28 +132,34 @@ func (vm *VM) doShutdown() {
|
||||
// runExitProcedures executes all EXIT PROCEDURE declarations.
|
||||
// Harbour: scans symbols for HB_FS_EXIT scope, executes in reverse module order.
|
||||
func (vm *VM) runExitProcedures() {
|
||||
// Collect EXIT procedures under lock, execute without lock
|
||||
vm.mu.RLock()
|
||||
defer vm.mu.RUnlock()
|
||||
|
||||
// Collect EXIT procedures from all modules (reverse order)
|
||||
var exitFuncs []func(*Thread)
|
||||
for i := len(vm.modules) - 1; i >= 0; i-- {
|
||||
m := vm.modules[i]
|
||||
for j := range m.Symbols {
|
||||
sym := &m.Symbols[j]
|
||||
if sym.Scope&FsExit != 0 && sym.Func != nil {
|
||||
safeCallThread(vm, sym.Func)
|
||||
exitFuncs = append(exitFuncs, sym.Func)
|
||||
}
|
||||
}
|
||||
}
|
||||
vm.mu.RUnlock()
|
||||
|
||||
for _, fn := range exitFuncs {
|
||||
safeCallThread(vm, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// closeAllWorkAreas closes all open database work areas.
|
||||
// Harbour: hb_rddCloseAll() — respects parent-child order.
|
||||
func (vm *VM) closeAllWorkAreas() {
|
||||
vm.mu.RLock()
|
||||
defer vm.mu.RUnlock()
|
||||
threads := make([]*Thread, len(vm.threads))
|
||||
copy(threads, vm.threads)
|
||||
vm.mu.RUnlock()
|
||||
|
||||
for _, t := range vm.threads {
|
||||
for _, t := range threads {
|
||||
if t.WA != nil {
|
||||
if closer, ok := t.WA.(interface{ CloseAll() }); ok {
|
||||
safeCall(func() { closer.CloseAll() })
|
||||
|
||||
377
hbrt/shutdown_test.go
Normal file
377
hbrt/shutdown_test.go
Normal file
@@ -0,0 +1,377 @@
|
||||
package hbrt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// === EXIT PROCEDURE ===
|
||||
|
||||
func TestShutdown_ExitProcedure(t *testing.T) {
|
||||
shutdownOnce = sync.Once{}
|
||||
atExitMu.Lock()
|
||||
atExitFuncs = nil
|
||||
atExitMu.Unlock()
|
||||
|
||||
vm := NewVM()
|
||||
var log []string
|
||||
|
||||
// Register module with EXIT procedure
|
||||
vm.RegisterModule(&Module{
|
||||
Name: "test_exit",
|
||||
Symbols: []Symbol{
|
||||
{
|
||||
Name: "CLEANUP",
|
||||
Scope: FsExit, // EXIT PROCEDURE
|
||||
Func: func(t *Thread) {
|
||||
log = append(log, "EXIT:CLEANUP")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "MAIN",
|
||||
Scope: FsPublic | FsLocal | FsFirst,
|
||||
Func: func(t *Thread) {
|
||||
t.Frame(0, 0)
|
||||
defer t.EndProc()
|
||||
log = append(log, "MAIN:running")
|
||||
t.RetNil()
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vm.Run("MAIN")
|
||||
// Shutdown already called via defer in Run()
|
||||
|
||||
if len(log) < 2 {
|
||||
t.Fatalf("expected 2 log entries, got %d: %v", len(log), log)
|
||||
}
|
||||
if log[0] != "MAIN:running" {
|
||||
t.Errorf("log[0]: got %q, want MAIN:running", log[0])
|
||||
}
|
||||
if log[1] != "EXIT:CLEANUP" {
|
||||
t.Errorf("log[1]: got %q, want EXIT:CLEANUP", log[1])
|
||||
}
|
||||
t.Logf("EXIT PROCEDURE executed: %v", log)
|
||||
}
|
||||
|
||||
// === Multiple EXIT in reverse order ===
|
||||
|
||||
func TestShutdown_ExitReverseOrder(t *testing.T) {
|
||||
shutdownOnce = sync.Once{}
|
||||
atExitMu.Lock()
|
||||
atExitFuncs = nil
|
||||
atExitMu.Unlock()
|
||||
|
||||
vm := NewVM()
|
||||
var log []string
|
||||
|
||||
vm.RegisterModule(&Module{
|
||||
Name: "mod_first",
|
||||
Symbols: []Symbol{
|
||||
{Name: "EXIT_A", Scope: FsExit, Func: func(t *Thread) { log = append(log, "A") }},
|
||||
},
|
||||
})
|
||||
vm.RegisterModule(&Module{
|
||||
Name: "mod_second",
|
||||
Symbols: []Symbol{
|
||||
{Name: "EXIT_B", Scope: FsExit, Func: func(t *Thread) { log = append(log, "B") }},
|
||||
},
|
||||
})
|
||||
vm.RegisterModule(&Module{
|
||||
Name: "mod_main",
|
||||
Symbols: []Symbol{
|
||||
{Name: "MAIN", Scope: FsPublic | FsLocal | FsFirst, Func: func(t *Thread) {
|
||||
t.Frame(0, 0)
|
||||
defer t.EndProc()
|
||||
t.RetNil()
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
vm.Run("MAIN")
|
||||
|
||||
// Reverse module order: B first (mod_second), then A (mod_first)
|
||||
if len(log) < 2 {
|
||||
t.Fatalf("expected 2 exits, got %d: %v", len(log), log)
|
||||
}
|
||||
if log[0] != "B" || log[1] != "A" {
|
||||
t.Errorf("expected [B, A], got %v", log)
|
||||
}
|
||||
t.Logf("Reverse order: %v", log)
|
||||
}
|
||||
|
||||
// === AtExit callbacks ===
|
||||
|
||||
func TestShutdown_AtExit(t *testing.T) {
|
||||
// Reset global state
|
||||
atExitMu.Lock()
|
||||
atExitFuncs = nil
|
||||
atExitMu.Unlock()
|
||||
shutdownOnce = sync.Once{}
|
||||
|
||||
vm := NewVM()
|
||||
var log []string
|
||||
|
||||
vm.RegisterModule(&Module{
|
||||
Name: "mod_main",
|
||||
Symbols: []Symbol{
|
||||
{Name: "MAIN", Scope: FsPublic | FsLocal | FsFirst, Func: func(t *Thread) {
|
||||
t.Frame(0, 0)
|
||||
defer t.EndProc()
|
||||
// Register AtExit during execution
|
||||
AtExit(func() { log = append(log, "atexit_1") })
|
||||
AtExit(func() { log = append(log, "atexit_2") })
|
||||
AtExit(func() { log = append(log, "atexit_3") })
|
||||
log = append(log, "MAIN")
|
||||
t.RetNil()
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
vm.Run("MAIN")
|
||||
|
||||
// LIFO order: atexit_3, atexit_2, atexit_1
|
||||
if len(log) != 4 {
|
||||
t.Fatalf("expected 4 entries, got %d: %v", len(log), log)
|
||||
}
|
||||
if log[0] != "MAIN" {
|
||||
t.Errorf("log[0]: got %q", log[0])
|
||||
}
|
||||
if log[1] != "atexit_3" || log[2] != "atexit_2" || log[3] != "atexit_1" {
|
||||
t.Errorf("LIFO order wrong: %v", log[1:])
|
||||
}
|
||||
t.Logf("AtExit LIFO: %v", log)
|
||||
}
|
||||
|
||||
// === WorkArea auto-close ===
|
||||
|
||||
type mockWorkArea struct {
|
||||
closed bool
|
||||
log *[]string
|
||||
}
|
||||
|
||||
func (m *mockWorkArea) CloseAll() {
|
||||
m.closed = true
|
||||
*m.log = append(*m.log, "WA:CloseAll")
|
||||
}
|
||||
|
||||
func TestShutdown_WorkAreaClose(t *testing.T) {
|
||||
shutdownOnce = sync.Once{}
|
||||
atExitMu.Lock()
|
||||
atExitFuncs = nil
|
||||
atExitMu.Unlock()
|
||||
|
||||
vm := NewVM()
|
||||
var log []string
|
||||
wa := &mockWorkArea{log: &log}
|
||||
|
||||
vm.RegisterModule(&Module{
|
||||
Name: "mod_main",
|
||||
Symbols: []Symbol{
|
||||
{Name: "MAIN", Scope: FsPublic | FsLocal | FsFirst, Func: func(t *Thread) {
|
||||
t.Frame(0, 0)
|
||||
defer t.EndProc()
|
||||
t.WA = wa
|
||||
log = append(log, "MAIN:using_db")
|
||||
t.RetNil()
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
vm.Run("MAIN")
|
||||
|
||||
if !wa.closed {
|
||||
t.Error("WorkArea was NOT closed on shutdown")
|
||||
}
|
||||
found := false
|
||||
for _, entry := range log {
|
||||
if entry == "WA:CloseAll" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("WA:CloseAll not in log")
|
||||
}
|
||||
t.Logf("WorkArea cleanup: %v", log)
|
||||
}
|
||||
|
||||
// === Panic in cleanup doesn't crash ===
|
||||
|
||||
func TestShutdown_PanicSafe(t *testing.T) {
|
||||
shutdownOnce = sync.Once{}
|
||||
atExitMu.Lock()
|
||||
atExitFuncs = nil
|
||||
atExitMu.Unlock()
|
||||
|
||||
vm := NewVM()
|
||||
var log []string
|
||||
|
||||
vm.RegisterModule(&Module{
|
||||
Name: "mod_main",
|
||||
Symbols: []Symbol{
|
||||
{Name: "EXIT_PANIC", Scope: FsExit, Func: func(t *Thread) {
|
||||
log = append(log, "before_panic")
|
||||
panic("test panic in EXIT")
|
||||
}},
|
||||
{Name: "MAIN", Scope: FsPublic | FsLocal | FsFirst, Func: func(t *Thread) {
|
||||
t.Frame(0, 0)
|
||||
defer t.EndProc()
|
||||
AtExit(func() { log = append(log, "atexit_after_panic") })
|
||||
log = append(log, "MAIN")
|
||||
t.RetNil()
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
// Should NOT panic — safeCall catches it
|
||||
vm.Run("MAIN")
|
||||
|
||||
if !logContains(log, "before_panic") {
|
||||
t.Error("EXIT proc didn't run before panic")
|
||||
}
|
||||
if !logContains(log, "atexit_after_panic") {
|
||||
t.Error("AtExit didn't run after EXIT panic")
|
||||
}
|
||||
t.Logf("Panic-safe cleanup: %v", log)
|
||||
}
|
||||
|
||||
// === Shutdown runs only once ===
|
||||
|
||||
func TestShutdown_OnlyOnce(t *testing.T) {
|
||||
shutdownOnce = sync.Once{}
|
||||
atExitMu.Lock()
|
||||
atExitFuncs = nil
|
||||
atExitMu.Unlock()
|
||||
|
||||
vm := NewVM()
|
||||
count := 0
|
||||
|
||||
AtExit(func() { count++ })
|
||||
|
||||
vm.RegisterModule(&Module{
|
||||
Name: "mod_main",
|
||||
Symbols: []Symbol{
|
||||
{Name: "MAIN", Scope: FsPublic | FsLocal | FsFirst, Func: func(t *Thread) {
|
||||
t.Frame(0, 0)
|
||||
defer t.EndProc()
|
||||
t.RetNil()
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
vm.Run("MAIN")
|
||||
// Run() calls Shutdown() via defer — count should be 1
|
||||
|
||||
// Call again explicitly — should NOT run again
|
||||
vm.Shutdown()
|
||||
vm.Shutdown()
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("Shutdown ran %d times, expected 1", count)
|
||||
}
|
||||
t.Logf("Shutdown ran exactly once: count=%d", count)
|
||||
}
|
||||
|
||||
// === Static variables cleared ===
|
||||
|
||||
func TestShutdown_StaticsClear(t *testing.T) {
|
||||
shutdownOnce = sync.Once{}
|
||||
atExitMu.Lock()
|
||||
atExitFuncs = nil
|
||||
atExitMu.Unlock()
|
||||
|
||||
vm := NewVM()
|
||||
|
||||
// Set some statics
|
||||
vm.mu.Lock()
|
||||
vm.statics["TEST"] = []Value{MakeString("hello"), MakeInt(42)}
|
||||
vm.mu.Unlock()
|
||||
|
||||
vm.RegisterModule(&Module{
|
||||
Name: "mod_main",
|
||||
Symbols: []Symbol{
|
||||
{Name: "MAIN", Scope: FsPublic | FsLocal | FsFirst, Func: func(t *Thread) {
|
||||
t.Frame(0, 0)
|
||||
defer t.EndProc()
|
||||
t.RetNil()
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
vm.Run("MAIN")
|
||||
|
||||
// Statics should be cleared
|
||||
vm.mu.RLock()
|
||||
for _, vals := range vm.statics {
|
||||
for _, v := range vals {
|
||||
if !v.IsNil() {
|
||||
t.Errorf("static not cleared: %v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
vm.mu.RUnlock()
|
||||
t.Log("Statics cleared on shutdown")
|
||||
}
|
||||
|
||||
// === Full sequence order ===
|
||||
|
||||
func TestShutdown_FullSequence(t *testing.T) {
|
||||
shutdownOnce = sync.Once{}
|
||||
atExitMu.Lock()
|
||||
atExitFuncs = nil
|
||||
atExitMu.Unlock()
|
||||
|
||||
vm := NewVM()
|
||||
var log []string
|
||||
wa := &mockWorkArea{log: &log}
|
||||
|
||||
vm.RegisterModule(&Module{
|
||||
Name: "mod_main",
|
||||
Symbols: []Symbol{
|
||||
{Name: "EXIT_PROC", Scope: FsExit, Func: func(t *Thread) {
|
||||
log = append(log, "1:EXIT_PROC")
|
||||
}},
|
||||
{Name: "MAIN", Scope: FsPublic | FsLocal | FsFirst, Func: func(t *Thread) {
|
||||
t.Frame(0, 0)
|
||||
defer t.EndProc()
|
||||
t.WA = wa
|
||||
AtExit(func() { log = append(log, "2:ATEXIT") })
|
||||
log = append(log, "0:MAIN")
|
||||
t.RetNil()
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
vm.SetOnExit(func() { log = append(log, "4:ONEXIT") })
|
||||
vm.Run("MAIN")
|
||||
|
||||
// Expected order:
|
||||
// 0:MAIN (during execution)
|
||||
// 1:EXIT_PROC (phase 1)
|
||||
// 2:ATEXIT (phase 2)
|
||||
// WA:CloseAll (phase 3)
|
||||
// 4:ONEXIT (phase 7)
|
||||
|
||||
t.Logf("Full sequence: %v", log)
|
||||
|
||||
expected := []string{"0:MAIN", "1:EXIT_PROC", "2:ATEXIT", "WA:CloseAll", "4:ONEXIT"}
|
||||
if len(log) != len(expected) {
|
||||
t.Fatalf("expected %d entries, got %d: %v", len(expected), len(log), log)
|
||||
}
|
||||
for i, want := range expected {
|
||||
if log[i] != want {
|
||||
t.Errorf("log[%d]: got %q, want %q", i, log[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logContains(list []string, s string) bool {
|
||||
for _, item := range list {
|
||||
if strings.Contains(item, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user