返回 DeepSeek-Reasonix
manager.go
根目录 / internal / sessiontemp / manager.go
1 // Package sessiontemp provides a logical-session private temporary directory
2 // manager. Bash and related sandboxed helpers share one directory for the
3 // duration of a logical chat session, while each bwrap/sandbox-exec invocation
4 // still enters its own namespace.
5 //
6 // The manager is intentionally invisible to models and users: no settings,
7 // tool parameters, or prompt surface is added. Hot rebuilds retain the same
8 // Manager; /new, /clear, resume of another session, and branch switches rotate
9 // to a fresh generation.
10 package sessiontemp
11
12 import (
13 "errors"
14 "fmt"
15 "io/fs"
16 "log/slog"
17 "os"
18 "path/filepath"
19 "strings"
20 "sync"
21 "sync/atomic"
22 "time"
23
24 "reasonix/internal/filelock"
25 )
26
27 const (
28 dirPrefix = "reasonix-session-tmp-"
29 ownerLockName = ".owner.lock"
30 staleAge = 24 * time.Hour
31 )
32
33 // ErrUnavailable reports that a session-private temporary directory could not
34 // be created or the manager is no longer accepting leases. Callers must fail
35 // the command rather than fall back to the host public temporary directory.
36 var ErrUnavailable = errors.New("session temporary directory unavailable")
37
38 // Manager owns one logical session's temporary generations. Controllers
39 // Retain/Release it so hot rebuilds share the directory; Acquire/Release
40 // leases keep retired generations alive while foreground or background
41 // commands still run.
42 //
43 // After the last Controller owner Releases, the Manager is irreversibly
44 // sealed: further Acquire calls fail closed so a late tool goroutine cannot
45 // recreate a generation with no owner (directory/lock leak).
46 type Manager struct {
47 mu sync.Mutex
48 owners int
49 // sealed is set irreversibly when owners drops to zero. Hot rebuilds
50 // always Retain the replacement before ReleaseResources on the old
51 // Controller, so they never seal the shared Manager.
52 sealed bool
53 gen *generation
54 // root is the OS temporary parent used for lazy creation and stale
55 // cleanup. Empty means os.TempDir() at the moment of use.
56 root string
57 // now is injectable for tests.
58 now func() time.Time
59 // mkDir is injectable for failure tests.
60 mkDir func(parent string) (string, error)
61 }
62
63 type generation struct {
64 id uint64
65 dir string
66 leases int
67 // releaseOwner drops the cross-process owner lock; nil when the lock was
68 // not acquired (should not happen for a live generation).
69 releaseOwner func()
70 }
71
72 // Lease is a running-process claim on one generation. Release must be called
73 // exactly once after the process exits (or fails to start).
74 type Lease struct {
75 m *Manager
76 gen *generation
77 dir string
78 once sync.Once
79 }
80
81 var nextGenID atomic.Uint64
82
83 // processCleanup tracks which canonical temp roots have already been swept
84 // in this process. Sub-agent Managers must not re-scan the whole temp tree.
85 var processCleanup struct {
86 sync.Mutex
87 done map[string]struct{}
88 }
89
90 // New returns a Manager with zero controller owners. Callers must Retain
91 // before use (Controller.New does this). The first New for each distinct
92 // temporary root in a process also sweeps stale directories left by crashed peers.
93 func New() *Manager {
94 m := &Manager{
95 now: time.Now,
96 mkDir: defaultMkDir,
97 }
98 cleanupStaleOnce(m.tempRoot(), m.now)
99 return m
100 }
101
102 // newForTest builds a Manager that writes under root and skips process-level
103 // stale cleanup (tests own the root).
104 func newForTest(root string) *Manager {
105 return &Manager{
106 root: root,
107 now: time.Now,
108 mkDir: defaultMkDir,
109 }
110 }
111
112 // NewWithRoot is like New but forces generations under root (tests). It does
113 // not run process-level stale cleanup against root.
114 func NewWithRoot(root string) *Manager {
115 return newForTest(root)
116 }
117
118 // SetMkDirForTest overrides directory creation (tests only).
119 func (m *Manager) SetMkDirForTest(fn func(parent string) (string, error)) {
120 if m == nil {
121 return
122 }
123 m.mu.Lock()
124 m.mkDir = fn
125 m.mu.Unlock()
126 }
127
128 // Retain adds a Controller owner reference. Hot rebuilds call Retain on the
129 // shared Manager before publishing the replacement Controller.
130 // Retain on a sealed Manager is a no-op that leaves it sealed (fail closed).
131 func (m *Manager) Retain() {
132 if m == nil {
133 return
134 }
135 m.mu.Lock()
136 if !m.sealed {
137 m.owners++
138 }
139 m.mu.Unlock()
140 }
141
142 // Release drops a Controller owner reference. When the last owner leaves, the
143 // Manager is sealed and the current generation is retired (deleted once its
144 // process leases drain).
145 func (m *Manager) Release() {
146 if m == nil {
147 return
148 }
149 m.mu.Lock()
150 if m.owners > 0 {
151 m.owners--
152 }
153 if m.owners == 0 {
154 m.sealed = true
155 m.retireLocked(m.gen)
156 m.gen = nil
157 }
158 m.mu.Unlock()
159 }
160
161 // Sealed reports whether the last Controller owner has released the Manager.
162 func (m *Manager) Sealed() bool {
163 if m == nil {
164 return true
165 }
166 m.mu.Lock()
167 defer m.mu.Unlock()
168 return m.sealed
169 }
170
171 // Owners returns the current Controller reference count (tests).
172 func (m *Manager) Owners() int {
173 if m == nil {
174 return 0
175 }
176 m.mu.Lock()
177 defer m.mu.Unlock()
178 return m.owners
179 }
180
181 // Dir returns the current generation directory without acquiring a lease.
182 // Empty when no generation has been created yet. Intended for diagnostics.
183 func (m *Manager) Dir() string {
184 if m == nil {
185 return ""
186 }
187 m.mu.Lock()
188 defer m.mu.Unlock()
189 if m.gen == nil {
190 return ""
191 }
192 return m.gen.dir
193 }
194
195 // Rotate retires the current generation and prepares a fresh one on the next
196 // Acquire. In-flight leases keep the old directory alive until they release.
197 // Rotate on a sealed Manager is a no-op.
198 func (m *Manager) Rotate() {
199 if m == nil {
200 return
201 }
202 m.mu.Lock()
203 if !m.sealed {
204 m.retireLocked(m.gen)
205 m.gen = nil
206 }
207 m.mu.Unlock()
208 }
209
210 // Acquire pins the current generation (creating it lazily) and increments the
211 // process lease count. The returned Lease must be released after the process
212 // exits; failing to start still requires Release.
213 //
214 // Acquire fails closed when the Manager is sealed or has no Controller owners,
215 // so a late Bash goroutine after Close cannot recreate a leaked generation.
216 func (m *Manager) Acquire() (*Lease, error) {
217 if m == nil {
218 return nil, fmt.Errorf("%w: manager is nil", ErrUnavailable)
219 }
220 m.mu.Lock()
221 defer m.mu.Unlock()
222
223 if m.sealed || m.owners == 0 {
224 return nil, fmt.Errorf("%w: manager closed", ErrUnavailable)
225 }
226
227 if m.gen == nil {
228 gen, err := m.createGenerationLocked()
229 if err != nil {
230 return nil, err
231 }
232 m.gen = gen
233 }
234 m.gen.leases++
235 return &Lease{m: m, gen: m.gen, dir: m.gen.dir}, nil
236 }
237
238 // Dir is the absolute path of the leased temporary directory.
239 func (l *Lease) Dir() string {
240 if l == nil {
241 return ""
242 }
243 return l.dir
244 }
245
246 // Release decrements the process lease. When a retired generation reaches
247 // zero leases it is deleted. Safe to call multiple times.
248 func (l *Lease) Release() {
249 if l == nil || l.m == nil || l.gen == nil {
250 return
251 }
252 l.once.Do(func() {
253 l.m.mu.Lock()
254 defer l.m.mu.Unlock()
255 if l.gen.leases > 0 {
256 l.gen.leases--
257 }
258 if l.gen.leases == 0 && l.m.gen != l.gen {
259 // Retired generation with no remaining process claims.
260 l.m.deleteGenerationLocked(l.gen)
261 }
262 })
263 }
264
265 func (m *Manager) createGenerationLocked() (*generation, error) {
266 parent := m.tempRoot()
267 mk := m.mkDir
268 if mk == nil {
269 mk = defaultMkDir
270 }
271 dir, err := mk(parent)
272 if err != nil {
273 return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
274 }
275 if err := os.Chmod(dir, 0o700); err != nil {
276 _ = os.RemoveAll(dir)
277 return nil, fmt.Errorf("%w: chmod: %v", ErrUnavailable, err)
278 }
279 lockPath := filepath.Join(dir, ownerLockName)
280 release, err := filelock.Acquire(nilContext(), lockPath)
281 if err != nil {
282 _ = os.RemoveAll(dir)
283 return nil, fmt.Errorf("%w: owner lock: %v", ErrUnavailable, err)
284 }
285 return &generation{
286 id: nextGenID.Add(1),
287 dir: dir,
288 releaseOwner: release,
289 }, nil
290 }
291
292 // Prefix is the directory name prefix used for session temporary directories.
293 // Exported for tests and diagnostics.
294 func Prefix() string { return dirPrefix }
295
296 func (m *Manager) retireLocked(gen *generation) {
297 if gen == nil {
298 return
299 }
300 if m.gen == gen {
301 m.gen = nil
302 }
303 if gen.leases == 0 {
304 m.deleteGenerationLocked(gen)
305 }
306 }
307
308 func (m *Manager) deleteGenerationLocked(gen *generation) {
309 if gen == nil {
310 return
311 }
312 if gen.releaseOwner != nil {
313 gen.releaseOwner()
314 gen.releaseOwner = nil
315 }
316 if gen.dir == "" {
317 return
318 }
319 if err := safeRemoveDir(gen.dir, m.tempRoot()); err != nil {
320 slog.Warn("sessiontemp: remove generation", "dir", gen.dir, "err", err)
321 }
322 gen.dir = ""
323 }
324
325 func (m *Manager) tempRoot() string {
326 if m != nil && m.root != "" {
327 return m.root
328 }
329 return os.TempDir()
330 }
331
332 func defaultMkDir(parent string) (string, error) {
333 if err := os.MkdirAll(parent, 0o700); err != nil {
334 return "", err
335 }
336 return os.MkdirTemp(parent, dirPrefix)
337 }
338
339 // cleanupStaleOnce runs cleanupStale at most once per canonical temp root per
340 // process. Sub-agent Managers share the same root and must not re-scan.
341 func cleanupStaleOnce(root string, now func() time.Time) {
342 if root == "" {
343 return
344 }
345 key := canonicalTempRoot(root)
346 processCleanup.Lock()
347 if processCleanup.done == nil {
348 processCleanup.done = map[string]struct{}{}
349 }
350 if _, ok := processCleanup.done[key]; ok {
351 processCleanup.Unlock()
352 return
353 }
354 processCleanup.done[key] = struct{}{}
355 processCleanup.Unlock()
356 cleanupStale(root, now)
357 }
358
359 func canonicalTempRoot(root string) string {
360 abs, err := filepath.Abs(root)
361 if err != nil {
362 return filepath.Clean(root)
363 }
364 if real, err := filepath.EvalSymlinks(abs); err == nil {
365 abs = real
366 }
367 return filepath.Clean(abs)
368 }
369
370 // resetProcessCleanupForTest clears the process-level cleanup map (tests only).
371 func resetProcessCleanupForTest() {
372 processCleanup.Lock()
373 processCleanup.done = nil
374 processCleanup.Unlock()
375 }
376
377 // cleanupStale removes reasonix-session-tmp-* direct children of root that are
378 // older than 24h and whose owner lock is free. Failures are logged only.
379 func cleanupStale(root string, now func() time.Time) {
380 if root == "" {
381 return
382 }
383 entries, err := os.ReadDir(root)
384 if err != nil {
385 slog.Warn("sessiontemp: list temp root for stale cleanup", "root", root, "err", err)
386 return
387 }
388 cutoff := now().Add(-staleAge)
389 for _, ent := range entries {
390 name := ent.Name()
391 if !strings.HasPrefix(name, dirPrefix) {
392 continue
393 }
394 path := filepath.Join(root, name)
395 // Do not follow a top-level symlink out of the temp root.
396 info, err := os.Lstat(path)
397 if err != nil {
398 continue
399 }
400 if info.Mode()&fs.ModeSymlink != 0 {
401 // Try to remove the symlink entry itself only when old enough.
402 if info.ModTime().Before(cutoff) {
403 if err := os.Remove(path); err != nil {
404 slog.Warn("sessiontemp: remove stale symlink", "path", path, "err", err)
405 }
406 }
407 continue
408 }
409 if !info.IsDir() {
410 continue
411 }
412 if !info.ModTime().Before(cutoff) {
413 continue
414 }
415 lockPath := filepath.Join(path, ownerLockName)
416 release, err := filelock.TryAcquire(lockPath)
417 if err != nil {
418 // Still held by a live process (or lock error) — skip.
419 continue
420 }
421 release()
422 if err := safeRemoveDir(path, root); err != nil {
423 slog.Warn("sessiontemp: remove stale dir", "path", path, "err", err)
424 }
425 }
426 }
427
428 // safeRemoveDir deletes path only when it is a direct child of parent, has the
429 // expected name prefix, and is not a symlink. It never follows a top-level
430 // symlink to a foreign target.
431 func safeRemoveDir(path, parent string) error {
432 path = filepath.Clean(path)
433 parent = filepath.Clean(parent)
434 if realParent, err := filepath.EvalSymlinks(parent); err == nil {
435 parent = realParent
436 }
437 base := filepath.Base(path)
438 if !strings.HasPrefix(base, dirPrefix) {
439 return fmt.Errorf("refusing to remove non-session-temp path %q", path)
440 }
441 if filepath.Dir(path) != parent {
442 // Also accept when path's parent resolves to the same canonical parent.
443 dirParent := filepath.Dir(path)
444 if real, err := filepath.EvalSymlinks(dirParent); err == nil {
445 dirParent = real
446 }
447 if dirParent != parent {
448 return fmt.Errorf("refusing to remove path outside temp root: %q", path)
449 }
450 }
451 info, err := os.Lstat(path)
452 if err != nil {
453 if os.IsNotExist(err) {
454 return nil
455 }
456 return err
457 }
458 if info.Mode()&fs.ModeSymlink != 0 {
459 return os.Remove(path)
460 }
461 if !info.IsDir() {
462 return fmt.Errorf("refusing to remove non-directory %q", path)
463 }
464 return os.RemoveAll(path)
465 }
466
466 lines GO