返回 DeepSeek-Reasonix
web_runtime.go
根目录 / internal / cli / web_runtime.go
1 package cli
2
3 import (
4 "crypto/rand"
5 "encoding/hex"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "net"
10 "os"
11 "path/filepath"
12 "sort"
13 "strconv"
14 "strings"
15 "sync"
16 "time"
17 "unicode/utf8"
18
19 "reasonix/internal/config"
20 "reasonix/internal/fileutil"
21 "reasonix/internal/store"
22 )
23
24 func serveConfigWithCommandDefaults(command string, authExplicit bool, cfg config.ServeConfig) config.ServeConfig {
25 if command == "web" && !authExplicit {
26 cfg.AuthMode = "token"
27 }
28 return cfg
29 }
30
31 const (
32 webPortRetryLimit = 100
33 webInstanceHeartbeat = 15 * time.Second
34 maxWebSessionIDBytes = 218 // leaves room for .jsonl and session sidecars
35 webInstanceDirectoryName = "instances"
36 )
37
38 // listenWebWithPortRetry binds addr, walking port+1 only when a concrete port
39 // is already occupied. Port 0 remains an ordinary kernel-assigned ephemeral
40 // bind, and non-EADDRINUSE failures are returned immediately.
41 func listenWebWithPortRetry(addr string) (net.Listener, error) {
42 host, rawPort, err := net.SplitHostPort(addr)
43 if err != nil {
44 return nil, err
45 }
46 port, err := strconv.Atoi(rawPort)
47 if err != nil || port < 0 || port > 65535 {
48 return nil, fmt.Errorf("invalid listen port %q", rawPort)
49 }
50 if port == 0 {
51 return net.Listen("tcp", addr)
52 }
53 for attempt := 0; ; attempt++ {
54 candidate := net.JoinHostPort(host, strconv.Itoa(port))
55 ln, listenErr := net.Listen("tcp", candidate)
56 if listenErr == nil {
57 return ln, nil
58 }
59 if !webAddressInUse(listenErr) || attempt >= webPortRetryLimit || port >= 65535 {
60 return nil, listenErr
61 }
62 port++
63 }
64 }
65
66 func requestedPort(addr string) int {
67 _, rawPort, err := net.SplitHostPort(addr)
68 if err != nil {
69 return -1
70 }
71 port, err := strconv.Atoi(rawPort)
72 if err != nil {
73 return -1
74 }
75 return port
76 }
77
78 func validateWebSessionID(id string) error {
79 if strings.TrimSpace(id) == "" {
80 return errors.New("--session-id cannot be empty")
81 }
82 if !utf8.ValidString(id) || len(id) > maxWebSessionIDBytes {
83 return fmt.Errorf("invalid Web session identity %q", id)
84 }
85 if id == "." || id == ".." || strings.ContainsAny(id, `/\`) || strings.IndexByte(id, 0) >= 0 {
86 return fmt.Errorf("invalid Web session identity %q", id)
87 }
88 if !store.IsSessionTranscriptName(id + ".jsonl") {
89 return fmt.Errorf("invalid Web session identity %q", id)
90 }
91 return nil
92 }
93
94 func freshWebSessionPath(dir, id string) (string, error) {
95 if err := validateWebSessionID(id); err != nil {
96 return "", err
97 }
98 path := filepath.Join(dir, id+".jsonl")
99 if _, err := os.Lstat(path); err == nil {
100 return "", fmt.Errorf("fresh Web session already exists: %s", path)
101 } else if !os.IsNotExist(err) {
102 return "", err
103 }
104 return path, nil
105 }
106
107 // webInstanceRecord is stable for independent operator-facing readers.
108 // Unknown fields remain forward-compatible with independent readers.
109 type webInstanceRecord struct {
110 ServerID string `json:"server_id"`
111 PID int `json:"pid"`
112 Host string `json:"host"`
113 Port int `json:"port"`
114 StartedAt int64 `json:"started_at"`
115 HeartbeatAt int64 `json:"heartbeat_at"`
116 }
117
118 type webInstanceRegistry struct {
119 dir string
120 now func() time.Time
121 heartbeatInterval time.Duration
122 processAlive func(int) bool
123 }
124
125 type webInstanceRegistration struct {
126 path string
127 record webInstanceRecord
128 registry *webInstanceRegistry
129 stop chan struct{}
130 done chan struct{}
131 releaseOne sync.Once
132 }
133
134 func registerWebInstance(reasonixHome, addr string) (*webInstanceRegistration, error) {
135 if strings.TrimSpace(reasonixHome) == "" {
136 return nil, errors.New("cannot register Web instance: Reasonix home is empty")
137 }
138 registry := &webInstanceRegistry{
139 dir: filepath.Join(reasonixHome, "server", webInstanceDirectoryName),
140 now: time.Now,
141 heartbeatInterval: webInstanceHeartbeat,
142 processAlive: webInstanceProcessAlive,
143 }
144 return registry.register(addr, os.Getpid())
145 }
146
147 func (r *webInstanceRegistry) register(addr string, pid int) (*webInstanceRegistration, error) {
148 if strings.TrimSpace(r.dir) == "" {
149 return nil, errors.New("cannot register Web instance: Reasonix home is empty")
150 }
151 host, rawPort, err := net.SplitHostPort(addr)
152 if err != nil {
153 return nil, fmt.Errorf("register Web instance: %w", err)
154 }
155 port, err := strconv.Atoi(rawPort)
156 if err != nil || port <= 0 || port > 65535 {
157 return nil, fmt.Errorf("register Web instance: invalid bound port %q", rawPort)
158 }
159 if err := os.MkdirAll(r.dir, 0o700); err != nil {
160 return nil, fmt.Errorf("create Web instance registry: %w", err)
161 }
162 if err := r.sweepStale(); err != nil {
163 return nil, fmt.Errorf("sweep Web instance registry: %w", err)
164 }
165
166 now := r.now().UnixMilli()
167 for range 8 {
168 serverID, err := randomWebInstanceID()
169 if err != nil {
170 return nil, fmt.Errorf("generate Web instance id: %w", err)
171 }
172 record := webInstanceRecord{
173 ServerID: serverID,
174 PID: pid,
175 Host: host,
176 Port: port,
177 StartedAt: now,
178 HeartbeatAt: now,
179 }
180 path := filepath.Join(r.dir, serverID+".json")
181 data, err := json.Marshal(record)
182 if err != nil {
183 return nil, err
184 }
185 if err := fileutil.AtomicCreateFile(path, data, 0o600); err != nil {
186 if errors.Is(err, os.ErrExist) {
187 continue
188 }
189 return nil, fmt.Errorf("register Web instance: %w", err)
190 }
191 reg := &webInstanceRegistration{
192 path: path,
193 record: record,
194 registry: r,
195 stop: make(chan struct{}),
196 done: make(chan struct{}),
197 }
198 go reg.heartbeat()
199 return reg, nil
200 }
201 return nil, errors.New("register Web instance: could not allocate a unique id")
202 }
203
204 func randomWebInstanceID() (string, error) {
205 var raw [16]byte
206 if _, err := rand.Read(raw[:]); err != nil {
207 return "", err
208 }
209 return hex.EncodeToString(raw[:]), nil
210 }
211
212 func (r *webInstanceRegistry) sweepStale() error {
213 entries, err := os.ReadDir(r.dir)
214 if os.IsNotExist(err) {
215 return nil
216 }
217 if err != nil {
218 return err
219 }
220 for _, entry := range entries {
221 if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
222 continue
223 }
224 path := filepath.Join(r.dir, entry.Name())
225 record, ok := readWebInstanceRecord(path)
226 // Malformed files may belong to a newer/live writer. Only delete an entry
227 // that can be positively identified as owned by a dead process.
228 if !ok || r.processAlive(record.PID) {
229 continue
230 }
231 if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
232 return err
233 }
234 }
235 return nil
236 }
237
238 func (r *webInstanceRegistry) listLive() ([]webInstanceRecord, error) {
239 if err := r.sweepStale(); err != nil {
240 return nil, err
241 }
242 entries, err := os.ReadDir(r.dir)
243 if os.IsNotExist(err) {
244 return nil, nil
245 }
246 if err != nil {
247 return nil, err
248 }
249 live := make([]webInstanceRecord, 0, len(entries))
250 for _, entry := range entries {
251 if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
252 continue
253 }
254 if record, ok := readWebInstanceRecord(filepath.Join(r.dir, entry.Name())); ok && r.processAlive(record.PID) {
255 live = append(live, record)
256 }
257 }
258 sort.Slice(live, func(i, j int) bool { return live[i].StartedAt < live[j].StartedAt })
259 return live, nil
260 }
261
262 func readWebInstanceRecord(path string) (webInstanceRecord, bool) {
263 data, err := os.ReadFile(path)
264 if err != nil {
265 return webInstanceRecord{}, false
266 }
267 var record webInstanceRecord
268 if json.Unmarshal(data, &record) != nil || record.ServerID == "" || record.PID <= 0 || record.Host == "" || record.Port <= 0 || record.StartedAt <= 0 || record.HeartbeatAt <= 0 {
269 return webInstanceRecord{}, false
270 }
271 return record, true
272 }
273
274 func (r *webInstanceRegistration) heartbeat() {
275 defer close(r.done)
276 interval := r.registry.heartbeatInterval
277 if interval <= 0 {
278 <-r.stop
279 return
280 }
281 ticker := time.NewTicker(interval)
282 defer ticker.Stop()
283 for {
284 select {
285 case <-ticker.C:
286 r.record.HeartbeatAt = r.registry.now().UnixMilli()
287 if data, err := json.Marshal(r.record); err == nil {
288 _ = fileutil.AtomicWriteFile(r.path, data, 0o600)
289 }
290 case <-r.stop:
291 return
292 }
293 }
294 }
295
296 // Release stops heartbeats before removing the single-writer instance file, so
297 // an in-flight atomic rename cannot recreate a supposedly released entry.
298 func (r *webInstanceRegistration) Release() {
299 if r == nil {
300 return
301 }
302 r.releaseOne.Do(func() {
303 close(r.stop)
304 <-r.done
305 _ = os.Remove(r.path)
306 })
307 }
308
308 lines GO