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 NativeLatch interface {
86 IsNative(host string) bool
87 MarkNative(host string)
88}
89
90type latchBox struct{ l NativeLatch }
91
92type nativeGate struct {
93 memo sync.Map
94 negMemo sync.Map
95 latch atomic.Pointer[latchBox]
96 useOnce sync.Once
97 now func() time.Time
98}
99
100func (g *nativeGate) clock() time.Time {
101 if g.now != nil {
102 return g.now()
103 }
104 return time.Now()
105}
106
107func (g *nativeGate) use(l NativeLatch) {
108 g.useOnce.Do(func() {
109 g.latch.Store(&latchBox{l: l})
110 })
111}
112
113func (g *nativeGate) currentLatch() NativeLatch {
114 if b := g.latch.Load(); b != nil {
115 return b.l
116 }
117 return nil
118}
119
120func (g *nativeGate) isNative(host string, probe func() bool) bool {
121 if _, ok := g.memo.Load(host); ok {
122 return true
123 }
124 if until, ok := g.negMemo.Load(host); ok {
125 if g.clock().Before(until.(time.Time)) {
126 return false
127 }
128 g.negMemo.Delete(host)
129 }
130 if l := g.currentLatch(); l != nil && l.IsNative(host) {
131 g.memo.Store(host, struct{}{})
132 return true
133 }
134 if !probe() {
135 g.negMemo.Store(host, g.clock().Add(versionProbeFresh))
136 return false
137 }
138 g.memo.Store(host, struct{}{})
139 if l := g.currentLatch(); l != nil {
140 l.MarkNative(host)
141 }
142 return true
143}
144
145var nativeProbeGate = &nativeGate{}
146
147func UseNativeLatch(l NativeLatch) {
148 nativeProbeGate.use(l)
149}
150
151func KnotSupports114(ctx context.Context, host string, dev bool) bool {
152 return knotSupportsVersion(ctx, host, dev, 1, 14, true)
153}
154
155func KnotHasCapability(ctx context.Context, host string, dev bool, capability consts.Capability) bool {
156 return nativeProbeGate.isNative(host, func() bool {
157 return knotDeclares(ctx, host, dev, capability)
158 })
159}
160
161func knotDeclares(ctx context.Context, host string, dev bool, capability consts.Capability) bool {
162 scheme := "https"
163 if dev {
164 scheme = "http"
165 }
166 client := &indigoxrpc.Client{
167 Host: fmt.Sprintf("%s://%s", scheme, host),
168 Client: &http.Client{Timeout: versionProbeTimeout},
169 }
170
171 ctx, cancel := context.WithTimeout(ctx, versionProbeTimeout)
172 defer cancel()
173
174 resp, err := tangled.KnotVersion(ctx, client)
175 if err != nil || resp == nil {
176 return false
177 }
178 return slices.Contains(resp.Capabilities, string(capability))
179}
180
181func knotSupportsVersion(ctx context.Context, host string, dev bool, minMajor, minMinor int, failOpen bool) bool {
182 return probeCache.supports(time.Now(), host, minMajor, minMinor, failOpen, func() (string, bool) {
183 return probeVersion(ctx, host, dev)
184 })
185}
186
187func probeVersion(ctx context.Context, host string, dev bool) (string, bool) {
188 scheme := "https"
189 if dev {
190 scheme = "http"
191 }
192 client := &indigoxrpc.Client{
193 Host: fmt.Sprintf("%s://%s", scheme, host),
194 Client: &http.Client{Timeout: versionProbeTimeout},
195 }
196
197 ctx, cancel := context.WithTimeout(ctx, versionProbeTimeout)
198 defer cancel()
199
200 resp, err := tangled.KnotVersion(ctx, client)
201 if err != nil || resp == nil {
202 return "", false
203 }
204 return resp.Version, true
205}
206
207func atLeast(v string, minMajor, minMinor int) bool {
208 v = strings.TrimSpace(v)
209 v = strings.TrimPrefix(v, "v")
210 if strings.HasPrefix(v, "(devel)") {
211 return true
212 }
213 if v == "" {
214 return false
215 }
216 parts := strings.SplitN(v, ".", 3)
217 if len(parts) < 2 {
218 return false
219 }
220 major, err := strconv.Atoi(parts[0])
221 if err != nil {
222 return false
223 }
224 minorRaw := strings.SplitN(parts[1], "-", 2)[0]
225 minor, err := strconv.Atoi(minorRaw)
226 if err != nil {
227 return false
228 }
229 return major > minMajor || (major == minMajor && minor >= minMinor)
230}