ort-thread-debugging
Diagnoses ORT thread-affinity crashes and sort-direction bugs in Go MCP servers.
SKILL.mdskills/ort-thread-debugging/SKILL.md
GitHubORT Thread Debugging Skill
Problem it solves
ONNX Runtime sessions are bound to the OS thread that created them. When a Go program creates a session on goroutine A and calls session.Run() from goroutine B (which may execute on a different OS thread), ORT crashes with cryptic access violations or index-out-of-range panics. This is the single most common failure when embedding ORT in Go MCP servers, CLI tools, or web services.
Additionally, insertion sort bugs (j++ instead of j--) produce panics that look like embedding dimension errors but are actually in sorting code.
Detection triggers
Activate when:
session.Run()crashes with access violation, index out of range, or segfault- Error mentions ORT, onnxruntime, or ONNX session together with a thread-related signal, such as native access violations under concurrent use or session creation and
Runoccurring in different goroutines - Embedding computation works in isolation but crashes in the server
- Panic message says
index out of range [N] with length N(check if N matches embedding dimension or result count) - Embeddings work in a test binary but fail when called from MCP/HTTP handlers
- Multiple goroutines share a single ORT session
Protocol
1. Diagnose thread affinity
ORT rule: session creation and all session.Run() calls MUST happen on the same OS thread.
Check for this pattern (WRONG):
// Created on goroutine A (main thread)
session, _ := ort.NewDynamicAdvancedSession(...)
// Called from goroutine B (MCP handler, HTTP handler, etc.)
go func() {
session.Run(inputs, outputs) // CRASH — different OS thread
}()
2. Fix: dedicated goroutine with LockOSThread
The correct pattern routes all ORT operations through a single goroutine that locks its OS thread:
type Embedder struct {
runCh chan computeRequest
stopCh chan struct{}
done chan struct{} // closed when runLoop exits
// ...
}
func (e *Embedder) runLoop() {
runtime.LockOSThread() // Pin this goroutine to one OS thread
defer runtime.UnlockOSThread()
defer close(e.done) // Signal that runLoop has exited
// Create session HERE — on the locked thread
session, err := ort.NewDynamicAdvancedSession(modelPath, inputs, outputs, nil)
if err != nil {
panic(err)
}
defer session.Destroy()
for {
select {
case req := <-e.runCh:
results, err := e.runOnSession(session, req.texts)
req.out <- computeResult{results: results, err: err}
case <-e.stopCh:
// Drain queued requests so Compute callers don't block forever
for {
select {
case req := <-e.runCh:
req.out <- computeResult{err: errors.New("embedder shutting down")}
default:
return
}
}
}
}
}
func (e *Embedder) Compute(text string) ([]float32, error) {
req := computeRequest{texts: []string{text}, out: make(chan computeResult, 1)}
select {
case e.runCh <- req:
case <-e.done:
return nil, errors.New("embedder stopped")
}
res := <-req.out
return res.results, res.err
}
Key points:
runtime.LockOSThread()MUST be called before session creation- Session is created inside the locked goroutine, not in
InitEmbedder - All compute calls route through a channel to this goroutine
- Use a buffered channel (
make(chan computeResult, 1)) to prevent deadlock Computechecks thedonechannel to avoid blocking forever ifrunLoophas exitedrunLoopdrains queued requests on shutdown so callers get an error instead of hanging
3. Distinguish ORT panics from sort/logic panics
When you see index out of range [N] with length N:
- If N equals the embedding dimension (e.g., 384): likely ORT output tensor slice error
- If N equals the number of results/skills: likely insertion sort bug (
j++instead ofj--) - If N is random: likely memory corruption from cross-thread ORT access
Check the stack trace line number. If it's in a sort function, fix the sort direction. If it's in runBatch slicing output tensors, investigate the tensor shape and slice indices. Diagnose thread-affinity issues only when session creation and execution occur on different locked OS threads or when native ORT failures provide evidence.
4. Verify with isolated test
Create a minimal test that calls Compute() from multiple goroutines:
func main() {
emb, _ := embedding.InitEmbedder()
defer emb.Close()
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
result, err := emb.Compute(fmt.Sprintf("test %d", n))
if err != nil {
log.Printf("goroutine %d: %v", n, err)
} else {
log.Printf("goroutine %d: %d dims", n, len(result))
}
}(i)
}
wg.Wait()
}
Use this test as evidence about reproducibility, then inspect the native stack, provider, model, inputs, and lifecycle independently; neither a crash nor a successful run is conclusive.
5. Check embedding dimensions
After successful compute, verify dimensions match expectations:
result, _ := emb.Compute("hello")
if len(result) != 384 { // all-MiniLM-L6-v2
log.Printf("wrong dimensions: got %d, want 384", len(result))
}
When NOT to use
- Python ORT usage (GIL handles thread safety differently)
- Single-threaded programs with no goroutines
- Pure SQLite/database crashes (not ORT-related)
- Dimension mismatches between different models (not a thread issue)
Cross-references
debugging-and-error-recovery — Apply the structured debugging protocol. ORT crashes often get misdiagnosed as embedding bugs when they're actually thread bugs.
anti-phantom-symbols — Verify ORT API calls (
NewDynamicAdvancedSession,NewTensor,session.Run) exist in the version being used. ORT Go API has changed across versions.safe-code-modifications — When fixing thread affinity, don't remove the old session creation without ensuring the new goroutine-based approach is verified.
verify-and-cite — Verify the fix works under concurrent load, not just single-goroutine tests.
Lessons learned
Real bugs caught by this skill:
defer embedding.CloseEmbedder()in MCP handlers kills ORT for all subsequent requests — handlers must NOT own the embedder lifecycle- Insertion sort
j++vsj--produces panics that look like embedding dimension errors runtime.LockOSThread()inInitEmbedderdoesn't help if the session is created there but run from handlers — the lock must be in the goroutine that BOTH creates AND runs the session