From 92ebd1ba5f9584fc6314ef1881c4773ee630b192 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 15:37:15 +0700
Subject: [PATCH 01/24] phase3: port frozen module registry

---
 pyfrozen/doc.go           |   2 +
 pyfrozen/registry.go      | 135 ++++++++++++++++++++++++++++++++++++++
 pyfrozen/registry_test.go | 122 ++++++++++++++++++++++++++++++++++
 3 files changed, 259 insertions(+)
 create mode 100644 pyfrozen/doc.go
 create mode 100644 pyfrozen/registry.go
 create mode 100644 pyfrozen/registry_test.go

diff --git a/pyfrozen/doc.go b/pyfrozen/doc.go
new file mode 100644
index 0000000..1938d96
--- /dev/null
+++ b/pyfrozen/doc.go
@@ -0,0 +1,2 @@
+// Package pyfrozen ports CPython frozen-module registry state.
+package pyfrozen
diff --git a/pyfrozen/registry.go b/pyfrozen/registry.go
new file mode 100644
index 0000000..bd22333
--- /dev/null
+++ b/pyfrozen/registry.go
@@ -0,0 +1,135 @@
+package pyfrozen
+
+// Module mirrors one entry in CPython's struct _frozen tables.
+type Module struct {
+	Name      string
+	Code      []byte
+	IsPackage bool
+}
+
+// Alias mirrors one entry in CPython's struct _module_alias table.
+// Target is empty when the alias intentionally has no import target.
+type Alias struct {
+	Name   string
+	Target string
+}
+
+var bootstrapModules = []Module{
+	{Name: "_frozen_importlib"},
+	{Name: "_frozen_importlib_external"},
+	{Name: "zipimport"},
+}
+
+var stdlibModules = []Module{
+	{Name: "abc"},
+	{Name: "codecs"},
+	{Name: "io"},
+	{Name: "_collections_abc"},
+	{Name: "_sitebuiltins"},
+	{Name: "genericpath"},
+	{Name: "ntpath"},
+	{Name: "posixpath"},
+	{Name: "os"},
+	{Name: "site"},
+	{Name: "stat"},
+	{Name: "importlib.util"},
+	{Name: "importlib.machinery"},
+	{Name: "runpy"},
+}
+
+var testModules = []Module{
+	{Name: "__hello__"},
+	{Name: "__hello_alias__"},
+	{Name: "__phello_alias__", IsPackage: true},
+	{Name: "__phello_alias__.spam"},
+	{Name: "__phello__", IsPackage: true},
+	{Name: "__phello__.__init__"},
+	{Name: "__phello__.ham", IsPackage: true},
+	{Name: "__phello__.ham.__init__"},
+	{Name: "__phello__.ham.eggs"},
+	{Name: "__phello__.spam"},
+	{Name: "__hello_only__"},
+}
+
+var aliases = []Alias{
+	{Name: "_frozen_importlib", Target: "importlib._bootstrap"},
+	{Name: "_frozen_importlib_external", Target: "importlib._bootstrap_external"},
+	{Name: "__hello_alias__", Target: "__hello__"},
+	{Name: "__phello_alias__", Target: "__hello__"},
+	{Name: "__phello_alias__.spam", Target: "__hello__"},
+	{Name: "__phello__.__init__", Target: "<__phello__"},
+	{Name: "__phello__.ham.__init__", Target: "<__phello__.ham"},
+	{Name: "__hello_only__"},
+}
+
+// FrozenModules matches CPython's embedding override pointer.
+// A nil slice means the built-in registry tables remain in effect.
+var FrozenModules []Module
+
+func BootstrapModules() []Module {
+	return cloneModules(bootstrapModules)
+}
+
+func StdlibModules() []Module {
+	return cloneModules(stdlibModules)
+}
+
+func TestModules() []Module {
+	return cloneModules(testModules)
+}
+
+func Aliases() []Alias {
+	return cloneAliases(aliases)
+}
+
+func SetFrozenModules(modules []Module) {
+	FrozenModules = cloneModules(modules)
+}
+
+func LookupBootstrap(name string) (Module, bool) {
+	return lookupModule(bootstrapModules, name)
+}
+
+func LookupStdlib(name string) (Module, bool) {
+	return lookupModule(stdlibModules, name)
+}
+
+func LookupTest(name string) (Module, bool) {
+	return lookupModule(testModules, name)
+}
+
+func LookupAlias(name string) (Alias, bool) {
+	for _, alias := range aliases {
+		if alias.Name == name {
+			return alias, true
+		}
+	}
+	return Alias{}, false
+}
+
+func cloneModules(src []Module) []Module {
+	if len(src) == 0 {
+		return nil
+	}
+	dst := make([]Module, len(src))
+	copy(dst, src)
+	return dst
+}
+
+func cloneAliases(src []Alias) []Alias {
+	if len(src) == 0 {
+		return nil
+	}
+	dst := make([]Alias, len(src))
+	copy(dst, src)
+	return dst
+}
+
+func lookupModule(src []Module, name string) (Module, bool) {
+	for _, module := range src {
+		if module.Name == name {
+			return module, true
+		}
+	}
+	return Module{}, false
+}
diff --git a/pyfrozen/registry_test.go b/pyfrozen/registry_test.go
new file mode 100644
index 0000000..cdb975e
--- /dev/null
+++ b/pyfrozen/registry_test.go
@@ -0,0 +1,122 @@
+package pyfrozen
+
+import "testing"
+
+func TestBootstrapRegistryShape(t *testing.T) {
+	modules := BootstrapModules()
+	if len(modules) != 3 {
+		t.Fatalf("bootstrap module count = %d, want 3", len(modules))
+	}
+	if modules[0].Name != "_frozen_importlib" {
+		t.Fatalf("bootstrap[0] = %q", modules[0].Name)
+	}
+	if modules[1].Name != "_frozen_importlib_external" {
+		t.Fatalf("bootstrap[1] = %q", modules[1].Name)
+	}
+	if modules[2].Name != "zipimport" {
+		t.Fatalf("bootstrap[2] = %q", modules[2].Name)
+	}
+}
+
+func TestStdlibRegistryContainsStartupModules(t *testing.T) {
+	for _, name := range []string{
+		"abc",
+		"codecs",
+		"io",
+		"_collections_abc",
+		"_sitebuiltins",
+		"genericpath",
+		"ntpath",
+		"posixpath",
+		"os",
+		"site",
+		"stat",
+		"importlib.util",
+		"importlib.machinery",
+		"runpy",
+	} {
+		if _, ok := LookupStdlib(name); !ok {
+			t.Fatalf("missing stdlib frozen module %q", name)
+		}
+	}
+}
+
+func TestTestRegistryPackageFlags(t *testing.T) {
+	cases := map[string]bool{
+		"__hello__":               false,
+		"__phello_alias__":        true,
+		"__phello__":              true,
+		"__phello__.ham":          true,
+		"__phello__.ham.eggs":     false,
+		"__phello__.spam":         false,
+		"__hello_only__":          false,
+		"__phello_alias__.spam":   false,
+		"__phello__.__init__":     false,
+		"__phello__.ham.__init__": false,
+	}
+	for name, wantPackage := range cases {
+		module, ok := LookupTest(name)
+		if !ok {
+			t.Fatalf("missing test frozen module %q", name)
+		}
+		if module.IsPackage != wantPackage {
+			t.Fatalf("%q package flag = %t, want %t", name, module.IsPackage, wantPackage)
+		}
+	}
+}
+
+func TestAliasTableMatchesFrozenC(t *testing.T) {
+	cases := map[string]string{
+		"_frozen_importlib":          "importlib._bootstrap",
+		"_frozen_importlib_external": "importlib._bootstrap_external",
+		"__hello_alias__":            "__hello__",
+		"__phello_alias__":           "__hello__",
+		"__phello_alias__.spam":      "__hello__",
+		"__phello__.__init__":        "<__phello__",
+		"__phello__.ham.__init__":    "<__phello__.ham",
+		"__hello_only__":             "",
+	}
+	for name, want := range cases {
+		alias, ok := LookupAlias(name)
+		if !ok {
+			t.Fatalf("missing alias %q", name)
+		}
+		if alias.Target != want {
+			t.Fatalf("%q target = %q, want %q", name, alias.Target, want)
+		}
+	}
+}
+
+func TestRegistrySlicesAreCloned(t *testing.T) {
+	modules := BootstrapModules()
+	modules[0].Name = "changed"
+	if got, _ := LookupBootstrap("_frozen_importlib"); got.Name != "_frozen_importlib" {
+		t.Fatalf("bootstrap registry mutated to %q", got.Name)
+	}
+
+	aliasList := Aliases()
+	aliasList[0].Target = "changed"
+	alias, _ := LookupAlias("_frozen_importlib")
+	if alias.Target != "importlib._bootstrap" {
+		t.Fatalf("alias registry mutated to %q", alias.Target)
+	}
+}
+
+func TestEmbeddingOverrideCopiesInput(t *testing.T) {
+	SetFrozenModules([]Module{{Name: "demo", IsPackage: true}})
+	if len(FrozenModules) != 1 || FrozenModules[0].Name != "demo" || !FrozenModules[0].IsPackage {
+		t.Fatalf("FrozenModules = %#v", FrozenModules)
+	}
+
+	override := []Module{{Name: "changed"}}
+	SetFrozenModules(override)
+	override[0].Name = "mutated"
+	if FrozenModules[0].Name != "changed" {
+		t.Fatalf("FrozenModules mutated through caller slice: %#v", FrozenModules)
+	}
+
+	SetFrozenModules(nil)
+	if FrozenModules != nil {
+		t.Fatalf("FrozenModules reset = %#v, want nil", FrozenModules)
+	}
+}

From 08639ac039e78a37e73772858b9c1241dea77274 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 15:48:50 +0700
Subject: [PATCH 02/24] phase3: port interpreter and preconfig helpers

---
 pyconfig/doc.go               |   2 +
 pyconfig/interpconfig.go      | 178 +++++++++++++++++++++++
 pyconfig/interpconfig_test.go | 140 ++++++++++++++++++
 pyconfig/preconfig.go         | 261 ++++++++++++++++++++++++++++++++++
 pyconfig/preconfig_test.go    | 119 ++++++++++++++++
 5 files changed, 700 insertions(+)
 create mode 100644 pyconfig/doc.go
 create mode 100644 pyconfig/interpconfig.go
 create mode 100644 pyconfig/interpconfig_test.go
 create mode 100644 pyconfig/preconfig.go
 create mode 100644 pyconfig/preconfig_test.go

diff --git a/pyconfig/doc.go b/pyconfig/doc.go
new file mode 100644
index 0000000..339155a
--- /dev/null
+++ b/pyconfig/doc.go
@@ -0,0 +1,2 @@
+// Package pyconfig ports CPython initialization and interpreter config helpers.
+package pyconfig
diff --git a/pyconfig/interpconfig.go b/pyconfig/interpconfig.go
new file mode 100644
index 0000000..b883e9b
--- /dev/null
+++ b/pyconfig/interpconfig.go
@@ -0,0 +1,178 @@
+package pyconfig
+
+import "fmt"
+
+type GILMode int
+
+const (
+	DefaultGIL GILMode = iota
+	SharedGIL
+	OwnGIL
+)
+
+const (
+	RuntimeFlagUseMainObmalloc uint32 = 1 << iota
+	RuntimeFlagFork
+	RuntimeFlagExec
+	RuntimeFlagThreads
+	RuntimeFlagDaemonThreads
+	RuntimeFlagMultiInterpExtensions
+)
+
+type InterpreterConfig struct {
+	UseMainObmalloc            bool
+	AllowFork                  bool
+	AllowExec                  bool
+	AllowThreads               bool
+	AllowDaemonThreads         bool
+	CheckMultiInterpExtensions bool
+	GIL                        GILMode
+}
+
+type InterpreterStateSnapshot struct {
+	FeatureFlags uint32
+	OwnGIL       bool
+}
+
+func (mode GILMode) String() (string, error) {
+	switch mode {
+	case DefaultGIL:
+		return "default", nil
+	case SharedGIL:
+		return "shared", nil
+	case OwnGIL:
+		return "own", nil
+	default:
+		return "", fmt.Errorf("invalid interpreter config 'gil' value")
+	}
+}
+
+func (config InterpreterConfig) AsMap() (map[string]any, error) {
+	gil, err := config.GIL.String()
+	if err != nil {
+		return nil, err
+	}
+	return map[string]any{
+		"use_main_obmalloc":             config.UseMainObmalloc,
+		"allow_fork":                    config.AllowFork,
+		"allow_exec":                    config.AllowExec,
+		"allow_threads":                 config.AllowThreads,
+		"allow_daemon_threads":          config.AllowDaemonThreads,
+		"check_multi_interp_extensions": config.CheckMultiInterpExtensions,
+		"gil":                           gil,
+	}, nil
+}
+
+func InitInterpreterConfigFromMap(config *InterpreterConfig, dict map[string]any) error {
+	if config == nil {
+		return fmt.Errorf("nil interpreter config")
+	}
+	return interpreterConfigFromMap(config, dict, false)
+}
+
+func UpdateInterpreterConfigFromMap(config *InterpreterConfig, dict map[string]any) error {
+	if config == nil {
+		return fmt.Errorf("nil interpreter config")
+	}
+	return interpreterConfigFromMap(config, dict, true)
+}
+
+func (config *InterpreterConfig) InitFromState(interp InterpreterStateSnapshot) {
+	config.UseMainObmalloc = interp.FeatureFlags&RuntimeFlagUseMainObmalloc != 0
+	config.AllowFork = interp.FeatureFlags&RuntimeFlagFork != 0
+	config.AllowExec = interp.FeatureFlags&RuntimeFlagExec != 0
+	config.AllowThreads = interp.FeatureFlags&RuntimeFlagThreads != 0
+	config.AllowDaemonThreads = interp.FeatureFlags&RuntimeFlagDaemonThreads != 0
+	config.CheckMultiInterpExtensions = interp.FeatureFlags&RuntimeFlagMultiInterpExtensions != 0
+	if interp.OwnGIL {
+		config.GIL = OwnGIL
+	} else {
+		config.GIL = SharedGIL
+	}
+}
+
+func interpreterConfigFromMap(config *InterpreterConfig, dict map[string]any, missingAllowed bool) error {
+	working := make(map[string]any, len(dict))
+	for key, value := range dict {
+		working[key] = value
+	}
+
+	if err := copyBoolField(working, "use_main_obmalloc", &config.UseMainObmalloc, missingAllowed); err != nil {
+		return err
+	}
+	if err := copyBoolField(working, "allow_fork", &config.AllowFork, missingAllowed); err != nil {
+		return err
+	}
+	if err := copyBoolField(working, "allow_exec", &config.AllowExec, missingAllowed); err != nil {
+		return err
+	}
+	if err := copyBoolField(working, "allow_threads", &config.AllowThreads, missingAllowed); err != nil {
+		return err
+	}
+	if err := copyBoolField(working, "allow_daemon_threads", &config.AllowDaemonThreads, missingAllowed); err != nil {
+		return err
+	}
+	if err := copyBoolField(working, "check_multi_interp_extensions", &config.CheckMultiInterpExtensions, missingAllowed); err != nil {
+		return err
+	}
+
+	if value, ok := working["gil"]; ok {
+		text, ok := value.(string)
+		if !ok {
+			return invalidConfigType("gil")
+		}
+		mode, err := parseGILMode(text)
+		if err != nil {
+			return err
+		}
+		config.GIL = mode
+		delete(working, "gil")
+	} else if !missingAllowed {
+		return missingConfigKey("gil")
+	}
+
+	if extra := len(working); extra == 1 {
+		return fmt.Errorf("config dict has 1 extra item")
+	} else if extra > 0 {
+		return fmt.Errorf("config dict has %d extra items", extra)
+	}
+	return nil
+}
+
+func copyBoolField(dict map[string]any, name string, dst *bool, missingAllowed bool) error {
+	value, ok := dict[name]
+	if !ok {
+		if missingAllowed {
+			return nil
+		}
+		return missingConfigKey(name)
+	}
+	flag, ok := value.(bool)
+	if !ok {
+		return invalidConfigType(name)
+	}
+	*dst = flag
+	delete(dict, name)
+	return nil
+}
+
+func parseGILMode(text string) (GILMode, error) {
+	switch text {
+	case "", "default":
+		return DefaultGIL, nil
+	case "shared":
+		return SharedGIL, nil
+	case "own":
+		return OwnGIL, nil
+	default:
+		return DefaultGIL, fmt.Errorf("unsupported interpreter config .gil value %q", text)
+	}
+}
+
+func missingConfigKey(name string) error {
+	return fmt.Errorf("missing config key: %s", name)
+}
+
+func invalidConfigType(name string) error {
+	return fmt.Errorf("invalid config type: %s", name)
+}
diff --git a/pyconfig/interpconfig_test.go b/pyconfig/interpconfig_test.go
new file mode 100644
index 0000000..1d75c63
--- /dev/null
+++ b/pyconfig/interpconfig_test.go
@@ -0,0 +1,140 @@
+package pyconfig
+
+import "testing"
+
+func TestInterpreterConfigAsMap(t *testing.T) {
+	config := InterpreterConfig{
+		UseMainObmalloc:            true,
+		AllowFork:                  true,
+		AllowExec:                  false,
+		AllowThreads:               true,
+		AllowDaemonThreads:         false,
+		CheckMultiInterpExtensions: true,
+		GIL:                        OwnGIL,
+	}
+	dict, err := config.AsMap()
+	if err != nil {
+		t.Fatalf("AsMap returned error: %v", err)
+	}
+	if dict["gil"] != "own" {
+		t.Fatalf("gil = %v, want own", dict["gil"])
+	}
+	if dict["allow_exec"] != false {
+		t.Fatalf("allow_exec = %v, want false", dict["allow_exec"])
+	}
+}
+
+func TestInterpreterConfigAsMapRejectsInvalidGIL(t *testing.T) {
+	_, err := InterpreterConfig{GIL: GILMode(99)}.AsMap()
+	if err == nil {
+		t.Fatal("AsMap should reject invalid GIL mode")
+	}
+}
+
+func TestInitInterpreterConfigFromMap(t *testing.T) {
+	var config InterpreterConfig
+	err := InitInterpreterConfigFromMap(&config, map[string]any{
+		"use_main_obmalloc":             true,
+		"allow_fork":                    false,
+		"allow_exec":                    true,
+		"allow_threads":                 true,
+		"allow_daemon_threads":          false,
+		"check_multi_interp_extensions": true,
+		"gil":                           "shared",
+	})
+	if err != nil {
+		t.Fatalf("InitInterpreterConfigFromMap returned error: %v", err)
+	}
+	if !config.UseMainObmalloc || config.AllowFork || !config.AllowExec || !config.AllowThreads {
+		t.Fatalf("config = %#v", config)
+	}
+	if config.GIL != SharedGIL {
+		t.Fatalf("GIL = %v, want SharedGIL", config.GIL)
+	}
+}
+
+func TestInitInterpreterConfigFromMapErrors(t *testing.T) {
+	tests := []map[string]any{
+		{
+			"use_main_obmalloc":             true,
+			"allow_fork":                    false,
+			"allow_exec":                    true,
+			"allow_threads":                 true,
+			"allow_daemon_threads":          false,
+			"check_multi_interp_extensions": true,
+		},
+		{
+			"use_main_obmalloc":             true,
+			"allow_fork":                    false,
+			"allow_exec":                    true,
+			"allow_threads":                 true,
+			"allow_daemon_threads":          false,
+			"check_multi_interp_extensions": "bad",
+			"gil":                           "shared",
+		},
+		{
+			"use_main_obmalloc":             true,
+			"allow_fork":                    false,
+			"allow_exec":                    true,
+			"allow_threads":                 true,
+			"allow_daemon_threads":          false,
+			"check_multi_interp_extensions": true,
+			"gil":                           "wrong",
+		},
+		{
+			"use_main_obmalloc":             true,
+			"allow_fork":                    false,
+			"allow_exec":                    true,
+			"allow_threads":                 true,
+			"allow_daemon_threads":          false,
+			"check_multi_interp_extensions": true,
+			"gil":                           "shared",
+			"extra":                         1,
+		},
+	}
+	for _, dict := range tests {
+		var config InterpreterConfig
+		if err := InitInterpreterConfigFromMap(&config, dict); err == nil {
+			t.Fatalf("InitInterpreterConfigFromMap(%v) should fail", dict)
+		}
+	}
+}
+
+func TestUpdateInterpreterConfigFromMap(t *testing.T) {
+	config := InterpreterConfig{
+		UseMainObmalloc:            true,
+		AllowFork:                  true,
+		AllowExec:                  true,
+		AllowThreads:               false,
+		AllowDaemonThreads:         false,
+		CheckMultiInterpExtensions: false,
+		GIL:                        SharedGIL,
+	}
+	err := UpdateInterpreterConfigFromMap(&config, map[string]any{
+		"allow_threads": true,
+		"gil":           "own",
+	})
+	if err != nil {
+		t.Fatalf("UpdateInterpreterConfigFromMap returned error: %v", err)
+	}
+	if !config.AllowThreads || config.GIL != OwnGIL || !config.UseMainObmalloc || !config.AllowFork {
+		t.Fatalf("config = %#v", config)
+	}
+}
+
+func TestInterpreterConfigInitFromState(t *testing.T) {
+	var config InterpreterConfig
+	config.InitFromState(InterpreterStateSnapshot{
+		FeatureFlags: RuntimeFlagUseMainObmalloc |
+			RuntimeFlagExec |
+			RuntimeFlagThreads |
+			RuntimeFlagMultiInterpExtensions,
+		OwnGIL: true,
+	})
+	if !config.UseMainObmalloc || config.AllowFork || !config.AllowExec || !config.AllowThreads {
+		t.Fatalf("config = %#v", config)
+	}
+	if !config.CheckMultiInterpExtensions || config.GIL != OwnGIL {
+		t.Fatalf("config = %#v", config)
+	}
+}
diff --git a/pyconfig/preconfig.go b/pyconfig/preconfig.go
new file mode 100644
index 0000000..872330f
--- /dev/null
+++ b/pyconfig/preconfig.go
@@ -0,0 +1,261 @@
+package pyconfig
+
+import (
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+	"sync"
+
+	"github.com/tamnd/gopython/pyos"
+)
+
+type ConfigInit int
+
+const (
+	ConfigInitCompat ConfigInit = iota
+	ConfigInitPython
+	ConfigInitIsolated
+)
+
+const MemoryAllocatorNotSet = -1
+
+type PreConfig struct {
+	ConfigInit              ConfigInit
+	ParseArgv               int
+	Isolated                int
+	UseEnvironment          int
+	ConfigureLocale         int
+	CoerceCLocale           int
+	CoerceCLocaleWarn       int
+	LegacyWindowsFSEncoding int
+	UTF8Mode                int
+	DevMode                 int
+	Allocator               int
+}
+
+type Argv struct {
+	UseBytesArgv bool
+	BytesArgv    [][]byte
+	WideArgv     [][]rune
+}
+
+type WideStringList struct {
+	Items [][]rune
+}
+
+type fileSystemEncodingState struct {
+	mu       sync.Mutex
+	encoding string
+	errors   string
+}
+
+var fsEncodingState fileSystemEncodingState
+
+func ClearFileSystemEncoding() {
+	fsEncodingState.mu.Lock()
+	defer fsEncodingState.mu.Unlock()
+	fsEncodingState.encoding = ""
+	fsEncodingState.errors = ""
+}
+
+func SetFileSystemEncoding(encoding, errors string) {
+	fsEncodingState.mu.Lock()
+	defer fsEncodingState.mu.Unlock()
+	fsEncodingState.encoding = encoding
+	fsEncodingState.errors = errors
+}
+
+func FileSystemEncoding() string {
+	fsEncodingState.mu.Lock()
+	defer fsEncodingState.mu.Unlock()
+	return fsEncodingState.encoding
+}
+
+func FileSystemErrors() string {
+	fsEncodingState.mu.Lock()
+	defer fsEncodingState.mu.Unlock()
+	return fsEncodingState.errors
+}
+
+func ArgvAsWideStringList(args *Argv, list *WideStringList) error {
+	var result WideStringList
+	if args.UseBytesArgv {
+		result.Items = make([][]rune, 0, len(args.BytesArgv))
+		for _, arg := range args.BytesArgv {
+			text, err := pyos.DecodeLocale(arg)
+			if err != nil {
+				return err
+			}
+			result.Items = append(result.Items, append([]rune(nil), text...))
+		}
+	} else {
+		result.Items = cloneRuneList(args.WideArgv)
+	}
+	list.Clear()
+	list.Items = result.Items
+	return nil
+}
+
+func (list *WideStringList) Clear() {
+	list.Items = nil
+}
+
+func (list *WideStringList) Append(item []rune) {
+	list.Items = append(list.Items, append([]rune(nil), item...))
+}
+
+func InitCompatPreConfig(config *PreConfig) {
+	*config = PreConfig{
+		ConfigInit:              ConfigInitCompat,
+		ParseArgv:               0,
+		Isolated:                -1,
+		UseEnvironment:          -1,
+		ConfigureLocale:         1,
+		CoerceCLocale:           0,
+		CoerceCLocaleWarn:       0,
+		LegacyWindowsFSEncoding: -1,
+		UTF8Mode:                0,
+		DevMode:                 -1,
+		Allocator:               MemoryAllocatorNotSet,
+	}
+}
+
+func InitPythonPreConfig(config *PreConfig) {
+	InitCompatPreConfig(config)
+	config.ConfigInit = ConfigInitPython
+	config.Isolated = 0
+	config.ParseArgv = 1
+	config.UseEnvironment = 1
+	config.CoerceCLocale = -1
+	config.CoerceCLocaleWarn = -1
+	config.UTF8Mode = -1
+	config.LegacyWindowsFSEncoding = 0
+}
+
+func InitIsolatedPreConfig(config *PreConfig) {
+	InitCompatPreConfig(config)
+	config.ConfigInit = ConfigInitIsolated
+	config.ConfigureLocale = 0
+	config.Isolated = 1
+	config.UseEnvironment = 0
+	config.UTF8Mode = 0
+	config.DevMode = 0
+	config.LegacyWindowsFSEncoding = 0
+}
+
+func (config PreConfig) AsMap() map[string]int {
+	return map[string]int{
+		"_config_init":               int(config.ConfigInit),
+		"parse_argv":                 config.ParseArgv,
+		"isolated":                   config.Isolated,
+		"use_environment":            config.UseEnvironment,
+		"configure_locale":           config.ConfigureLocale,
+		"coerce_c_locale":            config.CoerceCLocale,
+		"coerce_c_locale_warn":       config.CoerceCLocaleWarn,
+		"legacy_windows_fs_encoding": config.LegacyWindowsFSEncoding,
+		"utf8_mode":                  config.UTF8Mode,
+		"dev_mode":                   config.DevMode,
+		"allocator":                  config.Allocator,
+	}
+}
+
+func GetEnv(useEnvironment int, name string) string {
+	if useEnvironment <= 0 {
+		return ""
+	}
+	value := os.Getenv(name)
+	if value == "" {
+		return ""
+	}
+	return value
+}
+
+func StrToInt(text string) (int, error) {
+	value, err := strconv.ParseInt(text, 10, 0)
+	if err != nil {
+		return 0, err
+	}
+	return int(value), nil
+}
+
+func GetEnvFlag(useEnvironment int, flag *int, name string) {
+	value := GetEnv(useEnvironment, name)
+	if value == "" {
+		return
+	}
+	parsed, err := StrToInt(value)
+	if err != nil || parsed < 0 {
+		parsed = 1
+	}
+	if *flag < parsed {
+		*flag = parsed
+	}
+}
+
+func GetXOption(xoptions WideStringList, name string) []rune {
+	nameRunes := []rune(name)
+	for _, option := range xoptions.Items {
+		text := string(option)
+		if index := strings.IndexRune(text, '='); index >= 0 {
+			option = option[:index]
+		}
+		if runesEqual(option, nameRunes) {
+			return option
+		}
+	}
+	return nil
+}
+
+func cloneRuneList(src [][]rune) [][]rune {
+	if len(src) == 0 {
+		return nil
+	}
+	dst := make([][]rune, len(src))
+	for i, item := range src {
+		dst[i] = append([]rune(nil), item...)
+	}
+	return dst
+}
+
+func runesEqual(left, right []rune) bool {
+	if len(left) != len(right) {
+		return false
+	}
+	for i := range left {
+		if left[i] != right[i] {
+			return false
+		}
+	}
+	return true
+}
+
+func (config *PreConfig) CopyFrom(other PreConfig) {
+	*config = other
+}
+
+func (list WideStringList) StringItems() []string {
+	out := make([]string, len(list.Items))
+	for i, item := range list.Items {
+		out[i] = string(item)
+	}
+	return out
+}
+
+func ValidateUTF8XOption(option []rune) (int, error) {
+	text := string(option)
+	if text == "utf8" {
+		return 1, nil
+	}
+	if strings.HasPrefix(text, "utf8=") {
+		switch strings.TrimPrefix(text, "utf8=") {
+		case "0":
+			return 0, nil
+		case "1":
+			return 1, nil
+		default:
+			return 0, fmt.Errorf("invalid -X utf8 option value")
+		}
+	}
+	return -1, nil
+}
diff --git a/pyconfig/preconfig_test.go b/pyconfig/preconfig_test.go
new file mode 100644
index 0000000..2d5057c
--- /dev/null
+++ b/pyconfig/preconfig_test.go
@@ -0,0 +1,119 @@
+package pyconfig
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestFileSystemEncodingState(t *testing.T) {
+	ClearFileSystemEncoding()
+	SetFileSystemEncoding("utf-8", "surrogateescape")
+	if got := FileSystemEncoding(); got != "utf-8" {
+		t.Fatalf("FileSystemEncoding = %q, want utf-8", got)
+	}
+	if got := FileSystemErrors(); got != "surrogateescape" {
+		t.Fatalf("FileSystemErrors = %q, want surrogateescape", got)
+	}
+	ClearFileSystemEncoding()
+	if FileSystemEncoding() != "" || FileSystemErrors() != "" {
+		t.Fatal("ClearFileSystemEncoding should clear both values")
+	}
+}
+
+func TestArgvAsWideStringListBytes(t *testing.T) {
+	args := &Argv{
+		UseBytesArgv: true,
+		BytesArgv: [][]byte{
+			[]byte("python"),
+			{'a', 0x80, 'b'},
+		},
+	}
+	var list WideStringList
+	if err := ArgvAsWideStringList(args, &list); err != nil {
+		t.Fatalf("ArgvAsWideStringList returned error: %v", err)
+	}
+	if got := string(list.Items[0]); got != "python" {
+		t.Fatalf("argv[0] = %q", got)
+	}
+	want := []rune{'a', 0xdc80, 'b'}
+	if !reflect.DeepEqual(list.Items[1], want) {
+		t.Fatalf("argv[1] = %#v, want %#v", list.Items[1], want)
+	}
+}
+
+func TestArgvAsWideStringListWideCopy(t *testing.T) {
+	args := &Argv{
+		WideArgv: [][]rune{[]rune("python"), []rune("script.py")},
+	}
+	var list WideStringList
+	if err := ArgvAsWideStringList(args, &list); err != nil {
+		t.Fatalf("ArgvAsWideStringList returned error: %v", err)
+	}
+	args.WideArgv[0][0] = 'X'
+	if got := string(list.Items[0]); got != "python" {
+		t.Fatalf("copied argv mutated to %q", got)
+	}
+}
+
+func TestPreConfigInitializers(t *testing.T) {
+	var compat PreConfig
+	InitCompatPreConfig(&compat)
+	if compat.ConfigInit != ConfigInitCompat || compat.UseEnvironment != -1 || compat.UTF8Mode != 0 {
+		t.Fatalf("compat = %#v", compat)
+	}
+
+	var python PreConfig
+	InitPythonPreConfig(&python)
+	if python.ConfigInit != ConfigInitPython || python.ParseArgv != 1 || python.UseEnvironment != 1 || python.UTF8Mode != -1 {
+		t.Fatalf("python = %#v", python)
+	}
+
+	var isolated PreConfig
+	InitIsolatedPreConfig(&isolated)
+	if isolated.ConfigInit != ConfigInitIsolated || isolated.Isolated != 1 || isolated.UseEnvironment != 0 || isolated.ConfigureLocale != 0 {
+		t.Fatalf("isolated = %#v", isolated)
+	}
+}
+
+func TestGetEnvHelpers(t *testing.T) {
+	t.Setenv("PYCONFIG_TEST_FLAG", "2")
+	flag := 0
+	GetEnvFlag(1, &flag, "PYCONFIG_TEST_FLAG")
+	if flag != 2 {
+		t.Fatalf("flag = %d, want 2", flag)
+	}
+	if GetEnv(0, "PYCONFIG_TEST_FLAG") != "" {
+		t.Fatal("GetEnv should ignore env when disabled")
+	}
+}
+
+func TestGetEnvFlagInvalidFallsBackToOne(t *testing.T) {
+	t.Setenv("PYCONFIG_TEST_BAD", "bad")
+	flag := 0
+	GetEnvFlag(1, &flag, "PYCONFIG_TEST_BAD")
+	if flag != 1 {
+		t.Fatalf("flag = %d, want 1", flag)
+	}
+}
+
+func TestGetXOption(t *testing.T) {
+	options := WideStringList{Items: [][]rune{[]rune("dev"), []rune("utf8=1")}}
+	if got := GetXOption(options, "dev"); string(got) != "dev" {
+		t.Fatalf("GetXOption(dev) = %q", string(got))
+	}
+	if got := GetXOption(options, "utf8"); got == nil {
+		t.Fatal("GetXOption should match option names before =")
+	}
+}
+
+func TestValidateUTF8XOption(t *testing.T) {
+	if got, err := ValidateUTF8XOption([]rune("utf8")); err != nil || got != 1 {
+		t.Fatalf("ValidateUTF8XOption utf8 = (%d, %v)", got, err)
+	}
+	if got, err := ValidateUTF8XOption([]rune("utf8=0")); err != nil || got != 0 {
+		t.Fatalf("ValidateUTF8XOption utf8=0 = (%d, %v)", got, err)
+	}
+	if _, err := ValidateUTF8XOption([]rune("utf8=bad")); err == nil {
+		t.Fatal("ValidateUTF8XOption should reject invalid values")
+	}
+}

From 6c890c6d381718d181a20b94466b0833ae204cd8 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 15:51:31 +0700
Subject: [PATCH 03/24] phase3: port config data model and list helpers

---
 pyconfig/config.go      | 296 ++++++++++++++++++++++++++++++++++++++++
 pyconfig/config_test.go |  97 +++++++++++++
 pyconfig/preconfig.go   |   6 +-
 3 files changed, 396 insertions(+), 3 deletions(-)
 create mode 100644 pyconfig/config.go
 create mode 100644 pyconfig/config_test.go

