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:
2026-03-31 10:12:34 +09:00
parent 272576f6ce
commit 6b37cc19e4
3 changed files with 435 additions and 10 deletions

View File

@@ -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
View 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
}