Monorepo for Tangled
tangled.org
1package knotcompat
2
3import (
4 "context"
5 "fmt"
6 "net/http"
7 "slices"
8 "strconv"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "time"
13
14 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
15 "tangled.org/core/api/tangled"
16 "tangled.org/core/consts"
17)
18
19const (
20 versionProbeTimeout = 5 * time.Second
21 versionProbeFresh = 5 * time.Minute
22 versionProbeTrust = time.Hour
23 versionProbeCacheMax = 4096
24)
25
26type versionProbeEntry struct {
27 version string
28 probedAt time.Time
29}
30
31type versionProbeCache struct {
32 mu sync.Mutex
33 entries map[string]versionProbeEntry
34}
35
36func (c *versionProbeCache) get(host string) (versionProbeEntry, bool) {
37 c.mu.Lock()
38 defer c.mu.Unlock()
39 e, ok := c.entries[host]
40 return e, ok
41}
42
43func (c *versionProbeCache) put(host, version string, at time.Time) {
44 c.mu.Lock()
45 defer c.mu.Unlock()
46 if _, exists := c.entries[host]; !exists && len(c.entries) >= versionProbeCacheMax {
47 c.evictLocked(at)
48 }
49 c.entries[host] = versionProbeEntry{version: version, probedAt: at}
50}
51
52func (c *versionProbeCache) evictLocked(now time.Time) {
53 oldestHost := ""
54 var oldestAt time.Time
55 for h, e := range c.entries {
56 if now.Sub(e.probedAt) >= versionProbeTrust {
57 delete(c.entries, h)
58 continue
59 }
60 if oldestHost == "" || e.probedAt.Before(oldestAt) {
61 oldestHost, oldestAt = h, e.probedAt
62 }
63 }
64 if len(c.entries) >= versionProbeCacheMax && oldestHost != "" {
65 delete(c.entries, oldestHost)
66 }
67}
68
69func (c *versionProbeCache) supports(now time.Time, host string, minMajor, minMinor int, failOpen bool, probe func() (string, bool)) bool {
70 if e, ok := c.get(host); ok && now.Sub(e.probedAt) < versionProbeFresh {
71 return atLeast(e.version, minMajor, minMinor)
72 }
73 if version, ok := probe(); ok {
74 c.put(host, version, now)
75 return atLeast(version, minMajor, minMinor)
76 }
77 if e, ok := c.get(host); ok && now.Sub(e.probedAt) < versionProbeTrust {
78 return atLeast(e.version, minMajor, minMinor)
79 }
80 return failOpen
81}
82
83var probeCache = &versionProbeCache{entries: map[string]versionProbeEntry{}}
84
85type CapStatus int
86
87const (
88 CapUnknown CapStatus = iota
89 CapAbsent
90 CapPresent
91)
92
93type negEntry struct {
94 status CapStatus
95 until time.Time
96}
97
98type NativeLatch interface {
99 IsNative(host string) bool
100 MarkNative(host string)
101}
102
103type latchBox struct{ l NativeLatch }
104
105type nativeGate struct {
106 memo sync.Map
107 negMemo sync.Map
108 latch atomic.Pointer[latchBox]
109 useOnce sync.Once
110 now func() time.Time
111}
112
113func (g *nativeGate) clock() time.Time {
114 if g.now != nil {
115 return g.now()
116 }
117 return time.Now()
118}
119
120func (g *nativeGate) use(l NativeLatch) {
121 g.useOnce.Do(func() {
122 g.latch.Store(&latchBox{l: l})
123 })
124}
125
126func (g *nativeGate) currentLatch() NativeLatch {
127 if b := g.latch.Load(); b != nil {
128 return b.l
129 }
130 return nil
131}
132
133func (g *nativeGate) status(host string, probe func() CapStatus) CapStatus {
134 if _, ok := g.memo.Load(host); ok {
135 return CapPresent
136 }
137 if v, ok := g.negMemo.Load(host); ok {
138 e := v.(negEntry)
139 if g.clock().Before(e.until) {
140 return e.status
141 }
142 g.negMemo.Delete(host)
143 }
144 if l := g.currentLatch(); l != nil && l.IsNative(host) {
145 g.memo.Store(host, struct{}{})
146 return CapPresent
147 }
148 if s := probe(); s != CapPresent {
149 g.negMemo.Store(host, negEntry{status: s, until: g.clock().Add(versionProbeFresh)})
150 return s
151 }
152 g.memo.Store(host, struct{}{})
153 if l := g.currentLatch(); l != nil {
154 l.MarkNative(host)
155 }
156 return CapPresent
157}
158
159var nativeProbeGate = &nativeGate{}
160
161func UseNativeLatch(l NativeLatch) {
162 nativeProbeGate.use(l)
163}
164
165func KnotSupports114(ctx context.Context, host string, dev bool) bool {
166 return knotSupportsVersion(ctx, host, dev, 1, 14, true)
167}
168
169func KnotCapability(ctx context.Context, host string, dev bool, capability consts.Capability) CapStatus {
170 return nativeProbeGate.status(host, func() CapStatus {
171 return knotDeclares(ctx, host, dev, capability)
172 })
173}
174
175func KnotHasCapability(ctx context.Context, host string, dev bool, capability consts.Capability) bool {
176 return KnotCapability(ctx, host, dev, capability) == CapPresent
177}
178
179func knotDeclares(ctx context.Context, host string, dev bool, capability consts.Capability) CapStatus {
180 scheme := "https"
181 if dev {
182 scheme = "http"
183 }
184 client := &indigoxrpc.Client{
185 Host: fmt.Sprintf("%s://%s", scheme, host),
186 Client: &http.Client{Timeout: versionProbeTimeout},
187 }
188
189 ctx, cancel := context.WithTimeout(ctx, versionProbeTimeout)
190 defer cancel()
191
192 resp, err := tangled.KnotVersion(ctx, client)
193 if err != nil || resp == nil {
194 return CapUnknown
195 }
196 if slices.Contains(resp.Capabilities, string(capability)) {
197 return CapPresent
198 }
199 return CapAbsent
200}
201
202func knotSupportsVersion(ctx context.Context, host string, dev bool, minMajor, minMinor int, failOpen bool) bool {
203 return probeCache.supports(time.Now(), host, minMajor, minMinor, failOpen, func() (string, bool) {
204 return probeVersion(ctx, host, dev)
205 })
206}
207
208func probeVersion(ctx context.Context, host string, dev bool) (string, bool) {
209 scheme := "https"
210 if dev {
211 scheme = "http"
212 }
213 client := &indigoxrpc.Client{
214 Host: fmt.Sprintf("%s://%s", scheme, host),
215 Client: &http.Client{Timeout: versionProbeTimeout},
216 }
217
218 ctx, cancel := context.WithTimeout(ctx, versionProbeTimeout)
219 defer cancel()
220
221 resp, err := tangled.KnotVersion(ctx, client)
222 if err != nil || resp == nil {
223 return "", false
224 }
225 return resp.Version, true
226}
227
228func atLeast(v string, minMajor, minMinor int) bool {
229 v = strings.TrimSpace(v)
230 v = strings.TrimPrefix(v, "v")
231 if strings.HasPrefix(v, "(devel)") {
232 return true
233 }
234 if v == "" {
235 return false
236 }
237 parts := strings.SplitN(v, ".", 3)
238 if len(parts) < 2 {
239 return false
240 }
241 major, err := strconv.Atoi(parts[0])
242 if err != nil {
243 return false
244 }
245 minorRaw := strings.SplitN(parts[1], "-", 2)[0]
246 minor, err := strconv.Atoi(minorRaw)
247 if err != nil {
248 return false
249 }
250 return major > minMajor || (major == minMajor && minor >= minMinor)
251}