diff --git a/pyconfig/config.go b/pyconfig/config.go
new file mode 100644
index 0000000..44241b7
--- /dev/null
+++ b/pyconfig/config.go
@@ -0,0 +1,296 @@
+package pyconfig
+
+import "github.com/tamnd/gopython/pyos"
+
+const DefaultMaxStrDigits = 4300
+
+type Config struct {
+	ConfigInit            ConfigInit
+	Isolated              int
+	UseEnvironment        int
+	DevMode               int
+	InstallSignalHandlers int
+	UseHashSeed           int
+	HashSeed              uint64
+	Faulthandler          int
+	Tracemalloc           int
+	PerfProfiling         int
+	RemoteDebug           int
+	ImportTime            int
+	CodeDebugRanges       int
+	ShowRefCount          int
+	DumpRefs              int
+	DumpRefsFile          []rune
+	FilesystemEncoding    []rune
+	FilesystemErrors      []rune
+	PycachePrefix         []rune
+	ParseArgv             int
+	OrigArgv              WideStringList
+	Argv                  WideStringList
+	XOptions              WideStringList
+	WarnOptions           WideStringList
+	SiteImport            int
+	BytesWarning          int
+	WarnDefaultEncoding   int
+	Inspect               int
+	Interactive           int
+	OptimizationLevel     int
+	ParserDebug           int
+	WriteBytecode         int
+	Verbose               int
+	Quiet                 int
+	UserSiteDirectory     int
+	ConfigureCStdio       int
+	BufferedStdio         int
+	StdioEncoding         []rune
+	StdioErrors           []rune
+	LegacyWindowsStdio    int
+	CheckHashPycsMode     []rune
+	UseFrozenModules      int
+	SafePath              int
+	IntMaxStrDigits       int
+	ThreadInheritContext  int
+	ContextAwareWarnings  int
+	CPUCount              int
+	PathconfigWarnings    int
+	ProgramName           []rune
+	PythonpathEnv         []rune
+	Home                  []rune
+	Platlibdir            []rune
+	ModuleSearchPathsSet  int
+	ModuleSearchPaths     WideStringList
+	StdlibDir             []rune
+	Executable            []rune
+	BaseExecutable        []rune
+	Prefix                []rune
+	BasePrefix            []rune
+	ExecPrefix            []rune
+	BaseExecPrefix        []rune
+	SkipSourceFirstLine   int
+	RunCommand            []rune
+	RunModule             []rune
+	RunFilename           []rune
+	SysPath0              []rune
+	InstallImportlib      int
+	InitMain              int
+	IsPythonBuild         int
+}
+
+func InitCompatConfig(config *Config) {
+	*config = Config{}
+	config.ConfigInit = ConfigInitCompat
+	config.ImportTime = -1
+	config.Isolated = -1
+	config.UseEnvironment = -1
+	config.DevMode = -1
+	config.InstallSignalHandlers = 1
+	config.UseHashSeed = -1
+	config.Faulthandler = -1
+	config.Tracemalloc = -1
+	config.PerfProfiling = -1
+	config.RemoteDebug = -1
+	config.ModuleSearchPathsSet = 0
+	config.ParseArgv = 0
+	config.SiteImport = -1
+	config.BytesWarning = -1
+	config.WarnDefaultEncoding = 0
+	config.Inspect = -1
+	config.Interactive = -1
+	config.OptimizationLevel = -1
+	config.ParserDebug = -1
+	config.WriteBytecode = -1
+	config.Verbose = -1
+	config.Quiet = -1
+	config.UserSiteDirectory = -1
+	config.ConfigureCStdio = 0
+	config.BufferedStdio = -1
+	config.InstallImportlib = 1
+	config.PathconfigWarnings = -1
+	config.InitMain = 1
+	config.LegacyWindowsStdio = -1
+	config.UseFrozenModules = 1
+	config.SafePath = 0
+	config.IntMaxStrDigits = -1
+	config.IsPythonBuild = 0
+	config.CodeDebugRanges = 1
+	config.CPUCount = -1
+	config.ThreadInheritContext = 0
+	config.ContextAwareWarnings = 0
+}
+
+func InitPythonConfig(config *Config) {
+	initConfigDefaults(config)
+	config.ConfigInit = ConfigInitPython
+	config.ConfigureCStdio = 1
+	config.ParseArgv = 1
+}
+
+func InitIsolatedConfig(config *Config) {
+	initConfigDefaults(config)
+	config.ConfigInit = ConfigInitIsolated
+	config.Isolated = 1
+	config.UseEnvironment = 0
+	config.UserSiteDirectory = 0
+	config.DevMode = 0
+	config.InstallSignalHandlers = 0
+	config.UseHashSeed = 0
+	config.Tracemalloc = 0
+	config.PerfProfiling = 0
+	config.IntMaxStrDigits = DefaultMaxStrDigits
+	config.SafePath = 1
+	config.PathconfigWarnings = 0
+	config.ThreadInheritContext = 0
+	config.LegacyWindowsStdio = 0
+}
+
+func initConfigDefaults(config *Config) {
+	InitCompatConfig(config)
+	config.Isolated = 0
+	config.UseEnvironment = 1
+	config.SiteImport = 1
+	config.BytesWarning = 0
+	config.Inspect = 0
+	config.Interactive = 0
+	config.OptimizationLevel = 0
+	config.ParserDebug = 0
+	config.WriteBytecode = 1
+	config.Verbose = 0
+	config.Quiet = 0
+	config.UserSiteDirectory = 1
+	config.BufferedStdio = 1
+	config.PathconfigWarnings = 1
+	config.LegacyWindowsStdio = 0
+}
+
+func (config *Config) Clear() {
+	config.PycachePrefix = nil
+	config.PythonpathEnv = nil
+	config.Home = nil
+	config.ProgramName = nil
+	config.Argv.Clear()
+	config.WarnOptions.Clear()
+	config.XOptions.Clear()
+	config.ModuleSearchPaths.Clear()
+	config.ModuleSearchPathsSet = 0
+	config.StdlibDir = nil
+	config.Executable = nil
+	config.BaseExecutable = nil
+	config.Prefix = nil
+	config.BasePrefix = nil
+	config.ExecPrefix = nil
+	config.BaseExecPrefix = nil
+	config.Platlibdir = nil
+	config.SysPath0 = nil
+	config.FilesystemEncoding = nil
+	config.FilesystemErrors = nil
+	config.StdioEncoding = nil
+	config.StdioErrors = nil
+	config.RunCommand = nil
+	config.RunModule = nil
+	config.RunFilename = nil
+	config.CheckHashPycsMode = nil
+	config.OrigArgv.Clear()
+}
+
+func SetString(config *Config, field *[]rune, text []rune) {
+	_ = config
+	*field = cloneRunes(text)
+}
+
+func SetBytesString(config *Config, field *[]rune, text []byte) error {
+	_ = config
+	if text == nil {
+		*field = nil
+		return nil
+	}
+	decoded, err := pyos.DecodeLocale(text)
+	if err != nil {
+		return err
+	}
+	*field = cloneRunes(decoded)
+	return nil
+}
+
+func (config *Config) CopyFrom(other *Config) error {
+	config.Clear()
+	*config = *other
+	config.DumpRefsFile = cloneRunes(other.DumpRefsFile)
+	config.FilesystemEncoding = cloneRunes(other.FilesystemEncoding)
+	config.FilesystemErrors = cloneRunes(other.FilesystemErrors)
+	config.PycachePrefix = cloneRunes(other.PycachePrefix)
+	config.StdioEncoding = cloneRunes(other.StdioEncoding)
+	config.StdioErrors = cloneRunes(other.StdioErrors)
+	config.CheckHashPycsMode = cloneRunes(other.CheckHashPycsMode)
+	config.ProgramName = cloneRunes(other.ProgramName)
+	config.PythonpathEnv = cloneRunes(other.PythonpathEnv)
+	config.Home = cloneRunes(other.Home)
+	config.Platlibdir = cloneRunes(other.Platlibdir)
+	config.StdlibDir = cloneRunes(other.StdlibDir)
+	config.Executable = cloneRunes(other.Executable)
+	config.BaseExecutable = cloneRunes(other.BaseExecutable)
+	config.Prefix = cloneRunes(other.Prefix)
+	config.BasePrefix = cloneRunes(other.BasePrefix)
+	config.ExecPrefix = cloneRunes(other.ExecPrefix)
+	config.BaseExecPrefix = cloneRunes(other.BaseExecPrefix)
+	config.RunCommand = cloneRunes(other.RunCommand)
+	config.RunModule = cloneRunes(other.RunModule)
+	config.RunFilename = cloneRunes(other.RunFilename)
+	config.SysPath0 = cloneRunes(other.SysPath0)
+	config.OrigArgv.Items = cloneRuneList(other.OrigArgv.Items)
+	config.Argv.Items = cloneRuneList(other.Argv.Items)
+	config.XOptions.Items = cloneRuneList(other.XOptions.Items)
+	config.WarnOptions.Items = cloneRuneList(other.WarnOptions.Items)
+	config.ModuleSearchPaths.Items = cloneRuneList(other.ModuleSearchPaths.Items)
+	return nil
+}
+
+func (list *WideStringList) Insert(index int, item []rune) error {
+	if index < 0 {
+		return invalidWideStringListIndex()
+	}
+	if index > len(list.Items) {
+		index = len(list.Items)
+	}
+	itemCopy := cloneRunes(item)
+	list.Items = append(list.Items, nil)
+	copy(list.Items[index+1:], list.Items[index:])
+	list.Items[index] = itemCopy
+	return nil
+}
+
+func (list *WideStringList) AppendWide(item []rune) error {
+	return list.Insert(len(list.Items), item)
+}
+
+func (list *WideStringList) Extend(other WideStringList) error {
+	for _, item := range other.Items {
+		if err := list.AppendWide(item); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (list *WideStringList) CopyFrom(other WideStringList) {
+	list.Items = cloneRuneList(other.Items)
+}
+
+func (list WideStringList) Find(item []rune) bool {
+	for _, current := range list.Items {
+		if runesEqual(current, item) {
+			return true
+		}
+	}
+	return false
+}
+
+func cloneRunes(src []rune) []rune {
+	if src == nil {
+		return nil
+	}
+	return append([]rune(nil), src...)
+}
+
+func invalidWideStringListIndex() error {
+	return missingConfigKey("PyWideStringList_Insert index must be >= 0")
+}
diff --git a/pyconfig/config_test.go b/pyconfig/config_test.go
new file mode 100644
index 0000000..148fc79
--- /dev/null
+++ b/pyconfig/config_test.go
@@ -0,0 +1,97 @@
+package pyconfig
+
+import "testing"
+
+func TestConfigInitializers(t *testing.T) {
+	var compat Config
+	InitCompatConfig(&compat)
+	if compat.ConfigInit != ConfigInitCompat || compat.UseEnvironment != -1 || compat.ConfigureCStdio != 0 {
+		t.Fatalf("compat = %#v", compat)
+	}
+
+	var python Config
+	InitPythonConfig(&python)
+	if python.ConfigInit != ConfigInitPython || python.ParseArgv != 1 || python.UseEnvironment != 1 || python.ConfigureCStdio != 1 {
+		t.Fatalf("python = %#v", python)
+	}
+
+	var isolated Config
+	InitIsolatedConfig(&isolated)
+	if isolated.ConfigInit != ConfigInitIsolated || isolated.Isolated != 1 || isolated.SafePath != 1 || isolated.IntMaxStrDigits != DefaultMaxStrDigits {
+		t.Fatalf("isolated = %#v", isolated)
+	}
+}
+
+func TestConfigClear(t *testing.T) {
+	config := Config{
+		PycachePrefix:        []rune("cache"),
+		FilesystemEncoding:   []rune("utf-8"),
+		Argv:                 WideStringList{Items: [][]rune{[]rune("python")}},
+		ModuleSearchPaths:    WideStringList{Items: [][]rune{[]rune("/tmp")}},
+		ModuleSearchPathsSet: 1,
+	}
+	config.Clear()
+	if config.PycachePrefix != nil || config.FilesystemEncoding != nil {
+		t.Fatalf("clear left string fields populated: %#v", config)
+	}
+	if len(config.Argv.Items) != 0 || len(config.ModuleSearchPaths.Items) != 0 || config.ModuleSearchPathsSet != 0 {
+		t.Fatalf("clear left list fields populated: %#v", config)
+	}
+}
+
+func TestSetStringAndBytesString(t *testing.T) {
+	var config Config
+	var field []rune
+	SetString(&config, &field, []rune("value"))
+	if string(field) != "value" {
+		t.Fatalf("SetString = %q", string(field))
+	}
+	if err := SetBytesString(&config, &field, []byte{'a', 0x80, 'b'}); err != nil {
+		t.Fatalf("SetBytesString returned error: %v", err)
+	}
+	if len(field) != 3 || field[1] != 0xdc80 {
+		t.Fatalf("SetBytesString = %#v", field)
+	}
+}
+
+func TestConfigCopyFromClones(t *testing.T) {
+	src := &Config{
+		FilesystemEncoding: []rune("utf-8"),
+		Argv:               WideStringList{Items: [][]rune{[]rune("python")}},
+	}
+	var dst Config
+	if err := dst.CopyFrom(src); err != nil {
+		t.Fatalf("CopyFrom returned error: %v", err)
+	}
+	src.FilesystemEncoding[0] = 'X'
+	src.Argv.Items[0][0] = 'X'
+	if string(dst.FilesystemEncoding) != "utf-8" {
+		t.Fatalf("copied encoding mutated to %q", string(dst.FilesystemEncoding))
+	}
+	if string(dst.Argv.Items[0]) != "python" {
+		t.Fatalf("copied argv mutated to %q", string(dst.Argv.Items[0]))
+	}
+}
+
+func TestWideStringListHelpers(t *testing.T) {
+	var list WideStringList
+	if err := list.Insert(0, []rune("b")); err != nil {
+		t.Fatalf("Insert returned error: %v", err)
+	}
+	if err := list.Insert(0, []rune("a")); err != nil {
+		t.Fatalf("Insert returned error: %v", err)
+	}
+	if err := list.AppendWide([]rune("c")); err != nil {
+		t.Fatalf("AppendWide returned error: %v", err)
+	}
+	if !list.Find([]rune("b")) || list.Find([]rune("missing")) {
+		t.Fatalf("Find returned wrong result for %#v", list.Items)
+	}
+
+	var other WideStringList
+	other.CopyFrom(list)
+	list.Items[0][0] = 'X'
+	if string(other.Items[0]) != "a" {
+		t.Fatalf("CopyFrom mutated to %q", string(other.Items[0]))
+	}
+}
diff --git a/pyconfig/preconfig.go b/pyconfig/preconfig.go
index 872330f..ffa9c20 100644
--- a/pyconfig/preconfig.go
+++ b/pyconfig/preconfig.go
@@ -13,9 +13,9 @@ import (
 type ConfigInit int
 
 const (
-	ConfigInitCompat ConfigInit = iota
-	ConfigInitPython
-	ConfigInitIsolated
+	ConfigInitCompat   ConfigInit = 1
+	ConfigInitPython   ConfigInit = 2
+	ConfigInitIsolated ConfigInit = 3
 )
 
 const MemoryAllocatorNotSet = -1

From 74b70625ab8cfeb832715acf5461c0ed975eb57a Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 15:55:32 +0700
Subject: [PATCH 04/24] phase3: port context and frozen entry helpers

---
 pycontext/context.go         | 362 +++++++++++++++++++++++++++++++++++
 pycontext/context_test.go    | 130 +++++++++++++
 pycontext/doc.go             |   2 +
 pyruntime/doc.go             |   2 +
 pyruntime/frozenmain.go      |  90 +++++++++
 pyruntime/frozenmain_test.go | 108 +++++++++++
 6 files changed, 694 insertions(+)
 create mode 100644 pycontext/context.go
 create mode 100644 pycontext/context_test.go
 create mode 100644 pycontext/doc.go
 create mode 100644 pyruntime/doc.go
 create mode 100644 pyruntime/frozenmain.go
 create mode 100644 pyruntime/frozenmain_test.go

diff --git a/pycontext/context.go b/pycontext/context.go
new file mode 100644
index 0000000..7ee4309
--- /dev/null
+++ b/pycontext/context.go
@@ -0,0 +1,362 @@
+package pycontext
+
+import (
+	"fmt"
+	"sync"
+)
+
+const MaxWatchers = 8
+
+type Event int
+
+const (
+	ContextSwitched Event = 1
+)
+
+type WatchCallback func(Event, *Context) error
+
+type Context struct {
+	mu      sync.RWMutex
+	vars    map[*Var]any
+	prev    *Context
+	entered bool
+}
+
+type Var struct {
+	name       string
+	defaultVal any
+}
+
+type Token struct {
+	ctx    *Context
+	varRef *Var
+	oldVal any
+	hasOld bool
+	used   bool
+}
+
+type Module struct {
+	CopyContext func() *Context
+	ContextType any
+	VarType     any
+	TokenType   any
+}
+
+var runtimeState = struct {
+	mu       sync.Mutex
+	current  *Context
+	version  uint64
+	watchers [MaxWatchers]WatchCallback
+}{
+	current: newEmptyContext(),
+}
+
+func New() *Context {
+	return newEmptyContext()
+}
+
+func Copy(ctx *Context) *Context {
+	if ctx == nil {
+		return nil
+	}
+	ctx.mu.RLock()
+	defer ctx.mu.RUnlock()
+	return newContextFromVars(ctx.vars)
+}
+
+func CopyCurrent() *Context {
+	runtimeState.mu.Lock()
+	defer runtimeState.mu.Unlock()
+	if runtimeState.current == nil {
+		runtimeState.current = newEmptyContext()
+	}
+	return newContextFromVars(runtimeState.current.snapshotLocked())
+}
+
+func Enter(ctx *Context) error {
+	if ctx == nil {
+		return fmt.Errorf("an instance of Context was expected")
+	}
+	runtimeState.mu.Lock()
+	defer runtimeState.mu.Unlock()
+	if ctx.entered {
+		return fmt.Errorf("cannot enter context: context is already entered")
+	}
+	ctx.prev = runtimeState.current
+	ctx.entered = true
+	runtimeState.current = ctx
+	runtimeState.version++
+	notifyWatchersLocked(ContextSwitched, runtimeState.current)
+	return nil
+}
+
+func Exit(ctx *Context) error {
+	if ctx == nil {
+		return fmt.Errorf("an instance of Context was expected")
+	}
+	runtimeState.mu.Lock()
+	defer runtimeState.mu.Unlock()
+	if !ctx.entered {
+		return fmt.Errorf("cannot exit context: context has not been entered")
+	}
+	if runtimeState.current != ctx {
+		return fmt.Errorf("cannot exit context: thread state references a different context object")
+	}
+	runtimeState.current = ctx.prev
+	ctx.prev = nil
+	ctx.entered = false
+	runtimeState.version++
+	notifyWatchersLocked(ContextSwitched, runtimeState.current)
+	return nil
+}
+
+func (ctx *Context) Run(fn func() any) (any, error) {
+	if fn == nil {
+		return nil, fmt.Errorf("run() missing 1 required positional argument")
+	}
+	if err := Enter(ctx); err != nil {
+		return nil, err
+	}
+	result := fn()
+	if err := Exit(ctx); err != nil {
+		return nil, err
+	}
+	return result, nil
+}
+
+func (ctx *Context) Get(key *Var, defaultValue any) (any, bool, error) {
+	if key == nil {
+		return nil, false, fmt.Errorf("a ContextVar key was expected")
+	}
+	ctx.mu.RLock()
+	defer ctx.mu.RUnlock()
+	value, ok := ctx.vars[key]
+	if ok {
+		return value, true, nil
+	}
+	if defaultValue != nil {
+		return defaultValue, false, nil
+	}
+	return nil, false, nil
+}
+
+func (ctx *Context) Keys() []*Var {
+	ctx.mu.RLock()
+	defer ctx.mu.RUnlock()
+	out := make([]*Var, 0, len(ctx.vars))
+	for key := range ctx.vars {
+		out = append(out, key)
+	}
+	return out
+}
+
+func (ctx *Context) Values() []any {
+	ctx.mu.RLock()
+	defer ctx.mu.RUnlock()
+	out := make([]any, 0, len(ctx.vars))
+	for _, value := range ctx.vars {
+		out = append(out, value)
+	}
+	return out
+}
+
+func (ctx *Context) Items() [][2]any {
+	ctx.mu.RLock()
+	defer ctx.mu.RUnlock()
+	out := make([][2]any, 0, len(ctx.vars))
+	for key, value := range ctx.vars {
+		out = append(out, [2]any{key, value})
+	}
+	return out
+}
+
+func (ctx *Context) Len() int {
+	ctx.mu.RLock()
+	defer ctx.mu.RUnlock()
+	return len(ctx.vars)
+}
+
+func (ctx *Context) Contains(key *Var) bool {
+	ctx.mu.RLock()
+	defer ctx.mu.RUnlock()
+	_, ok := ctx.vars[key]
+	return ok
+}
+
+func (ctx *Context) Copy() *Context {
+	return Copy(ctx)
+}
+
+func NewVar(name string, def any) *Var {
+	return &Var{name: name, defaultVal: def}
+}
+
+func (v *Var) Name() string {
+	return v.name
+}
+
+func (v *Var) Default() any {
+	return v.defaultVal
+}
+
+func Get(v *Var, def any) (any, error) {
+	if v == nil {
+		return nil, fmt.Errorf("an instance of ContextVar was expected")
+	}
+	runtimeState.mu.Lock()
+	if runtimeState.current == nil {
+		runtimeState.current = newEmptyContext()
+	}
+	current := runtimeState.current
+	runtimeState.mu.Unlock()
+
+	current.mu.RLock()
+	value, ok := current.vars[v]
+	current.mu.RUnlock()
+	if ok {
+		return value, nil
+	}
+	if def != nil {
+		return def, nil
+	}
+	if v.defaultVal != nil {
+		return v.defaultVal, nil
+	}
+	return nil, nil
+}
+
+func Set(v *Var, value any) (*Token, error) {
+	if v == nil {
+		return nil, fmt.Errorf("an instance of ContextVar was expected")
+	}
+	ctx := currentContext()
+	ctx.mu.Lock()
+	old, ok := ctx.vars[v]
+	ctx.vars[v] = value
+	ctx.mu.Unlock()
+	return &Token{ctx: ctx, varRef: v, oldVal: old, hasOld: ok}, nil
+}
+
+func Reset(v *Var, tok *Token) error {
+	if v == nil {
+		return fmt.Errorf("an instance of ContextVar was expected")
+	}
+	if tok == nil {
+		return fmt.Errorf("an instance of Token was expected")
+	}
+	if tok.used {
+		return fmt.Errorf("token has already been used once")
+	}
+	if tok.varRef != v {
+		return fmt.Errorf("token was created by a different ContextVar")
+	}
+	ctx := currentContext()
+	if ctx != tok.ctx {
+		return fmt.Errorf("token was created in a different Context")
+	}
+	tok.used = true
+	ctx.mu.Lock()
+	defer ctx.mu.Unlock()
+	if tok.hasOld {
+		ctx.vars[v] = tok.oldVal
+		return nil
+	}
+	if _, ok := ctx.vars[v]; !ok {
+		return fmt.Errorf("lookup error")
+	}
+	delete(ctx.vars, v)
+	return nil
+}
+
+func (v *Var) Get(defaultValue any) (any, error) {
+	return Get(v, defaultValue)
+}
+
+func (v *Var) Set(value any) (*Token, error) {
+	return Set(v, value)
+}
+
+func (v *Var) Reset(tok *Token) error {
+	return Reset(v, tok)
+}
+
+func AddWatcher(callback WatchCallback) (int, error) {
+	runtimeState.mu.Lock()
+	defer runtimeState.mu.Unlock()
+	for i, current := range runtimeState.watchers {
+		if current == nil {
+			runtimeState.watchers[i] = callback
+			return i, nil
+		}
+	}
+	return -1, fmt.Errorf("no more context watcher IDs available")
+}
+
+func ClearWatcher(id int) error {
+	runtimeState.mu.Lock()
+	defer runtimeState.mu.Unlock()
+	if id < 0 || id >= MaxWatchers {
+		return fmt.Errorf("invalid context watcher ID %d", id)
+	}
+	if runtimeState.watchers[id] == nil {
+		return fmt.Errorf("no context watcher set for ID %d", id)
+	}
+	runtimeState.watchers[id] = nil
+	return nil
+}
+
+func InitModule() Module {
+	return Module{
+		CopyContext: CopyCurrent,
+		ContextType: (*Context)(nil),
+		VarType:     (*Var)(nil),
+		TokenType:   (*Token)(nil),
+	}
+}
+
+func ResetRuntimeForTests() {
+	runtimeState.mu.Lock()
+	defer runtimeState.mu.Unlock()
+	runtimeState.current = newEmptyContext()
+	runtimeState.version = 0
+	runtimeState.watchers = [MaxWatchers]WatchCallback{}
+}
+
+func currentContext() *Context {
+	runtimeState.mu.Lock()
+	defer runtimeState.mu.Unlock()
+	if runtimeState.current == nil {
+		runtimeState.current = newEmptyContext()
+	}
+	return runtimeState.current
+}
+
+func newEmptyContext() *Context {
+	return &Context{vars: map[*Var]any{}}
+}
+
+func newContextFromVars(vars map[*Var]any) *Context {
+	copyVars := make(map[*Var]any, len(vars))
+	for key, value := range vars {
+		copyVars[key] = value
+	}
+	return &Context{vars: copyVars}
+}
+
+func (ctx *Context) snapshotLocked() map[*Var]any {
+	ctx.mu.RLock()
+	defer ctx.mu.RUnlock()
+	out := make(map[*Var]any, len(ctx.vars))
+	for key, value := range ctx.vars {
+		out[key] = value
+	}
+	return out
+}
+
+func notifyWatchersLocked(event Event, ctx *Context) {
+	for _, watcher := range runtimeState.watchers {
+		if watcher != nil {
+			_ = watcher(event, ctx)
+		}
+	}
+}
diff --git a/pycontext/context_test.go b/pycontext/context_test.go
new file mode 100644
index 0000000..53c17e3
--- /dev/null
+++ b/pycontext/context_test.go
@@ -0,0 +1,130 @@
+package pycontext
+
+import "testing"
+
+func TestCopyCurrentAndCopy(t *testing.T) {
+	ResetRuntimeForTests()
+	varA := NewVar("a", nil)
+	if _, err := Set(varA, 1); err != nil {
+		t.Fatalf("Set returned error: %v", err)
+	}
+	current := CopyCurrent()
+	value, ok, err := current.Get(varA, nil)
+	if err != nil || !ok || value != 1 {
+		t.Fatalf("current.Get = (%v, %t, %v)", value, ok, err)
+	}
+	cloned := current.Copy()
+	if _, err := Set(varA, 2); err != nil {
+		t.Fatalf("Set returned error: %v", err)
+	}
+	value, ok, err = cloned.Get(varA, nil)
+	if err != nil || !ok || value != 1 {
+		t.Fatalf("cloned.Get = (%v, %t, %v)", value, ok, err)
+	}
+}
+
+func TestEnterExitAndRun(t *testing.T) {
+	ResetRuntimeForTests()
+	ctx := New()
+	v := NewVar("v", nil)
+	ctx.mu.Lock()
+	ctx.vars[v] = 10
+	ctx.mu.Unlock()
+
+	if err := Enter(ctx); err != nil {
+		t.Fatalf("Enter returned error: %v", err)
+	}
+	if value, err := Get(v, nil); err != nil || value != 10 {
+		t.Fatalf("Get in entered context = (%v, %v)", value, err)
+	}
+	if err := Exit(ctx); err != nil {
+		t.Fatalf("Exit returned error: %v", err)
+	}
+
+	value, err := ctx.Run(func() any {
+		got, innerErr := Get(v, nil)
+		if innerErr != nil {
+			t.Fatalf("inner Get returned error: %v", innerErr)
+		}
+		return got
+	})
+	if err != nil || value != 10 {
+		t.Fatalf("Run = (%v, %v)", value, err)
+	}
+}
+
+func TestVarSetGetReset(t *testing.T) {
+	ResetRuntimeForTests()
+	v := NewVar("flag", "default")
+	value, err := Get(v, nil)
+	if err != nil || value != "default" {
+		t.Fatalf("default Get = (%v, %v)", value, err)
+	}
+	token, err := Set(v, "new")
+	if err != nil {
+		t.Fatalf("Set returned error: %v", err)
+	}
+	value, err = Get(v, nil)
+	if err != nil || value != "new" {
+		t.Fatalf("set Get = (%v, %v)", value, err)
+	}
+	if err := Reset(v, token); err != nil {
+		t.Fatalf("Reset returned error: %v", err)
+	}
+	value, err = Get(v, nil)
+	if err != nil || value != "default" {
+		t.Fatalf("reset Get = (%v, %v)", value, err)
+	}
+}
+
+func TestContextCollections(t *testing.T) {
+	ResetRuntimeForTests()
+	ctx := New()
+	v1 := NewVar("a", nil)
+	v2 := NewVar("b", nil)
+	ctx.mu.Lock()
+	ctx.vars[v1] = 1
+	ctx.vars[v2] = 2
+	ctx.mu.Unlock()
+	if ctx.Len() != 2 || !ctx.Contains(v1) || ctx.Contains(NewVar("c", nil)) {
+		t.Fatalf("context collection helpers failed")
+	}
+	if len(ctx.Keys()) != 2 || len(ctx.Values()) != 2 || len(ctx.Items()) != 2 {
+		t.Fatalf("context iter helpers failed")
+	}
+}
+
+func TestWatchers(t *testing.T) {
+	ResetRuntimeForTests()
+	ctx := New()
+	calls := 0
+	id, err := AddWatcher(func(event Event, current *Context) error {
+		if event != ContextSwitched {
+			t.Fatalf("event = %v", event)
+		}
+		calls++
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("AddWatcher returned error: %v", err)
+	}
+	if err := Enter(ctx); err != nil {
+		t.Fatalf("Enter returned error: %v", err)
+	}
+	if err := Exit(ctx); err != nil {
+		t.Fatalf("Exit returned error: %v", err)
+	}
+	if calls != 2 {
+		t.Fatalf("watcher calls = %d, want 2", calls)
+	}
+	if err := ClearWatcher(id); err != nil {
+		t.Fatalf("ClearWatcher returned error: %v", err)
+	}
+}
+
+func TestInitModule(t *testing.T) {
+	module := InitModule()
+	if module.CopyContext == nil || module.ContextType == nil || module.VarType == nil || module.TokenType == nil {
+		t.Fatalf("module = %#v", module)
+	}
+}
diff --git a/pycontext/doc.go b/pycontext/doc.go
new file mode 100644
index 0000000..fa98c1e
--- /dev/null
+++ b/pycontext/doc.go
@@ -0,0 +1,2 @@
+// Package pycontext ports CPython contextvars runtime state.
+package pycontext
diff --git a/pyruntime/doc.go b/pyruntime/doc.go
new file mode 100644
index 0000000..5b18923
--- /dev/null
+++ b/pyruntime/doc.go
@@ -0,0 +1,2 @@
+// Package pyruntime ports CPython runtime entry helpers.
+package pyruntime
diff --git a/pyruntime/frozenmain.go b/pyruntime/frozenmain.go
new file mode 100644
index 0000000..4a41231
--- /dev/null
+++ b/pyruntime/frozenmain.go
@@ -0,0 +1,90 @@
+package pyruntime
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/tamnd/gopython/pyconfig"
+)
+
+type FrozenMainHooks struct {
+	SetBytesArgv     func(config *pyconfig.Config, argv [][]byte) error
+	Initialize       func(config *pyconfig.Config) error
+	SetRunningMain   func() error
+	ClearRunningMain func()
+	InitWindows      func()
+	TermWindows      func()
+	ImportFrozenMain func() (int, error)
+	RunStdin         func() error
+	Finalize         func() error
+	Version          func() string
+	Copyright        func() string
+	Stdout           *os.File
+	StdinIsTTY       bool
+}
+
+func FrozenMain(argv [][]byte, hooks FrozenMainHooks) (int, error) {
+	var config pyconfig.Config
+	pyconfig.InitPythonConfig(&config)
+	config.PathconfigWarnings = 0
+	config.ParseArgv = 0
+
+	if hooks.SetBytesArgv != nil {
+		if err := hooks.SetBytesArgv(&config, argv); err != nil {
+			return 1, err
+		}
+	}
+
+	inspect := os.Getenv("PYTHONINSPECT") != ""
+
+	if hooks.InitWindows != nil {
+		hooks.InitWindows()
+	}
+
+	if hooks.Initialize != nil {
+		if err := hooks.Initialize(&config); err != nil {
+			return 1, err
+		}
+	}
+
+	if hooks.SetRunningMain != nil {
+		if err := hooks.SetRunningMain(); err != nil {
+			return 1, err
+		}
+	}
+
+	if config.Verbose > 0 && hooks.Stdout != nil && hooks.Version != nil && hooks.Copyright != nil {
+		_, _ = fmt.Fprintf(hooks.Stdout, "Python %s\n%s\n", hooks.Version(), hooks.Copyright())
+	}
+
+	status := 1
+	if hooks.ImportFrozenMain != nil {
+		n, err := hooks.ImportFrozenMain()
+		if err != nil {
+			status = 1
+		} else if n == 0 {
+			return 1, fmt.Errorf("the __main__ module is not frozen")
+		} else if n > 0 {
+			status = 0
+		}
+	}
+
+	if inspect && hooks.StdinIsTTY && hooks.RunStdin != nil {
+		if err := hooks.RunStdin(); err != nil {
+			status = 1
+		}
+	}
+
+	if hooks.TermWindows != nil {
+		hooks.TermWindows()
+	}
+	if hooks.ClearRunningMain != nil {
+		hooks.ClearRunningMain()
+	}
+	if hooks.Finalize != nil {
+		if err := hooks.Finalize(); err != nil {
+			return 120, nil
+		}
+	}
+	return status, nil
+}
diff --git a/pyruntime/frozenmain_test.go b/pyruntime/frozenmain_test.go
new file mode 100644
index 0000000..1d01c99
--- /dev/null
+++ b/pyruntime/frozenmain_test.go
@@ -0,0 +1,108 @@
+package pyruntime
+
+import (
+	"bytes"
+	"os"
+	"testing"
+
+	"github.com/tamnd/gopython/pyconfig"
+)
+
+func TestFrozenMainSuccess(t *testing.T) {
+	t.Setenv("PYTHONINSPECT", "")
+	var sequence []string
+	code, err := FrozenMain([][]byte{[]byte("prog")}, FrozenMainHooks{
+		SetBytesArgv: func(config *pyconfig.Config, argv [][]byte) error {
+			sequence = append(sequence, "argv")
+			return nil
+		},
+		Initialize: func(config *pyconfig.Config) error {
+			sequence = append(sequence, "init")
+			return nil
+		},
+		SetRunningMain: func() error {
+			sequence = append(sequence, "set-running")
+			return nil
+		},
+		ImportFrozenMain: func() (int, error) {
+			sequence = append(sequence, "import")
+			return 1, nil
+		},
+		ClearRunningMain: func() {
+			sequence = append(sequence, "clear-running")
+		},
+		Finalize: func() error {
+			sequence = append(sequence, "finalize")
+			return nil
+		},
+	})
+	if err != nil || code != 0 {
+		t.Fatalf("FrozenMain = (%d, %v)", code, err)
+	}
+	if len(sequence) != 6 {
+		t.Fatalf("sequence = %#v", sequence)
+	}
+}
+
+func TestFrozenMainFrozenMissing(t *testing.T) {
+	_, err := FrozenMain(nil, FrozenMainHooks{
+		ImportFrozenMain: func() (int, error) { return 0, nil },
+	})
+	if err == nil {
+		t.Fatal("FrozenMain should reject missing __main__")
+	}
+}
+
+func TestFrozenMainInspectAndVerbose(t *testing.T) {
+	t.Setenv("PYTHONINSPECT", "1")
+	tmp, err := os.CreateTemp(t.TempDir(), "frozenmain-out")
+	if err != nil {
+		t.Fatalf("CreateTemp returned error: %v", err)
+	}
+	defer tmp.Close()
+	ranStdin := false
+	code, err := FrozenMain(nil, FrozenMainHooks{
+		Initialize: func(config *pyconfig.Config) error {
+			config.Verbose = 1
+			return nil
+		},
+		ImportFrozenMain: func() (int, error) { return 1, nil },
+		RunStdin: func() error {
+			ranStdin = true
+			return nil
+		},
+		Finalize: func() error { return nil },
+		Version:  func() string { return "3.14" },
+		Copyright: func() string {
+			return "copyright"
+		},
+		Stdout:     tmp,
+		StdinIsTTY: true,
+	})
+	if err != nil || code != 0 {
+		t.Fatalf("FrozenMain = (%d, %v)", code, err)
+	}
+	if !ranStdin {
+		t.Fatal("RunStdin should have run")
+	}
+	if _, err := tmp.Seek(0, 0); err != nil {
+		t.Fatalf("Seek returned error: %v", err)
+	}
+	data, err := os.ReadFile(tmp.Name())
+	if err != nil {
+		t.Fatalf("ReadFile returned error: %v", err)
+	}
+	if !bytes.Contains(data, []byte("Python 3.14")) {
+		t.Fatalf("verbose output = %q", string(data))
+	}
+}
+
+func TestFrozenMainFinalizeFailureUses120(t *testing.T) {
+	code, err := FrozenMain(nil, FrozenMainHooks{
+		ImportFrozenMain: func() (int, error) { return 1, nil },
+		Finalize:         func() error { return os.ErrInvalid },
+	})
+	if err != nil || code != 120 {
+		t.Fatalf("FrozenMain = (%d, %v)", code, err)
+	}
+}

From 09f45dd91e919b9a2f87577b3728ab12680efad1 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 15:58:39 +0700
Subject: [PATCH 05/24] phase3: port runtime state and cross-interpreter
 helpers

---
 pycrossinterp/doc.go           |   2 +
 pycrossinterp/errors.go        |  39 ++++++++
 pycrossinterp/registry.go      |  78 ++++++++++++++++
 pycrossinterp/registry_test.go |  46 +++++++++
 pyruntime/pythonrun.go         |  52 +++++++++++
 pyruntime/pythonrun_test.go    |  45 +++++++++
 pystate/doc.go                 |   2 +
 pystate/errors.go              |  10 ++
 pystate/state.go               | 165 +++++++++++++++++++++++++++++++++
 pystate/state_test.go          |  77 +++++++++++++++
 10 files changed, 516 insertions(+)
 create mode 100644 pycrossinterp/doc.go
 create mode 100644 pycrossinterp/errors.go
 create mode 100644 pycrossinterp/registry.go
 create mode 100644 pycrossinterp/registry_test.go
 create mode 100644 pyruntime/pythonrun.go
 create mode 100644 pyruntime/pythonrun_test.go
 create mode 100644 pystate/doc.go
 create mode 100644 pystate/errors.go
 create mode 100644 pystate/state.go
 create mode 100644 pystate/state_test.go

diff --git a/pycrossinterp/doc.go b/pycrossinterp/doc.go
new file mode 100644
index 0000000..a83f193
--- /dev/null
+++ b/pycrossinterp/doc.go
@@ -0,0 +1,2 @@
+// Package pycrossinterp ports CPython cross-interpreter registry and errors.
+package pycrossinterp
diff --git a/pycrossinterp/errors.go b/pycrossinterp/errors.go
new file mode 100644
index 0000000..216f5a5
--- /dev/null
+++ b/pycrossinterp/errors.go
@@ -0,0 +1,39 @@
+package pycrossinterp
+
+import "fmt"
+
+type InterpreterError struct{ Message string }
+
+func (e *InterpreterError) Error() string { return e.Message }
+
+type InterpreterNotFoundError struct{ Message string }
+
+func (e *InterpreterNotFoundError) Error() string { return e.Message }
+
+type NotShareableError struct {
+	Message string
+	Cause   error
+}
+
+func (e *NotShareableError) Error() string { return e.Message }
+
+func SetNotShareableError(msg string) error {
+	return &NotShareableError{Message: msg}
+}
+
+func FormatNotShareableError(format string, args ...any) error {
+	return &NotShareableError{Message: fmt.Sprintf(format, args...)}
+}
+
+func UnwrapNotShareableError(err error) error {
+	if err == nil {
+		return nil
+	}
+	if ns, ok := err.(*NotShareableError); ok {
+		if ns.Cause != nil {
+			return ns.Cause
+		}
+		return ns
+	}
+	return nil
+}
diff --git a/pycrossinterp/registry.go b/pycrossinterp/registry.go
new file mode 100644
index 0000000..3c6bf4d
--- /dev/null
+++ b/pycrossinterp/registry.go
@@ -0,0 +1,78 @@
+package pycrossinterp
+
+import (
+	"reflect"
+	"sync"
+)
+
+type GetDataFunc func(any) (any, error)
+
+type RegistryItem struct {
+	Type    reflect.Type
+	GetData GetDataFunc
+}
+
+type Registry struct {
+	mu          sync.Mutex
+	initialized bool
+	items       []RegistryItem
+}
+
+type LookupState struct {
+	Global Registry
+	Local  Registry
+}
+
+func (registry *Registry) Init() {
+	registry.mu.Lock()
+	defer registry.mu.Unlock()
+	if registry.initialized {
+		return
+	}
+	registry.initialized = true
+}
+
+func (registry *Registry) Fini() {
+	registry.mu.Lock()
+	defer registry.mu.Unlock()
+	registry.initialized = false
+	registry.items = nil
+}
+
+func (registry *Registry) Add(sample any, getdata GetDataFunc) {
+	registry.mu.Lock()
+	defer registry.mu.Unlock()
+	registry.items = append(registry.items, RegistryItem{
+		Type:    reflect.TypeOf(sample),
+		GetData: getdata,
+	})
+}
+
+func (registry *Registry) Lookup(obj any) GetDataFunc {
+	registry.mu.Lock()
+	defer registry.mu.Unlock()
+	objType := reflect.TypeOf(obj)
+	for _, item := range registry.items {
+		if item.Type == objType {
+			return item.GetData
+		}
+	}
+	return nil
+}
+
+func (lookup *LookupState) Init() {
+	lookup.Global.Init()
+	lookup.Local.Init()
+}
+
+func (lookup *LookupState) Fini() {
+	lookup.Global.Fini()
+	lookup.Local.Fini()
+}
+
+func (lookup *LookupState) Lookup(obj any) GetDataFunc {
+	if fn := lookup.Local.Lookup(obj); fn != nil {
+		return fn
+	}
+	return lookup.Global.Lookup(obj)
+}
diff --git a/pycrossinterp/registry_test.go b/pycrossinterp/registry_test.go
new file mode 100644
index 0000000..35a4780
--- /dev/null
+++ b/pycrossinterp/registry_test.go
@@ -0,0 +1,46 @@
+package pycrossinterp
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestLookupState(t *testing.T) {
+	var lookup LookupState
+	lookup.Init()
+	lookup.Global.Add("", func(v any) (any, error) { return len(v.(string)), nil })
+	lookup.Local.Add(0, func(v any) (any, error) { return v.(int) + 1, nil })
+	if fn := lookup.Lookup("abc"); fn == nil {
+		t.Fatal("expected global lookup")
+	} else if value, err := fn("abc"); err != nil || value != 3 {
+		t.Fatalf("global lookup = (%v, %v)", value, err)
+	}
+	if fn := lookup.Lookup(4); fn == nil {
+		t.Fatal("expected local lookup")
+	} else if value, err := fn(4); err != nil || value != 5 {
+		t.Fatalf("local lookup = (%v, %v)", value, err)
+	}
+	lookup.Fini()
+	if fn := lookup.Lookup("abc"); fn != nil {
+		t.Fatal("expected lookup cleared after fini")
+	}
+}
+
+func TestNotShareableErrors(t *testing.T) {
+	err := SetNotShareableError("not shareable")
+	if err == nil || err.Error() != "not shareable" {
+		t.Fatalf("SetNotShareableError = %v", err)
+	}
+	err = FormatNotShareableError("bad %s", "value")
+	if err == nil || err.Error() != "bad value" {
+		t.Fatalf("FormatNotShareableError = %v", err)
+	}
+	root := errors.New("root")
+	ns := &NotShareableError{Message: "wrapped", Cause: root}
+	if got := UnwrapNotShareableError(ns); got != root {
+		t.Fatalf("UnwrapNotShareableError = %v, want root", got)
+	}
+	if got := UnwrapNotShareableError(errors.New("plain")); got != nil {
+		t.Fatalf("UnwrapNotShareableError plain = %v, want nil", got)
+	}
+}
diff --git a/pyruntime/pythonrun.go b/pyruntime/pythonrun.go
new file mode 100644
index 0000000..f621951
--- /dev/null
+++ b/pyruntime/pythonrun.go
@@ -0,0 +1,52 @@
+package pyruntime
+
+import "fmt"
+
+func AnyFileObject(filename string, interactive bool, runInteractive func(string) error, runSimple func(string) error) error {
+	if filename == "" {
+		filename = "???"
+	}
+	if interactive {
+		if runInteractive == nil {
+			return nil
+		}
+		return runInteractive(filename)
+	}
+	if runSimple == nil {
+		return nil
+	}
+	return runSimple(filename)
+}
+
+func InteractiveLoop(defaultPS1, defaultPS2 string, prompts map[string]string, runOne func() (int, error)) (map[string]string, error) {
+	if prompts == nil {
+		prompts = map[string]string{}
+	}
+	if prompts["ps1"] == "" {
+		prompts["ps1"] = defaultPS1
+	}
+	if prompts["ps2"] == "" {
+		prompts["ps2"] = defaultPS2
+	}
+	nomemCount := 0
+	for {
+		ret, err := runOne()
+		if err != nil {
+			if err.Error() == "MemoryError" {
+				nomemCount++
+				if nomemCount > 16 {
+					return prompts, fmt.Errorf("too many consecutive memory errors")
+				}
+				continue
+			}
+			nomemCount = 0
+			continue
+		}
+		nomemCount = 0
+		if ret == EOF {
+			return prompts, nil
+		}
+	}
+}
+
+const EOF = 11
diff --git a/pyruntime/pythonrun_test.go b/pyruntime/pythonrun_test.go
new file mode 100644
index 0000000..6024cb7
--- /dev/null
+++ b/pyruntime/pythonrun_test.go
@@ -0,0 +1,45 @@
+package pyruntime
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestAnyFileObject(t *testing.T) {
+	called := ""
+	if err := AnyFileObject("", true, func(name string) error {
+		called = "interactive:" + name
+		return nil
+	}, nil); err != nil {
+		t.Fatalf("AnyFileObject interactive returned error: %v", err)
+	}
+	if called != "interactive:???" {
+		t.Fatalf("called = %q", called)
+	}
+	if err := AnyFileObject("file.py", false, nil, func(name string) error {
+		called = "simple:" + name
+		return nil
+	}); err != nil {
+		t.Fatalf("AnyFileObject simple returned error: %v", err)
+	}
+	if called != "simple:file.py" {
+		t.Fatalf("called = %q", called)
+	}
+}
+
+func TestInteractiveLoop(t *testing.T) {
+	count := 0
+	prompts, err := InteractiveLoop(">>> ", "... ", nil, func() (int, error) {
+		count++
+		if count == 1 {
+			return 0, errors.New("MemoryError")
+		}
+		return EOF, nil
+	})
+	if err != nil {
+		t.Fatalf("InteractiveLoop returned error: %v", err)
+	}
+	if prompts["ps1"] != ">>> " || prompts["ps2"] != "... " {
+		t.Fatalf("prompts = %#v", prompts)
+	}
+}
diff --git a/pystate/doc.go b/pystate/doc.go
new file mode 100644
index 0000000..784b379
--- /dev/null
+++ b/pystate/doc.go
@@ -0,0 +1,2 @@
+// Package pystate ports CPython interpreter and thread-state scaffolding.
+package pystate
diff --git a/pystate/errors.go b/pystate/errors.go
new file mode 100644
index 0000000..f5a1816
--- /dev/null
+++ b/pystate/errors.go
@@ -0,0 +1,10 @@
+package pystate
+
+import "errors"
+
+var (
+	ErrNoInterpreter    = errors.New("no interpreter")
+	ErrNoWatcherSlot    = errors.New("no more context watcher IDs available")
+	ErrInvalidWatcherID = errors.New("invalid context watcher ID")
+	ErrMissingWatcher   = errors.New("no context watcher set for ID")
+)
diff --git a/pystate/state.go b/pystate/state.go
new file mode 100644
index 0000000..6213826
--- /dev/null
+++ b/pystate/state.go
@@ -0,0 +1,165 @@
+package pystate
+
+import "sync"
+
+type ThreadStatus struct {
+	Initialized   bool
+	Bound         bool
+	Unbound       bool
+	BoundGILState bool
+	Active        bool
+	Finalizing    bool
+	Cleared       bool
+	Finalized     bool
+}
+
+type ThreadState struct {
+	Interp         *InterpreterState
+	ThreadID       uint64
+	NativeThreadID uint64
+	Status         ThreadStatus
+}
+
+type InterpreterState struct {
+	ID                int64
+	Runtime           *RuntimeState
+	RunningMain       bool
+	ContextWatchers   [8]func(any) error
+	ActiveContextBits uint8
+	contextWatchersMu sync.Mutex
+}
+
+type RuntimeState struct {
+	mu              sync.Mutex
+	initialized     bool
+	mainThread      uint64
+	nextInterpreter int64
+	interpreters    []*InterpreterState
+	current         *ThreadState
+}
+
+func NewRuntimeState() *RuntimeState {
+	return &RuntimeState{}
+}
+
+func (runtime *RuntimeState) Init(mainThread uint64) {
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	runtime.initialized = true
+	runtime.mainThread = mainThread
+}
+
+func (runtime *RuntimeState) Initialized() bool {
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	return runtime.initialized
+}
+
+func (runtime *RuntimeState) EnableInterpreters() {
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	runtime.nextInterpreter = 0
+}
+
+func (runtime *RuntimeState) NewInterpreter() *InterpreterState {
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	interp := &InterpreterState{
+		ID:      runtime.nextInterpreter,
+		Runtime: runtime,
+	}
+	runtime.nextInterpreter++
+	runtime.interpreters = append(runtime.interpreters, interp)
+	return interp
+}
+
+func (runtime *RuntimeState) Interpreters() []*InterpreterState {
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	out := make([]*InterpreterState, len(runtime.interpreters))
+	copy(out, runtime.interpreters)
+	return out
+}
+
+func NewThreadState(interp *InterpreterState) *ThreadState {
+	return &ThreadState{Interp: interp}
+}
+
+func BindThread(ts *ThreadState, threadID, nativeThreadID uint64) {
+	ts.ThreadID = threadID
+	ts.NativeThreadID = nativeThreadID
+	ts.Status.Bound = true
+}
+
+func UnbindThread(ts *ThreadState) {
+	ts.Status.Unbound = true
+}
+
+func BindGILState(runtime *RuntimeState, ts *ThreadState) {
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	if runtime.current != nil {
+		runtime.current.Status.BoundGILState = false
+	}
+	runtime.current = ts
+	ts.Status.BoundGILState = true
+}
+
+func UnbindGILState(runtime *RuntimeState, ts *ThreadState) {
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	if runtime.current == ts {
+		runtime.current = nil
+	}
+	ts.Status.BoundGILState = false
+}
+
+func CurrentThreadState(runtime *RuntimeState) *ThreadState {
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	return runtime.current
+}
+
+func SetRunningMain(interp *InterpreterState) error {
+	if interp == nil {
+		return ErrNoInterpreter
+	}
+	if interp.RunningMain {
+		return nil
+	}
+	interp.RunningMain = true
+	return nil
+}
+
+func ClearRunningMain(interp *InterpreterState) {
+	if interp != nil {
+		interp.RunningMain = false
+	}
+}
+
+func AddContextWatcher(interp *InterpreterState, cb func(any) error) (int, error) {
+	interp.contextWatchersMu.Lock()
+	defer interp.contextWatchersMu.Unlock()
+	for i, current := range interp.ContextWatchers {
+		if current == nil {
+			interp.ContextWatchers[i] = cb
+			interp.ActiveContextBits |= 1 << i
+			return i, nil
+		}
+	}
+	return -1, ErrNoWatcherSlot
+}
+
+func ClearContextWatcher(interp *InterpreterState, id int) error {
+	interp.contextWatchersMu.Lock()
+	defer interp.contextWatchersMu.Unlock()
+	if id < 0 || id >= len(interp.ContextWatchers) {
+		return ErrInvalidWatcherID
+	}
+	if interp.ContextWatchers[id] == nil {
+		return ErrMissingWatcher
+	}
+	interp.ContextWatchers[id] = nil
+	interp.ActiveContextBits &^= 1 << id
+	return nil
+}
diff --git a/pystate/state_test.go b/pystate/state_test.go
new file mode 100644
index 0000000..bb961f8
--- /dev/null
+++ b/pystate/state_test.go
@@ -0,0 +1,77 @@
+package pystate
+
+import "testing"
+
+func TestRuntimeLifecycle(t *testing.T) {
+	runtime := NewRuntimeState()
+	if runtime.Initialized() {
+		t.Fatal("runtime should start uninitialized")
+	}
+	runtime.Init(11)
+	if !runtime.Initialized() {
+		t.Fatal("runtime should be initialized")
+	}
+	runtime.EnableInterpreters()
+	interp := runtime.NewInterpreter()
+	if interp.ID != 0 {
+		t.Fatalf("interp.ID = %d, want 0", interp.ID)
+	}
+	if len(runtime.Interpreters()) != 1 {
+		t.Fatal("expected one interpreter")
+	}
+}
+
+func TestThreadBindingAndCurrent(t *testing.T) {
+	runtime := NewRuntimeState()
+	interp := runtime.NewInterpreter()
+	ts := NewThreadState(interp)
+	BindThread(ts, 101, 202)
+	if !ts.Status.Bound || ts.ThreadID != 101 || ts.NativeThreadID != 202 {
+		t.Fatalf("thread state = %#v", ts)
+	}
+	BindGILState(runtime, ts)
+	if CurrentThreadState(runtime) != ts || !ts.Status.BoundGILState {
+		t.Fatalf("current thread binding failed: %#v", ts)
+	}
+	UnbindGILState(runtime, ts)
+	if CurrentThreadState(runtime) != nil || ts.Status.BoundGILState {
+		t.Fatalf("current thread unbinding failed: %#v", ts)
+	}
+	UnbindThread(ts)
+	if !ts.Status.Unbound {
+		t.Fatalf("thread should be marked unbound: %#v", ts)
+	}
+}
+
+func TestRunningMain(t *testing.T) {
+	runtime := NewRuntimeState()
+	interp := runtime.NewInterpreter()
+	if err := SetRunningMain(interp); err != nil {
+		t.Fatalf("SetRunningMain returned error: %v", err)
+	}
+	if !interp.RunningMain {
+		t.Fatal("interp should be marked running main")
+	}
+	ClearRunningMain(interp)
+	if interp.RunningMain {
+		t.Fatal("interp should be cleared from running main")
+	}
+}
+
+func TestContextWatchers(t *testing.T) {
+	runtime := NewRuntimeState()
+	interp := runtime.NewInterpreter()
+	id, err := AddContextWatcher(interp, func(any) error { return nil })
+	if err != nil {
+		t.Fatalf("AddContextWatcher returned error: %v", err)
+	}
+	if interp.ActiveContextBits == 0 {
+		t.Fatal("expected active watcher bit")
+	}
+	if err := ClearContextWatcher(interp, id); err != nil {
+		t.Fatalf("ClearContextWatcher returned error: %v", err)
+	}
+	if interp.ActiveContextBits != 0 {
+		t.Fatal("expected watcher bits cleared")
+	}
+}

From 265f244f6aca6d32ee0c0df18f7528cb441f4933 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 16:59:09 +0700
Subject: [PATCH 06/24] phase3: port lifecycle and xidata helpers

---
 pycrossinterp/data.go         |  63 ++++++++++++
 pycrossinterp/data_test.go    |  53 ++++++++++
 pyruntime/lifecycle.go        | 184 ++++++++++++++++++++++++++++++++++
 pyruntime/lifecycle_errors.go |   8 ++
 pyruntime/lifecycle_test.go   |  96 ++++++++++++++++++
 5 files changed, 404 insertions(+)
 create mode 100644 pycrossinterp/data.go
 create mode 100644 pycrossinterp/data_test.go
 create mode 100644 pyruntime/lifecycle.go
 create mode 100644 pyruntime/lifecycle_errors.go
 create mode 100644 pyruntime/lifecycle_test.go

diff --git a/pycrossinterp/data.go b/pycrossinterp/data.go
new file mode 100644
index 0000000..b670b0c
--- /dev/null
+++ b/pycrossinterp/data.go
@@ -0,0 +1,63 @@
+package pycrossinterp
+
+import "fmt"
+
+type XIData struct {
+	InterpID  int64
+	Data      any
+	Obj       any
+	NewObject func(any) (any, error)
+	Free      func(any)
+}
+
+func NewXIData() *XIData {
+	return &XIData{InterpID: -1}
+}
+
+func (x *XIData) Init(interpID int64, shared any, obj any, newObject func(any) (any, error)) {
+	*x = XIData{
+		InterpID:  interpID,
+		Data:      shared,
+		Obj:       obj,
+		NewObject: newObject,
+	}
+}
+
+func (x *XIData) InitWithSize(interpID int64, size int, obj any, newObject func(any) (any, error)) {
+	x.Init(interpID, make([]byte, size), obj, newObject)
+}
+
+func (x *XIData) Clear(interpID int64) error {
+	if x.InterpID != -1 && interpID != 0 && x.InterpID != interpID {
+		return fmt.Errorf("wrong owning interpreter")
+	}
+	if x.Free != nil && x.Data != nil {
+		x.Free(x.Data)
+	}
+	x.Data = nil
+	x.Obj = nil
+	x.NewObject = nil
+	x.Free = nil
+	x.InterpID = -1
+	return nil
+}
+
+func CheckXIData(x *XIData) error {
+	if x.InterpID < 0 {
+		return fmt.Errorf("missing interp")
+	}
+	if x.NewObject == nil {
+		return fmt.Errorf("missing new_object func")
+	}
+	return nil
+}
+
+func CallInInterpreter(currentID, targetID int64, fn func(any) int, arg any, schedule func(int64, func(any) int, any)) int {
+	if currentID == targetID {
+		return fn(arg)
+	}
+	if schedule != nil {
+		schedule(targetID, fn, arg)
+	}
+	return 0
+}
diff --git a/pycrossinterp/data_test.go b/pycrossinterp/data_test.go
new file mode 100644
index 0000000..978c83b
--- /dev/null
+++ b/pycrossinterp/data_test.go
@@ -0,0 +1,53 @@
+package pycrossinterp
+
+import "testing"
+
+func TestXIDataLifecycle(t *testing.T) {
+	x := NewXIData()
+	if x.InterpID != -1 {
+		t.Fatalf("InterpID = %d, want -1", x.InterpID)
+	}
+	x.Init(4, "data", "obj", func(v any) (any, error) { return v, nil })
+	if err := CheckXIData(x); err != nil {
+		t.Fatalf("CheckXIData returned error: %v", err)
+	}
+	if err := x.Clear(4); err != nil {
+		t.Fatalf("Clear returned error: %v", err)
+	}
+	if x.InterpID != -1 || x.Data != nil || x.Obj != nil {
+		t.Fatalf("cleared xidata = %#v", x)
+	}
+}
+
+func TestXIDataErrors(t *testing.T) {
+	x := NewXIData()
+	x.InterpID = -1
+	if err := CheckXIData(x); err == nil {
+		t.Fatal("expected missing interp error")
+	}
+	x.InterpID = 1
+	if err := CheckXIData(x); err == nil {
+		t.Fatal("expected missing new_object func error")
+	}
+}
+
+func TestCallInInterpreter(t *testing.T) {
+	called := 0
+	got := CallInInterpreter(1, 1, func(any) int {
+		called++
+		return 7
+	}, nil, nil)
+	if got != 7 || called != 1 {
+		t.Fatalf("direct call = (%d, %d)", got, called)
+	}
+	scheduled := 0
+	got = CallInInterpreter(1, 2, func(any) int {
+		scheduled++
+		return 9
+	}, nil, func(id int64, fn func(any) int, arg any) {
+		scheduled = int(id)
+	})
+	if got != 0 || scheduled != 2 {
+		t.Fatalf("scheduled call = (%d, %d)", got, scheduled)
+	}
+}
diff --git a/pyruntime/lifecycle.go b/pyruntime/lifecycle.go
new file mode 100644
index 0000000..60b3e31
--- /dev/null
+++ b/pyruntime/lifecycle.go
@@ -0,0 +1,184 @@
+package pyruntime
+
+import (
+	"os"
+	"strings"
+	"sync"
+
+	"github.com/tamnd/gopython/pyconfig"
+	"github.com/tamnd/gopython/pystate"
+)
+
+var lifecycleState = struct {
+	mu                 sync.Mutex
+	runtimeInitialized bool
+	coreInitialized    bool
+	initialized        bool
+	runtime            *pystate.RuntimeState
+}{
+	runtime: pystate.NewRuntimeState(),
+}
+
+var localeTargets = []string{"C.UTF-8", "C.utf8", "UTF-8"}
+
+func RuntimeInitialize(mainThread uint64) *pystate.RuntimeState {
+	lifecycleState.mu.Lock()
+	defer lifecycleState.mu.Unlock()
+	if lifecycleState.runtimeInitialized {
+		return lifecycleState.runtime
+	}
+	lifecycleState.runtimeInitialized = true
+	lifecycleState.runtime.Init(mainThread)
+	return lifecycleState.runtime
+}
+
+func RuntimeFinalize() {
+	lifecycleState.mu.Lock()
+	defer lifecycleState.mu.Unlock()
+	lifecycleState.runtimeInitialized = false
+	lifecycleState.coreInitialized = false
+	lifecycleState.initialized = false
+	lifecycleState.runtime = pystate.NewRuntimeState()
+}
+
+func IsCoreInitialized() bool {
+	lifecycleState.mu.Lock()
+	defer lifecycleState.mu.Unlock()
+	return lifecycleState.coreInitialized
+}
+
+func IsInitialized() bool {
+	lifecycleState.mu.Lock()
+	defer lifecycleState.mu.Unlock()
+	return lifecycleState.initialized
+}
+
+func SetCoreInitialized(value bool) {
+	lifecycleState.mu.Lock()
+	defer lifecycleState.mu.Unlock()
+	lifecycleState.coreInitialized = value
+}
+
+func SetInitialized(value bool) {
+	lifecycleState.mu.Lock()
+	defer lifecycleState.mu.Unlock()
+	lifecycleState.initialized = value
+}
+
+func LegacyLocaleDetected(lcAll string, ctype string, windows bool) bool {
+	if windows {
+		return false
+	}
+	if lcAll != "" {
+		return false
+	}
+	return ctype == "C"
+}
+
+func IsLocaleCoercionTarget(ctype string) bool {
+	for _, target := range localeTargets {
+		if ctype == target {
+			return true
+		}
+	}
+	return false
+}
+
+func CoerceLegacyLocale(lcAll string, current string, candidates []string) (string, bool) {
+	if lcAll != "" {
+		return current, false
+	}
+	for _, candidate := range candidates {
+		if candidate != "" {
+			return candidate, true
+		}
+	}
+	return current, false
+}
+
+func SetLocaleFromEnv(lcAll, lcCType, lang string) string {
+	for _, value := range []string{lcAll, lcCType, lang} {
+		if value != "" {
+			return value
+		}
+	}
+	return ""
+}
+
+type InterpreterConfigView struct {
+	UseMainObmalloc            bool
+	AllowFork                  bool
+	AllowExec                  bool
+	AllowThreads               bool
+	AllowDaemonThreads         bool
+	CheckMultiInterpExtensions bool
+	GIL                        pyconfig.GILMode
+}
+
+const (
+	FeatureUseMainObmalloc uint32 = 1 << iota
+	FeatureFork
+	FeatureExec
+	FeatureThreads
+	FeatureDaemonThreads
+	FeatureMultiInterpExtensions
+)
+
+func InitInterpreterSettings(config InterpreterConfigView) (uint32, error) {
+	var flags uint32
+	if config.UseMainObmalloc {
+		flags |= FeatureUseMainObmalloc
+	} else if !config.CheckMultiInterpExtensions {
+		return 0, errSinglePhaseExtension
+	}
+	if config.AllowFork {
+		flags |= FeatureFork
+	}
+	if config.AllowExec {
+		flags |= FeatureExec
+	}
+	if config.AllowThreads {
+		flags |= FeatureThreads
+	}
+	if config.AllowDaemonThreads {
+		flags |= FeatureDaemonThreads
+	}
+	if config.CheckMultiInterpExtensions {
+		flags |= FeatureMultiInterpExtensions
+	}
+	switch config.GIL {
+	case pyconfig.DefaultGIL, pyconfig.SharedGIL, pyconfig.OwnGIL:
+	default:
+		return 0, errInvalidGIL
+	}
+	return flags, nil
+}
+
+func UpdateEnvForLocale(name, value string) {
+	if name != "" {
+		_ = os.Setenv(name, value)
+	}
+}
+
+func NormalizeAndroidLocale(lcAll, lcCType, lang string) string {
+	for _, value := range []string{lcAll, lcCType, lang} {
+		if value == "C.UTF-8" || value == "en_US.UTF-8" {
+			return "C.UTF-8"
+		}
+		if value != "" {
+			return "C"
+		}
+	}
+	return "C.UTF-8"
+}
+
+func EmitLegacyLocaleWarning(enabled bool) string {
+	if !enabled {
+		return ""
+	}
+	return "Python runtime initialized with LC_CTYPE=C (a locale with default ASCII encoding), which may cause Unicode compatibility problems. Using C.UTF-8, C.utf8, or UTF-8 (if available) as alternative Unicode-compatible locales is recommended.\n"
+}
+
+func IsInspectEnvSet() bool {
+	return strings.TrimSpace(os.Getenv("PYTHONINSPECT")) != ""
+}
diff --git a/pyruntime/lifecycle_errors.go b/pyruntime/lifecycle_errors.go
new file mode 100644
index 0000000..73c79d4
--- /dev/null
+++ b/pyruntime/lifecycle_errors.go
@@ -0,0 +1,8 @@
+package pyruntime
+
+import "errors"
+
+var (
+	errSinglePhaseExtension = errors.New("per-interpreter obmalloc does not support single-phase init extension modules")
+	errInvalidGIL           = errors.New("invalid interpreter config 'gil' value")
+)
diff --git a/pyruntime/lifecycle_test.go b/pyruntime/lifecycle_test.go
new file mode 100644
index 0000000..e461c73
--- /dev/null
+++ b/pyruntime/lifecycle_test.go
@@ -0,0 +1,96 @@
+package pyruntime
+
+import (
+	"os"
+	"testing"
+
+	"github.com/tamnd/gopython/pyconfig"
+)
+
+func TestRuntimeLifecycleFlags(t *testing.T) {
+	RuntimeFinalize()
+	if IsCoreInitialized() || IsInitialized() {
+		t.Fatal("runtime flags should start false")
+	}
+	runtime := RuntimeInitialize(33)
+	if runtime == nil {
+		t.Fatal("RuntimeInitialize returned nil")
+	}
+	SetCoreInitialized(true)
+	SetInitialized(true)
+	if !IsCoreInitialized() || !IsInitialized() {
+		t.Fatal("runtime flags should be true")
+	}
+	RuntimeFinalize()
+	if IsCoreInitialized() || IsInitialized() {
+		t.Fatal("runtime flags should be reset")
+	}
+}
+
+func TestLocaleHelpers(t *testing.T) {
+	if !LegacyLocaleDetected("", "C", false) {
+		t.Fatal("expected legacy locale detection")
+	}
+	if LegacyLocaleDetected("x", "C", false) || LegacyLocaleDetected("", "C", true) {
+		t.Fatal("unexpected legacy locale detection")
+	}
+	if !IsLocaleCoercionTarget("C.UTF-8") || IsLocaleCoercionTarget("fr_FR.UTF-8") {
+		t.Fatal("locale coercion target mismatch")
+	}
+	locale, ok := CoerceLegacyLocale("", "C", []string{"C.UTF-8"})
+	if !ok || locale != "C.UTF-8" {
+		t.Fatalf("CoerceLegacyLocale = (%q, %t)", locale, ok)
+	}
+	if got := SetLocaleFromEnv("", "en_US.UTF-8", ""); got != "en_US.UTF-8" {
+		t.Fatalf("SetLocaleFromEnv = %q", got)
+	}
+	if got := NormalizeAndroidLocale("", "", ""); got != "C.UTF-8" {
+		t.Fatalf("NormalizeAndroidLocale = %q", got)
+	}
+	if EmitLegacyLocaleWarning(false) != "" || EmitLegacyLocaleWarning(true) == "" {
+		t.Fatal("legacy locale warning mismatch")
+	}
+}
+
+func TestInitInterpreterSettings(t *testing.T) {
+	flags, err := InitInterpreterSettings(InterpreterConfigView{
+		UseMainObmalloc:            true,
+		AllowFork:                  true,
+		AllowExec:                  true,
+		AllowThreads:               true,
+		AllowDaemonThreads:         false,
+		CheckMultiInterpExtensions: true,
+		GIL:                        pyconfig.SharedGIL,
+	})
+	if err != nil {
+		t.Fatalf("InitInterpreterSettings returned error: %v", err)
+	}
+	if flags&FeatureUseMainObmalloc == 0 || flags&FeatureFork == 0 || flags&FeatureExec == 0 || flags&FeatureThreads == 0 || flags&FeatureMultiInterpExtensions == 0 {
+		t.Fatalf("flags = %b", flags)
+	}
+	if _, err := InitInterpreterSettings(InterpreterConfigView{
+		UseMainObmalloc:            false,
+		CheckMultiInterpExtensions: false,
+		GIL:                        pyconfig.SharedGIL,
+	}); err == nil {
+		t.Fatal("expected single-phase extension error")
+	}
+	if _, err := InitInterpreterSettings(InterpreterConfigView{
+		UseMainObmalloc:            true,
+		CheckMultiInterpExtensions: true,
+		GIL:                        pyconfig.GILMode(99),
+	}); err == nil {
+		t.Fatal("expected invalid GIL error")
+	}
+}
+
+func TestInspectEnv(t *testing.T) {
+	t.Setenv("PYTHONINSPECT", " 1 ")
+	if !IsInspectEnvSet() {
+		t.Fatal("expected inspect env set")
+	}
+	_ = os.Unsetenv("PYTHONINSPECT")
+	if IsInspectEnvSet() {
+		t.Fatal("expected inspect env clear")
+	}
+}

From 3d424e2db908b5a634fe9b72cffd350b9fe05578 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 17:02:53 +0700
Subject: [PATCH 07/24] phase3: port pythonrun and serialization helpers

---
 pycrossinterp/serial.go      | 92 ++++++++++++++++++++++++++++++++++++
 pycrossinterp/serial_test.go | 48 +++++++++++++++++++
 pyruntime/pythonrun.go       | 67 +++++++++++++++++++++++++-
 pyruntime/pythonrun_test.go  | 62 ++++++++++++++++++++++++
 4 files changed, 268 insertions(+), 1 deletion(-)
 create mode 100644 pycrossinterp/serial.go
 create mode 100644 pycrossinterp/serial_test.go

diff --git a/pycrossinterp/serial.go b/pycrossinterp/serial.go
new file mode 100644
index 0000000..ccd0bf1
--- /dev/null
+++ b/pycrossinterp/serial.go
@@ -0,0 +1,92 @@
+package pycrossinterp
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+)
+
+type PickleContext struct {
+	MainFile string
+}
+
+type SharedPickleData struct {
+	Pickled []byte
+	Context PickleContext
+}
+
+func PickleToXIData(obj any, mainFile string, xid *XIData) error {
+	data, err := json.Marshal(obj)
+	if err != nil {
+		return fmt.Errorf("object could not be pickled: %w", err)
+	}
+	shared := SharedPickleData{
+		Pickled: data,
+		Context: PickleContext{MainFile: mainFile},
+	}
+	xid.Init(1, shared, obj, func(v any) (any, error) {
+		return LoadPickleFromXIData(v.(*XIData))
+	})
+	return nil
+}
+
+func LoadPickleFromXIData(xid *XIData) (any, error) {
+	shared, ok := xid.Data.(SharedPickleData)
+	if !ok {
+		return nil, fmt.Errorf("object could not be unpickled")
+	}
+	var out any
+	if err := json.Unmarshal(shared.Pickled, &out); err != nil {
+		return nil, fmt.Errorf("object could not be unpickled: %w", err)
+	}
+	return out, nil
+}
+
+func MarshalToXIData(obj any, xid *XIData) error {
+	data, err := json.Marshal(obj)
+	if err != nil {
+		return fmt.Errorf("object could not be marshalled: %w", err)
+	}
+	xid.Init(1, data, obj, func(v any) (any, error) {
+		return ReadMarshalFromXIData(v.(*XIData))
+	})
+	return nil
+}
+
+func ReadMarshalFromXIData(xid *XIData) (any, error) {
+	data, ok := xid.Data.([]byte)
+	if !ok {
+		return nil, fmt.Errorf("object could not be unmarshalled")
+	}
+	var out any
+	if err := json.Unmarshal(data, &out); err != nil {
+		return nil, fmt.Errorf("object could not be unmarshalled: %w", err)
+	}
+	return out, nil
+}
+
+func ScriptToXIData(obj any, pure bool, xid *XIData) error {
+	script, ok := obj.(string)
+	if !ok {
+		return fmt.Errorf("object not a valid script")
+	}
+	if pure && stringsContainsAny(script, []string{"global ", "nonlocal "}) {
+		return fmt.Errorf("object not a valid script")
+	}
+	if stringsContainsAny(script, []string{"return "}) {
+		return fmt.Errorf("object not a valid script")
+	}
+	xid.Init(1, []byte(script), obj, func(v any) (any, error) {
+		return string(v.(*XIData).Data.([]byte)), nil
+	})
+	return nil
+}
+
+func stringsContainsAny(text string, needles []string) bool {
+	for _, needle := range needles {
+		if strings.Contains(text, needle) {
+			return true
+		}
+	}
+	return false
+}
diff --git a/pycrossinterp/serial_test.go b/pycrossinterp/serial_test.go
new file mode 100644
index 0000000..195de32
--- /dev/null
+++ b/pycrossinterp/serial_test.go
@@ -0,0 +1,48 @@
+package pycrossinterp
+
+import "testing"
+
+func TestPickleRoundTripXIData(t *testing.T) {
+	x := NewXIData()
+	if err := PickleToXIData(map[string]any{"a": 1.0}, "main.py", x); err != nil {
+		t.Fatalf("PickleToXIData returned error: %v", err)
+	}
+	got, err := LoadPickleFromXIData(x)
+	if err != nil {
+		t.Fatalf("LoadPickleFromXIData returned error: %v", err)
+	}
+	value := got.(map[string]any)["a"]
+	if value != 1.0 {
+		t.Fatalf("roundtrip value = %#v", got)
+	}
+}
+
+func TestMarshalRoundTripXIData(t *testing.T) {
+	x := NewXIData()
+	if err := MarshalToXIData([]any{"x", 2.0}, x); err != nil {
+		t.Fatalf("MarshalToXIData returned error: %v", err)
+	}
+	got, err := ReadMarshalFromXIData(x)
+	if err != nil {
+		t.Fatalf("ReadMarshalFromXIData returned error: %v", err)
+	}
+	if len(got.([]any)) != 2 {
+		t.Fatalf("roundtrip = %#v", got)
+	}
+}
+
+func TestScriptToXIData(t *testing.T) {
+	x := NewXIData()
+	if err := ScriptToXIData("print('x')", false, x); err != nil {
+		t.Fatalf("ScriptToXIData returned error: %v", err)
+	}
+	if got, err := x.NewObject(x); err != nil || got.(string) != "print('x')" {
+		t.Fatalf("script new object = (%v, %v)", got, err)
+	}
+	if err := ScriptToXIData("return 1", false, NewXIData()); err == nil {
+		t.Fatal("expected invalid script error")
+	}
+	if err := ScriptToXIData("global x\nprint(x)", true, NewXIData()); err == nil {
+		t.Fatal("expected invalid pure script error")
+	}
+}
diff --git a/pyruntime/pythonrun.go b/pyruntime/pythonrun.go
index f621951..10d1618 100644
--- a/pyruntime/pythonrun.go
+++ b/pyruntime/pythonrun.go
@@ -1,6 +1,9 @@
 package pyruntime
 
-import "fmt"
+import (
+	"fmt"
+	"strings"
+)
 
 func AnyFileObject(filename string, interactive bool, runInteractive func(string) error, runSimple func(string) error) error {
 	if filename == "" {
@@ -50,3 +53,65 @@ func InteractiveLoop(defaultPS1, defaultPS2 string, prompts map[string]string, r
 }
 
 const EOF = 11
+
+func SimpleString(command string, name string, runString func(string, string) error) error {
+	if runString == nil {
+		return nil
+	}
+	return runString(command, name)
+}
+
+func SimpleFile(filename string, closeIt bool, maybePyc bool, runPyc func(string) error, runSource func(string) error) error {
+	if filename == "" {
+		filename = "???"
+	}
+	if maybePyc {
+		if runPyc == nil {
+			return nil
+		}
+		return runPyc(filename)
+	}
+	if runSource == nil {
+		return nil
+	}
+	return runSource(filename)
+}
+
+func HandleSystemExit(exceptionType string, inspect bool, code any) (bool, int, string) {
+	if exceptionType == "KeyboardInterrupt" {
+		return false, 0, ""
+	}
+	if inspect {
+		return false, 0, ""
+	}
+	if exceptionType != "SystemExit" {
+		return false, 0, ""
+	}
+	switch value := code.(type) {
+	case int:
+		return true, value, ""
+	case int64:
+		return true, int(value), ""
+	case nil:
+		return true, 0, ""
+	case string:
+		return true, 1, value
+	default:
+		return true, 1, fmt.Sprint(value)
+	}
+}
+
+func ExceptionMessage(moduleName, qualName, message string) string {
+	var prefix string
+	if moduleName != "" && moduleName != "builtins" && moduleName != "__main__" {
+		prefix = moduleName + "."
+	}
+	if message == "" {
+		return prefix + qualName
+	}
+	return prefix + qualName + ": " + message
+}
+
+func RenderChainedExceptions(messages []string) string {
+	return strings.Join(messages, "\n\n")
+}
diff --git a/pyruntime/pythonrun_test.go b/pyruntime/pythonrun_test.go
index 6024cb7..0d7f2c1 100644
--- a/pyruntime/pythonrun_test.go
+++ b/pyruntime/pythonrun_test.go
@@ -43,3 +43,65 @@ func TestInteractiveLoop(t *testing.T) {
 		t.Fatalf("prompts = %#v", prompts)
 	}
 }
+
+func TestSimpleStringAndFile(t *testing.T) {
+	var got string
+	if err := SimpleString("print(1)", "mod", func(command string, name string) error {
+		got = command + "@" + name
+		return nil
+	}); err != nil {
+		t.Fatalf("SimpleString returned error: %v", err)
+	}
+	if got != "print(1)@mod" {
+		t.Fatalf("got = %q", got)
+	}
+
+	if err := SimpleFile("x.pyc", true, true, func(name string) error {
+		got = "pyc:" + name
+		return nil
+	}, nil); err != nil {
+		t.Fatalf("SimpleFile pyc returned error: %v", err)
+	}
+	if got != "pyc:x.pyc" {
+		t.Fatalf("got = %q", got)
+	}
+
+	if err := SimpleFile("x.py", true, false, nil, func(name string) error {
+		got = "src:" + name
+		return nil
+	}); err != nil {
+		t.Fatalf("SimpleFile src returned error: %v", err)
+	}
+	if got != "src:x.py" {
+		t.Fatalf("got = %q", got)
+	}
+}
+
+func TestHandleSystemExit(t *testing.T) {
+	if handled, _, _ := HandleSystemExit("KeyboardInterrupt", false, nil); handled {
+		t.Fatal("keyboard interrupt should not exit here")
+	}
+	if handled, _, _ := HandleSystemExit("SystemExit", true, nil); handled {
+		t.Fatal("inspect should suppress exit")
+	}
+	handled, code, text := HandleSystemExit("SystemExit", false, 7)
+	if !handled || code != 7 || text != "" {
+		t.Fatalf("int exit = (%t, %d, %q)", handled, code, text)
+	}
+	handled, code, text = HandleSystemExit("SystemExit", false, "bye")
+	if !handled || code != 1 || text != "bye" {
+		t.Fatalf("string exit = (%t, %d, %q)", handled, code, text)
+	}
+}
+
+func TestExceptionFormattingHelpers(t *testing.T) {
+	if got := ExceptionMessage("m", "Error", "bad"); got != "m.Error: bad" {
+		t.Fatalf("ExceptionMessage = %q", got)
+	}
+	if got := ExceptionMessage("builtins", "Error", ""); got != "Error" {
+		t.Fatalf("ExceptionMessage builtins = %q", got)
+	}
+	if got := RenderChainedExceptions([]string{"first", "second"}); got != "first\n\nsecond" {
+		t.Fatalf("RenderChainedExceptions = %q", got)
+	}
+}

From c4dbae2367742bb9607371ccea48e3e4fac47c33 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 17:04:49 +0700
Subject: [PATCH 08/24] phase3: port bootstrap and main module sync

---
 pycrossinterp/mainmodule.go      | 82 ++++++++++++++++++++++++++++++++
 pycrossinterp/mainmodule_test.go | 60 +++++++++++++++++++++++
 pyruntime/bootstrap.go           | 73 ++++++++++++++++++++++++++++
 pyruntime/bootstrap_test.go      | 55 +++++++++++++++++++++
 4 files changed, 270 insertions(+)
 create mode 100644 pycrossinterp/mainmodule.go
 create mode 100644 pycrossinterp/mainmodule_test.go
 create mode 100644 pyruntime/bootstrap.go
 create mode 100644 pyruntime/bootstrap_test.go

diff --git a/pycrossinterp/mainmodule.go b/pycrossinterp/mainmodule.go
new file mode 100644
index 0000000..0402866
--- /dev/null
+++ b/pycrossinterp/mainmodule.go
@@ -0,0 +1,82 @@
+package pycrossinterp
+
+import "fmt"
+
+type SyncModuleResult struct {
+	Module any
+	Loaded any
+	Failed error
+}
+
+type SyncModule struct {
+	Filename string
+	Cached   SyncModuleResult
+}
+
+type MainModuleHooks struct {
+	GetMainModule  func() (any, error)
+	LoadFromPath   func(filename string, modname string) (any, error)
+	SetMainModule  func(module any) error
+	CloneNamespace func(module any) (any, error)
+}
+
+func (m *SyncModule) Clear() {
+	m.Filename = ""
+	m.Cached = SyncModuleResult{}
+}
+
+func EnsureIsolatedMain(main *SyncModule, hooks MainModuleHooks) error {
+	if main.Cached.Failed != nil || main.Cached.Loaded != nil {
+		return nil
+	}
+	if main.Filename == "" {
+		return fmt.Errorf("not implemented")
+	}
+	if hooks.GetMainModule == nil || hooks.LoadFromPath == nil {
+		return fmt.Errorf("missing main module hooks")
+	}
+	mod, err := hooks.GetMainModule()
+	if err != nil {
+		return err
+	}
+	loaded, err := hooks.LoadFromPath(main.Filename, "<fake __main__>")
+	if err != nil {
+		main.Cached.Failed = err
+		return err
+	}
+	main.Cached = SyncModuleResult{
+		Module: mod,
+		Loaded: loaded,
+	}
+	return nil
+}
+
+func ApplyIsolatedMain(main *SyncModule, hooks MainModuleHooks) error {
+	if main.Cached.Failed != nil {
+		return main.Cached.Failed
+	}
+	if main.Cached.Loaded == nil {
+		return fmt.Errorf("missing isolated main")
+	}
+	if hooks.SetMainModule == nil {
+		return fmt.Errorf("missing set main hook")
+	}
+	return hooks.SetMainModule(main.Cached.Loaded)
+}
+
+func RestoreMain(main *SyncModule, hooks MainModuleHooks) error {
+	if main.Cached.Module == nil {
+		return nil
+	}
+	if hooks.SetMainModule == nil {
+		return fmt.Errorf("missing set main hook")
+	}
+	return hooks.SetMainModule(main.Cached.Module)
+}
+
+func CheckMissingMainAttr(err error) bool {
+	if err == nil {
+		return false
+	}
+	return len(err.Error()) >= 36 && err.Error()[:36] == "module '__main__' has no attribute '"
+}
diff --git a/pycrossinterp/mainmodule_test.go b/pycrossinterp/mainmodule_test.go
new file mode 100644
index 0000000..b889175
--- /dev/null
+++ b/pycrossinterp/mainmodule_test.go
@@ -0,0 +1,60 @@
+package pycrossinterp
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestSyncModuleLifecycle(t *testing.T) {
+	current := "current"
+	main := &SyncModule{Filename: "main.py"}
+	hooks := MainModuleHooks{
+		GetMainModule: func() (any, error) { return current, nil },
+		LoadFromPath: func(filename string, modname string) (any, error) {
+			return filename + ":" + modname, nil
+		},
+		SetMainModule: func(module any) error {
+			current = module.(string)
+			return nil
+		},
+	}
+	if err := EnsureIsolatedMain(main, hooks); err != nil {
+		t.Fatalf("EnsureIsolatedMain returned error: %v", err)
+	}
+	if err := ApplyIsolatedMain(main, hooks); err != nil {
+		t.Fatalf("ApplyIsolatedMain returned error: %v", err)
+	}
+	if current != "main.py:<fake __main__>" {
+		t.Fatalf("current = %q", current)
+	}
+	if err := RestoreMain(main, hooks); err != nil {
+		t.Fatalf("RestoreMain returned error: %v", err)
+	}
+	if current != "current" {
+		t.Fatalf("current = %q", current)
+	}
+}
+
+func TestSyncModuleFailureCaching(t *testing.T) {
+	main := &SyncModule{Filename: "main.py"}
+	want := errors.New("boom")
+	err := EnsureIsolatedMain(main, MainModuleHooks{
+		GetMainModule: func() (any, error) { return "main", nil },
+		LoadFromPath:  func(string, string) (any, error) { return nil, want },
+	})
+	if err != want || main.Cached.Failed != want {
+		t.Fatalf("cached failure = %v, err = %v", main.Cached.Failed, err)
+	}
+	if err := ApplyIsolatedMain(main, MainModuleHooks{}); err != want {
+		t.Fatalf("ApplyIsolatedMain error = %v, want %v", err, want)
+	}
+}
+
+func TestCheckMissingMainAttr(t *testing.T) {
+	if !CheckMissingMainAttr(errors.New("module '__main__' has no attribute 'x'")) {
+		t.Fatal("expected missing __main__ attr match")
+	}
+	if CheckMissingMainAttr(errors.New("other")) {
+		t.Fatal("unexpected match")
+	}
+}
diff --git a/pyruntime/bootstrap.go b/pyruntime/bootstrap.go
new file mode 100644
index 0000000..66e7ea8
--- /dev/null
+++ b/pyruntime/bootstrap.go
@@ -0,0 +1,73 @@
+package pyruntime
+
+import (
+	"fmt"
+
+	"github.com/tamnd/gopython/pyconfig"
+	"github.com/tamnd/gopython/pystate"
+)
+
+type BootstrapHooks struct {
+	InitGlobalObjects func() error
+	InitTypes         func() error
+	InitBuiltins      func() error
+	InitCrossInterp   func() error
+	InitImportCore    func() error
+}
+
+type BootstrapState struct {
+	Runtime *pystate.RuntimeState
+	Thread  *pystate.ThreadState
+	Config  pyconfig.Config
+}
+
+func PyInitConfig(runtime *pystate.RuntimeState, config pyconfig.Config, hooks BootstrapHooks) (*BootstrapState, error) {
+	if runtime == nil {
+		runtime = pystate.NewRuntimeState()
+		runtime.Init(1)
+	}
+	interp := runtime.NewInterpreter()
+	thread := pystate.NewThreadState(interp)
+	pystate.BindThread(thread, 1, 1)
+	pystate.BindGILState(runtime, thread)
+
+	if hooks.InitGlobalObjects != nil {
+		if err := hooks.InitGlobalObjects(); err != nil {
+			return nil, err
+		}
+	}
+	if hooks.InitTypes != nil {
+		if err := hooks.InitTypes(); err != nil {
+			return nil, err
+		}
+	}
+	if hooks.InitBuiltins != nil {
+		if err := hooks.InitBuiltins(); err != nil {
+			return nil, err
+		}
+	}
+	if hooks.InitCrossInterp != nil {
+		if err := hooks.InitCrossInterp(); err != nil {
+			return nil, err
+		}
+	}
+	if hooks.InitImportCore != nil {
+		if err := hooks.InitImportCore(); err != nil {
+			return nil, err
+		}
+	}
+	SetCoreInitialized(true)
+	return &BootstrapState{
+		Runtime: runtime,
+		Thread:  thread,
+		Config:  config,
+	}, nil
+}
+
+func Reconfigure(state *BootstrapState, config pyconfig.Config) error {
+	if state == nil {
+		return fmt.Errorf("missing bootstrap state")
+	}
+	state.Config = config
+	return nil
+}
diff --git a/pyruntime/bootstrap_test.go b/pyruntime/bootstrap_test.go
new file mode 100644
index 0000000..72cf68e
--- /dev/null
+++ b/pyruntime/bootstrap_test.go
@@ -0,0 +1,55 @@
+package pyruntime
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/tamnd/gopython/pyconfig"
+	"github.com/tamnd/gopython/pystate"
+)
+
+func TestPyInitConfig(t *testing.T) {
+	RuntimeFinalize()
+	runtime := pystate.NewRuntimeState()
+	runtime.Init(5)
+	order := []string{}
+	state, err := PyInitConfig(runtime, pyconfig.Config{}, BootstrapHooks{
+		InitGlobalObjects: func() error { order = append(order, "global"); return nil },
+		InitTypes:         func() error { order = append(order, "types"); return nil },
+		InitBuiltins:      func() error { order = append(order, "builtins"); return nil },
+		InitCrossInterp:   func() error { order = append(order, "xi"); return nil },
+		InitImportCore:    func() error { order = append(order, "import"); return nil },
+	})
+	if err != nil {
+		t.Fatalf("PyInitConfig returned error: %v", err)
+	}
+	if state == nil || state.Thread == nil || !IsCoreInitialized() {
+		t.Fatalf("state = %#v", state)
+	}
+	if len(order) != 5 {
+		t.Fatalf("order = %#v", order)
+	}
+}
+
+func TestPyInitConfigFailure(t *testing.T) {
+	_, err := PyInitConfig(nil, pyconfig.Config{}, BootstrapHooks{
+		InitTypes: func() error { return errors.New("fail") },
+	})
+	if err == nil {
+		t.Fatal("expected init failure")
+	}
+}
+
+func TestReconfigure(t *testing.T) {
+	state := &BootstrapState{}
+	cfg := pyconfig.Config{Isolated: 1}
+	if err := Reconfigure(state, cfg); err != nil {
+		t.Fatalf("Reconfigure returned error: %v", err)
+	}
+	if state.Config.Isolated != 1 {
+		t.Fatalf("state.Config = %#v", state.Config)
+	}
+	if err := Reconfigure(nil, cfg); err == nil {
+		t.Fatal("expected reconfigure error")
+	}
+}

From c6f41f806ed225c61cc81334e7c2179931e8aac2 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 17:36:06 +0700
Subject: [PATCH 09/24] phase3: port preinit and isolated main cache

---
 pycrossinterp/excinfo.go         | 118 +++++++++++++++++++++++++++
 pycrossinterp/excinfo_test.go    |  53 ++++++++++++
 pycrossinterp/mainmodule.go      |  32 ++++++--
 pycrossinterp/mainmodule_test.go |  39 +++++++++
 pyruntime/preinit.go             | 135 +++++++++++++++++++++++++++++++
 pyruntime/preinit_test.go        | 115 ++++++++++++++++++++++++++
 6 files changed, 484 insertions(+), 8 deletions(-)
 create mode 100644 pycrossinterp/excinfo.go
 create mode 100644 pycrossinterp/excinfo_test.go
 create mode 100644 pyruntime/preinit.go
 create mode 100644 pyruntime/preinit_test.go

diff --git a/pycrossinterp/excinfo.go b/pycrossinterp/excinfo.go
new file mode 100644
index 0000000..d2241a9
--- /dev/null
+++ b/pycrossinterp/excinfo.go
@@ -0,0 +1,118 @@
+package pycrossinterp
+
+import (
+	"fmt"
+	"strings"
+)
+
+type ExcInfoType struct {
+	Name     string
+	QualName string
+	Module   string
+}
+
+type ExcInfo struct {
+	Type       ExcInfoType
+	Message    string
+	ErrDisplay string
+}
+
+type ExcSnapshotObject struct {
+	Type       map[string]string
+	Msg        string
+	Formatted  string
+	ErrDisplay string
+}
+
+func (info *ExcInfo) Clear() {
+	*info = ExcInfo{}
+}
+
+func (info ExcInfo) IsSet() bool {
+	return info.Type.Name != "" || info.Message != ""
+}
+
+func (info ExcInfo) Format() string {
+	module := info.Type.Module
+	qualname := info.Type.QualName
+	if qualname == "" {
+		qualname = info.Type.Name
+	}
+	if module == "builtins" || module == "__main__" {
+		module = ""
+	}
+	if qualname != "" {
+		if module != "" {
+			if info.Message != "" {
+				return fmt.Sprintf("%s.%s: %s", module, qualname, info.Message)
+			}
+			return fmt.Sprintf("%s.%s", module, qualname)
+		}
+		if info.Message != "" {
+			return fmt.Sprintf("%s: %s", qualname, info.Message)
+		}
+		return qualname
+	}
+	return info.Message
+}
+
+func InitExcInfoFromException(typeName, qualName, module, message, errDisplay string) *ExcInfo {
+	return &ExcInfo{
+		Type: ExcInfoType{
+			Name:     typeName,
+			QualName: qualName,
+			Module:   module,
+		},
+		Message:    message,
+		ErrDisplay: strings.TrimSuffix(errDisplay, "\n"),
+	}
+}
+
+func InitExcInfoFromObject(obj ExcSnapshotObject) (*ExcInfo, error) {
+	typeName, ok := obj.Type["__name__"]
+	if !ok {
+		return nil, fmt.Errorf("exception snapshot missing 'type' attribute")
+	}
+	return &ExcInfo{
+		Type: ExcInfoType{
+			Name:     typeName,
+			QualName: obj.Type["__qualname__"],
+			Module:   obj.Type["__module__"],
+		},
+		Message:    obj.Msg,
+		ErrDisplay: obj.ErrDisplay,
+	}, nil
+}
+
+func (info ExcInfo) Apply() string {
+	if info.ErrDisplay != "" {
+		return info.ErrDisplay
+	}
+	return info.Format()
+}
+
+func (info ExcInfo) TypeAsObject() map[string]string {
+	result := map[string]string{}
+	if info.Type.Name != "" {
+		result["__name__"] = info.Type.Name
+	}
+	if info.Type.QualName != "" {
+		result["__qualname__"] = info.Type.QualName
+	}
+	if info.Type.Module != "" {
+		result["__module__"] = info.Type.Module
+	}
+	if len(result) == 0 {
+		return nil
+	}
+	return result
+}
+
+func (info ExcInfo) AsObject() ExcSnapshotObject {
+	return ExcSnapshotObject{
+		Type:       info.TypeAsObject(),
+		Msg:        info.Message,
+		Formatted:  info.Format(),
+		ErrDisplay: info.ErrDisplay,
+	}
+}
diff --git a/pycrossinterp/excinfo_test.go b/pycrossinterp/excinfo_test.go
new file mode 100644
index 0000000..f2686c9
--- /dev/null
+++ b/pycrossinterp/excinfo_test.go
@@ -0,0 +1,53 @@
+package pycrossinterp
+
+import "testing"
+
+func TestExcInfoFormat(t *testing.T) {
+	info := ExcInfo{
+		Type:    ExcInfoType{Name: "ValueError", QualName: "ValueError", Module: "builtins"},
+		Message: "bad",
+	}
+	if got := info.Format(); got != "ValueError: bad" {
+		t.Fatalf("Format = %q", got)
+	}
+	info.Type.Module = "pkg"
+	if got := info.Format(); got != "pkg.ValueError: bad" {
+		t.Fatalf("Format = %q", got)
+	}
+}
+
+func TestExcInfoFromObjectAndApply(t *testing.T) {
+	info, err := InitExcInfoFromObject(ExcSnapshotObject{
+		Type: map[string]string{
+			"__name__":     "TypeError",
+			"__qualname__": "TypeError",
+			"__module__":   "builtins",
+		},
+		Msg:        "boom",
+		ErrDisplay: "trace",
+	})
+	if err != nil {
+		t.Fatalf("InitExcInfoFromObject returned error: %v", err)
+	}
+	if got := info.Apply(); got != "trace" {
+		t.Fatalf("Apply = %q", got)
+	}
+	obj := info.AsObject()
+	if obj.Formatted == "" || obj.Type["__name__"] != "TypeError" {
+		t.Fatalf("AsObject = %#v", obj)
+	}
+}
+
+func TestExcInfoClearAndIsSet(t *testing.T) {
+	info := InitExcInfoFromException("RuntimeError", "", "__main__", "bad", "bad\n")
+	if !info.IsSet() {
+		t.Fatal("info should be set")
+	}
+	if info.ErrDisplay != "bad" {
+		t.Fatalf("ErrDisplay = %q", info.ErrDisplay)
+	}
+	info.Clear()
+	if info.IsSet() {
+		t.Fatal("info should be cleared")
+	}
+}
diff --git a/pycrossinterp/mainmodule.go b/pycrossinterp/mainmodule.go
index 0402866..cca2d09 100644
--- a/pycrossinterp/mainmodule.go
+++ b/pycrossinterp/mainmodule.go
@@ -14,10 +14,11 @@ type SyncModule struct {
 }
 
 type MainModuleHooks struct {
-	GetMainModule  func() (any, error)
-	LoadFromPath   func(filename string, modname string) (any, error)
-	SetMainModule  func(module any) error
-	CloneNamespace func(module any) (any, error)
+	GetMainModule   func() (any, error)
+	LoadFromPath    func(filename string, modname string) (any, error)
+	SetMainModule   func(module any) error
+	GetCachedModule func(name string) (any, error)
+	SetCachedModule func(name string, module any) error
 }
 
 func (m *SyncModule) Clear() {
@@ -39,10 +40,25 @@ func EnsureIsolatedMain(main *SyncModule, hooks MainModuleHooks) error {
 	if err != nil {
 		return err
 	}
-	loaded, err := hooks.LoadFromPath(main.Filename, "<fake __main__>")
-	if err != nil {
-		main.Cached.Failed = err
-		return err
+	var loaded any
+	if hooks.GetCachedModule != nil {
+		loaded, err = hooks.GetCachedModule("CACHED_MODULE_NS___main__")
+		if err != nil {
+			return err
+		}
+	}
+	if loaded == nil {
+		loaded, err = hooks.LoadFromPath(main.Filename, "<fake __main__>")
+		if err != nil {
+			main.Cached.Failed = err
+			return err
+		}
+		if hooks.SetCachedModule != nil {
+			if err := hooks.SetCachedModule("CACHED_MODULE_NS___main__", loaded); err != nil {
+				main.Cached.Failed = err
+				return err
+			}
+		}
 	}
 	main.Cached = SyncModuleResult{
 		Module: mod,
diff --git a/pycrossinterp/mainmodule_test.go b/pycrossinterp/mainmodule_test.go
index b889175..79f8668 100644
--- a/pycrossinterp/mainmodule_test.go
+++ b/pycrossinterp/mainmodule_test.go
@@ -50,6 +50,45 @@ func TestSyncModuleFailureCaching(t *testing.T) {
 	}
 }
 
+func TestSyncModuleUsesInterpreterCache(t *testing.T) {
+	current := "current"
+	cache := map[string]any{}
+	main := &SyncModule{Filename: "main.py"}
+	loads := 0
+	hooks := MainModuleHooks{
+		GetMainModule: func() (any, error) { return current, nil },
+		LoadFromPath: func(filename string, modname string) (any, error) {
+			loads++
+			return filename + ":" + modname, nil
+		},
+		SetMainModule: func(module any) error {
+			current = module.(string)
+			return nil
+		},
+		GetCachedModule: func(name string) (any, error) {
+			return cache[name], nil
+		},
+		SetCachedModule: func(name string, module any) error {
+			cache[name] = module
+			return nil
+		},
+	}
+	if err := EnsureIsolatedMain(main, hooks); err != nil {
+		t.Fatalf("first EnsureIsolatedMain error: %v", err)
+	}
+	if loads != 1 {
+		t.Fatalf("loads = %d, want 1", loads)
+	}
+	main.Clear()
+	main.Filename = "main.py"
+	if err := EnsureIsolatedMain(main, hooks); err != nil {
+		t.Fatalf("second EnsureIsolatedMain error: %v", err)
+	}
+	if loads != 1 {
+		t.Fatalf("loads = %d, want cached reuse", loads)
+	}
+}
+
 func TestCheckMissingMainAttr(t *testing.T) {
 	if !CheckMissingMainAttr(errors.New("module '__main__' has no attribute 'x'")) {
 		t.Fatal("expected missing __main__ attr match")
diff --git a/pyruntime/preinit.go b/pyruntime/preinit.go
new file mode 100644
index 0000000..a6b8d2c
--- /dev/null
+++ b/pyruntime/preinit.go
@@ -0,0 +1,135 @@
+package pyruntime
+
+import (
+	"fmt"
+
+	"github.com/tamnd/gopython/pyconfig"
+	"github.com/tamnd/gopython/pystate"
+)
+
+type PreInitState struct {
+	Runtime         *pystate.RuntimeState
+	Preinitializing bool
+	Preinitialized  bool
+	Preconfig       pyconfig.PreConfig
+}
+
+func PreInitializeFromConfig(config pyconfig.PreConfig, alreadyPreinitialized bool) (bool, error) {
+	if alreadyPreinitialized {
+		return true, nil
+	}
+	return true, nil
+}
+
+func PreInitializeFromPyArgv(state *PreInitState, src pyconfig.PreConfig, args *pyconfig.Argv) error {
+	if state == nil {
+		return fmt.Errorf("missing preinit state")
+	}
+	if state.Runtime == nil {
+		state.Runtime = RuntimeInitialize(0)
+	}
+	if state.Preinitialized {
+		return nil
+	}
+
+	state.Preinitializing = true
+	config := src
+	if args != nil && src.ParseArgv != 0 {
+		var argv pyconfig.WideStringList
+		if err := pyconfig.ArgvAsWideStringList(args, &argv); err != nil {
+			return err
+		}
+	}
+	state.Preconfig = config
+	state.Preinitializing = false
+	state.Preinitialized = true
+	return nil
+}
+
+func PreInitializeFromConfigObject(state *PreInitState, config pyconfig.Config, args *pyconfig.Argv) error {
+	if state == nil {
+		return fmt.Errorf("missing preinit state")
+	}
+	if state.Preinitialized {
+		return nil
+	}
+
+	var preconfig pyconfig.PreConfig
+	switch config.ConfigInit {
+	case pyconfig.ConfigInitIsolated:
+		pyconfig.InitIsolatedPreConfig(&preconfig)
+	default:
+		pyconfig.InitPythonPreConfig(&preconfig)
+	}
+	preconfig.ParseArgv = config.ParseArgv
+
+	if config.ParseArgv == 0 {
+		return PreInitializeFromPyArgv(state, preconfig, nil)
+	}
+	if args == nil {
+		args = &pyconfig.Argv{
+			UseBytesArgv: false,
+			WideArgv:     config.Argv.Items,
+		}
+	}
+	return PreInitializeFromPyArgv(state, preconfig, args)
+}
+
+func InitCoreFromConfig(runtime *pystate.RuntimeState, config pyconfig.Config, hooks BootstrapHooks, alreadyCore bool) (*BootstrapState, error) {
+	if runtime == nil {
+		runtime = pystate.NewRuntimeState()
+		runtime.Init(1)
+	}
+	if !alreadyCore {
+		return PyInitConfig(runtime, config, hooks)
+	}
+	return &BootstrapState{Runtime: runtime, Config: config, Thread: pystate.CurrentThreadState(runtime)}, nil
+}
+
+func InitMain(state *BootstrapState, installImportlib bool, initInterpMain func() error, reconfigure func() error, alreadyInitialized bool) error {
+	if state == nil || state.Runtime == nil {
+		return fmt.Errorf("runtime core not initialized")
+	}
+	if alreadyInitialized {
+		if reconfigure != nil {
+			return reconfigure()
+		}
+		return nil
+	}
+	if !installImportlib {
+		SetInitialized(true)
+		return nil
+	}
+	if initInterpMain != nil {
+		if err := initInterpMain(); err != nil {
+			return err
+		}
+	}
+	SetInitialized(true)
+	return nil
+}
+
+func InitializeFromConfig(config pyconfig.Config, runtime *pystate.RuntimeState, hooks BootstrapHooks, initInterpMain func() error) (*BootstrapState, error) {
+	state, err := InitCoreFromConfig(runtime, config, hooks, IsCoreInitialized())
+	if err != nil {
+		return nil, err
+	}
+	if config.InitMain != 0 {
+		if err := InitMain(state, config.InstallImportlib != 0, initInterpMain, func() error {
+			return Reconfigure(state, config)
+		}, IsInitialized()); err != nil {
+			return nil, err
+		}
+	}
+	return state, nil
+}
+
+func InitializeEx(installSignals int, runtime *pystate.RuntimeState, hooks BootstrapHooks, initInterpMain func() error) (*BootstrapState, error) {
+	if IsInitialized() {
+		return nil, nil
+	}
+	var config pyconfig.Config
+	pyconfig.InitCompatConfig(&config)
+	config.InstallSignalHandlers = installSignals
+	return InitializeFromConfig(config, runtime, hooks, initInterpMain)
+}
diff --git a/pyruntime/preinit_test.go b/pyruntime/preinit_test.go
new file mode 100644
index 0000000..7c73ee5
--- /dev/null
+++ b/pyruntime/preinit_test.go
@@ -0,0 +1,115 @@
+package pyruntime
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/tamnd/gopython/pyconfig"
+	"github.com/tamnd/gopython/pystate"
+)
+
+func TestPreInitializeFromConfig(t *testing.T) {
+	ok, err := PreInitializeFromConfig(pyconfig.PreConfig{}, false)
+	if err != nil || !ok {
+		t.Fatalf("PreInitializeFromConfig = (%t, %v)", ok, err)
+	}
+	ok, err = PreInitializeFromConfig(pyconfig.PreConfig{}, true)
+	if err != nil || !ok {
+		t.Fatalf("PreInitializeFromConfig existing = (%t, %v)", ok, err)
+	}
+}
+
+func TestPreInitializeFromPyArgv(t *testing.T) {
+	RuntimeFinalize()
+	state := &PreInitState{}
+	var preconfig pyconfig.PreConfig
+	pyconfig.InitPythonPreConfig(&preconfig)
+	args := &pyconfig.Argv{
+		UseBytesArgv: true,
+		BytesArgv:    [][]byte{[]byte("python"), []byte("-c")},
+	}
+	if err := PreInitializeFromPyArgv(state, preconfig, args); err != nil {
+		t.Fatalf("PreInitializeFromPyArgv error: %v", err)
+	}
+	if !state.Preinitialized || state.Preinitializing {
+		t.Fatalf("state = %#v", state)
+	}
+}
+
+func TestPreInitializeFromConfigObject(t *testing.T) {
+	RuntimeFinalize()
+	state := &PreInitState{}
+	var config pyconfig.Config
+	pyconfig.InitPythonConfig(&config)
+	config.Argv.AppendWide([]rune("python"))
+	if err := PreInitializeFromConfigObject(state, config, nil); err != nil {
+		t.Fatalf("PreInitializeFromConfigObject error: %v", err)
+	}
+	if !state.Preinitialized || state.Preconfig.ParseArgv != 1 {
+		t.Fatalf("state = %#v", state)
+	}
+}
+
+func TestInitCoreFromConfig(t *testing.T) {
+	RuntimeFinalize()
+	runtime := pystate.NewRuntimeState()
+	runtime.Init(1)
+	state, err := InitCoreFromConfig(runtime, pyconfig.Config{}, BootstrapHooks{}, false)
+	if err != nil || state == nil || state.Runtime == nil {
+		t.Fatalf("InitCoreFromConfig = (%#v, %v)", state, err)
+	}
+	state2, err := InitCoreFromConfig(runtime, pyconfig.Config{}, BootstrapHooks{}, true)
+	if err != nil || state2 == nil {
+		t.Fatalf("InitCoreFromConfig existing = (%#v, %v)", state2, err)
+	}
+}
+
+func TestInitMain(t *testing.T) {
+	RuntimeFinalize()
+	state := &BootstrapState{Runtime: pystate.NewRuntimeState()}
+	state.Runtime.Init(1)
+	if err := InitMain(state, false, nil, nil, false); err != nil || !IsInitialized() {
+		t.Fatalf("InitMain no importlib = %v", err)
+	}
+	RuntimeFinalize()
+	state.Runtime.Init(1)
+	err := InitMain(state, true, func() error { return nil }, nil, false)
+	if err != nil || !IsInitialized() {
+		t.Fatalf("InitMain importlib = %v", err)
+	}
+	err = InitMain(state, true, nil, func() error { return errors.New("reconfig") }, true)
+	if err == nil {
+		t.Fatal("expected reconfigure failure")
+	}
+	if err := InitMain(nil, true, nil, nil, false); err == nil {
+		t.Fatal("expected missing runtime error")
+	}
+}
+
+func TestInitializeFromConfig(t *testing.T) {
+	RuntimeFinalize()
+	var config pyconfig.Config
+	pyconfig.InitCompatConfig(&config)
+	called := false
+	state, err := InitializeFromConfig(config, nil, BootstrapHooks{}, func() error {
+		called = true
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("InitializeFromConfig error: %v", err)
+	}
+	if state == nil || !called || !IsInitialized() {
+		t.Fatalf("state = %#v called=%t initialized=%t", state, called, IsInitialized())
+	}
+}
+
+func TestInitializeEx(t *testing.T) {
+	RuntimeFinalize()
+	state, err := InitializeEx(1, nil, BootstrapHooks{}, func() error { return nil })
+	if err != nil {
+		t.Fatalf("InitializeEx error: %v", err)
+	}
+	if state == nil || state.Config.InstallSignalHandlers != 1 {
+		t.Fatalf("state = %#v", state)
+	}
+}

From 7e875ea5d007a78fa23110101508201e9a0877b3 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 17:37:49 +0700
Subject: [PATCH 10/24] phase3: port interpreter main init flow

---
 pyruntime/maininit.go      | 101 +++++++++++++++++++++++++++++
 pyruntime/maininit_test.go | 127 +++++++++++++++++++++++++++++++++++++
 2 files changed, 228 insertions(+)
 create mode 100644 pyruntime/maininit.go
 create mode 100644 pyruntime/maininit_test.go

diff --git a/pyruntime/maininit.go b/pyruntime/maininit.go
new file mode 100644
index 0000000..964fe6a
--- /dev/null
+++ b/pyruntime/maininit.go
@@ -0,0 +1,101 @@
+package pyruntime
+
+import (
+	"fmt"
+
+	"github.com/tamnd/gopython/pyconfig"
+)
+
+type InitMainHooks struct {
+	InitImportConfig   func(*pyconfig.Config) error
+	UpdateConfig       func(pathOnly bool) error
+	InitImportExternal func() error
+	InitSignals        func(install int) error
+	InitPerf           func(mode int) error
+	InitSysStreams     func() error
+	InitBuiltinsOpen   func() error
+	AddMainModule      func() error
+	ImportSite         func() error
+	InsertSysPath0     func(path []rune) error
+}
+
+func ReconfigureMain(state *BootstrapState, hooks InitMainHooks) error {
+	if state == nil || state.Thread == nil || state.Runtime == nil {
+		return fmt.Errorf("runtime core not initialized")
+	}
+	if hooks.UpdateConfig != nil {
+		if err := hooks.UpdateConfig(false); err != nil {
+			return fmt.Errorf("fail to reconfigure Python: %w", err)
+		}
+	}
+	return nil
+}
+
+func InitInterpMain(state *BootstrapState, hooks InitMainHooks, isMainInterp bool) error {
+	if state == nil || state.Thread == nil || state.Runtime == nil {
+		return fmt.Errorf("runtime core not initialized")
+	}
+	config := &state.Config
+
+	if config.InstallImportlib == 0 {
+		if isMainInterp {
+			SetInitialized(true)
+		}
+		return nil
+	}
+
+	if hooks.InitImportConfig != nil {
+		if err := hooks.InitImportConfig(config); err != nil {
+			return err
+		}
+	}
+	if hooks.UpdateConfig != nil {
+		if err := hooks.UpdateConfig(true); err != nil {
+			return fmt.Errorf("failed to update the Python config: %w", err)
+		}
+	}
+	if hooks.InitImportExternal != nil {
+		if err := hooks.InitImportExternal(); err != nil {
+			return err
+		}
+	}
+	if isMainInterp && hooks.InitSignals != nil {
+		if err := hooks.InitSignals(config.InstallSignalHandlers); err != nil {
+			return err
+		}
+	}
+	if isMainInterp && config.PerfProfiling > 0 && hooks.InitPerf != nil {
+		if err := hooks.InitPerf(config.PerfProfiling); err != nil {
+			return err
+		}
+	}
+	if hooks.InitSysStreams != nil {
+		if err := hooks.InitSysStreams(); err != nil {
+			return err
+		}
+	}
+	if hooks.InitBuiltinsOpen != nil {
+		if err := hooks.InitBuiltinsOpen(); err != nil {
+			return err
+		}
+	}
+	if hooks.AddMainModule != nil {
+		if err := hooks.AddMainModule(); err != nil {
+			return err
+		}
+	}
+	if isMainInterp {
+		SetInitialized(true)
+	}
+	if config.SiteImport != 0 && hooks.ImportSite != nil {
+		if err := hooks.ImportSite(); err != nil {
+			return err
+		}
+	}
+	if !isMainInterp && len(config.SysPath0) > 0 && hooks.InsertSysPath0 != nil {
+		if err := hooks.InsertSysPath0(config.SysPath0); err != nil {
+			return fmt.Errorf("can't initialize sys.path[0]: %w", err)
+		}
+	}
+	return nil
+}
diff --git a/pyruntime/maininit_test.go b/pyruntime/maininit_test.go
new file mode 100644
index 0000000..355db39
--- /dev/null
+++ b/pyruntime/maininit_test.go
@@ -0,0 +1,127 @@
+package pyruntime
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/tamnd/gopython/pyconfig"
+	"github.com/tamnd/gopython/pystate"
+)
+
+func TestReconfigureMain(t *testing.T) {
+	state := &BootstrapState{
+		Runtime: pystate.NewRuntimeState(),
+		Thread:  &pystate.ThreadState{},
+	}
+	state.Runtime.Init(1)
+	pathOnly := []bool{}
+	if err := ReconfigureMain(state, InitMainHooks{
+		UpdateConfig: func(path bool) error {
+			pathOnly = append(pathOnly, path)
+			return nil
+		},
+	}); err != nil {
+		t.Fatalf("ReconfigureMain error: %v", err)
+	}
+	if len(pathOnly) != 1 || pathOnly[0] {
+		t.Fatalf("pathOnly = %#v", pathOnly)
+	}
+	if err := ReconfigureMain(nil, InitMainHooks{}); err == nil {
+		t.Fatal("expected missing runtime error")
+	}
+}
+
+func TestInitInterpMainMainInterpreter(t *testing.T) {
+	RuntimeFinalize()
+	var config pyconfig.Config
+	pyconfig.InitPythonConfig(&config)
+	config.PerfProfiling = 1
+	state := &BootstrapState{
+		Runtime: pystate.NewRuntimeState(),
+		Thread:  &pystate.ThreadState{},
+		Config:  config,
+	}
+	state.Runtime.Init(1)
+	order := []string{}
+	err := InitInterpMain(state, InitMainHooks{
+		InitImportConfig: func(*pyconfig.Config) error { order = append(order, "import-config"); return nil },
+		UpdateConfig: func(path bool) error {
+			if !path {
+				t.Fatal("expected path-only update")
+			}
+			order = append(order, "update")
+			return nil
+		},
+		InitImportExternal: func() error { order = append(order, "external"); return nil },
+		InitSignals:        func(int) error { order = append(order, "signals"); return nil },
+		InitPerf:           func(int) error { order = append(order, "perf"); return nil },
+		InitSysStreams:     func() error { order = append(order, "streams"); return nil },
+		InitBuiltinsOpen:   func() error { order = append(order, "open"); return nil },
+		AddMainModule:      func() error { order = append(order, "main"); return nil },
+		ImportSite:         func() error { order = append(order, "site"); return nil },
+	}, true)
+	if err != nil {
+		t.Fatalf("InitInterpMain error: %v", err)
+	}
+	if !IsInitialized() {
+		t.Fatal("expected initialized runtime")
+	}
+	if len(order) != 9 {
+		t.Fatalf("order = %#v", order)
+	}
+}
+
+func TestInitInterpMainSubinterpreterPath0(t *testing.T) {
+	RuntimeFinalize()
+	var config pyconfig.Config
+	pyconfig.InitPythonConfig(&config)
+	config.SysPath0 = []rune("pkg")
+	state := &BootstrapState{
+		Runtime: pystate.NewRuntimeState(),
+		Thread:  &pystate.ThreadState{},
+		Config:  config,
+	}
+	state.Runtime.Init(1)
+	inserted := []rune(nil)
+	err := InitInterpMain(state, InitMainHooks{
+		InsertSysPath0: func(path []rune) error {
+			inserted = append([]rune(nil), path...)
+			return nil
+		},
+	}, false)
+	if err != nil {
+		t.Fatalf("InitInterpMain error: %v", err)
+	}
+	if string(inserted) != "pkg" {
+		t.Fatalf("inserted = %q", string(inserted))
+	}
+	if IsInitialized() {
+		t.Fatal("subinterpreter should not mark runtime initialized")
+	}
+}
+
+func TestInitInterpMainNoImportlibAndFailures(t *testing.T) {
+	RuntimeFinalize()
+	state := &BootstrapState{
+		Runtime: pystate.NewRuntimeState(),
+		Thread:  &pystate.ThreadState{},
+		Config: pyconfig.Config{
+			InstallImportlib: 0,
+		},
+	}
+	state.Runtime.Init(1)
+	if err := InitInterpMain(state, InitMainHooks{}, true); err != nil {
+		t.Fatalf("InitInterpMain no importlib error: %v", err)
+	}
+	if !IsInitialized() {
+		t.Fatal("expected initialized runtime")
+	}
+
+	RuntimeFinalize()
+	pyconfig.InitPythonConfig(&state.Config)
+	if err := InitInterpMain(state, InitMainHooks{
+		AddMainModule: func() error { return errors.New("boom") },
+	}, true); err == nil {
+		t.Fatal("expected hook failure")
+	}
+}

From aa01928380f6b1022110054fc9dfc8c5b838513b Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 17:46:30 +0700
Subject: [PATCH 11/24] phase3: port preinit wrappers and xidata flow

---
 pycrossinterp/data.go      | 85 ++++++++++++++++++++++++++++++++++++++
 pycrossinterp/data_test.go | 62 +++++++++++++++++++++++++++
 pyruntime/preinit.go       | 26 ++++++++++++
 pyruntime/preinit_test.go  | 31 ++++++++++++++
 4 files changed, 204 insertions(+)

diff --git a/pycrossinterp/data.go b/pycrossinterp/data.go
index b670b0c..b23453a 100644
--- a/pycrossinterp/data.go
+++ b/pycrossinterp/data.go
@@ -14,6 +14,16 @@ func NewXIData() *XIData {
 	return &XIData{InterpID: -1}
 }
 
+func FreeXIData(interpID int64, x *XIData) error {
+	if x == nil {
+		return nil
+	}
+	if err := x.Clear(interpID); err != nil {
+		return err
+	}
+	return nil
+}
+
 func (x *XIData) Init(interpID int64, shared any, obj any, newObject func(any) (any, error)) {
 	*x = XIData{
 		InterpID:  interpID,
@@ -25,6 +35,7 @@ func (x *XIData) Init(interpID int64, shared any, obj any, newObject func(any) (
 
 func (x *XIData) InitWithSize(interpID int64, size int, obj any, newObject func(any) (any, error)) {
 	x.Init(interpID, make([]byte, size), obj, newObject)
+	x.Free = func(any) {}
 }
 
 func (x *XIData) Clear(interpID int64) error {
@@ -61,3 +72,77 @@ func CallInInterpreter(currentID, targetID int64, fn func(any) int, arg any, sch
 	}
 	return 0
 }
+
+func CallInInterpreterAndRawFree(currentID, targetID int64, fn func(any) int, arg any, schedule func(int64, func(any) int, any)) int {
+	if currentID == targetID {
+		return fn(arg)
+	}
+	if schedule != nil {
+		schedule(targetID, fn, arg)
+	}
+	return 0
+}
+
+type XIDataFallback int
+
+const (
+	XIDataOnly XIDataFallback = iota + 1
+	XIDataFullFallback
+)
+
+func SetXIDataLookupFailure(obj any, msg string, cause error) error {
+	if msg != "" {
+		return &NotShareableError{Message: msg, Cause: cause}
+	}
+	if obj == nil {
+		return &NotShareableError{Message: "object does not support cross-interpreter data", Cause: cause}
+	}
+	return &NotShareableError{
+		Message: fmt.Sprintf("%v does not support cross-interpreter data", obj),
+		Cause:   cause,
+	}
+}
+
+func ObjectCheckXIData(lookup *LookupState, obj any) error {
+	if lookup == nil {
+		return SetXIDataLookupFailure(obj, "", nil)
+	}
+	getdata := lookup.Lookup(obj)
+	if getdata == nil {
+		return SetXIDataLookupFailure(obj, "", nil)
+	}
+	return nil
+}
+
+func ObjectGetXIData(lookup *LookupState, interpID int64, obj any, fallback XIDataFallback, xidata *XIData) error {
+	if xidata == nil {
+		return fmt.Errorf("missing xidata")
+	}
+	if xidata.Data != nil || xidata.Obj != nil {
+		return fmt.Errorf("xidata not cleared")
+	}
+	if lookup != nil {
+		if getdata := lookup.Lookup(obj); getdata != nil {
+			shared, err := getdata(obj)
+			if err != nil {
+				return SetXIDataLookupFailure(obj, "", err)
+			}
+			xidata.Init(interpID, shared, obj, func(v any) (any, error) {
+				return v.(*XIData).Data, nil
+			})
+			return CheckXIData(xidata)
+		}
+	}
+	switch fallback {
+	case XIDataOnly:
+		return SetXIDataLookupFailure(obj, "", nil)
+	case XIDataFullFallback:
+		if err := PickleToXIData(obj, "", xidata); err == nil {
+			xidata.InterpID = interpID
+			return CheckXIData(xidata)
+		}
+		return SetXIDataLookupFailure(obj, "", nil)
+	default:
+		return fmt.Errorf("unsupported xidata fallback option")
+	}
+}
diff --git a/pycrossinterp/data_test.go b/pycrossinterp/data_test.go
index 978c83b..55a8830 100644
--- a/pycrossinterp/data_test.go
+++ b/pycrossinterp/data_test.go
@@ -31,6 +31,20 @@ func TestXIDataErrors(t *testing.T) {
 	}
 }
 
+func TestXIDataAllocHelpers(t *testing.T) {
+	x := NewXIData()
+	x.InitWithSize(3, 8, "obj", func(v any) (any, error) { return v, nil })
+	if data, ok := x.Data.([]byte); !ok || len(data) != 8 {
+		t.Fatalf("x.Data = %#v", x.Data)
+	}
+	if err := FreeXIData(3, x); err != nil {
+		t.Fatalf("FreeXIData returned error: %v", err)
+	}
+	if x.InterpID != -1 {
+		t.Fatalf("InterpID = %d, want -1", x.InterpID)
+	}
+}
+
 func TestCallInInterpreter(t *testing.T) {
 	called := 0
 	got := CallInInterpreter(1, 1, func(any) int {
@@ -51,3 +65,51 @@ func TestCallInInterpreter(t *testing.T) {
 		t.Fatalf("scheduled call = (%d, %d)", got, scheduled)
 	}
 }
+
+func TestCallInInterpreterAndRawFree(t *testing.T) {
+	called := 0
+	got := CallInInterpreterAndRawFree(1, 1, func(any) int {
+		called++
+		return 4
+	}, "arg", nil)
+	if got != 4 || called != 1 {
+		t.Fatalf("direct call = (%d, %d)", got, called)
+	}
+	scheduled := 0
+	got = CallInInterpreterAndRawFree(1, 5, func(any) int { return 9 }, "arg", func(id int64, fn func(any) int, arg any) {
+		scheduled = int(id)
+	})
+	if got != 0 || scheduled != 5 {
+		t.Fatalf("scheduled call = (%d, %d)", got, scheduled)
+	}
+}
+
+func TestObjectXIDataLookupAndFallback(t *testing.T) {
+	var lookup LookupState
+	lookup.Init()
+	lookup.Global.Add("", func(v any) (any, error) { return "shared:" + v.(string), nil })
+
+	x := NewXIData()
+	if err := ObjectCheckXIData(&lookup, "abc"); err != nil {
+		t.Fatalf("ObjectCheckXIData returned error: %v", err)
+	}
+	if err := ObjectGetXIData(&lookup, 7, "abc", XIDataOnly, x); err != nil {
+		t.Fatalf("ObjectGetXIData returned error: %v", err)
+	}
+	if x.InterpID != 7 || x.Data != "shared:abc" {
+		t.Fatalf("x = %#v", x)
+	}
+
+	y := NewXIData()
+	if err := ObjectGetXIData(nil, 8, map[string]any{"a": 1}, XIDataFullFallback, y); err != nil {
+		t.Fatalf("ObjectGetXIData fallback returned error: %v", err)
+	}
+	if y.InterpID != 8 {
+		t.Fatalf("y = %#v", y)
+	}
+
+	z := NewXIData()
+	if err := ObjectGetXIData(nil, 9, make(chan int), XIDataOnly, z); err == nil {
+		t.Fatal("expected xidata failure")
+	}
+}
diff --git a/pyruntime/preinit.go b/pyruntime/preinit.go
index a6b8d2c..4cafa6a 100644
--- a/pyruntime/preinit.go
+++ b/pyruntime/preinit.go
@@ -14,6 +14,26 @@ type PreInitState struct {
 	Preconfig       pyconfig.PreConfig
 }
 
+func PreInitializeFromBytesArgs(state *PreInitState, src pyconfig.PreConfig, argv [][]byte) error {
+	args := &pyconfig.Argv{
+		UseBytesArgv: true,
+		BytesArgv:    argv,
+	}
+	return PreInitializeFromPyArgv(state, src, args)
+}
+
+func PreInitializeFromArgs(state *PreInitState, src pyconfig.PreConfig, argv [][]rune) error {
+	args := &pyconfig.Argv{
+		UseBytesArgv: false,
+		WideArgv:     argv,
+	}
+	return PreInitializeFromPyArgv(state, src, args)
+}
+
+func PreInitialize(state *PreInitState, src pyconfig.PreConfig) error {
+	return PreInitializeFromPyArgv(state, src, nil)
+}
+
 func PreInitializeFromConfig(config pyconfig.PreConfig, alreadyPreinitialized bool) (bool, error) {
 	if alreadyPreinitialized {
 		return true, nil
@@ -75,6 +95,12 @@ func PreInitializeFromConfigObject(state *PreInitState, config pyconfig.Config,
 	return PreInitializeFromPyArgv(state, preconfig, args)
 }
 
+func IsRuntimeInitialized() bool {
+	lifecycleState.mu.Lock()
+	defer lifecycleState.mu.Unlock()
+	return lifecycleState.runtimeInitialized
+}
+
 func InitCoreFromConfig(runtime *pystate.RuntimeState, config pyconfig.Config, hooks BootstrapHooks, alreadyCore bool) (*BootstrapState, error) {
 	if runtime == nil {
 		runtime = pystate.NewRuntimeState()
diff --git a/pyruntime/preinit_test.go b/pyruntime/preinit_test.go
index 7c73ee5..074bd5c 100644
--- a/pyruntime/preinit_test.go
+++ b/pyruntime/preinit_test.go
@@ -19,6 +19,37 @@ func TestPreInitializeFromConfig(t *testing.T) {
 	}
 }
 
+func TestPreInitializeWrappers(t *testing.T) {
+	RuntimeFinalize()
+	state := &PreInitState{}
+	var preconfig pyconfig.PreConfig
+	pyconfig.InitPythonPreConfig(&preconfig)
+	if err := PreInitializeFromBytesArgs(state, preconfig, [][]byte{[]byte("python")}); err != nil {
+		t.Fatalf("PreInitializeFromBytesArgs error: %v", err)
+	}
+	if !state.Preinitialized || !IsRuntimeInitialized() {
+		t.Fatalf("state = %#v", state)
+	}
+
+	RuntimeFinalize()
+	state = &PreInitState{}
+	if err := PreInitializeFromArgs(state, preconfig, [][]rune{[]rune("python")}); err != nil {
+		t.Fatalf("PreInitializeFromArgs error: %v", err)
+	}
+	if !state.Preinitialized {
+		t.Fatalf("state = %#v", state)
+	}
+
+	RuntimeFinalize()
+	state = &PreInitState{}
+	if err := PreInitialize(state, preconfig); err != nil {
+		t.Fatalf("PreInitialize error: %v", err)
+	}
+	if !state.Preinitialized {
+		t.Fatalf("state = %#v", state)
+	}
+}
+
 func TestPreInitializeFromPyArgv(t *testing.T) {
 	RuntimeFinalize()
 	state := &PreInitState{}

From c4dc4660135f0b5ceabf6f27b8f3311912def516 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 17:53:40 +0700
Subject: [PATCH 12/24] phase3: port runtime finalize flow

---
 pyruntime/finalize.go      | 217 +++++++++++++++++++++++++++++++++++++
 pyruntime/finalize_test.go | 116 ++++++++++++++++++++
 pystate/state.go           |  51 +++++++++
 pystate/state_test.go      |  26 ++++-
 4 files changed, 409 insertions(+), 1 deletion(-)
 create mode 100644 pyruntime/finalize.go
 create mode 100644 pyruntime/finalize_test.go

diff --git a/pyruntime/finalize.go b/pyruntime/finalize.go
new file mode 100644
index 0000000..de51114
--- /dev/null
+++ b/pyruntime/finalize.go
@@ -0,0 +1,217 @@
+package pyruntime
+
+import (
+	"fmt"
+
+	"github.com/tamnd/gopython/pystate"
+)
+
+type FinalizeHooks struct {
+	ResolveFinalThreadState func(runtime *pystate.RuntimeState) *pystate.ThreadState
+	WaitForThreadShutdown   func(*pystate.ThreadState)
+	FinishPendingCalls      func(*pystate.ThreadState)
+	AtExitCall              func(*pystate.InterpreterState)
+	FinalizeSubinterpreters func()
+	StopTheWorld            func(*pystate.RuntimeState)
+	StartTheWorld           func(*pystate.RuntimeState)
+	MarkShuttingDown        func(*pystate.ThreadState)
+	DeleteThreadStateList   func([]*pystate.ThreadState)
+	FlushStdFiles           func() error
+	DisableSignals          func()
+	GCCollect               func()
+	ImportFiniExternal      func(*pystate.InterpreterState)
+	FinalizeModules         func(*pystate.ThreadState)
+	EvalFini                func()
+	TraceMallocFini         func()
+	ImportFiniCore          func(*pystate.InterpreterState)
+	ImportFini              func()
+	FaulthandlerFini        func()
+	HashFini                func()
+	FinalizeInterpClear     func(*pystate.ThreadState)
+	FinalizeInterpDelete    func(*pystate.InterpreterState)
+	CallExitFuncs           func(*pystate.RuntimeState)
+	RuntimeFinalize         func()
+}
+
+func ResolveFinalThreadState(runtime *pystate.RuntimeState) *pystate.ThreadState {
+	if runtime == nil {
+		return nil
+	}
+	if ts := pystate.MainThreadState(runtime); ts != nil {
+		return ts
+	}
+	return pystate.CurrentThreadState(runtime)
+}
+
+func FinalizeRuntime(runtime *pystate.RuntimeState, hooks FinalizeHooks) int {
+	if runtime == nil || !runtime.Initialized() {
+		return 0
+	}
+
+	resolve := hooks.ResolveFinalThreadState
+	if resolve == nil {
+		resolve = ResolveFinalThreadState
+	}
+	tstate := resolve(runtime)
+	if tstate == nil || tstate.Interp == nil {
+		return 0
+	}
+
+	tstate.Status.Finalizing = true
+	interp := tstate.Interp
+
+	if hooks.WaitForThreadShutdown != nil {
+		hooks.WaitForThreadShutdown(tstate)
+	}
+	if hooks.FinishPendingCalls != nil {
+		hooks.FinishPendingCalls(tstate)
+	}
+	if hooks.AtExitCall != nil {
+		hooks.AtExitCall(interp)
+	}
+	if hooks.FinalizeSubinterpreters != nil {
+		hooks.FinalizeSubinterpreters()
+	}
+
+	if hooks.StopTheWorld != nil {
+		hooks.StopTheWorld(runtime)
+	}
+	others := pystate.RemoveExcept(runtime, tstate)
+	for _, other := range others {
+		other.Status.Finalizing = true
+		if hooks.MarkShuttingDown != nil {
+			hooks.MarkShuttingDown(other)
+		}
+	}
+	if hooks.StartTheWorld != nil {
+		hooks.StartTheWorld(runtime)
+	}
+	if hooks.DeleteThreadStateList != nil {
+		hooks.DeleteThreadStateList(others)
+	}
+
+	status := 0
+	if hooks.FlushStdFiles != nil {
+		if err := hooks.FlushStdFiles(); err != nil {
+			status = -1
+		}
+	}
+	if hooks.DisableSignals != nil {
+		hooks.DisableSignals()
+	}
+	if hooks.GCCollect != nil {
+		hooks.GCCollect()
+	}
+	if hooks.ImportFiniExternal != nil {
+		hooks.ImportFiniExternal(interp)
+	}
+	if hooks.FinalizeModules != nil {
+		hooks.FinalizeModules(tstate)
+	}
+	if hooks.EvalFini != nil {
+		hooks.EvalFini()
+	}
+	if hooks.FlushStdFiles != nil {
+		if err := hooks.FlushStdFiles(); err != nil {
+			status = -1
+		}
+	}
+	if hooks.TraceMallocFini != nil {
+		hooks.TraceMallocFini()
+	}
+	if hooks.ImportFiniCore != nil {
+		hooks.ImportFiniCore(interp)
+	}
+	if hooks.ImportFini != nil {
+		hooks.ImportFini()
+	}
+	if hooks.FaulthandlerFini != nil {
+		hooks.FaulthandlerFini()
+	}
+	if hooks.HashFini != nil {
+		hooks.HashFini()
+	}
+	if hooks.FinalizeInterpClear != nil {
+		hooks.FinalizeInterpClear(tstate)
+	}
+	tstate.Status.Cleared = true
+	if hooks.FinalizeInterpDelete != nil {
+		hooks.FinalizeInterpDelete(interp)
+	}
+	pystate.DeleteInterpreter(runtime, interp)
+	tstate.Status.Finalized = true
+	if hooks.CallExitFuncs != nil {
+		hooks.CallExitFuncs(runtime)
+	}
+	if hooks.RuntimeFinalize != nil {
+		hooks.RuntimeFinalize()
+	} else {
+		RuntimeFinalize()
+	}
+	return status
+}
+
+func FinalizeEx(hooks FinalizeHooks) int {
+	lifecycleState.mu.Lock()
+	runtime := lifecycleState.runtime
+	lifecycleState.mu.Unlock()
+	return FinalizeRuntime(runtime, hooks)
+}
+
+func Finalize(hooks FinalizeHooks) {
+	_ = FinalizeEx(hooks)
+}
+
+func EndInterpreter(tstate *pystate.ThreadState, hooks FinalizeHooks) error {
+	if tstate == nil || tstate.Interp == nil {
+		return fmt.Errorf("missing thread state")
+	}
+	runtime := tstate.Interp.Runtime
+	if runtime == nil {
+		return fmt.Errorf("missing runtime")
+	}
+	tstate.Status.Finalizing = true
+	if hooks.WaitForThreadShutdown != nil {
+		hooks.WaitForThreadShutdown(tstate)
+	}
+	if hooks.FinishPendingCalls != nil {
+		hooks.FinishPendingCalls(tstate)
+	}
+	if hooks.AtExitCall != nil {
+		hooks.AtExitCall(tstate.Interp)
+	}
+	if hooks.StopTheWorld != nil {
+		hooks.StopTheWorld(runtime)
+	}
+	others := pystate.RemoveExcept(runtime, tstate)
+	for _, other := range others {
+		if hooks.MarkShuttingDown != nil {
+			hooks.MarkShuttingDown(other)
+		}
+	}
+	if hooks.StartTheWorld != nil {
+		hooks.StartTheWorld(runtime)
+	}
+	if hooks.DeleteThreadStateList != nil {
+		hooks.DeleteThreadStateList(others)
+	}
+	if hooks.ImportFiniExternal != nil {
+		hooks.ImportFiniExternal(tstate.Interp)
+	}
+	if hooks.FinalizeModules != nil {
+		hooks.FinalizeModules(tstate)
+	}
+	if hooks.ImportFiniCore != nil {
+		hooks.ImportFiniCore(tstate.Interp)
+	}
+	if hooks.FinalizeInterpClear != nil {
+		hooks.FinalizeInterpClear(tstate)
+	}
+	tstate.Status.Cleared = true
+	if hooks.FinalizeInterpDelete != nil {
+		hooks.FinalizeInterpDelete(tstate.Interp)
+	}
+	pystate.DeleteInterpreter(runtime, tstate.Interp)
+	tstate.Status.Finalized = true
+	return nil
+}
diff --git a/pyruntime/finalize_test.go b/pyruntime/finalize_test.go
new file mode 100644
index 0000000..383c800
--- /dev/null
+++ b/pyruntime/finalize_test.go
@@ -0,0 +1,116 @@
+package pyruntime
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/tamnd/gopython/pystate"
+)
+
+func TestFinalizeRuntimeOrderAndStatus(t *testing.T) {
+	RuntimeFinalize()
+	runtime := RuntimeInitialize(1)
+	interp := runtime.NewInterpreter()
+	ts := pystate.NewThreadState(interp)
+	pystate.BindThread(ts, 1, 1)
+	pystate.BindGILState(runtime, ts)
+	pystate.SetMainThreadState(runtime, ts)
+	SetCoreInitialized(true)
+	SetInitialized(true)
+
+	order := []string{}
+	status := FinalizeRuntime(runtime, FinalizeHooks{
+		WaitForThreadShutdown: func(*pystate.ThreadState) { order = append(order, "wait") },
+		FinishPendingCalls:    func(*pystate.ThreadState) { order = append(order, "pending") },
+		AtExitCall:            func(*pystate.InterpreterState) { order = append(order, "atexit") },
+		FinalizeSubinterpreters: func() {
+			order = append(order, "subinterpreters")
+		},
+		StopTheWorld:  func(*pystate.RuntimeState) { order = append(order, "stop") },
+		StartTheWorld: func(*pystate.RuntimeState) { order = append(order, "start") },
+		DeleteThreadStateList: func([]*pystate.ThreadState) {
+			order = append(order, "delete-list")
+		},
+		FlushStdFiles: func() error {
+			order = append(order, "flush")
+			if len(order) == 8 {
+				return errors.New("flush")
+			}
+			return nil
+		},
+		DisableSignals:      func() { order = append(order, "signals") },
+		GCCollect:           func() { order = append(order, "gc") },
+		ImportFiniExternal:  func(*pystate.InterpreterState) { order = append(order, "import-external") },
+		FinalizeModules:     func(*pystate.ThreadState) { order = append(order, "modules") },
+		EvalFini:            func() { order = append(order, "eval") },
+		TraceMallocFini:     func() { order = append(order, "tracemalloc") },
+		ImportFiniCore:      func(*pystate.InterpreterState) { order = append(order, "import-core") },
+		ImportFini:          func() { order = append(order, "import") },
+		FaulthandlerFini:    func() { order = append(order, "faulthandler") },
+		HashFini:            func() { order = append(order, "hash") },
+		FinalizeInterpClear: func(*pystate.ThreadState) { order = append(order, "clear") },
+		FinalizeInterpDelete: func(*pystate.InterpreterState) {
+			order = append(order, "delete")
+		},
+		CallExitFuncs:   func(*pystate.RuntimeState) { order = append(order, "exitfuncs") },
+		RuntimeFinalize: func() { order = append(order, "runtime-finalize"); RuntimeFinalize() },
+	})
+	if status != -1 {
+		t.Fatalf("status = %d, want -1", status)
+	}
+	if len(order) < 18 {
+		t.Fatalf("order = %#v", order)
+	}
+	if IsInitialized() || IsCoreInitialized() {
+		t.Fatal("runtime flags should be reset")
+	}
+	if !ts.Status.Finalizing || !ts.Status.Cleared || !ts.Status.Finalized {
+		t.Fatalf("thread status = %#v", ts.Status)
+	}
+}
+
+func TestFinalizeExEarlyExit(t *testing.T) {
+	RuntimeFinalize()
+	if status := FinalizeEx(FinalizeHooks{}); status != 0 {
+		t.Fatalf("status = %d, want 0", status)
+	}
+}
+
+func TestEndInterpreter(t *testing.T) {
+	runtime := pystate.NewRuntimeState()
+	runtime.Init(1)
+	interp1 := runtime.NewInterpreter()
+	interp2 := runtime.NewInterpreter()
+	ts1 := pystate.NewThreadState(interp1)
+	ts2 := pystate.NewThreadState(interp2)
+	pystate.BindGILState(runtime, ts1)
+	pystate.SetMainThreadState(runtime, ts1)
+	pystate.BindGILState(runtime, ts2)
+
+	called := []string{}
+	err := EndInterpreter(ts2, FinalizeHooks{
+		WaitForThreadShutdown: func(*pystate.ThreadState) { called = append(called, "wait") },
+		FinishPendingCalls:    func(*pystate.ThreadState) { called = append(called, "pending") },
+		AtExitCall:            func(*pystate.InterpreterState) { called = append(called, "atexit") },
+		StopTheWorld:          func(*pystate.RuntimeState) { called = append(called, "stop") },
+		StartTheWorld:         func(*pystate.RuntimeState) { called = append(called, "start") },
+		DeleteThreadStateList: func([]*pystate.ThreadState) { called = append(called, "delete-list") },
+		ImportFiniExternal:    func(*pystate.InterpreterState) { called = append(called, "import-external") },
+		FinalizeModules:       func(*pystate.ThreadState) { called = append(called, "modules") },
+		ImportFiniCore:        func(*pystate.InterpreterState) { called = append(called, "import-core") },
+		FinalizeInterpClear:   func(*pystate.ThreadState) { called = append(called, "clear") },
+		FinalizeInterpDelete:  func(*pystate.InterpreterState) { called = append(called, "delete") },
+	})
+	if err != nil {
+		t.Fatalf("EndInterpreter error: %v", err)
+	}
+	if len(runtime.Interpreters()) != 1 {
+		t.Fatalf("interpreters = %#v", runtime.Interpreters())
+	}
+	if !ts2.Status.Cleared || !ts2.Status.Finalized {
+		t.Fatalf("thread status = %#v", ts2.Status)
+	}
+	if len(called) != 11 {
+		t.Fatalf("called = %#v", called)
+	}
+}
diff --git a/pystate/state.go b/pystate/state.go
index 6213826..5d84735 100644
--- a/pystate/state.go
+++ b/pystate/state.go
@@ -35,6 +35,7 @@ type RuntimeState struct {
 	mainThread      uint64
 	nextInterpreter int64
 	interpreters    []*InterpreterState
+	main            *ThreadState
 	current         *ThreadState
 }
 
@@ -98,6 +99,9 @@ func UnbindThread(ts *ThreadState) {
 func BindGILState(runtime *RuntimeState, ts *ThreadState) {
 	runtime.mu.Lock()
 	defer runtime.mu.Unlock()
+	if runtime.main == nil {
+		runtime.main = ts
+	}
 	if runtime.current != nil {
 		runtime.current.Status.BoundGILState = false
 	}
@@ -120,6 +124,53 @@ func CurrentThreadState(runtime *RuntimeState) *ThreadState {
 	return runtime.current
 }
 
+func MainThreadState(runtime *RuntimeState) *ThreadState {
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	return runtime.main
+}
+
+func SetMainThreadState(runtime *RuntimeState, ts *ThreadState) {
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	runtime.main = ts
+}
+
+func RemoveExcept(runtime *RuntimeState, keep *ThreadState) []*ThreadState {
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	var removed []*ThreadState
+	if keep == nil {
+		removed = append(removed, runtime.current)
+		runtime.current = nil
+		runtime.main = nil
+		return removed
+	}
+	if runtime.current != nil && runtime.current != keep {
+		removed = append(removed, runtime.current)
+	}
+	runtime.current = keep
+	return removed
+}
+
+func DeleteInterpreter(runtime *RuntimeState, interp *InterpreterState) {
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	out := runtime.interpreters[:0]
+	for _, current := range runtime.interpreters {
+		if current != interp {
+			out = append(out, current)
+		}
+	}
+	runtime.interpreters = out
+	if runtime.main != nil && runtime.main.Interp == interp {
+		runtime.main = nil
+	}
+	if runtime.current != nil && runtime.current.Interp == interp {
+		runtime.current = nil
+	}
+}
+
 func SetRunningMain(interp *InterpreterState) error {
 	if interp == nil {
 		return ErrNoInterpreter
diff --git a/pystate/state_test.go b/pystate/state_test.go
index bb961f8..bd8e3ef 100644
--- a/pystate/state_test.go
+++ b/pystate/state_test.go
@@ -30,7 +30,7 @@ func TestThreadBindingAndCurrent(t *testing.T) {
 		t.Fatalf("thread state = %#v", ts)
 	}
 	BindGILState(runtime, ts)
-	if CurrentThreadState(runtime) != ts || !ts.Status.BoundGILState {
+	if CurrentThreadState(runtime) != ts || MainThreadState(runtime) != ts || !ts.Status.BoundGILState {
 		t.Fatalf("current thread binding failed: %#v", ts)
 	}
 	UnbindGILState(runtime, ts)
@@ -43,6 +43,30 @@ func TestThreadBindingAndCurrent(t *testing.T) {
 	}
 }
 
+func TestRuntimeRemovalHelpers(t *testing.T) {
+	runtime := NewRuntimeState()
+	interp1 := runtime.NewInterpreter()
+	interp2 := runtime.NewInterpreter()
+	ts1 := NewThreadState(interp1)
+	ts2 := NewThreadState(interp2)
+	BindGILState(runtime, ts1)
+	SetMainThreadState(runtime, ts1)
+	BindGILState(runtime, ts2)
+
+	removed := RemoveExcept(runtime, ts1)
+	if len(removed) != 1 || removed[0] != ts2 {
+		t.Fatalf("removed = %#v", removed)
+	}
+	if CurrentThreadState(runtime) != ts1 {
+		t.Fatalf("current = %#v", CurrentThreadState(runtime))
+	}
+
+	DeleteInterpreter(runtime, interp2)
+	if len(runtime.Interpreters()) != 1 {
+		t.Fatalf("interpreters = %#v", runtime.Interpreters())
+	}
+}
+
 func TestRunningMain(t *testing.T) {
 	runtime := NewRuntimeState()
 	interp := runtime.NewInterpreter()

From 5f16b31d5e58c807de38b81536f14b0168b8221b Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 17:57:45 +0700
Subject: [PATCH 13/24] phase3: port subinterpreter creation flow

---
 pyruntime/finalize.go       |   2 +
 pyruntime/subinterp.go      | 157 ++++++++++++++++++++++++++++++++++++
 pyruntime/subinterp_test.go | 132 ++++++++++++++++++++++++++++++
 pystate/state.go            |   3 +
 4 files changed, 294 insertions(+)
 create mode 100644 pyruntime/subinterp.go
 create mode 100644 pyruntime/subinterp_test.go

diff --git a/pyruntime/finalize.go b/pyruntime/finalize.go
index de51114..eb2e9e4 100644
--- a/pyruntime/finalize.go
+++ b/pyruntime/finalize.go
@@ -59,6 +59,7 @@ func FinalizeRuntime(runtime *pystate.RuntimeState, hooks FinalizeHooks) int {
 
 	tstate.Status.Finalizing = true
 	interp := tstate.Interp
+	interp.Finalizing = true
 
 	if hooks.WaitForThreadShutdown != nil {
 		hooks.WaitForThreadShutdown(tstate)
@@ -171,6 +172,7 @@ func EndInterpreter(tstate *pystate.ThreadState, hooks FinalizeHooks) error {
 		return fmt.Errorf("missing runtime")
 	}
 	tstate.Status.Finalizing = true
+	tstate.Interp.Finalizing = true
 	if hooks.WaitForThreadShutdown != nil {
 		hooks.WaitForThreadShutdown(tstate)
 	}
diff --git a/pyruntime/subinterp.go b/pyruntime/subinterp.go
new file mode 100644
index 0000000..41dfa7c
--- /dev/null
+++ b/pyruntime/subinterp.go
@@ -0,0 +1,157 @@
+package pyruntime
+
+import (
+	"fmt"
+
+	"github.com/tamnd/gopython/pyconfig"
+	"github.com/tamnd/gopython/pystate"
+)
+
+const (
+	InterpreterWhenceCAPI = 1 + iota
+	InterpreterWhenceLegacyCAPI
+	InterpreterWhenceFinalize
+)
+
+type NewInterpreterHooks struct {
+	InitObjectState   func(*pystate.InterpreterState) error
+	InitObmalloc      func(*pystate.InterpreterState) error
+	InitGIL           func(*pystate.ThreadState, pyconfig.GILMode)
+	InterpInit        func(*pystate.ThreadState) error
+	InitInterpMain    func(*pystate.ThreadState) error
+	DetachThread      func(*pystate.ThreadState)
+	AttachThread      func(*pystate.ThreadState)
+	OnGILStateDisable func(*pystate.RuntimeState)
+}
+
+func legacyInterpreterConfig() pyconfig.InterpreterConfig {
+	return pyconfig.InterpreterConfig{
+		UseMainObmalloc:            true,
+		AllowFork:                  true,
+		AllowExec:                  true,
+		AllowThreads:               true,
+		AllowDaemonThreads:         true,
+		CheckMultiInterpExtensions: true,
+		GIL:                        pyconfig.OwnGIL,
+	}
+}
+
+func NewInterpreterFromConfig(runtime *pystate.RuntimeState, config pyconfig.InterpreterConfig, whence int, hooks NewInterpreterHooks) (*pystate.ThreadState, error) {
+	if runtime == nil || !runtime.Initialized() {
+		return nil, fmt.Errorf("Py_Initialize must be called first")
+	}
+	if hooks.OnGILStateDisable != nil {
+		hooks.OnGILStateDisable(runtime)
+	}
+
+	save := pystate.CurrentThreadState(runtime)
+	if save != nil && hooks.DetachThread != nil {
+		hooks.DetachThread(save)
+	}
+
+	interp := runtime.NewInterpreter()
+	interp.Whence = whence
+	interpCfgView := InterpreterConfigView{
+		UseMainObmalloc:            config.UseMainObmalloc,
+		AllowFork:                  config.AllowFork,
+		AllowExec:                  config.AllowExec,
+		AllowThreads:               config.AllowThreads,
+		AllowDaemonThreads:         config.AllowDaemonThreads,
+		CheckMultiInterpExtensions: config.CheckMultiInterpExtensions,
+		GIL:                        config.GIL,
+	}
+	if _, err := InitInterpreterSettings(interpCfgView); err != nil {
+		pystate.DeleteInterpreter(runtime, interp)
+		if save != nil && hooks.AttachThread != nil {
+			hooks.AttachThread(save)
+		}
+		return nil, err
+	}
+	if hooks.InitObjectState != nil {
+		if err := hooks.InitObjectState(interp); err != nil {
+			pystate.DeleteInterpreter(runtime, interp)
+			if save != nil && hooks.AttachThread != nil {
+				hooks.AttachThread(save)
+			}
+			return nil, err
+		}
+	}
+	if hooks.InitObmalloc != nil {
+		if err := hooks.InitObmalloc(interp); err != nil {
+			pystate.DeleteInterpreter(runtime, interp)
+			if save != nil && hooks.AttachThread != nil {
+				hooks.AttachThread(save)
+			}
+			return nil, err
+		}
+	}
+
+	tstate := pystate.NewThreadState(interp)
+	tstate.Whence = InterpreterWhenceCAPI
+	pystate.BindThread(tstate, uint64(interp.ID+1), uint64(interp.ID+1))
+	pystate.BindGILState(runtime, tstate)
+
+	if hooks.InitGIL != nil {
+		hooks.InitGIL(tstate, config.GIL)
+	}
+	if hooks.InterpInit != nil {
+		if err := hooks.InterpInit(tstate); err != nil {
+			_ = EndInterpreter(tstate, FinalizeHooks{})
+			if save != nil && hooks.AttachThread != nil {
+				hooks.AttachThread(save)
+			}
+			return nil, err
+		}
+	}
+	if hooks.InitInterpMain != nil {
+		if err := hooks.InitInterpMain(tstate); err != nil {
+			_ = EndInterpreter(tstate, FinalizeHooks{})
+			if save != nil && hooks.AttachThread != nil {
+				hooks.AttachThread(save)
+			}
+			return nil, err
+		}
+	}
+	return tstate, nil
+}
+
+func NewInterpreter(runtime *pystate.RuntimeState, hooks NewInterpreterHooks) (*pystate.ThreadState, error) {
+	return NewInterpreterFromConfig(runtime, legacyInterpreterConfig(), InterpreterWhenceLegacyCAPI, hooks)
+}
+
+func IsInterpreterFinalizing(runtime *pystate.RuntimeState, interp *pystate.InterpreterState) bool {
+	if runtime != nil {
+		if ts := pystate.CurrentThreadState(runtime); ts != nil && ts.Status.Finalizing {
+			return true
+		}
+	}
+	return interp != nil && interp.Finalizing
+}
+
+func FinalizeSubinterpreters(runtime *pystate.RuntimeState, hooks FinalizeHooks) error {
+	if runtime == nil {
+		return nil
+	}
+	all := runtime.Interpreters()
+	if len(all) <= 1 {
+		return nil
+	}
+	mainTS := pystate.MainThreadState(runtime)
+	if mainTS == nil {
+		return fmt.Errorf("missing main thread state")
+	}
+	for _, interp := range all {
+		if mainTS.Interp == interp {
+			continue
+		}
+		tstate := pystate.NewThreadState(interp)
+		tstate.Whence = InterpreterWhenceFinalize
+		pystate.BindThread(tstate, uint64(interp.ID+1), uint64(interp.ID+1))
+		pystate.BindGILState(runtime, tstate)
+		if err := EndInterpreter(tstate, hooks); err != nil {
+			return err
+		}
+	}
+	pystate.BindGILState(runtime, mainTS)
+	return nil
+}
diff --git a/pyruntime/subinterp_test.go b/pyruntime/subinterp_test.go
new file mode 100644
index 0000000..db8d3d0
--- /dev/null
+++ b/pyruntime/subinterp_test.go
@@ -0,0 +1,132 @@
+package pyruntime
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/tamnd/gopython/pyconfig"
+	"github.com/tamnd/gopython/pystate"
+)
+
+func TestNewInterpreterFromConfig(t *testing.T) {
+	runtime := pystate.NewRuntimeState()
+	runtime.Init(1)
+	mainInterp := runtime.NewInterpreter()
+	mainTS := pystate.NewThreadState(mainInterp)
+	pystate.BindThread(mainTS, 1, 1)
+	pystate.BindGILState(runtime, mainTS)
+	pystate.SetMainThreadState(runtime, mainTS)
+
+	order := []string{}
+	ts, err := NewInterpreterFromConfig(runtime, pyconfig.InterpreterConfig{
+		UseMainObmalloc:            true,
+		AllowFork:                  true,
+		AllowExec:                  true,
+		AllowThreads:               true,
+		AllowDaemonThreads:         true,
+		CheckMultiInterpExtensions: true,
+		GIL:                        pyconfig.OwnGIL,
+	}, InterpreterWhenceCAPI, NewInterpreterHooks{
+		OnGILStateDisable: func(*pystate.RuntimeState) { order = append(order, "disable-gilstate") },
+		DetachThread:      func(*pystate.ThreadState) { order = append(order, "detach") },
+		InitObjectState:   func(*pystate.InterpreterState) error { order = append(order, "object"); return nil },
+		InitObmalloc:      func(*pystate.InterpreterState) error { order = append(order, "obmalloc"); return nil },
+		InitGIL:           func(*pystate.ThreadState, pyconfig.GILMode) { order = append(order, "gil") },
+		InterpInit:        func(*pystate.ThreadState) error { order = append(order, "interp-init"); return nil },
+		InitInterpMain:    func(*pystate.ThreadState) error { order = append(order, "main-init"); return nil },
+	})
+	if err != nil {
+		t.Fatalf("NewInterpreterFromConfig error: %v", err)
+	}
+	if ts == nil || ts.Interp == nil || ts.Interp.Whence != InterpreterWhenceCAPI {
+		t.Fatalf("ts = %#v", ts)
+	}
+	if len(runtime.Interpreters()) != 2 {
+		t.Fatalf("interpreters = %#v", runtime.Interpreters())
+	}
+	if len(order) != 7 {
+		t.Fatalf("order = %#v", order)
+	}
+}
+
+func TestNewInterpreterFailureRestoresMainThread(t *testing.T) {
+	runtime := pystate.NewRuntimeState()
+	runtime.Init(1)
+	mainInterp := runtime.NewInterpreter()
+	mainTS := pystate.NewThreadState(mainInterp)
+	pystate.BindThread(mainTS, 1, 1)
+	pystate.BindGILState(runtime, mainTS)
+	pystate.SetMainThreadState(runtime, mainTS)
+
+	attached := false
+	_, err := NewInterpreter(runtime, NewInterpreterHooks{
+		DetachThread: func(*pystate.ThreadState) {},
+		AttachThread: func(ts *pystate.ThreadState) { attached = ts == mainTS },
+		InitObmalloc: func(*pystate.InterpreterState) error { return errors.New("boom") },
+		InitObjectState: func(*pystate.InterpreterState) error {
+			return nil
+		},
+	})
+	if err == nil {
+		t.Fatal("expected failure")
+	}
+	if !attached {
+		t.Fatal("expected saved thread reattach")
+	}
+	if len(runtime.Interpreters()) != 1 {
+		t.Fatalf("interpreters = %#v", runtime.Interpreters())
+	}
+}
+
+func TestNewInterpreterRequiresInitializedRuntime(t *testing.T) {
+	if _, err := NewInterpreterFromConfig(pystate.NewRuntimeState(), pyconfig.InterpreterConfig{}, InterpreterWhenceCAPI, NewInterpreterHooks{}); err == nil {
+		t.Fatal("expected initialization error")
+	}
+}
+
+func TestIsInterpreterFinalizing(t *testing.T) {
+	runtime := pystate.NewRuntimeState()
+	runtime.Init(1)
+	interp := runtime.NewInterpreter()
+	ts := pystate.NewThreadState(interp)
+	pystate.BindThread(ts, 1, 1)
+	pystate.BindGILState(runtime, ts)
+	if IsInterpreterFinalizing(runtime, interp) {
+		t.Fatal("unexpected finalizing")
+	}
+	interp.Finalizing = true
+	if !IsInterpreterFinalizing(runtime, interp) {
+		t.Fatal("expected interp finalizing")
+	}
+}
+
+func TestFinalizeSubinterpreters(t *testing.T) {
+	runtime := pystate.NewRuntimeState()
+	runtime.Init(1)
+	mainInterp := runtime.NewInterpreter()
+	sub1 := runtime.NewInterpreter()
+	sub2 := runtime.NewInterpreter()
+	mainTS := pystate.NewThreadState(mainInterp)
+	pystate.BindThread(mainTS, 1, 1)
+	pystate.BindGILState(runtime, mainTS)
+	pystate.SetMainThreadState(runtime, mainTS)
+
+	ended := []int64{}
+	err := FinalizeSubinterpreters(runtime, FinalizeHooks{
+		FinalizeInterpDelete: func(interp *pystate.InterpreterState) {
+			ended = append(ended, interp.ID)
+		},
+	})
+	if err != nil {
+		t.Fatalf("FinalizeSubinterpreters error: %v", err)
+	}
+	if len(ended) != 2 || ended[0] != sub1.ID || ended[1] != sub2.ID {
+		t.Fatalf("ended = %#v", ended)
+	}
+	if len(runtime.Interpreters()) != 1 {
+		t.Fatalf("interpreters = %#v", runtime.Interpreters())
+	}
+	if pystate.CurrentThreadState(runtime) != mainTS {
+		t.Fatalf("current = %#v", pystate.CurrentThreadState(runtime))
+	}
+}
diff --git a/pystate/state.go b/pystate/state.go
index 5d84735..711a8e2 100644
--- a/pystate/state.go
+++ b/pystate/state.go
@@ -17,6 +17,7 @@ type ThreadState struct {
 	Interp         *InterpreterState
 	ThreadID       uint64
 	NativeThreadID uint64
+	Whence         int
 	Status         ThreadStatus
 }
 
@@ -24,6 +25,8 @@ type InterpreterState struct {
 	ID                int64
 	Runtime           *RuntimeState
 	RunningMain       bool
+	Whence            int
+	Finalizing        bool
 	ContextWatchers   [8]func(any) error
 	ActiveContextBits uint8
 	contextWatchersMu sync.Mutex

From 2a8dff1713950722ffdaad5f45e5ff9c0e48c980 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 18:08:26 +0700
Subject: [PATCH 14/24] phase3: port shared namespace transport

---
 pycrossinterp/serial.go        |  78 ++++++++++++++++---
 pycrossinterp/serial_test.go   |  33 ++++++++
 pycrossinterp/sharedns.go      | 138 +++++++++++++++++++++++++++++++++
 pycrossinterp/sharedns_test.go |  66 ++++++++++++++++
 4 files changed, 306 insertions(+), 9 deletions(-)
 create mode 100644 pycrossinterp/sharedns.go
 create mode 100644 pycrossinterp/sharedns_test.go

diff --git a/pycrossinterp/serial.go b/pycrossinterp/serial.go
index ccd0bf1..e9a5c2d 100644
--- a/pycrossinterp/serial.go
+++ b/pycrossinterp/serial.go
@@ -6,6 +6,25 @@ import (
 	"strings"
 )
 
+type ScriptCode struct {
+	Source          string
+	Checked         bool
+	Pure            bool
+	ArgCount        int
+	PosOnlyArgCount int
+	KwOnlyArgCount  int
+	VarArgs         bool
+	VarKeywords     bool
+	ReturnsValue    bool
+	UsesGlobals     bool
+	Stateless       bool
+}
+
+type ScriptFunction struct {
+	Code      ScriptCode
+	Stateless bool
+}
+
 type PickleContext struct {
 	MainFile string
 }
@@ -66,15 +85,9 @@ func ReadMarshalFromXIData(xid *XIData) (any, error) {
 }
 
 func ScriptToXIData(obj any, pure bool, xid *XIData) error {
-	script, ok := obj.(string)
-	if !ok {
-		return fmt.Errorf("object not a valid script")
-	}
-	if pure && stringsContainsAny(script, []string{"global ", "nonlocal "}) {
-		return fmt.Errorf("object not a valid script")
-	}
-	if stringsContainsAny(script, []string{"return "}) {
-		return fmt.Errorf("object not a valid script")
+	script, err := normalizeScriptObject(obj, pure)
+	if err != nil {
+		return fmt.Errorf("object not a valid script: %w", err)
 	}
 	xid.Init(1, []byte(script), obj, func(v any) (any, error) {
 		return string(v.(*XIData).Data.([]byte)), nil
@@ -90,3 +103,50 @@ func stringsContainsAny(text string, needles []string) bool {
 	}
 	return false
 }
+
+func normalizeScriptObject(obj any, pure bool) (string, error) {
+	switch value := obj.(type) {
+	case string:
+		if pure && stringsContainsAny(value, []string{"global ", "nonlocal "}) {
+			return "", fmt.Errorf("uses globals")
+		}
+		if stringsContainsAny(value, []string{"return "}) {
+			return "", fmt.Errorf("returns value")
+		}
+		return value, nil
+	case ScriptCode:
+		if err := VerifyScript(value, pure); err != nil {
+			return "", err
+		}
+		return value.Source, nil
+	case ScriptFunction:
+		code := value.Code
+		if pure && !value.Stateless {
+			return "", fmt.Errorf("function is not stateless")
+		}
+		code.Checked = value.Stateless
+		code.Stateless = value.Stateless
+		if err := VerifyScript(code, pure); err != nil {
+			return "", err
+		}
+		return code.Source, nil
+	default:
+		return "", fmt.Errorf("unsupported script")
+	}
+}
+
+func VerifyScript(code ScriptCode, pure bool) error {
+	if pure && code.UsesGlobals {
+		return fmt.Errorf("uses globals")
+	}
+	if pure && !code.Checked && !code.Stateless {
+		return fmt.Errorf("not stateless")
+	}
+	if code.ArgCount > 0 || code.PosOnlyArgCount > 0 || code.KwOnlyArgCount > 0 || code.VarArgs || code.VarKeywords {
+		return fmt.Errorf("code with args not supported")
+	}
+	if code.ReturnsValue {
+		return fmt.Errorf("code that returns a value is not a script")
+	}
+	return nil
+}
diff --git a/pycrossinterp/serial_test.go b/pycrossinterp/serial_test.go
index 195de32..4a6f4c3 100644
--- a/pycrossinterp/serial_test.go
+++ b/pycrossinterp/serial_test.go
@@ -46,3 +46,36 @@ func TestScriptToXIData(t *testing.T) {
 		t.Fatal("expected invalid pure script error")
 	}
 }
+
+func TestScriptCodeAndFunctionValidation(t *testing.T) {
+	x := NewXIData()
+	err := ScriptToXIData(ScriptCode{
+		Source:       "pass",
+		ReturnsValue: false,
+		Stateless:    true,
+		Checked:      true,
+	}, true, x)
+	if err != nil {
+		t.Fatalf("ScriptCode validation error: %v", err)
+	}
+	if got, err := x.NewObject(x); err != nil || got.(string) != "pass" {
+		t.Fatalf("script code new object = (%v, %v)", got, err)
+	}
+
+	if err := ScriptToXIData(ScriptCode{
+		Source:       "x = 1",
+		ArgCount:     1,
+		ReturnsValue: false,
+	}, false, NewXIData()); err == nil {
+		t.Fatal("expected arg validation error")
+	}
+	if err := ScriptToXIData(ScriptFunction{
+		Code: ScriptCode{
+			Source:       "value = 1",
+			ReturnsValue: false,
+		},
+		Stateless: false,
+	}, true, NewXIData()); err == nil {
+		t.Fatal("expected stateless function validation error")
+	}
+}
diff --git a/pycrossinterp/sharedns.go b/pycrossinterp/sharedns.go
new file mode 100644
index 0000000..89f1c87
--- /dev/null
+++ b/pycrossinterp/sharedns.go
@@ -0,0 +1,138 @@
+package pycrossinterp
+
+import "fmt"
+
+type SharedNamespaceItem struct {
+	Name   string
+	XIData *XIData
+}
+
+type SharedNamespace struct {
+	MaxItems  int
+	NumNames  int
+	NumValues int
+	Items     []SharedNamespaceItem
+}
+
+func AllocSharedNamespace(maxitems int) (*SharedNamespace, error) {
+	if maxitems < 0 {
+		return nil, fmt.Errorf("bad internal call")
+	}
+	if maxitems == 0 {
+		return nil, fmt.Errorf("empty namespaces not allowed")
+	}
+	return &SharedNamespace{
+		MaxItems: maxitems,
+		Items:    make([]SharedNamespaceItem, maxitems),
+	}, nil
+}
+
+func CreateSharedNamespace(names []string) (*SharedNamespace, error) {
+	ns, err := AllocSharedNamespace(len(names))
+	if err != nil {
+		return nil, err
+	}
+	for i, name := range names {
+		ns.Items[i].Name = name
+		ns.NumNames++
+	}
+	return ns, nil
+}
+
+func (ns *SharedNamespace) Fill(values map[string]any, fallback XIDataFallback) error {
+	if ns == nil {
+		return fmt.Errorf("missing shared namespace")
+	}
+	for i := 0; i < ns.MaxItems; i++ {
+		item := &ns.Items[i]
+		value, ok := values[item.Name]
+		if !ok {
+			ns.NumValues++
+			continue
+		}
+		item.XIData = NewXIData()
+		if err := ObjectGetXIData(nil, 1, value, fallback, item.XIData); err != nil {
+			for j := 0; j < i; j++ {
+				ns.Items[j].ClearValue()
+			}
+			ns.NumValues = 0
+			return err
+		}
+		ns.NumValues++
+	}
+	return nil
+}
+
+func (item *SharedNamespaceItem) ClearValue() {
+	if item.XIData != nil {
+		_ = FreeXIData(0, item.XIData)
+		item.XIData = nil
+	}
+}
+
+func (item *SharedNamespaceItem) Apply(target map[string]any, dflt any) error {
+	if target == nil {
+		return fmt.Errorf("missing target namespace")
+	}
+	if item.XIData != nil {
+		value, err := item.XIData.NewObject(item.XIData)
+		if err != nil {
+			return err
+		}
+		target[item.Name] = value
+		return nil
+	}
+	target[item.Name] = dflt
+	return nil
+}
+
+func (ns *SharedNamespace) Apply(target map[string]any, dflt any) error {
+	if ns == nil {
+		return fmt.Errorf("missing shared namespace")
+	}
+	for i := 0; i < ns.MaxItems; i++ {
+		if err := ns.Items[i].Apply(target, dflt); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (ns *SharedNamespace) Destroy(currentInterpID int64, lookup func(int64) bool, schedule func(int64, func(any) int, any)) {
+	if ns == nil {
+		return
+	}
+	if ns.NumValues == 0 {
+		ns.Free()
+		return
+	}
+	interpID := int64(-1)
+	for i := 0; i < ns.MaxItems; i++ {
+		if ns.Items[i].XIData != nil {
+			interpID = ns.Items[i].XIData.InterpID
+			break
+		}
+	}
+	if interpID == -1 || interpID == currentInterpID || (lookup != nil && !lookup(interpID)) {
+		ns.Free()
+		return
+	}
+	if schedule != nil {
+		CallInInterpreter(currentInterpID, interpID, func(any) int {
+			ns.Free()
+			return 0
+		}, ns, schedule)
+	}
+}
+
+func (ns *SharedNamespace) Free() {
+	if ns == nil {
+		return
+	}
+	for i := 0; i < ns.NumNames; i++ {
+		ns.Items[i].Name = ""
+		ns.Items[i].ClearValue()
+	}
+	ns.NumNames = 0
+	ns.NumValues = 0
+}
diff --git a/pycrossinterp/sharedns_test.go b/pycrossinterp/sharedns_test.go
new file mode 100644
index 0000000..343e032
--- /dev/null
+++ b/pycrossinterp/sharedns_test.go
@@ -0,0 +1,66 @@
+package pycrossinterp
+
+import "testing"
+
+func TestSharedNamespaceLifecycle(t *testing.T) {
+	ns, err := CreateSharedNamespace([]string{"a", "b"})
+	if err != nil {
+		t.Fatalf("CreateSharedNamespace error: %v", err)
+	}
+	err = ns.Fill(map[string]any{
+		"a": map[string]any{"x": 1},
+	}, XIDataFullFallback)
+	if err != nil {
+		t.Fatalf("Fill error: %v", err)
+	}
+	if ns.NumNames != 2 || ns.NumValues != 2 {
+		t.Fatalf("ns counts = %#v", ns)
+	}
+
+	target := map[string]any{}
+	if err := ns.Apply(target, "default"); err != nil {
+		t.Fatalf("Apply error: %v", err)
+	}
+	if target["b"] != "default" {
+		t.Fatalf("target = %#v", target)
+	}
+	got := target["a"].(map[string]any)
+	if got["x"] != float64(1) {
+		t.Fatalf("target[a] = %#v", target["a"])
+	}
+
+	ns.Free()
+	if ns.NumNames != 0 || ns.NumValues != 0 {
+		t.Fatalf("ns after free = %#v", ns)
+	}
+}
+
+func TestSharedNamespaceDestroyUsesPendingRelease(t *testing.T) {
+	ns, err := CreateSharedNamespace([]string{"a"})
+	if err != nil {
+		t.Fatalf("CreateSharedNamespace error: %v", err)
+	}
+	if err := ns.Fill(map[string]any{"a": "value"}, XIDataFullFallback); err != nil {
+		t.Fatalf("Fill error: %v", err)
+	}
+	scheduled := int64(0)
+	ns.Destroy(2, func(id int64) bool { return id == 1 }, func(id int64, fn func(any) int, arg any) {
+		scheduled = id
+		fn(arg)
+	})
+	if scheduled != 1 {
+		t.Fatalf("scheduled = %d", scheduled)
+	}
+	if ns.NumNames != 0 || ns.NumValues != 0 {
+		t.Fatalf("ns after destroy = %#v", ns)
+	}
+}
+
+func TestSharedNamespaceErrors(t *testing.T) {
+	if _, err := AllocSharedNamespace(0); err == nil {
+		t.Fatal("expected empty namespace error")
+	}
+	if _, err := AllocSharedNamespace(-1); err == nil {
+		t.Fatal("expected negative namespace error")
+	}
+}

From f744b06ca9806e9cb12e0a713a600cbf904720bd Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 18:11:28 +0700
Subject: [PATCH 15/24] phase3: port switched interpreter sessions

---
 pycrossinterp/session.go      | 332 ++++++++++++++++++++++++++++++++++
 pycrossinterp/session_test.go | 116 ++++++++++++
 2 files changed, 448 insertions(+)
 create mode 100644 pycrossinterp/session.go
 create mode 100644 pycrossinterp/session_test.go

diff --git a/pycrossinterp/session.go b/pycrossinterp/session.go
new file mode 100644
index 0000000..26fc7ab
--- /dev/null
+++ b/pycrossinterp/session.go
@@ -0,0 +1,332 @@
+package pycrossinterp
+
+import "fmt"
+
+type ErrCode int
+
+const (
+	ErrNoError ErrCode = iota
+	ErrOther
+	ErrNoMemory
+	ErrAlreadyRunning
+	ErrMainNSFailure
+	ErrApplyNSFailure
+	ErrPreserveFailure
+	ErrExcPropagationFailure
+	ErrNotShareable
+	ErrUncaughtException
+)
+
+type Failure struct {
+	Code ErrCode
+	Msg  string
+}
+
+type ErrorState struct {
+	InterpID int64
+	Override *Failure
+	Uncaught ExcInfo
+}
+
+type SessionResult struct {
+	ExcInfo   any
+	Preserved map[string]any
+	ErrCode   ErrCode
+}
+
+type Session struct {
+	Status       int
+	Switched     bool
+	PrevInterpID int64
+	InitInterpID int64
+	Running      bool
+	MainNS       map[string]any
+	Preserved    map[string]any
+}
+
+const (
+	SessionUnused = 0
+	SessionActive = 1
+)
+
+type SessionHooks struct {
+	CurrentInterpID  func() int64
+	SwitchTo         func(int64) error
+	SwitchBack       func(int64) error
+	SetRunningMain   func(int64) error
+	ClearRunningMain func(int64)
+	GetMainNamespace func(int64) (map[string]any, error)
+}
+
+func NewSession() *Session {
+	return &Session{}
+}
+
+func FreeSession(session *Session) error {
+	if session == nil {
+		return nil
+	}
+	if session.Status != SessionUnused {
+		return fmt.Errorf("session still active")
+	}
+	return nil
+}
+
+func sessionActive(session *Session) bool {
+	return session != nil && session.Status == SessionActive
+}
+
+func (session *Session) Preserve(name string, value any) {
+	if session.Preserved == nil {
+		session.Preserved = map[string]any{}
+	}
+	session.Preserved[name] = value
+}
+
+func (session *Session) GetPreserved(name string) (any, bool) {
+	if session.Preserved == nil {
+		return nil, false
+	}
+	value, ok := session.Preserved[name]
+	return value, ok
+}
+
+func enterSession(session *Session, interpID int64, hooks SessionHooks) error {
+	if session == nil {
+		return fmt.Errorf("missing session")
+	}
+	if session.Status != SessionUnused {
+		return fmt.Errorf("session already active")
+	}
+	currentID := int64(0)
+	if hooks.CurrentInterpID != nil {
+		currentID = hooks.CurrentInterpID()
+	}
+	switched := currentID != interpID
+	if switched && hooks.SwitchTo != nil {
+		if err := hooks.SwitchTo(interpID); err != nil {
+			return err
+		}
+	}
+	*session = Session{
+		Status:       SessionActive,
+		Switched:     switched,
+		PrevInterpID: currentID,
+		InitInterpID: interpID,
+	}
+	return nil
+}
+
+func exitSession(session *Session, hooks SessionHooks) error {
+	if !sessionActive(session) {
+		return nil
+	}
+	if session.Running && hooks.ClearRunningMain != nil {
+		hooks.ClearRunningMain(session.InitInterpID)
+	}
+	if session.Switched && hooks.SwitchBack != nil {
+		if err := hooks.SwitchBack(session.PrevInterpID); err != nil {
+			return err
+		}
+	}
+	*session = Session{}
+	return nil
+}
+
+func PropagateNotShareableError(err error, override *Failure) {
+	if override == nil {
+		return
+	}
+	if _, ok := err.(*NotShareableError); ok {
+		override.Code = ErrNotShareable
+	}
+}
+
+func CaptureErrorState(interpID int64, raised error, override *Failure) ErrorState {
+	state := ErrorState{InterpID: interpID}
+	if override != nil && override.Code != ErrNoError && override.Code != ErrUncaughtException {
+		copy := *override
+		state.Override = &copy
+	}
+	if raised != nil {
+		info := InitExcInfoFromException("Exception", "Exception", "builtins", raised.Error(), raised.Error())
+		state.Uncaught = *info
+		if state.Override == nil {
+			state.Override = &Failure{Code: ErrUncaughtException}
+		}
+	}
+	return state
+}
+
+func ApplyErrorCode(code ErrCode) error {
+	switch code {
+	case ErrOther:
+		return &InterpreterError{Message: ""}
+	case ErrNoMemory:
+		return fmt.Errorf("out of memory")
+	case ErrAlreadyRunning:
+		return &InterpreterError{Message: "interpreter already running"}
+	case ErrMainNSFailure:
+		return &InterpreterError{Message: "failed to get __main__ namespace"}
+	case ErrApplyNSFailure:
+		return &InterpreterError{Message: "failed to apply namespace to __main__"}
+	case ErrPreserveFailure:
+		return &InterpreterError{Message: "failed to preserve objects across session"}
+	case ErrExcPropagationFailure:
+		return &InterpreterError{Message: "failed to transfer exception between interpreters"}
+	case ErrNotShareable:
+		return SetXIDataLookupFailure(nil, "", nil)
+	default:
+		return fmt.Errorf("unsupported error code %d", code)
+	}
+}
+
+func ApplyError(state ErrorState, failure string) (any, error) {
+	if failure != "" {
+		return nil, nil
+	}
+	code := ErrUncaughtException
+	if state.Override != nil {
+		code = state.Override.Code
+	}
+	if code == ErrUncaughtException {
+		return state.Uncaught.AsObject(), nil
+	}
+	if code == ErrNotShareable {
+		msg := state.Uncaught.Message
+		if state.Override != nil && state.Override.Msg != "" {
+			msg = state.Override.Msg
+		}
+		return nil, &NotShareableError{Message: msg}
+	}
+	err := ApplyErrorCode(code)
+	if state.Uncaught.IsSet() {
+		return nil, fmt.Errorf("%w: %s", err, state.Uncaught.Apply())
+	}
+	return nil, err
+}
+
+func ensureMainNS(session *Session, hooks SessionHooks, override *Failure) error {
+	if session.MainNS != nil {
+		return nil
+	}
+	if hooks.GetMainNamespace == nil {
+		if override != nil {
+			override.Code = ErrMainNSFailure
+		}
+		return fmt.Errorf("missing main namespace hook")
+	}
+	ns, err := hooks.GetMainNamespace(session.InitInterpID)
+	if err != nil {
+		if override != nil {
+			override.Code = ErrMainNSFailure
+		}
+		return err
+	}
+	session.MainNS = ns
+	return nil
+}
+
+func Enter(session *Session, interpID int64, nsupdates map[string]any, hooks SessionHooks, result *SessionResult) error {
+	var sharedns *SharedNamespace
+	if len(nsupdates) > 0 {
+		names := make([]string, 0, len(nsupdates))
+		for name := range nsupdates {
+			names = append(names, name)
+		}
+		var err error
+		sharedns, err = CreateSharedNamespace(names)
+		if err != nil {
+			if result != nil {
+				result.ErrCode = ErrApplyNSFailure
+			}
+			return err
+		}
+		if err := sharedns.Fill(nsupdates, XIDataFullFallback); err != nil {
+			sharedns.Destroy(0, nil, nil)
+			if result != nil {
+				result.ErrCode = ErrApplyNSFailure
+			}
+			return err
+		}
+	}
+
+	if err := enterSession(session, interpID, hooks); err != nil {
+		return err
+	}
+	override := Failure{Code: ErrUncaughtException}
+
+	if hooks.SetRunningMain != nil {
+		if err := hooks.SetRunningMain(interpID); err != nil {
+			override.Code = ErrAlreadyRunning
+			state := CaptureErrorState(interpID, err, &override)
+			_ = exitSession(session, hooks)
+			if sharedns != nil {
+				sharedns.Destroy(interpID, nil, nil)
+			}
+			excinfo, applyErr := ApplyError(state, "")
+			if result != nil {
+				result.ExcInfo = excinfo
+				result.ErrCode = override.Code
+			}
+			return applyErr
+		}
+	}
+	session.Running = true
+
+	if sharedns != nil {
+		if err := ensureMainNS(session, hooks, &override); err != nil {
+			state := CaptureErrorState(interpID, err, &override)
+			_ = exitSession(session, hooks)
+			sharedns.Destroy(interpID, nil, nil)
+			excinfo, applyErr := ApplyError(state, "")
+			if result != nil {
+				result.ExcInfo = excinfo
+				result.ErrCode = override.Code
+			}
+			return applyErr
+		}
+		if err := sharedns.Apply(session.MainNS, nil); err != nil {
+			override.Code = ErrApplyNSFailure
+			state := CaptureErrorState(interpID, err, &override)
+			_ = exitSession(session, hooks)
+			sharedns.Destroy(interpID, nil, nil)
+			excinfo, applyErr := ApplyError(state, "")
+			if result != nil {
+				result.ExcInfo = excinfo
+				result.ErrCode = override.Code
+			}
+			return applyErr
+		}
+		sharedns.Destroy(interpID, nil, nil)
+	}
+	return nil
+}
+
+func Exit(session *Session, raised error, override *Failure, hooks SessionHooks, result *SessionResult) error {
+	if session == nil {
+		return fmt.Errorf("missing session")
+	}
+	state := ErrorState{}
+	if raised != nil || (override != nil && override.Code != ErrNoError) {
+		state = CaptureErrorState(session.InitInterpID, raised, override)
+	}
+	preserved := session.Preserved
+	if err := exitSession(session, hooks); err != nil {
+		return err
+	}
+	if result != nil {
+		result.Preserved = preserved
+		if state.Override != nil {
+			result.ErrCode = state.Override.Code
+		}
+	}
+	if state.Override == nil && !state.Uncaught.IsSet() {
+		return nil
+	}
+	excinfo, err := ApplyError(state, "")
+	if result != nil {
+		result.ExcInfo = excinfo
+	}
+	return err
+}
diff --git a/pycrossinterp/session_test.go b/pycrossinterp/session_test.go
new file mode 100644
index 0000000..a73e7a2
--- /dev/null
+++ b/pycrossinterp/session_test.go
@@ -0,0 +1,116 @@
+package pycrossinterp
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestSessionEnterAndExit(t *testing.T) {
+	session := NewSession()
+	current := int64(1)
+	mainNS := map[string]any{}
+	cleared := int64(0)
+	if err := Enter(session, 2, map[string]any{}, SessionHooks{
+		CurrentInterpID: func() int64 { return current },
+		SwitchTo: func(id int64) error {
+			current = id
+			return nil
+		},
+		SetRunningMain: func(id int64) error { return nil },
+		GetMainNamespace: func(id int64) (map[string]any, error) {
+			return mainNS, nil
+		},
+		ClearRunningMain: func(id int64) { cleared = id },
+		SwitchBack: func(id int64) error {
+			current = id
+			return nil
+		},
+	}, nil); err != nil {
+		t.Fatalf("Enter error: %v", err)
+	}
+	if !sessionActive(session) || !session.Switched || current != 2 {
+		t.Fatalf("session = %#v current=%d", session, current)
+	}
+	session.Preserve("x", 1)
+	result := &SessionResult{}
+	if err := Exit(session, nil, nil, SessionHooks{
+		ClearRunningMain: func(id int64) { cleared = id },
+		SwitchBack: func(id int64) error {
+			current = id
+			return nil
+		},
+	}, result); err != nil {
+		t.Fatalf("Exit error: %v", err)
+	}
+	if current != 1 || cleared != 2 || result.Preserved["x"] != 1 {
+		t.Fatalf("current=%d cleared=%d result=%#v", current, cleared, result)
+	}
+}
+
+func TestSessionEnterApplyNamespace(t *testing.T) {
+	session := NewSession()
+	mainNS := map[string]any{}
+	err := Enter(session, 1, map[string]any{"a": map[string]any{"x": 1}}, SessionHooks{
+		CurrentInterpID: func() int64 { return 1 },
+		SetRunningMain:  func(int64) error { return nil },
+		GetMainNamespace: func(id int64) (map[string]any, error) {
+			return mainNS, nil
+		},
+	}, nil)
+	if err != nil {
+		t.Fatalf("Enter error: %v", err)
+	}
+	got := mainNS["a"].(map[string]any)
+	if got["x"] != float64(1) {
+		t.Fatalf("mainNS = %#v", mainNS)
+	}
+}
+
+func TestSessionEnterFailurePropagates(t *testing.T) {
+	session := NewSession()
+	result := &SessionResult{}
+	err := Enter(session, 3, nil, SessionHooks{
+		CurrentInterpID: func() int64 { return 3 },
+		SetRunningMain: func(int64) error {
+			return errors.New("already running")
+		},
+	}, result)
+	if err == nil {
+		t.Fatal("expected enter failure")
+	}
+	if result.ErrCode != ErrAlreadyRunning {
+		t.Fatalf("result = %#v", result)
+	}
+}
+
+func TestSessionExitPropagatesErrors(t *testing.T) {
+	session := &Session{
+		Status:       SessionActive,
+		InitInterpID: 5,
+		PrevInterpID: 5,
+		Running:      true,
+	}
+	result := &SessionResult{}
+	override := &Failure{Code: ErrNotShareable, Msg: "custom"}
+	err := Exit(session, errors.New("boom"), override, SessionHooks{
+		ClearRunningMain: func(int64) {},
+	}, result)
+	var ns *NotShareableError
+	if !errors.As(err, &ns) {
+		t.Fatalf("err = %v", err)
+	}
+	if result.ErrCode != ErrNotShareable {
+		t.Fatalf("result = %#v", result)
+	}
+}
+
+func TestApplyErrorCodeAndFailureCapture(t *testing.T) {
+	if err := ApplyErrorCode(ErrApplyNSFailure); err == nil || err.Error() != "failed to apply namespace to __main__" {
+		t.Fatalf("ApplyErrorCode = %v", err)
+	}
+	override := &Failure{Code: ErrPreserveFailure, Msg: "preserve"}
+	state := CaptureErrorState(7, errors.New("bad"), override)
+	if state.Override == nil || state.Override.Code != ErrPreserveFailure || !state.Uncaught.IsSet() {
+		t.Fatalf("state = %#v", state)
+	}
+}

From bb7a210a1a7e3a099099d3f2dbc7b9ca6637af58 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 18:17:42 +0700
Subject: [PATCH 16/24] phase3: port xidata release and preserve helpers

---
 pycrossinterp/data.go         |  35 ++++++++++
 pycrossinterp/data_test.go    |  30 +++++++++
 pycrossinterp/session.go      | 117 +++++++++++++++++++++++++++++++++-
 pycrossinterp/session_test.go |  53 ++++++++++++++-
 4 files changed, 233 insertions(+), 2 deletions(-)

diff --git a/pycrossinterp/data.go b/pycrossinterp/data.go
index b23453a..087d7cc 100644
--- a/pycrossinterp/data.go
+++ b/pycrossinterp/data.go
@@ -24,6 +24,32 @@ func FreeXIData(interpID int64, x *XIData) error {
 	return nil
 }
 
+func ReleaseXIData(x *XIData, currentID int64, lookup func(int64) bool, schedule func(int64, func(any) int, any)) int {
+	if x == nil {
+		return 0
+	}
+	if (x.Data == nil || x.Free == nil) && x.Obj == nil {
+		x.Data = nil
+		return 0
+	}
+	if lookup != nil && !lookup(x.InterpID) {
+		_ = x.Clear(0)
+		return -1
+	}
+	if x.InterpID == currentID {
+		_ = x.Clear(0)
+		return 0
+	}
+	return CallInInterpreter(currentID, x.InterpID, func(any) int {
+		_ = x.Clear(0)
+		return 0
+	}, x, schedule)
+}
+
+func ReleaseAndRawFreeXIData(x *XIData, currentID int64, lookup func(int64) bool, schedule func(int64, func(any) int, any)) int {
+	return ReleaseXIData(x, currentID, lookup, schedule)
+}
+
 func (x *XIData) Init(interpID int64, shared any, obj any, newObject func(any) (any, error)) {
 	*x = XIData{
 		InterpID:  interpID,
@@ -146,3 +172,12 @@ func ObjectGetXIData(lookup *LookupState, interpID int64, obj any, fallback XIDa
 		return fmt.Errorf("unsupported xidata fallback option")
 	}
 }
+
+func CopyStringObjectRaw(text string) (string, int, error) {
+	for _, ch := range text {
+		if ch == 0 {
+			return "", 0, fmt.Errorf("found embedded NULL character")
+		}
+	}
+	return text, len(text), nil
+}
diff --git a/pycrossinterp/data_test.go b/pycrossinterp/data_test.go
index 55a8830..26e8f81 100644
--- a/pycrossinterp/data_test.go
+++ b/pycrossinterp/data_test.go
@@ -45,6 +45,36 @@ func TestXIDataAllocHelpers(t *testing.T) {
 	}
 }
 
+func TestXIDataReleaseHelpers(t *testing.T) {
+	x := NewXIData()
+	cleared := false
+	x.Init(4, "data", "obj", func(v any) (any, error) { return v, nil })
+	x.Free = func(any) { cleared = true }
+	if got := ReleaseXIData(x, 4, nil, nil); got != 0 || !cleared {
+		t.Fatalf("ReleaseXIData = %d cleared=%t", got, cleared)
+	}
+
+	y := NewXIData()
+	y.Init(8, "data", "obj", func(v any) (any, error) { return v, nil })
+	scheduled := int64(0)
+	if got := ReleaseAndRawFreeXIData(y, 3, func(id int64) bool { return id == 8 }, func(id int64, fn func(any) int, arg any) {
+		scheduled = id
+		fn(arg)
+	}); got != 0 || scheduled != 8 {
+		t.Fatalf("ReleaseAndRawFreeXIData = %d scheduled=%d", got, scheduled)
+	}
+}
+
+func TestCopyStringObjectRaw(t *testing.T) {
+	text, size, err := CopyStringObjectRaw("abc")
+	if err != nil || text != "abc" || size != 3 {
+		t.Fatalf("CopyStringObjectRaw = (%q, %d, %v)", text, size, err)
+	}
+	if _, _, err := CopyStringObjectRaw("a\x00b"); err == nil {
+		t.Fatal("expected embedded NUL error")
+	}
+}
+
 func TestCallInInterpreter(t *testing.T) {
 	called := 0
 	got := CallInInterpreter(1, 1, func(any) int {
diff --git a/pycrossinterp/session.go b/pycrossinterp/session.go
index 26fc7ab..4370703 100644
--- a/pycrossinterp/session.go
+++ b/pycrossinterp/session.go
@@ -142,6 +142,43 @@ func PropagateNotShareableError(err error, override *Failure) {
 	}
 }
 
+func NewFailure() *Failure {
+	return &Failure{Code: ErrNoError}
+}
+
+func FreeFailure(failure *Failure) {
+	if failure == nil {
+		return
+	}
+	*failure = Failure{Code: ErrNoError}
+}
+
+func GetFailureCode(failure *Failure) ErrCode {
+	if failure == nil {
+		return ErrNoError
+	}
+	return failure.Code
+}
+
+func InitFailureUTF8(failure *Failure, code ErrCode, msg string) {
+	if failure == nil {
+		return
+	}
+	*failure = Failure{Code: code, Msg: msg}
+}
+
+func InitFailure(failure *Failure, code ErrCode, obj any) error {
+	if failure == nil {
+		return nil
+	}
+	*failure = Failure{Code: code}
+	if obj == nil {
+		return nil
+	}
+	failure.Msg = fmt.Sprint(obj)
+	return nil
+}
+
 func CaptureErrorState(interpID int64, raised error, override *Failure) ErrorState {
 	state := ErrorState{InterpID: interpID}
 	if override != nil && override.Code != ErrNoError && override.Code != ErrUncaughtException {
@@ -227,6 +264,68 @@ func ensureMainNS(session *Session, hooks SessionHooks, override *Failure) error
 	return nil
 }
 
+func GetMainNamespace(session *Session, hooks SessionHooks, failure *Failure) (map[string]any, error) {
+	if !sessionActive(session) {
+		return nil, fmt.Errorf("session not active")
+	}
+	if err := ensureMainNS(session, hooks, failure); err != nil {
+		return nil, err
+	}
+	return session.MainNS, nil
+}
+
+func popPreserved(session *Session, failure *Failure) (*SharedNamespace, map[string]any, error) {
+	if session.Preserved == nil {
+		return nil, nil, nil
+	}
+	if !session.Switched {
+		preserved := session.Preserved
+		session.Preserved = nil
+		return nil, preserved, nil
+	}
+	if len(session.Preserved) == 0 {
+		session.Preserved = nil
+		return nil, nil, nil
+	}
+	names := make([]string, 0, len(session.Preserved))
+	for name := range session.Preserved {
+		names = append(names, name)
+	}
+	shared, err := CreateSharedNamespace(names)
+	if err != nil {
+		if failure != nil {
+			failure.Code = ErrPreserveFailure
+		}
+		return nil, nil, err
+	}
+	if err := shared.Fill(session.Preserved, XIDataFullFallback); err != nil {
+		if failure != nil {
+			if _, ok := err.(*NotShareableError); ok {
+				failure.Code = ErrNotShareable
+				failure.Msg = err.Error()
+			} else {
+				failure.Code = ErrPreserveFailure
+			}
+		}
+		shared.Destroy(session.InitInterpID, nil, nil)
+		return nil, nil, err
+	}
+	session.Preserved = nil
+	return shared, nil, nil
+}
+
+func finishPreserved(shared *SharedNamespace) (map[string]any, error) {
+	if shared == nil {
+		return nil, nil
+	}
+	defer shared.Destroy(0, nil, nil)
+	ns := map[string]any{}
+	if err := shared.Apply(ns, nil); err != nil {
+		return nil, err
+	}
+	return ns, nil
+}
+
 func Enter(session *Session, interpID int64, nsupdates map[string]any, hooks SessionHooks, result *SessionResult) error {
 	var sharedns *SharedNamespace
 	if len(nsupdates) > 0 {
@@ -311,10 +410,23 @@ func Exit(session *Session, raised error, override *Failure, hooks SessionHooks,
 	if raised != nil || (override != nil && override.Code != ErrNoError) {
 		state = CaptureErrorState(session.InitInterpID, raised, override)
 	}
-	preserved := session.Preserved
+	var localFailure Failure
+	sharedPreserved, directPreserved, popErr := popPreserved(session, &localFailure)
+	if popErr != nil && override == nil {
+		override = &localFailure
+	}
 	if err := exitSession(session, hooks); err != nil {
 		return err
 	}
+	preserved := directPreserved
+	if sharedPreserved != nil {
+		var err error
+		preserved, err = finishPreserved(sharedPreserved)
+		if err != nil && override == nil {
+			override = &Failure{Code: ErrPreserveFailure}
+			state = CaptureErrorState(session.InitInterpID, err, override)
+		}
+	}
 	if result != nil {
 		result.Preserved = preserved
 		if state.Override != nil {
@@ -327,6 +439,9 @@ func Exit(session *Session, raised error, override *Failure, hooks SessionHooks,
 	excinfo, err := ApplyError(state, "")
 	if result != nil {
 		result.ExcInfo = excinfo
+		if result.ErrCode == ErrNoError && state.Override != nil {
+			result.ErrCode = state.Override.Code
+		}
 	}
 	return err
 }
diff --git a/pycrossinterp/session_test.go b/pycrossinterp/session_test.go
index a73e7a2..a039f6f 100644
--- a/pycrossinterp/session_test.go
+++ b/pycrossinterp/session_test.go
@@ -42,7 +42,7 @@ func TestSessionEnterAndExit(t *testing.T) {
 	}, result); err != nil {
 		t.Fatalf("Exit error: %v", err)
 	}
-	if current != 1 || cleared != 2 || result.Preserved["x"] != 1 {
+	if current != 1 || cleared != 2 || result.Preserved["x"] != float64(1) {
 		t.Fatalf("current=%d cleared=%d result=%#v", current, cleared, result)
 	}
 }
@@ -66,6 +66,34 @@ func TestSessionEnterApplyNamespace(t *testing.T) {
 	}
 }
 
+func TestGetMainNamespaceAndFailureHelpers(t *testing.T) {
+	session := &Session{
+		Status:       SessionActive,
+		InitInterpID: 3,
+	}
+	ns, err := GetMainNamespace(session, SessionHooks{
+		GetMainNamespace: func(id int64) (map[string]any, error) {
+			return map[string]any{"x": 1}, nil
+		},
+	}, nil)
+	if err != nil || ns["x"] != 1 {
+		t.Fatalf("GetMainNamespace = (%#v, %v)", ns, err)
+	}
+
+	failure := NewFailure()
+	InitFailureUTF8(failure, ErrPreserveFailure, "bad")
+	if GetFailureCode(failure) != ErrPreserveFailure || failure.Msg != "bad" {
+		t.Fatalf("failure = %#v", failure)
+	}
+	if err := InitFailure(failure, ErrApplyNSFailure, 123); err != nil || failure.Msg != "123" {
+		t.Fatalf("failure = %#v err=%v", failure, err)
+	}
+	FreeFailure(failure)
+	if failure.Code != ErrNoError || failure.Msg != "" {
+		t.Fatalf("failure after free = %#v", failure)
+	}
+}
+
 func TestSessionEnterFailurePropagates(t *testing.T) {
 	session := NewSession()
 	result := &SessionResult{}
@@ -104,6 +132,29 @@ func TestSessionExitPropagatesErrors(t *testing.T) {
 	}
 }
 
+func TestSessionPreserveAcrossSwitch(t *testing.T) {
+	session := &Session{
+		Status:       SessionActive,
+		Switched:     true,
+		InitInterpID: 5,
+		PrevInterpID: 1,
+		Running:      true,
+	}
+	session.Preserve("x", map[string]any{"a": 1})
+	result := &SessionResult{}
+	err := Exit(session, nil, nil, SessionHooks{
+		ClearRunningMain: func(int64) {},
+		SwitchBack:       func(int64) error { return nil },
+	}, result)
+	if err != nil {
+		t.Fatalf("Exit error: %v", err)
+	}
+	got := result.Preserved["x"].(map[string]any)
+	if got["a"] != float64(1) {
+		t.Fatalf("result = %#v", result)
+	}
+}
+
 func TestApplyErrorCodeAndFailureCapture(t *testing.T) {
 	if err := ApplyErrorCode(ErrApplyNSFailure); err == nil || err.Error() != "failed to apply namespace to __main__" {
 		t.Fatalf("ApplyErrorCode = %v", err)

From 364ecfe7aadff194cdd020c3a0587751dfa45188 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 18:20:35 +0700
Subject: [PATCH 17/24] phase3: port crossinterp convenience wrappers

---
 pycrossinterp/data.go         |  7 +++++++
 pycrossinterp/data_test.go    | 12 +++++++++++
 pycrossinterp/excinfo.go      | 38 +++++++++++++++++++++++++++++++++++
 pycrossinterp/excinfo_test.go | 26 +++++++++++++++++++++++-
 pycrossinterp/mainmodule.go   |  3 ++-
 pycrossinterp/session.go      | 16 +++++++++++++++
 pycrossinterp/session_test.go |  7 +++++++
 7 files changed, 107 insertions(+), 2 deletions(-)

diff --git a/pycrossinterp/data.go b/pycrossinterp/data.go
index 087d7cc..f8e8c8f 100644
--- a/pycrossinterp/data.go
+++ b/pycrossinterp/data.go
@@ -59,6 +59,13 @@ func (x *XIData) Init(interpID int64, shared any, obj any, newObject func(any) (
 	}
 }
 
+func NewObjectFromXIData(x *XIData) (any, error) {
+	if x == nil || x.NewObject == nil {
+		return nil, fmt.Errorf("missing new_object func")
+	}
+	return x.NewObject(x)
+}
+
 func (x *XIData) InitWithSize(interpID int64, size int, obj any, newObject func(any) (any, error)) {
 	x.Init(interpID, make([]byte, size), obj, newObject)
 	x.Free = func(any) {}
diff --git a/pycrossinterp/data_test.go b/pycrossinterp/data_test.go
index 26e8f81..ff5b743 100644
--- a/pycrossinterp/data_test.go
+++ b/pycrossinterp/data_test.go
@@ -19,6 +19,18 @@ func TestXIDataLifecycle(t *testing.T) {
 	}
 }
 
+func TestNewObjectFromXIData(t *testing.T) {
+	x := NewXIData()
+	x.Init(4, "data", "obj", func(v any) (any, error) { return "made", nil })
+	got, err := NewObjectFromXIData(x)
+	if err != nil || got != "made" {
+		t.Fatalf("NewObjectFromXIData = (%v, %v)", got, err)
+	}
+	if _, err := NewObjectFromXIData(NewXIData()); err == nil {
+		t.Fatal("expected missing new object error")
+	}
+}
+
 func TestXIDataErrors(t *testing.T) {
 	x := NewXIData()
 	x.InterpID = -1
diff --git a/pycrossinterp/excinfo.go b/pycrossinterp/excinfo.go
index d2241a9..c378663 100644
--- a/pycrossinterp/excinfo.go
+++ b/pycrossinterp/excinfo.go
@@ -24,6 +24,44 @@ type ExcSnapshotObject struct {
 	ErrDisplay string
 }
 
+func NewExcInfo(exc any) (*ExcInfo, error) {
+	switch value := exc.(type) {
+	case nil:
+		return nil, fmt.Errorf("missing exc")
+	case ExcSnapshotObject:
+		return InitExcInfoFromObject(value)
+	case *ExcSnapshotObject:
+		if value == nil {
+			return nil, fmt.Errorf("missing exc")
+		}
+		return InitExcInfoFromObject(*value)
+	case error:
+		return InitExcInfoFromException("Exception", "Exception", "builtins", value.Error(), value.Error()), nil
+	default:
+		return InitExcInfoFromException("Exception", "Exception", "builtins", fmt.Sprint(value), fmt.Sprint(value)), nil
+	}
+}
+
+func FreeExcInfo(info *ExcInfo) {
+	if info != nil {
+		info.Clear()
+	}
+}
+
+func FormatExcInfo(info *ExcInfo) string {
+	if info == nil {
+		return ""
+	}
+	return info.Format()
+}
+
+func ExcInfoAsObject(info *ExcInfo) ExcSnapshotObject {
+	if info == nil {
+		return ExcSnapshotObject{}
+	}
+	return info.AsObject()
+}
+
 func (info *ExcInfo) Clear() {
 	*info = ExcInfo{}
 }
diff --git a/pycrossinterp/excinfo_test.go b/pycrossinterp/excinfo_test.go
index f2686c9..fe0bd03 100644
--- a/pycrossinterp/excinfo_test.go
+++ b/pycrossinterp/excinfo_test.go
@@ -1,6 +1,9 @@
 package pycrossinterp
 
-import "testing"
+import (
+	"errors"
+	"testing"
+)
 
 func TestExcInfoFormat(t *testing.T) {
 	info := ExcInfo{
@@ -51,3 +54,24 @@ func TestExcInfoClearAndIsSet(t *testing.T) {
 		t.Fatal("info should be cleared")
 	}
 }
+
+func TestExcInfoPublicWrappers(t *testing.T) {
+	info, err := NewExcInfo(errors.New("boom"))
+	if err != nil {
+		t.Fatalf("NewExcInfo returned error: %v", err)
+	}
+	if FormatExcInfo(info) != "Exception: boom" {
+		t.Fatalf("FormatExcInfo = %q", FormatExcInfo(info))
+	}
+	obj := ExcInfoAsObject(info)
+	if obj.Msg != "boom" {
+		t.Fatalf("ExcInfoAsObject = %#v", obj)
+	}
+	FreeExcInfo(info)
+	if info.IsSet() {
+		t.Fatal("info should be cleared")
+	}
+	if _, err := NewExcInfo(nil); err == nil {
+		t.Fatal("expected missing exc error")
+	}
+}
diff --git a/pycrossinterp/mainmodule.go b/pycrossinterp/mainmodule.go
index cca2d09..5dd225c 100644
--- a/pycrossinterp/mainmodule.go
+++ b/pycrossinterp/mainmodule.go
@@ -31,7 +31,8 @@ func EnsureIsolatedMain(main *SyncModule, hooks MainModuleHooks) error {
 		return nil
 	}
 	if main.Filename == "" {
-		return fmt.Errorf("not implemented")
+		main.Cached.Failed = fmt.Errorf("not implemented")
+		return main.Cached.Failed
 	}
 	if hooks.GetMainModule == nil || hooks.LoadFromPath == nil {
 		return fmt.Errorf("missing main module hooks")
diff --git a/pycrossinterp/session.go b/pycrossinterp/session.go
index 4370703..e51b29e 100644
--- a/pycrossinterp/session.go
+++ b/pycrossinterp/session.go
@@ -274,6 +274,22 @@ func GetMainNamespace(session *Session, hooks SessionHooks, failure *Failure) (m
 	return session.MainNS, nil
 }
 
+func GetPreserved(result *SessionResult, name string) any {
+	if result == nil || result.Preserved == nil {
+		return nil
+	}
+	return result.Preserved[name]
+}
+
+func ClearResult(result *SessionResult) {
+	if result == nil {
+		return
+	}
+	result.Preserved = nil
+	result.ExcInfo = nil
+	result.ErrCode = ErrNoError
+}
+
 func popPreserved(session *Session, failure *Failure) (*SharedNamespace, map[string]any, error) {
 	if session.Preserved == nil {
 		return nil, nil, nil
diff --git a/pycrossinterp/session_test.go b/pycrossinterp/session_test.go
index a039f6f..f263dd2 100644
--- a/pycrossinterp/session_test.go
+++ b/pycrossinterp/session_test.go
@@ -153,6 +153,13 @@ func TestSessionPreserveAcrossSwitch(t *testing.T) {
 	if got["a"] != float64(1) {
 		t.Fatalf("result = %#v", result)
 	}
+	if GetPreserved(result, "x") == nil {
+		t.Fatalf("GetPreserved = %#v", result)
+	}
+	ClearResult(result)
+	if result.Preserved != nil || result.ExcInfo != nil || result.ErrCode != ErrNoError {
+		t.Fatalf("result after clear = %#v", result)
+	}
 }
 
 func TestApplyErrorCodeAndFailureCapture(t *testing.T) {

From e127ae19ed2c4ca88c3f01d306d3d17130291742 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 18:26:50 +0700
Subject: [PATCH 18/24] phase3: port crossinterp runtime wrappers

---
 pycrossinterp/runtime.go      | 142 ++++++++++++++++++++++++++++++++++
 pycrossinterp/runtime_test.go | 105 +++++++++++++++++++++++++
 2 files changed, 247 insertions(+)
 create mode 100644 pycrossinterp/runtime.go
 create mode 100644 pycrossinterp/runtime_test.go

diff --git a/pycrossinterp/runtime.go b/pycrossinterp/runtime.go
new file mode 100644
index 0000000..9024051
--- /dev/null
+++ b/pycrossinterp/runtime.go
@@ -0,0 +1,142 @@
+package pycrossinterp
+
+import (
+	"fmt"
+
+	"github.com/tamnd/gopython/pyconfig"
+	"github.com/tamnd/gopython/pyruntime"
+	"github.com/tamnd/gopython/pystate"
+)
+
+type ExceptionTypes struct {
+	StaticInitialized bool
+	HeapInitialized   bool
+}
+
+type GlobalState struct {
+	DataLookup LookupState
+}
+
+type State struct {
+	DataLookup  LookupState
+	Exceptions  ExceptionTypes
+	Initialized bool
+}
+
+func InitGlobalState(state *GlobalState) error {
+	if state == nil {
+		return fmt.Errorf("missing global cross-interpreter state")
+	}
+	state.DataLookup.Init()
+	return nil
+}
+
+func FiniGlobalState(state *GlobalState) {
+	if state == nil {
+		return
+	}
+	state.DataLookup.Fini()
+}
+
+func InitState(state *State, initStaticTypes bool) error {
+	if state == nil {
+		return fmt.Errorf("failed to get interpreter's cross-interpreter state")
+	}
+	state.DataLookup.Init()
+	if initStaticTypes {
+		state.Exceptions.StaticInitialized = true
+	}
+	state.Exceptions.HeapInitialized = true
+	state.Initialized = true
+	return nil
+}
+
+func FiniState(state *State, finiStaticTypes bool) {
+	if state == nil {
+		return
+	}
+	state.Exceptions.HeapInitialized = false
+	if finiStaticTypes {
+		state.Exceptions.StaticInitialized = false
+	}
+	state.DataLookup.Fini()
+	state.Initialized = false
+}
+
+func Init(interp *pystate.InterpreterState, state *State, global *GlobalState, isMain bool) error {
+	if isMain {
+		if err := InitGlobalState(global); err != nil {
+			return fmt.Errorf("failed to initialize  global cross-interpreter state")
+		}
+	}
+	if interp == nil || state == nil {
+		return fmt.Errorf("failed to get interpreter's cross-interpreter state")
+	}
+	if err := InitState(state, false); err != nil {
+		return fmt.Errorf("failed to initialize interpreter's cross-interpreter state")
+	}
+	return nil
+}
+
+func Fini(interp *pystate.InterpreterState, state *State, global *GlobalState, isMain bool) {
+	_ = interp
+	FiniState(state, false)
+	if isMain {
+		FiniGlobalState(global)
+	}
+}
+
+func InitTypes(state *State) error {
+	if state == nil {
+		return fmt.Errorf("failed to initialize the cross-interpreter exception types")
+	}
+	state.Exceptions.StaticInitialized = true
+	return nil
+}
+
+func FiniTypes(state *State) {
+	if state == nil {
+		return
+	}
+	state.Exceptions.StaticInitialized = false
+}
+
+type InterpreterAPIResult struct {
+	Interp     *pystate.InterpreterState
+	Thread     *pystate.ThreadState
+	SaveThread *pystate.ThreadState
+}
+
+func NewInterpreter(runtime *pystate.RuntimeState, config pyconfig.InterpreterConfig, whence *int, hooks pyruntime.NewInterpreterHooks) (*InterpreterAPIResult, error) {
+	save := pystate.CurrentThreadState(runtime)
+	useWhence := pyruntime.InterpreterWhenceCAPI
+	if whence != nil {
+		useWhence = *whence
+	}
+	thread, err := pyruntime.NewInterpreterFromConfig(runtime, config, useWhence, hooks)
+	if err != nil {
+		return nil, fmt.Errorf("sub-interpreter creation failed: %w", err)
+	}
+	return &InterpreterAPIResult{
+		Interp:     thread.Interp,
+		Thread:     thread,
+		SaveThread: save,
+	}, nil
+}
+
+func EndInterpreter(interp *pystate.InterpreterState, thread *pystate.ThreadState, save **pystate.ThreadState, hooks pyruntime.FinalizeHooks) error {
+	if interp == nil {
+		return nil
+	}
+	if thread == nil {
+		pystate.DeleteInterpreter(interp.Runtime, interp)
+		return nil
+	}
+	if err := pyruntime.EndInterpreter(thread, hooks); err != nil {
+		return err
+	}
+	if save != nil {
+		*save = nil
+	}
+	return nil
+}
diff --git a/pycrossinterp/runtime_test.go b/pycrossinterp/runtime_test.go
new file mode 100644
index 0000000..75cf191
--- /dev/null
+++ b/pycrossinterp/runtime_test.go
@@ -0,0 +1,105 @@
+package pycrossinterp
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/tamnd/gopython/pyconfig"
+	"github.com/tamnd/gopython/pyruntime"
+	"github.com/tamnd/gopython/pystate"
+)
+
+func TestRuntimeStateLifecycle(t *testing.T) {
+	var global GlobalState
+	var state State
+	if err := InitGlobalState(&global); err != nil {
+		t.Fatalf("InitGlobalState error: %v", err)
+	}
+	if err := InitState(&state, true); err != nil {
+		t.Fatalf("InitState error: %v", err)
+	}
+	if !state.Initialized || !state.Exceptions.StaticInitialized || !state.Exceptions.HeapInitialized {
+		t.Fatalf("unexpected state flags: initialized=%t static=%t heap=%t", state.Initialized, state.Exceptions.StaticInitialized, state.Exceptions.HeapInitialized)
+	}
+	FiniState(&state, true)
+	if state.Initialized || state.Exceptions.StaticInitialized || state.Exceptions.HeapInitialized {
+		t.Fatalf("state after fini: initialized=%t static=%t heap=%t", state.Initialized, state.Exceptions.StaticInitialized, state.Exceptions.HeapInitialized)
+	}
+	FiniGlobalState(&global)
+}
+
+func TestInitAndTypeWrappers(t *testing.T) {
+	runtime := pystate.NewRuntimeState()
+	runtime.Init(1)
+	interp := runtime.NewInterpreter()
+	var global GlobalState
+	var state State
+	if err := Init(interp, &state, &global, true); err != nil {
+		t.Fatalf("Init error: %v", err)
+	}
+	if err := InitTypes(&state); err != nil {
+		t.Fatalf("InitTypes error: %v", err)
+	}
+	if !state.Exceptions.StaticInitialized {
+		t.Fatalf("unexpected static init flag: %+v", state.Exceptions)
+	}
+	FiniTypes(&state)
+	Fini(interp, &state, &global, true)
+	if state.Initialized {
+		t.Fatalf("state after fini: initialized=%t", state.Initialized)
+	}
+}
+
+func TestInterpreterAPIWrappers(t *testing.T) {
+	runtime := pystate.NewRuntimeState()
+	runtime.Init(1)
+	mainInterp := runtime.NewInterpreter()
+	mainThread := pystate.NewThreadState(mainInterp)
+	pystate.BindThread(mainThread, 1, 1)
+	pystate.BindGILState(runtime, mainThread)
+	pystate.SetMainThreadState(runtime, mainThread)
+
+	result, err := NewInterpreter(runtime, pyconfig.InterpreterConfig{
+		UseMainObmalloc:            true,
+		AllowFork:                  true,
+		AllowExec:                  true,
+		AllowThreads:               true,
+		AllowDaemonThreads:         true,
+		CheckMultiInterpExtensions: true,
+		GIL:                        pyconfig.OwnGIL,
+	}, nil, pyruntime.NewInterpreterHooks{})
+	if err != nil {
+		t.Fatalf("NewInterpreter wrapper error: %v", err)
+	}
+	if result == nil || result.Interp == nil || result.Thread == nil || result.SaveThread != mainThread {
+		t.Fatalf("result = %#v", result)
+	}
+
+	var save *pystate.ThreadState = mainThread
+	if err := EndInterpreter(result.Interp, result.Thread, &save, pyruntime.FinalizeHooks{}); err != nil {
+		t.Fatalf("EndInterpreter wrapper error: %v", err)
+	}
+	if save != nil {
+		t.Fatalf("save = %#v", save)
+	}
+}
+
+func TestInterpreterAPIErrorWrap(t *testing.T) {
+	runtime := pystate.NewRuntimeState()
+	runtime.Init(1)
+	mainInterp := runtime.NewInterpreter()
+	mainThread := pystate.NewThreadState(mainInterp)
+	pystate.BindThread(mainThread, 1, 1)
+	pystate.BindGILState(runtime, mainThread)
+
+	_, err := NewInterpreter(runtime, pyconfig.InterpreterConfig{
+		CheckMultiInterpExtensions: false,
+		GIL:                        pyconfig.SharedGIL,
+	}, nil, pyruntime.NewInterpreterHooks{
+		InitObjectState: func(*pystate.InterpreterState) error { return nil },
+		InitObmalloc:    func(*pystate.InterpreterState) error { return errors.New("boom") },
+	})
+	if err == nil {
+		t.Fatal("expected wrapped creation error")
+	}
+}

From 759386b88e965889c01c2e842af3d15f4b2ada05 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 18:51:37 +0700
Subject: [PATCH 19/24] phase3: tighten crossinterp end interpreter flow

---
 pycrossinterp/runtime.go      | 29 +++++++++++++++++++++++++++--
 pycrossinterp/runtime_test.go | 30 +++++++++++++++++++++++++++++-
 pystate/state.go              |  2 ++
 3 files changed, 58 insertions(+), 3 deletions(-)

diff --git a/pycrossinterp/runtime.go b/pycrossinterp/runtime.go
index 9024051..c649bac 100644
--- a/pycrossinterp/runtime.go
+++ b/pycrossinterp/runtime.go
@@ -128,15 +128,40 @@ func EndInterpreter(interp *pystate.InterpreterState, thread *pystate.ThreadStat
 	if interp == nil {
 		return nil
 	}
-	if thread == nil {
+	if !interp.Ready {
 		pystate.DeleteInterpreter(interp.Runtime, interp)
 		return nil
 	}
+	runtime := interp.Runtime
+	cur := pystate.CurrentThreadState(runtime)
+	restore := (*pystate.ThreadState)(nil)
+	if save != nil {
+		restore = *save
+	}
+	if thread == nil {
+		if cur != nil && cur.Interp == interp {
+			thread = cur
+		} else {
+			thread = pystate.NewThreadState(interp)
+			thread.Whence = pyruntime.InterpreterWhenceFinalize
+			pystate.BindThread(thread, uint64(interp.ID+1), uint64(interp.ID+1))
+			restore = cur
+			pystate.BindGILState(runtime, thread)
+		}
+	} else if cur != thread {
+		if cur != nil && cur.Interp != interp {
+			restore = cur
+		}
+		pystate.BindGILState(runtime, thread)
+	}
 	if err := pyruntime.EndInterpreter(thread, hooks); err != nil {
 		return err
 	}
+	if restore != nil {
+		pystate.BindGILState(runtime, restore)
+	}
 	if save != nil {
-		*save = nil
+		*save = restore
 	}
 	return nil
 }
diff --git a/pycrossinterp/runtime_test.go b/pycrossinterp/runtime_test.go
index 75cf191..6ca5e38 100644
--- a/pycrossinterp/runtime_test.go
+++ b/pycrossinterp/runtime_test.go
@@ -79,7 +79,7 @@ func TestInterpreterAPIWrappers(t *testing.T) {
 	if err := EndInterpreter(result.Interp, result.Thread, &save, pyruntime.FinalizeHooks{}); err != nil {
 		t.Fatalf("EndInterpreter wrapper error: %v", err)
 	}
-	if save != nil {
+	if save != mainThread {
 		t.Fatalf("save = %#v", save)
 	}
 }
@@ -103,3 +103,31 @@ func TestInterpreterAPIErrorWrap(t *testing.T) {
 		t.Fatal("expected wrapped creation error")
 	}
 }
+
+func TestEndInterpreterNotReadyAndNilThread(t *testing.T) {
+	runtime := pystate.NewRuntimeState()
+	runtime.Init(1)
+	mainInterp := runtime.NewInterpreter()
+	mainThread := pystate.NewThreadState(mainInterp)
+	pystate.BindThread(mainThread, 1, 1)
+	pystate.BindGILState(runtime, mainThread)
+	pystate.SetMainThreadState(runtime, mainThread)
+
+	notReady := runtime.NewInterpreter()
+	notReady.Ready = false
+	if err := EndInterpreter(notReady, nil, nil, pyruntime.FinalizeHooks{}); err != nil {
+		t.Fatalf("EndInterpreter not-ready error: %v", err)
+	}
+
+	other := runtime.NewInterpreter()
+	save := mainThread
+	if err := EndInterpreter(other, nil, &save, pyruntime.FinalizeHooks{}); err != nil {
+		t.Fatalf("EndInterpreter nil-thread error: %v", err)
+	}
+	if save != mainThread {
+		t.Fatalf("save = %#v", save)
+	}
+	if pystate.CurrentThreadState(runtime) != mainThread {
+		t.Fatalf("current = %#v", pystate.CurrentThreadState(runtime))
+	}
+}
diff --git a/pystate/state.go b/pystate/state.go
index 711a8e2..b8235c8 100644
--- a/pystate/state.go
+++ b/pystate/state.go
@@ -24,6 +24,7 @@ type ThreadState struct {
 type InterpreterState struct {
 	ID                int64
 	Runtime           *RuntimeState
+	Ready             bool
 	RunningMain       bool
 	Whence            int
 	Finalizing        bool
@@ -71,6 +72,7 @@ func (runtime *RuntimeState) NewInterpreter() *InterpreterState {
 	interp := &InterpreterState{
 		ID:      runtime.nextInterpreter,
 		Runtime: runtime,
+		Ready:   true,
 	}
 	runtime.nextInterpreter++
 	runtime.interpreters = append(runtime.interpreters, interp)

From b142be53634b3ea232f16e623f6657997edf7354 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 18:56:59 +0700
Subject: [PATCH 20/24] phase3: fix xi interpreter wrapper branches

---
 pycrossinterp/runtime.go      | 21 ++++++++++++++++++++-
 pycrossinterp/runtime_test.go | 32 ++++++++++++++++++++++++++++++++
 pyruntime/subinterp.go        |  1 +
 3 files changed, 53 insertions(+), 1 deletion(-)

diff --git a/pycrossinterp/runtime.go b/pycrossinterp/runtime.go
index c649bac..c33e365 100644
--- a/pycrossinterp/runtime.go
+++ b/pycrossinterp/runtime.go
@@ -109,7 +109,7 @@ type InterpreterAPIResult struct {
 
 func NewInterpreter(runtime *pystate.RuntimeState, config pyconfig.InterpreterConfig, whence *int, hooks pyruntime.NewInterpreterHooks) (*InterpreterAPIResult, error) {
 	save := pystate.CurrentThreadState(runtime)
-	useWhence := pyruntime.InterpreterWhenceCAPI
+	useWhence := pyruntime.InterpreterWhenceXI
 	if whence != nil {
 		useWhence = *whence
 	}
@@ -124,6 +124,25 @@ func NewInterpreter(runtime *pystate.RuntimeState, config pyconfig.InterpreterCo
 	}, nil
 }
 
+func NewInterpreterStateOnly(runtime *pystate.RuntimeState, config pyconfig.InterpreterConfig, whence *int, hooks pyruntime.NewInterpreterHooks) (*pystate.InterpreterState, *pystate.ThreadState, error) {
+	result, err := NewInterpreter(runtime, config, whence, hooks)
+	if err != nil {
+		return nil, nil, err
+	}
+	if result == nil || result.Interp == nil {
+		return nil, result.SaveThread, nil
+	}
+	if result.Thread != nil {
+		if result.SaveThread != nil {
+			pystate.BindGILState(runtime, result.SaveThread)
+		}
+		result.Thread.Status.Cleared = true
+		result.Thread.Status.Finalized = true
+		result.Thread = nil
+	}
+	return result.Interp, result.SaveThread, nil
+}
+
 func EndInterpreter(interp *pystate.InterpreterState, thread *pystate.ThreadState, save **pystate.ThreadState, hooks pyruntime.FinalizeHooks) error {
 	if interp == nil {
 		return nil
diff --git a/pycrossinterp/runtime_test.go b/pycrossinterp/runtime_test.go
index 6ca5e38..e1b362d 100644
--- a/pycrossinterp/runtime_test.go
+++ b/pycrossinterp/runtime_test.go
@@ -74,6 +74,9 @@ func TestInterpreterAPIWrappers(t *testing.T) {
 	if result == nil || result.Interp == nil || result.Thread == nil || result.SaveThread != mainThread {
 		t.Fatalf("result = %#v", result)
 	}
+	if result.Interp.Whence != pyruntime.InterpreterWhenceXI {
+		t.Fatalf("interp whence = %d", result.Interp.Whence)
+	}
 
 	var save *pystate.ThreadState = mainThread
 	if err := EndInterpreter(result.Interp, result.Thread, &save, pyruntime.FinalizeHooks{}); err != nil {
@@ -104,6 +107,35 @@ func TestInterpreterAPIErrorWrap(t *testing.T) {
 	}
 }
 
+func TestInterpreterStateOnlyWrapper(t *testing.T) {
+	runtime := pystate.NewRuntimeState()
+	runtime.Init(1)
+	mainInterp := runtime.NewInterpreter()
+	mainThread := pystate.NewThreadState(mainInterp)
+	pystate.BindThread(mainThread, 1, 1)
+	pystate.BindGILState(runtime, mainThread)
+	pystate.SetMainThreadState(runtime, mainThread)
+
+	interp, save, err := NewInterpreterStateOnly(runtime, pyconfig.InterpreterConfig{
+		UseMainObmalloc:            true,
+		AllowFork:                  true,
+		AllowExec:                  true,
+		AllowThreads:               true,
+		AllowDaemonThreads:         true,
+		CheckMultiInterpExtensions: true,
+		GIL:                        pyconfig.OwnGIL,
+	}, nil, pyruntime.NewInterpreterHooks{})
+	if err != nil {
+		t.Fatalf("NewInterpreterStateOnly error: %v", err)
+	}
+	if interp == nil || save != mainThread {
+		t.Fatalf("interp=%#v save=%#v", interp, save)
+	}
+	if pystate.CurrentThreadState(runtime) != mainThread {
+		t.Fatalf("current = %#v", pystate.CurrentThreadState(runtime))
+	}
+}
+
 func TestEndInterpreterNotReadyAndNilThread(t *testing.T) {
 	runtime := pystate.NewRuntimeState()
 	runtime.Init(1)
diff --git a/pyruntime/subinterp.go b/pyruntime/subinterp.go
index 41dfa7c..3e993b0 100644
--- a/pyruntime/subinterp.go
+++ b/pyruntime/subinterp.go
@@ -11,6 +11,7 @@ const (
 	InterpreterWhenceCAPI = 1 + iota
 	InterpreterWhenceLegacyCAPI
 	InterpreterWhenceFinalize
+	InterpreterWhenceXI
 )
 
 type NewInterpreterHooks struct {

From e8ec27a26fd2d7f4450aa9f6e9af7b3a7dc5d030 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 19:17:48 +0700
Subject: [PATCH 21/24] phase3: port pickle context retry flow

---
 pycrossinterp/serial.go      | 53 ++++++++++++++++++++++++++++++++----
 pycrossinterp/serial_test.go | 50 +++++++++++++++++++++++++++++++++-
 2 files changed, 97 insertions(+), 6 deletions(-)

diff --git a/pycrossinterp/serial.go b/pycrossinterp/serial.go
index e9a5c2d..a47b55b 100644
--- a/pycrossinterp/serial.go
+++ b/pycrossinterp/serial.go
@@ -29,6 +29,14 @@ type PickleContext struct {
 	MainFile string
 }
 
+type UnpickleHooks struct {
+	Load                 func([]byte) (any, error)
+	EnsureIsolatedMain   func(filename string) error
+	ApplyIsolatedMain    func() error
+	RestoreMain          func()
+	CheckMissingMainAttr func(error) bool
+}
+
 type SharedPickleData struct {
 	Pickled []byte
 	Context PickleContext
@@ -49,16 +57,51 @@ func PickleToXIData(obj any, mainFile string, xid *XIData) error {
 	return nil
 }
 
+func SetPickleContext(mainFile string) PickleContext {
+	return PickleContext{MainFile: mainFile}
+}
+
+func LoadPickleWithContext(shared SharedPickleData, hooks UnpickleHooks) (any, error) {
+	if hooks.Load == nil {
+		var out any
+		if err := json.Unmarshal(shared.Pickled, &out); err != nil {
+			return nil, fmt.Errorf("object could not be unpickled: %w", err)
+		}
+		return out, nil
+	}
+	obj, err := hooks.Load(shared.Pickled)
+	if err == nil {
+		return obj, nil
+	}
+	checkMissing := hooks.CheckMissingMainAttr
+	if checkMissing == nil {
+		checkMissing = CheckMissingMainAttr
+	}
+	if shared.Context.MainFile == "" || !checkMissing(err) {
+		return nil, err
+	}
+	if hooks.EnsureIsolatedMain != nil {
+		if ensureErr := hooks.EnsureIsolatedMain(shared.Context.MainFile); ensureErr != nil {
+			return nil, ensureErr
+		}
+	}
+	if hooks.ApplyIsolatedMain != nil {
+		if applyErr := hooks.ApplyIsolatedMain(); applyErr != nil {
+			return nil, applyErr
+		}
+	}
+	if hooks.RestoreMain != nil {
+		defer hooks.RestoreMain()
+	}
+	return hooks.Load(shared.Pickled)
+}
+
 func LoadPickleFromXIData(xid *XIData) (any, error) {
 	shared, ok := xid.Data.(SharedPickleData)
 	if !ok {
 		return nil, fmt.Errorf("object could not be unpickled")
 	}
-	var out any
-	if err := json.Unmarshal(shared.Pickled, &out); err != nil {
-		return nil, fmt.Errorf("object could not be unpickled: %w", err)
-	}
-	return out, nil
+	return LoadPickleWithContext(shared, UnpickleHooks{})
 }
 
 func MarshalToXIData(obj any, xid *XIData) error {
diff --git a/pycrossinterp/serial_test.go b/pycrossinterp/serial_test.go
index 4a6f4c3..90eb7e1 100644
--- a/pycrossinterp/serial_test.go
+++ b/pycrossinterp/serial_test.go
@@ -1,6 +1,9 @@
 package pycrossinterp
 
-import "testing"
+import (
+	"errors"
+	"testing"
+)
 
 func TestPickleRoundTripXIData(t *testing.T) {
 	x := NewXIData()
@@ -17,6 +20,51 @@ func TestPickleRoundTripXIData(t *testing.T) {
 	}
 }
 
+func TestLoadPickleWithContextRetryMain(t *testing.T) {
+	loads := 0
+	restored := false
+	got, err := LoadPickleWithContext(SharedPickleData{
+		Pickled: []byte(`{"a":1}`),
+		Context: SetPickleContext("main.py"),
+	}, UnpickleHooks{
+		Load: func(data []byte) (any, error) {
+			loads++
+			if loads == 1 {
+				return nil, errors.New("module '__main__' has no attribute 'x'")
+			}
+			return map[string]any{"ok": true}, nil
+		},
+		EnsureIsolatedMain: func(filename string) error {
+			if filename != "main.py" {
+				t.Fatalf("filename = %q", filename)
+			}
+			return nil
+		},
+		ApplyIsolatedMain: func() error { return nil },
+		RestoreMain:       func() { restored = true },
+	})
+	if err != nil {
+		t.Fatalf("LoadPickleWithContext returned error: %v", err)
+	}
+	if loads != 2 || !restored || got.(map[string]any)["ok"] != true {
+		t.Fatalf("loads=%d restored=%t got=%#v", loads, restored, got)
+	}
+}
+
+func TestLoadPickleWithContextNoRetry(t *testing.T) {
+	_, err := LoadPickleWithContext(SharedPickleData{
+		Pickled: []byte(`{`),
+		Context: SetPickleContext(""),
+	}, UnpickleHooks{
+		Load: func(data []byte) (any, error) {
+			return nil, errors.New("plain failure")
+		},
+	})
+	if err == nil {
+		t.Fatal("expected unpickle failure")
+	}
+}
+
 func TestMarshalRoundTripXIData(t *testing.T) {
 	x := NewXIData()
 	if err := MarshalToXIData([]any{"x", 2.0}, x); err != nil {

From 30aedf40cfe4d32aaa92b33389d60f0ce293ed2b Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 19:20:25 +0700
Subject: [PATCH 22/24] phase3: resolve isolated main filename

---
 pycrossinterp/mainmodule.go      | 16 ++++++++++++++-
 pycrossinterp/mainmodule_test.go | 35 ++++++++++++++++++++++++++++++++
 2 files changed, 50 insertions(+), 1 deletion(-)

diff --git a/pycrossinterp/mainmodule.go b/pycrossinterp/mainmodule.go
index 5dd225c..01240b4 100644
--- a/pycrossinterp/mainmodule.go
+++ b/pycrossinterp/mainmodule.go
@@ -15,6 +15,7 @@ type SyncModule struct {
 
 type MainModuleHooks struct {
 	GetMainModule   func() (any, error)
+	GetMainFilename func() (string, error)
 	LoadFromPath    func(filename string, modname string) (any, error)
 	SetMainModule   func(module any) error
 	GetCachedModule func(name string) (any, error)
@@ -26,11 +27,24 @@ func (m *SyncModule) Clear() {
 	m.Cached = SyncModuleResult{}
 }
 
+func resolveMainFilename(main *SyncModule, hooks MainModuleHooks) string {
+	if main.Filename != "" {
+		return main.Filename
+	}
+	if hooks.GetMainFilename != nil {
+		if filename, err := hooks.GetMainFilename(); err == nil && filename != "" {
+			main.Filename = filename
+			return filename
+		}
+	}
+	return ""
+}
+
 func EnsureIsolatedMain(main *SyncModule, hooks MainModuleHooks) error {
 	if main.Cached.Failed != nil || main.Cached.Loaded != nil {
 		return nil
 	}
-	if main.Filename == "" {
+	if resolveMainFilename(main, hooks) == "" {
 		main.Cached.Failed = fmt.Errorf("not implemented")
 		return main.Cached.Failed
 	}
diff --git a/pycrossinterp/mainmodule_test.go b/pycrossinterp/mainmodule_test.go
index 79f8668..7327689 100644
--- a/pycrossinterp/mainmodule_test.go
+++ b/pycrossinterp/mainmodule_test.go
@@ -89,6 +89,41 @@ func TestSyncModuleUsesInterpreterCache(t *testing.T) {
 	}
 }
 
+func TestSyncModuleResolvesFilenameFromHook(t *testing.T) {
+	current := "current"
+	main := &SyncModule{}
+	err := EnsureIsolatedMain(main, MainModuleHooks{
+		GetMainFilename: func() (string, error) { return "main.py", nil },
+		GetMainModule:   func() (any, error) { return current, nil },
+		LoadFromPath: func(filename string, modname string) (any, error) {
+			return filename + ":" + modname, nil
+		},
+		SetMainModule: func(module any) error {
+			current = module.(string)
+			return nil
+		},
+	})
+	if err != nil {
+		t.Fatalf("EnsureIsolatedMain returned error: %v", err)
+	}
+	if main.Filename != "main.py" {
+		t.Fatalf("main.Filename = %q", main.Filename)
+	}
+}
+
+func TestSyncModuleFilenameHookFailureFallsBackToNotImplemented(t *testing.T) {
+	main := &SyncModule{}
+	err := EnsureIsolatedMain(main, MainModuleHooks{
+		GetMainFilename: func() (string, error) { return "", errors.New("boom") },
+	})
+	if err == nil || err.Error() != "not implemented" {
+		t.Fatalf("EnsureIsolatedMain error = %v", err)
+	}
+	if main.Cached.Failed == nil || main.Cached.Failed.Error() != "not implemented" {
+		t.Fatalf("cached failure = %v", main.Cached.Failed)
+	}
+}
+
 func TestCheckMissingMainAttr(t *testing.T) {
 	if !CheckMissingMainAttr(errors.New("module '__main__' has no attribute 'x'")) {
 		t.Fatal("expected missing __main__ attr match")

From c8b57579797f501ecc2cd4d9138dacf7cf6c9426 Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 22:36:07 +0700
Subject: [PATCH 23/24] phase3: complete crossinterp wrapper surface

---
 pycrossinterp/data.go         |  4 ++++
 pycrossinterp/data_test.go    |  8 ++++++++
 pycrossinterp/serial.go       |  8 ++++++++
 pycrossinterp/serial_test.go  |  9 +++++++++
 pycrossinterp/session.go      | 12 ++++++++++++
 pycrossinterp/session_test.go | 10 ++++++++++
 6 files changed, 51 insertions(+)

diff --git a/pycrossinterp/data.go b/pycrossinterp/data.go
index f8e8c8f..14948a0 100644
--- a/pycrossinterp/data.go
+++ b/pycrossinterp/data.go
@@ -147,6 +147,10 @@ func ObjectCheckXIData(lookup *LookupState, obj any) error {
 	return nil
 }
 
+func ObjectGetXIDataNoFallback(lookup *LookupState, interpID int64, obj any, xidata *XIData) error {
+	return ObjectGetXIData(lookup, interpID, obj, XIDataOnly, xidata)
+}
+
 func ObjectGetXIData(lookup *LookupState, interpID int64, obj any, fallback XIDataFallback, xidata *XIData) error {
 	if xidata == nil {
 		return fmt.Errorf("missing xidata")
diff --git a/pycrossinterp/data_test.go b/pycrossinterp/data_test.go
index ff5b743..2403eb1 100644
--- a/pycrossinterp/data_test.go
+++ b/pycrossinterp/data_test.go
@@ -142,6 +142,14 @@ func TestObjectXIDataLookupAndFallback(t *testing.T) {
 		t.Fatalf("x = %#v", x)
 	}
 
+	nf := NewXIData()
+	if err := ObjectGetXIDataNoFallback(&lookup, 10, "abc", nf); err != nil {
+		t.Fatalf("ObjectGetXIDataNoFallback returned error: %v", err)
+	}
+	if nf.InterpID != 10 || nf.Data != "shared:abc" {
+		t.Fatalf("nf = %#v", nf)
+	}
+
 	y := NewXIData()
 	if err := ObjectGetXIData(nil, 8, map[string]any{"a": 1}, XIDataFullFallback, y); err != nil {
 		t.Fatalf("ObjectGetXIData fallback returned error: %v", err)
diff --git a/pycrossinterp/serial.go b/pycrossinterp/serial.go
index a47b55b..e248a18 100644
--- a/pycrossinterp/serial.go
+++ b/pycrossinterp/serial.go
@@ -138,6 +138,14 @@ func ScriptToXIData(obj any, pure bool, xid *XIData) error {
 	return nil
 }
 
+func CodeToScriptXIData(obj any, xid *XIData) error {
+	return ScriptToXIData(obj, false, xid)
+}
+
+func CodeToPureScriptXIData(obj any, xid *XIData) error {
+	return ScriptToXIData(obj, true, xid)
+}
+
 func stringsContainsAny(text string, needles []string) bool {
 	for _, needle := range needles {
 		if strings.Contains(text, needle) {
diff --git a/pycrossinterp/serial_test.go b/pycrossinterp/serial_test.go
index 90eb7e1..84d02eb 100644
--- a/pycrossinterp/serial_test.go
+++ b/pycrossinterp/serial_test.go
@@ -93,6 +93,15 @@ func TestScriptToXIData(t *testing.T) {
 	if err := ScriptToXIData("global x\nprint(x)", true, NewXIData()); err == nil {
 		t.Fatal("expected invalid pure script error")
 	}
+
+	y := NewXIData()
+	if err := CodeToScriptXIData("print('y')", y); err != nil {
+		t.Fatalf("CodeToScriptXIData returned error: %v", err)
+	}
+	z := NewXIData()
+	if err := CodeToPureScriptXIData("print('z')", z); err != nil {
+		t.Fatalf("CodeToPureScriptXIData returned error: %v", err)
+	}
 }
 
 func TestScriptCodeAndFunctionValidation(t *testing.T) {
diff --git a/pycrossinterp/session.go b/pycrossinterp/session.go
index e51b29e..effaf29 100644
--- a/pycrossinterp/session.go
+++ b/pycrossinterp/session.go
@@ -83,6 +83,18 @@ func (session *Session) Preserve(name string, value any) {
 	session.Preserved[name] = value
 }
 
+func Preserve(session *Session, name string, value any, failure *Failure) error {
+	if !sessionActive(session) {
+		return fmt.Errorf("session not active")
+	}
+	if session.Preserved == nil {
+		session.Preserved = map[string]any{}
+	}
+	session.Preserved[name] = value
+	_ = failure
+	return nil
+}
+
 func (session *Session) GetPreserved(name string) (any, bool) {
 	if session.Preserved == nil {
 		return nil, false
diff --git a/pycrossinterp/session_test.go b/pycrossinterp/session_test.go
index f263dd2..a3a39db 100644
--- a/pycrossinterp/session_test.go
+++ b/pycrossinterp/session_test.go
@@ -92,6 +92,16 @@ func TestGetMainNamespaceAndFailureHelpers(t *testing.T) {
 	if failure.Code != ErrNoError || failure.Msg != "" {
 		t.Fatalf("failure after free = %#v", failure)
 	}
+
+	if err := Preserve(session, "y", 2, nil); err != nil {
+		t.Fatalf("Preserve returned error: %v", err)
+	}
+	if got, ok := session.GetPreserved("y"); !ok || got != 2 {
+		t.Fatalf("preserved = (%v, %t)", got, ok)
+	}
+	if err := Preserve(NewSession(), "z", 3, nil); err == nil {
+		t.Fatal("expected inactive preserve failure")
+	}
 }
 
 func TestSessionEnterFailurePropagates(t *testing.T) {

From 9a2184c33777896e31822b3cbf079b91e56ada7b Mon Sep 17 00:00:00 2001
From: tamnd <1218621+tamnd@users.noreply.github.com>
Date: Fri, 1 May 2026 22:48:20 +0700
Subject: [PATCH 24/24] phase3: tighten runtime and config wrappers

---
 pyconfig/interpconfig.go      |  15 +++
 pyconfig/interpconfig_test.go |  22 +++++
 pyconfig/preconfig.go         |  15 +++
 pyconfig/preconfig_test.go    |  19 ++++
 pycontext/context.go          |   4 +
 pycontext/context_test.go     |   7 ++
 pystate/errors.go             |  11 ++-
 pystate/state.go              | 181 +++++++++++++++++++++++++++++++++-
 pystate/state_test.go         | 106 ++++++++++++++++++++
 9 files changed, 375 insertions(+), 5 deletions(-)

diff --git a/pyconfig/interpconfig.go b/pyconfig/interpconfig.go
index b883e9b..5121839 100644
--- a/pyconfig/interpconfig.go
+++ b/pyconfig/interpconfig.go
@@ -63,6 +63,13 @@ func (config InterpreterConfig) AsMap() (map[string]any, error) {
 	}, nil
 }
 
+func InterpreterConfigAsMap(config *InterpreterConfig) (map[string]any, error) {
+	if config == nil {
+		return nil, fmt.Errorf("nil interpreter config")
+	}
+	return config.AsMap()
+}
+
 func InitInterpreterConfigFromMap(config *InterpreterConfig, dict map[string]any) error {
 	if config == nil {
 		return fmt.Errorf("nil interpreter config")
@@ -91,6 +98,14 @@ func (config *InterpreterConfig) InitFromState(interp InterpreterStateSnapshot)
 	}
 }
 
+func InitInterpreterConfigFromState(config *InterpreterConfig, interp InterpreterStateSnapshot) error {
+	if config == nil {
+		return fmt.Errorf("nil interpreter config")
+	}
+	config.InitFromState(interp)
+	return nil
+}
+
 func interpreterConfigFromMap(config *InterpreterConfig, dict map[string]any, missingAllowed bool) error {
 	working := make(map[string]any, len(dict))
 	for key, value := range dict {
diff --git a/pyconfig/interpconfig_test.go b/pyconfig/interpconfig_test.go
index 1d75c63..8b79119 100644
--- a/pyconfig/interpconfig_test.go
+++ b/pyconfig/interpconfig_test.go
@@ -138,3 +138,25 @@ func TestInterpreterConfigInitFromState(t *testing.T) {
 		t.Fatalf("config = %#v", config)
 	}
 }
+
+func TestInterpreterConfigWrapperHelpers(t *testing.T) {
+	config := &InterpreterConfig{GIL: SharedGIL}
+	dict, err := InterpreterConfigAsMap(config)
+	if err != nil || dict["gil"] != "shared" {
+		t.Fatalf("InterpreterConfigAsMap = (%#v, %v)", dict, err)
+	}
+	if _, err := InterpreterConfigAsMap(nil); err == nil {
+		t.Fatal("expected nil config error")
+	}
+
+	var fromState InterpreterConfig
+	if err := InitInterpreterConfigFromState(&fromState, InterpreterStateSnapshot{OwnGIL: true}); err != nil {
+		t.Fatalf("InitInterpreterConfigFromState returned error: %v", err)
+	}
+	if fromState.GIL != OwnGIL {
+		t.Fatalf("fromState = %#v", fromState)
+	}
+	if err := InitInterpreterConfigFromState(nil, InterpreterStateSnapshot{}); err == nil {
+		t.Fatal("expected nil config error")
+	}
+}
diff --git a/pyconfig/preconfig.go b/pyconfig/preconfig.go
index ffa9c20..8f9f65d 100644
--- a/pyconfig/preconfig.go
+++ b/pyconfig/preconfig.go
@@ -144,6 +144,21 @@ func InitIsolatedPreConfig(config *PreConfig) {
 	config.LegacyWindowsFSEncoding = 0
 }
 
+func InitPreConfigFromConfig(preconfig *PreConfig, config *Config) {
+	if preconfig == nil || config == nil {
+		return
+	}
+	preconfig.ConfigInit = config.ConfigInit
+	preconfig.ParseArgv = config.ParseArgv
+	preconfig.Isolated = config.Isolated
+	preconfig.UseEnvironment = config.UseEnvironment
+	preconfig.DevMode = config.DevMode
+}
+
+func GetPreConfigFromConfig(preconfig *PreConfig, config *Config) {
+	InitPreConfigFromConfig(preconfig, config)
+}
+
 func (config PreConfig) AsMap() map[string]int {
 	return map[string]int{
 		"_config_init":               int(config.ConfigInit),
diff --git a/pyconfig/preconfig_test.go b/pyconfig/preconfig_test.go
index 2d5057c..255b28e 100644
--- a/pyconfig/preconfig_test.go
+++ b/pyconfig/preconfig_test.go
@@ -75,6 +75,25 @@ func TestPreConfigInitializers(t *testing.T) {
 	}
 }
 
+func TestPreConfigFromConfigHelpers(t *testing.T) {
+	var config Config
+	InitPythonConfig(&config)
+	config.ParseArgv = 1
+	config.DevMode = 1
+
+	var pre PreConfig
+	InitPreConfigFromConfig(&pre, &config)
+	if pre.ConfigInit != ConfigInitPython || pre.ParseArgv != 1 || pre.DevMode != 1 {
+		t.Fatalf("pre = %#v", pre)
+	}
+
+	var copied PreConfig
+	GetPreConfigFromConfig(&copied, &config)
+	if copied != pre {
+		t.Fatalf("copied = %#v pre = %#v", copied, pre)
+	}
+}
+
 func TestGetEnvHelpers(t *testing.T) {
 	t.Setenv("PYCONFIG_TEST_FLAG", "2")
 	flag := 0
diff --git a/pycontext/context.go b/pycontext/context.go
index 7ee4309..fd36e81 100644
--- a/pycontext/context.go
+++ b/pycontext/context.go
@@ -55,6 +55,10 @@ func New() *Context {
 	return newEmptyContext()
 }
 
+func NewHamtForTests() map[*Var]any {
+	return map[*Var]any{}
+}
+
 func Copy(ctx *Context) *Context {
 	if ctx == nil {
 		return nil
diff --git a/pycontext/context_test.go b/pycontext/context_test.go
index 53c17e3..bb02b99 100644
--- a/pycontext/context_test.go
+++ b/pycontext/context_test.go
@@ -128,3 +128,10 @@ func TestInitModule(t *testing.T) {
 		t.Fatalf("module = %#v", module)
 	}
 }
+
+func TestNewHamtForTests(t *testing.T) {
+	hamt := NewHamtForTests()
+	if hamt == nil || len(hamt) != 0 {
+		t.Fatalf("hamt = %#v", hamt)
+	}
+}
diff --git a/pystate/errors.go b/pystate/errors.go
index f5a1816..bc1757d 100644
--- a/pystate/errors.go
+++ b/pystate/errors.go
@@ -3,8 +3,11 @@ package pystate
 import "errors"
 
 var (
-	ErrNoInterpreter    = errors.New("no interpreter")
-	ErrNoWatcherSlot    = errors.New("no more context watcher IDs available")
-	ErrInvalidWatcherID = errors.New("invalid context watcher ID")
-	ErrMissingWatcher   = errors.New("no context watcher set for ID")
+	ErrNoInterpreter             = errors.New("no interpreter")
+	ErrNoWatcherSlot             = errors.New("no more context watcher IDs available")
+	ErrInvalidWatcherID          = errors.New("invalid context watcher ID")
+	ErrMissingWatcher            = errors.New("no context watcher set for ID")
+	ErrInterpreterAlreadyRunning = errors.New("interpreter already running")
+	ErrMainModuleNotFound        = errors.New("__main__ module not found")
+	ErrInvalidMainModule         = errors.New("invalid __main__ module")
 )
diff --git a/pystate/state.go b/pystate/state.go
index b8235c8..3c7e237 100644
--- a/pystate/state.go
+++ b/pystate/state.go
@@ -18,6 +18,7 @@ type ThreadState struct {
 	ThreadID       uint64
 	NativeThreadID uint64
 	Whence         int
+	Dict           map[string]any
 	Status         ThreadStatus
 }
 
@@ -28,6 +29,10 @@ type InterpreterState struct {
 	RunningMain       bool
 	Whence            int
 	Finalizing        bool
+	MainModule        any
+	Dict              map[string]any
+	IDRefCount        int
+	RequiresIDRefFlag bool
 	ContextWatchers   [8]func(any) error
 	ActiveContextBits uint8
 	contextWatchersMu sync.Mutex
@@ -176,12 +181,56 @@ func DeleteInterpreter(runtime *RuntimeState, interp *InterpreterState) {
 	}
 }
 
+func RuntimeStateFini(runtime *RuntimeState) {
+	if runtime == nil {
+		return
+	}
+	runtime.mu.Lock()
+	defer runtime.mu.Unlock()
+	runtime.initialized = false
+	runtime.mainThread = 0
+	runtime.interpreters = nil
+	runtime.main = nil
+	runtime.current = nil
+}
+
+func ClearInterpreter(interp *InterpreterState) {
+	if interp == nil {
+		return
+	}
+	interp.MainModule = nil
+	interp.Dict = nil
+	interp.RunningMain = false
+	interp.Finalizing = false
+	interp.Ready = false
+	interp.ActiveContextBits = 0
+	interp.ContextWatchers = [8]func(any) error{}
+}
+
+func ClearInterpreterFromThread(ts *ThreadState) {
+	if ts == nil {
+		return
+	}
+	ClearInterpreter(ts.Interp)
+}
+
+func DeleteInterpreterState(interp *InterpreterState) {
+	if interp == nil || interp.Runtime == nil {
+		return
+	}
+	DeleteInterpreter(interp.Runtime, interp)
+}
+
+func SetInterpreterAlreadyRunning() error {
+	return ErrInterpreterAlreadyRunning
+}
+
 func SetRunningMain(interp *InterpreterState) error {
 	if interp == nil {
 		return ErrNoInterpreter
 	}
 	if interp.RunningMain {
-		return nil
+		return ErrInterpreterAlreadyRunning
 	}
 	interp.RunningMain = true
 	return nil
@@ -193,6 +242,136 @@ func ClearRunningMain(interp *InterpreterState) {
 	}
 }
 
+func IsRunningMain(interp *InterpreterState) bool {
+	return interp != nil && interp.RunningMain
+}
+
+func ThreadIsRunningMain(ts *ThreadState) bool {
+	return ts != nil && ts.Interp != nil && ts.Interp.RunningMain && ts == MainThreadState(ts.Interp.Runtime)
+}
+
+func ReinitRunningMain(ts *ThreadState) {
+	if ts == nil || ts.Interp == nil {
+		return
+	}
+	if !ThreadIsRunningMain(ts) {
+		ts.Interp.RunningMain = false
+	}
+}
+
+func IsInterpreterReady(interp *InterpreterState) bool {
+	return interp != nil && interp.Ready
+}
+
+func SetInterpreterWhence(interp *InterpreterState, whence int) {
+	if interp != nil {
+		interp.Whence = whence
+	}
+}
+
+func GetMainModule(ts *ThreadState) any {
+	if ts == nil || ts.Interp == nil {
+		return nil
+	}
+	return ts.Interp.MainModule
+}
+
+func CheckMainModule(module any) error {
+	if module == nil {
+		return ErrMainModuleNotFound
+	}
+	m, ok := module.(map[string]any)
+	if !ok {
+		return ErrInvalidMainModule
+	}
+	name, ok := m["__name__"].(string)
+	if !ok || name != "__main__" {
+		return ErrInvalidMainModule
+	}
+	return nil
+}
+
+func InterpreterGetDict(interp *InterpreterState) map[string]any {
+	if interp == nil {
+		return nil
+	}
+	if interp.Dict == nil {
+		interp.Dict = map[string]any{}
+	}
+	return interp.Dict
+}
+
+func ThreadGetDict(ts *ThreadState) map[string]any {
+	if ts == nil {
+		return nil
+	}
+	if ts.Dict == nil {
+		ts.Dict = map[string]any{}
+	}
+	return ts.Dict
+}
+
+func GetInterpreterIDObject(interp *InterpreterState) any {
+	if interp == nil || interp.ID < 0 {
+		return nil
+	}
+	return interp.ID
+}
+
+func InterpreterIDIncref(interp *InterpreterState) {
+	if interp != nil {
+		interp.IDRefCount++
+	}
+}
+
+func InterpreterIDDecref(interp *InterpreterState) {
+	if interp != nil && interp.IDRefCount > 0 {
+		interp.IDRefCount--
+	}
+}
+
+func InterpreterRequiresIDRef(interp *InterpreterState) bool {
+	return interp != nil && interp.RequiresIDRefFlag
+}
+
+func RequireInterpreterIDRef(interp *InterpreterState, required bool) {
+	if interp != nil {
+		interp.RequiresIDRefFlag = required
+	}
+}
+
+func AttachThread(ts *ThreadState) {
+	if ts != nil {
+		ts.Status.Active = true
+	}
+}
+
+func DetachThread(ts *ThreadState) {
+	if ts != nil {
+		ts.Status.Active = false
+	}
+}
+
+func SuspendThread(ts *ThreadState) {
+	if ts != nil {
+		ts.Status.Active = false
+	}
+}
+
+func SetThreadShuttingDown(ts *ThreadState) {
+	if ts != nil {
+		ts.Status.Finalizing = true
+	}
+}
+
+func IsMainThread(runtime *RuntimeState, ts *ThreadState) bool {
+	return runtime != nil && ts != nil && MainThreadState(runtime) == ts
+}
+
+func IsMainInterpreterFinalizing(interp *InterpreterState) bool {
+	return interp != nil && interp.Finalizing
+}
+
 func AddContextWatcher(interp *InterpreterState, cb func(any) error) (int, error) {
 	interp.contextWatchersMu.Lock()
 	defer interp.contextWatchersMu.Unlock()
diff --git a/pystate/state_test.go b/pystate/state_test.go
index bd8e3ef..1810b70 100644
--- a/pystate/state_test.go
+++ b/pystate/state_test.go
@@ -82,6 +82,112 @@ func TestRunningMain(t *testing.T) {
 	}
 }
 
+func TestInterpreterAccessorsAndStateWrappers(t *testing.T) {
+	runtime := NewRuntimeState()
+	runtime.Init(7)
+	interp := runtime.NewInterpreter()
+	ts := NewThreadState(interp)
+	BindThread(ts, 1, 2)
+	BindGILState(runtime, ts)
+	SetMainThreadState(runtime, ts)
+
+	interp.MainModule = map[string]any{"__name__": "__main__"}
+	if err := CheckMainModule(GetMainModule(ts)); err != nil {
+		t.Fatalf("CheckMainModule returned error: %v", err)
+	}
+	if err := CheckMainModule(nil); err == nil {
+		t.Fatal("expected missing main module error")
+	}
+	if err := CheckMainModule(map[string]any{"__name__": "other"}); err == nil {
+		t.Fatal("expected invalid main module error")
+	}
+
+	interpDict := InterpreterGetDict(interp)
+	threadDict := ThreadGetDict(ts)
+	interpDict["x"] = 1
+	threadDict["y"] = 2
+	if InterpreterGetDict(interp)["x"] != 1 || ThreadGetDict(ts)["y"] != 2 {
+		t.Fatalf("dicts = %#v %#v", interpDict, threadDict)
+	}
+
+	if GetInterpreterIDObject(interp) != int64(0) {
+		t.Fatalf("id object = %#v", GetInterpreterIDObject(interp))
+	}
+	RequireInterpreterIDRef(interp, true)
+	InterpreterIDIncref(interp)
+	InterpreterIDDecref(interp)
+	if !InterpreterRequiresIDRef(interp) || interp.IDRefCount != 0 {
+		t.Fatalf("interp = %#v", interp)
+	}
+
+	if !IsInterpreterReady(interp) {
+		t.Fatal("expected ready interpreter")
+	}
+	SetInterpreterWhence(interp, 5)
+	if interp.Whence != 5 {
+		t.Fatalf("whence = %d", interp.Whence)
+	}
+
+	if err := SetRunningMain(interp); err != nil {
+		t.Fatalf("SetRunningMain returned error: %v", err)
+	}
+	if !IsRunningMain(interp) || !ThreadIsRunningMain(ts) {
+		t.Fatalf("running main state = %#v %#v", interp, ts)
+	}
+	DetachThread(ts)
+	ReinitRunningMain(ts)
+	if !IsRunningMain(interp) {
+		t.Fatal("main thread should remain running while still main thread")
+	}
+
+	SetThreadShuttingDown(ts)
+	if !ts.Status.Finalizing {
+		t.Fatalf("ts = %#v", ts)
+	}
+	AttachThread(ts)
+	if !ts.Status.Active {
+		t.Fatalf("ts = %#v", ts)
+	}
+	SuspendThread(ts)
+	if ts.Status.Active {
+		t.Fatalf("ts = %#v", ts)
+	}
+	if !IsMainThread(runtime, ts) {
+		t.Fatal("expected main thread")
+	}
+
+	interp.Finalizing = true
+	if !IsMainInterpreterFinalizing(interp) {
+		t.Fatal("expected finalizing interpreter")
+	}
+
+	ClearInterpreterFromThread(ts)
+	if interp.Ready || interp.MainModule != nil {
+		t.Fatalf("interp = %#v", interp)
+	}
+}
+
+func TestRuntimeFinalizeAndDeleteWrappers(t *testing.T) {
+	runtime := NewRuntimeState()
+	runtime.Init(3)
+	interp := runtime.NewInterpreter()
+	DeleteInterpreterState(interp)
+	if len(runtime.Interpreters()) != 0 {
+		t.Fatalf("interpreters = %#v", runtime.Interpreters())
+	}
+
+	interp = runtime.NewInterpreter()
+	ClearInterpreter(interp)
+	if interp.Ready {
+		t.Fatalf("interp = %#v", interp)
+	}
+
+	RuntimeStateFini(runtime)
+	if runtime.Initialized() || len(runtime.Interpreters()) != 0 {
+		t.Fatalf("runtime = %#v", runtime)
+	}
+}
+
 func TestContextWatchers(t *testing.T) {
 	runtime := NewRuntimeState()
 	interp := runtime.NewInterpreter()
