From 6b37cc19e47bc5f862c5b6cac833f259c0e260d2 Mon Sep 17 00:00:00 2001 From: Charles KWON OhJun Date: Tue, 31 Mar 2026 10:12:34 +0900 Subject: [PATCH] =?UTF-8?q?test:=208=20shutdown=20tests=20=E2=80=94=20EXIT?= =?UTF-8?q?,=20AtExit,=20WorkArea,=20panic-safe,=20statics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/.pdca-status.json | 50 +++++- hbrt/shutdown.go | 18 +- hbrt/shutdown_test.go | 377 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 435 insertions(+), 10 deletions(-) create mode 100644 hbrt/shutdown_test.go diff --git a/docs/.pdca-status.json b/docs/.pdca-status.json index d7cb2ea..cac8696 100644 --- a/docs/.pdca-status.json +++ b/docs/.pdca-status.json @@ -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" } ] } \ No newline at end of file diff --git a/hbrt/shutdown.go b/hbrt/shutdown.go index d86b4ad..cbe4a67 100644 --- a/hbrt/shutdown.go +++ b/hbrt/shutdown.go @@ -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() }) diff --git a/hbrt/shutdown_test.go b/hbrt/shutdown_test.go new file mode 100644 index 0000000..9566537 --- /dev/null +++ b/hbrt/shutdown_test.go @@ -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 +}