-
Notifications
You must be signed in to change notification settings - Fork 365
fix: don't close shared session store in runtime.Close #2879
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import ( | |
| "sync" | ||
|
|
||
| "github.com/docker/docker-agent/pkg/config" | ||
| pathx "github.com/docker/docker-agent/pkg/path" | ||
| "github.com/docker/docker-agent/pkg/runtime" | ||
| "github.com/docker/docker-agent/pkg/session" | ||
| "github.com/docker/docker-agent/pkg/teamloader" | ||
|
|
@@ -28,6 +29,11 @@ type backend interface { | |
| CreateSession(ctx context.Context, loaded *teamloader.LoadResult, req runtime.CreateSessionRequest) (runtime.Runtime, *session.Session, func(), error) | ||
|
|
||
| Spawner(rt runtime.Runtime) tui.SessionSpawner | ||
|
|
||
| // Close releases backend-owned resources (e.g., the shared session | ||
| // store). It is called once when the embedder is shutting down, | ||
| // after every per-session cleanup has run. | ||
| Close() error | ||
| } | ||
|
|
||
| // selectBackend picks the backend implied by the current flags. | ||
|
|
@@ -43,9 +49,20 @@ func (f *runExecFlags) selectBackend(agentFileName string) (backend, error) { | |
| } | ||
|
|
||
| // localBackend builds the in-process runtime and session. | ||
| // | ||
| // The session store is owned by the backend (not by individual | ||
| // runtimes) because the TUI's session spawner reuses the same store | ||
| // across spawned sessions. Closing it inside a per-session cleanup | ||
| // would break later session lookups (issue #2872). The store is | ||
| // lazily opened on the first CreateSession call and closed once when | ||
| // Close is invoked. | ||
| type localBackend struct { | ||
| flags *runExecFlags | ||
| agentSource config.Source | ||
|
|
||
| storeOnce sync.Once | ||
| storeErr error | ||
| store session.Store | ||
| } | ||
|
|
||
| func (b *localBackend) LoadTeamRequest() runtime.LoadTeamRequest { | ||
|
|
@@ -60,8 +77,34 @@ func (b *localBackend) CreateSessionRequest(workingDir string) runtime.CreateSes | |
| return b.flags.createSessionRequest(workingDir) | ||
| } | ||
|
|
||
| // sessionStore returns the backend-owned session store, opening it on | ||
| // first use. The store is shared by the initial runtime and by every | ||
| // runtime spawned by [localBackend.Spawner]. | ||
| func (b *localBackend) sessionStore(req runtime.CreateSessionRequest) (session.Store, error) { | ||
| b.storeOnce.Do(func() { | ||
| sessionDB, err := pathx.ExpandHomeDir(req.SessionDB) | ||
| if err != nil { | ||
| b.storeErr = err | ||
| return | ||
| } | ||
| store, err := session.NewSQLiteSessionStore(sessionDB) | ||
| if err != nil { | ||
| b.storeErr = fmt.Errorf("creating session store: %w", err) | ||
| return | ||
| } | ||
| b.store = store | ||
| }) | ||
| return b.store, b.storeErr | ||
| } | ||
|
|
||
| func (b *localBackend) CreateSession(ctx context.Context, loaded *teamloader.LoadResult, req runtime.CreateSessionRequest) (runtime.Runtime, *session.Session, func(), error) { | ||
| rt, sess, err := b.flags.createLocalRuntimeAndSession(ctx, loaded, req) | ||
| store, err := b.sessionStore(req) | ||
| if err != nil { | ||
| stopToolSets(loaded.Team) | ||
| return nil, nil, nil, err | ||
| } | ||
|
|
||
| rt, sess, err := b.flags.createLocalRuntimeAndSession(ctx, loaded, req, store) | ||
| if err != nil { | ||
| stopToolSets(loaded.Team) | ||
| return nil, nil, nil, err | ||
|
|
@@ -83,6 +126,17 @@ func (b *localBackend) Spawner(rt runtime.Runtime) tui.SessionSpawner { | |
| return b.flags.createSessionSpawner(b.agentSource, rt.SessionStore()) | ||
| } | ||
|
|
||
| func (b *localBackend) Close() error { | ||
| // Ensure any in-progress sessionStore initialization is observed | ||
| // before reading b.store. If sessionStore was never called, the Do | ||
| // runs an empty closure and b.store stays nil. | ||
| b.storeOnce.Do(func() {}) | ||
| if b.store == nil { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM] Potential data race:
If Suggested fix — either use func (b *localBackend) Close() error {
b.storeOnce.Do(func() {}) // ensure the write, if any, has completed
if b.store == nil {
return nil
}
return b.store.Close()
}Or, more robustly, add a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nil guard is correct and defensive. |
||
| return nil | ||
| } | ||
| return b.store.Close() | ||
| } | ||
|
|
||
| // remoteBackend talks to a docker-agent server. | ||
| type remoteBackend struct { | ||
| flags *runExecFlags | ||
|
|
@@ -142,3 +196,7 @@ func (b *remoteBackend) CreateSession(ctx context.Context, _ *teamloader.LoadRes | |
| func (b *remoteBackend) Spawner(runtime.Runtime) tui.SessionSpawner { | ||
| return nil | ||
| } | ||
|
|
||
| func (b *remoteBackend) Close() error { | ||
| return nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| package runtime | ||
|
|
||
| import ( | ||
| "sync/atomic" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/docker/docker-agent/pkg/agent" | ||
| "github.com/docker/docker-agent/pkg/session" | ||
| "github.com/docker/docker-agent/pkg/team" | ||
| ) | ||
|
|
||
| // trackingStore wraps an in-memory store so a test can detect Close() calls. | ||
| type trackingStore struct { | ||
| session.Store | ||
|
|
||
| closes atomic.Int32 | ||
| } | ||
|
|
||
| func (s *trackingStore) Close() error { | ||
| s.closes.Add(1) | ||
| return s.Store.Close() | ||
| } | ||
|
|
||
| // TestRuntimeClose_DoesNotCloseExternalSessionStore is a regression test for | ||
| // the bug fixed in #2872: closing one runtime would close the SQLite session | ||
| // store shared with other runtimes (e.g. spawned by the TUI's session | ||
| // browser), making subsequent reads fail with "sql: database is closed". | ||
| func TestRuntimeClose_DoesNotCloseExternalSessionStore(t *testing.T) { | ||
| store := &trackingStore{Store: session.NewInMemorySessionStore()} | ||
|
|
||
| prov := &mockProvider{id: "test/mock-model", stream: &mockStream{}} | ||
| root := agent.New("root", "You are a test agent", agent.WithModel(prov)) | ||
| tm := team.New(team.WithAgents(root)) | ||
|
|
||
| rt, err := New(tm, | ||
| WithSessionCompaction(false), | ||
| WithModelStore(mockModelStore{}), | ||
| WithSessionStore(store), | ||
| ) | ||
| require.NoError(t, err) | ||
|
|
||
| require.NoError(t, rt.Close()) | ||
| assert.Zero(t, store.closes.Load(), | ||
| "runtime.Close() must not close an externally supplied session store; "+ | ||
| "the store may be shared with other runtimes") | ||
|
|
||
| // The store is still usable after the runtime is closed. | ||
| _, err = store.GetSessionSummaries(t.Context()) | ||
| require.NoError(t, err) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sticky
sync.Oncefailure (low severity): ifExpandHomeDirorNewSQLiteSessionStoreerrors here,storeOnceis permanently consumed and every subsequent call tosessionStore()will return(nil, storeErr)— even if the underlying problem has been corrected (e.g., a retry with a differentreq). In the current call graphCreateSessionis called exactly once, so this is not a practical issue. Flagging in case future callers add retry logic.