Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

appview: stop pds-record acl fallback if knot cap probe fails

Lewis: May this revision serve well! <lewis@tangled.org>

author
Lewis
committer
Tangled
date (Jun 16, 2026, 9:04 PM +0300) commit f8c6b8c6 parent 88aad293 change-id otkqruqs
+111 -37
+36 -15
appview/knotcompat/version.go
··· 82 82 83 83 var probeCache = &versionProbeCache{entries: map[string]versionProbeEntry{}} 84 84 85 + type CapStatus int 86 + 87 + const ( 88 + CapUnknown CapStatus = iota 89 + CapAbsent 90 + CapPresent 91 + ) 92 + 93 + type negEntry struct { 94 + status CapStatus 95 + until time.Time 96 + } 97 + 85 98 type NativeLatch interface { 86 99 IsNative(host string) bool 87 100 MarkNative(host string) ··· 117 130 return nil 118 131 } 119 132 120 - func (g *nativeGate) isNative(host string, probe func() bool) bool { 133 + func (g *nativeGate) status(host string, probe func() CapStatus) CapStatus { 121 134 if _, ok := g.memo.Load(host); ok { 122 - return true 135 + return CapPresent 123 136 } 124 - if until, ok := g.negMemo.Load(host); ok { 125 - if g.clock().Before(until.(time.Time)) { 126 - return false 137 + if v, ok := g.negMemo.Load(host); ok { 138 + e := v.(negEntry) 139 + if g.clock().Before(e.until) { 140 + return e.status 127 141 } 128 142 g.negMemo.Delete(host) 129 143 } 130 144 if l := g.currentLatch(); l != nil && l.IsNative(host) { 131 145 g.memo.Store(host, struct{}{}) 132 - return true 146 + return CapPresent 133 147 } 134 - if !probe() { 135 - g.negMemo.Store(host, g.clock().Add(versionProbeFresh)) 136 - return false 148 + if s := probe(); s != CapPresent { 149 + g.negMemo.Store(host, negEntry{status: s, until: g.clock().Add(versionProbeFresh)}) 150 + return s 137 151 } 138 152 g.memo.Store(host, struct{}{}) 139 153 if l := g.currentLatch(); l != nil { 140 154 l.MarkNative(host) 141 155 } 142 - return true 156 + return CapPresent 143 157 } 144 158 145 159 var nativeProbeGate = &nativeGate{} ··· 152 166 return knotSupportsVersion(ctx, host, dev, 1, 14, true) 153 167 } 154 168 155 - func KnotHasCapability(ctx context.Context, host string, dev bool, capability consts.Capability) bool { 156 - return nativeProbeGate.isNative(host, func() bool { 169 + func KnotCapability(ctx context.Context, host string, dev bool, capability consts.Capability) CapStatus { 170 + return nativeProbeGate.status(host, func() CapStatus { 157 171 return knotDeclares(ctx, host, dev, capability) 158 172 }) 159 173 } 160 174 161 - func knotDeclares(ctx context.Context, host string, dev bool, capability consts.Capability) bool { 175 + func KnotHasCapability(ctx context.Context, host string, dev bool, capability consts.Capability) bool { 176 + return KnotCapability(ctx, host, dev, capability) == CapPresent 177 + } 178 + 179 + func knotDeclares(ctx context.Context, host string, dev bool, capability consts.Capability) CapStatus { 162 180 scheme := "https" 163 181 if dev { 164 182 scheme = "http" ··· 173 191 174 192 resp, err := tangled.KnotVersion(ctx, client) 175 193 if err != nil || resp == nil { 176 - return false 194 + return CapUnknown 195 + } 196 + if slices.Contains(resp.Capabilities, string(capability)) { 197 + return CapPresent 177 198 } 178 - return slices.Contains(resp.Capabilities, string(capability)) 199 + return CapAbsent 179 200 } 180 201 181 202 func knotSupportsVersion(ctx context.Context, host string, dev bool, minMajor, minMinor int, failOpen bool) bool {
+49 -18
appview/knotcompat/version_test.go
··· 44 44 return len(f.marks) 45 45 } 46 46 47 - func probeReturning(v bool, calls *int) func() bool { 48 - return func() bool { 47 + func probeReturning(s CapStatus, calls *int) func() CapStatus { 48 + return func() CapStatus { 49 49 *calls++ 50 - return v 50 + return s 51 51 } 52 52 } 53 53 54 + func isNativeForTest(g *nativeGate, host string, probe func() CapStatus) bool { 55 + return g.status(host, probe) == CapPresent 56 + } 57 + 54 58 func TestNativeGateMemoSkipsSecondProbe(t *testing.T) { 55 59 g := &nativeGate{} 56 60 calls := 0 57 - probe := probeReturning(true, &calls) 61 + probe := probeReturning(CapPresent, &calls) 58 62 59 - if !g.isNative("clam.nel.pet", probe) { 63 + if !isNativeForTest(g, "clam.nel.pet", probe) { 60 64 t.Fatal("first probe true: want native") 61 65 } 62 - if !g.isNative("clam.nel.pet", probe) { 66 + if !isNativeForTest(g, "clam.nel.pet", probe) { 63 67 t.Fatal("memoized: want native") 64 68 } 65 69 if calls != 1 { ··· 74 78 g.use(fl) 75 79 76 80 calls := 0 77 - if !g.isNative("whelk.nel.pet", probeReturning(false, &calls)) { 81 + if !isNativeForTest(g, "whelk.nel.pet", probeReturning(CapAbsent, &calls)) { 78 82 t.Fatal("latched native: want native even though the probe would fail") 79 83 } 80 84 if calls != 0 { ··· 91 95 g.use(fl) 92 96 93 97 calls := 0 94 - probe := probeReturning(true, &calls) 95 - if !g.isNative("limpet.nel.pet", probe) { 98 + probe := probeReturning(CapPresent, &calls) 99 + if !isNativeForTest(g, "limpet.nel.pet", probe) { 96 100 t.Fatal("probe true: want native") 97 101 } 98 102 if fl.markCount() != 1 { 99 103 t.Fatalf("marks = %d, want 1; a first successful probe must latch the host", fl.markCount()) 100 104 } 101 105 102 - g.isNative("limpet.nel.pet", probe) 106 + isNativeForTest(g, "limpet.nel.pet", probe) 103 107 if fl.markCount() != 1 { 104 108 t.Fatalf("marks = %d, want 1; the memo must prevent a second mark", fl.markCount()) 105 109 } ··· 111 115 g.use(fl) 112 116 113 117 calls := 0 114 - if g.isNative("clam.nel.pet", probeReturning(false, &calls)) { 118 + if isNativeForTest(g, "clam.nel.pet", probeReturning(CapAbsent, &calls)) { 115 119 t.Fatal("probe false on a fresh host: want not native") 116 120 } 117 121 if fl.markCount() != 0 { ··· 125 129 warm := &nativeGate{} 126 130 warm.use(fl) 127 131 calls := 0 128 - if !warm.isNative("whelk.nel.pet", probeReturning(true, &calls)) { 132 + if !isNativeForTest(warm, "whelk.nel.pet", probeReturning(CapPresent, &calls)) { 129 133 t.Fatal("warm gate probe true: want native") 130 134 } 131 135 132 136 restarted := &nativeGate{} 133 137 restarted.use(fl) 134 138 cold := 0 135 - if !restarted.isNative("whelk.nel.pet", probeReturning(false, &cold)) { 139 + if !isNativeForTest(restarted, "whelk.nel.pet", probeReturning(CapAbsent, &cold)) { 136 140 t.Fatal("after restart the durable latch must resolve native without a probe") 137 141 } 138 142 if cold != 0 { ··· 147 151 g.use(fl) 148 152 149 153 calls := 0 150 - probe := probeReturning(false, &calls) 154 + probe := probeReturning(CapAbsent, &calls) 151 155 152 156 for range 5 { 153 - if g.isNative("clam.nel.pet", probe) { 157 + if isNativeForTest(g, "clam.nel.pet", probe) { 154 158 t.Fatal("a probe-false host must not be native") 155 159 } 156 160 } ··· 162 166 } 163 167 164 168 now = now.Add(versionProbeFresh + time.Second) 165 - if g.isNative("clam.nel.pet", probe) { 169 + if isNativeForTest(g, "clam.nel.pet", probe) { 166 170 t.Fatal("still not native after the window") 167 171 } 168 172 if fl.readCount() != 2 { ··· 180 184 g.use(fl) 181 185 182 186 calls := 0 183 - if g.isNative("whelk.nel.pet", probeReturning(false, &calls)) { 187 + if isNativeForTest(g, "whelk.nel.pet", probeReturning(CapAbsent, &calls)) { 184 188 t.Fatal("probe false on a fresh host: want not native") 185 189 } 186 190 ··· 189 193 fl.mu.Unlock() 190 194 191 195 now = now.Add(versionProbeFresh + time.Second) 192 - if !g.isNative("whelk.nel.pet", probeReturning(false, &calls)) { 196 + if !isNativeForTest(g, "whelk.nel.pet", probeReturning(CapAbsent, &calls)) { 193 197 t.Fatal("once the negative memo expires a latched host must resolve native again") 198 + } 199 + } 200 + 201 + func TestNativeGateUnknownDistinctFromAbsent(t *testing.T) { 202 + now := time.Now() 203 + g := &nativeGate{now: func() time.Time { return now }} 204 + fl := newFakeLatch() 205 + g.use(fl) 206 + 207 + calls := 0 208 + probe := probeReturning(CapUnknown, &calls) 209 + 210 + for range 3 { 211 + if got := g.status("clam.nel.pet", probe); got != CapUnknown { 212 + t.Fatalf("status = %v, want CapUnknown for a failed probe", got) 213 + } 214 + } 215 + if calls != 1 { 216 + t.Fatalf("probe calls = %d, want 1; a memoized unknown must throttle re-probes within the window", calls) 217 + } 218 + if fl.markCount() != 0 { 219 + t.Fatalf("marks = %d, want 0; an unknown probe must never latch", fl.markCount()) 220 + } 221 + 222 + now = now.Add(versionProbeFresh + time.Second) 223 + if got := g.status("clam.nel.pet", probeReturning(CapAbsent, &calls)); got != CapAbsent { 224 + t.Fatalf("status = %v, want CapAbsent once a fresh probe reaches the knot", got) 194 225 } 195 226 } 196 227
+14 -2
appview/knots/knots.go
··· 553 553 return 554 554 } 555 555 556 - if knotcompat.KnotHasCapability(r.Context(), domain, k.Config.Core.Dev, consts.CapKnotACL) { 556 + capStatus := knotcompat.KnotCapability(r.Context(), domain, k.Config.Core.Dev, consts.CapKnotACL) 557 + if capStatus == knotcompat.CapUnknown { 558 + l.Error("knot capability probe failed") 559 + k.Pages.Notice(w, noticeId, "Could not reach the knot to add the member. Try again later.") 560 + return 561 + } 562 + if capStatus == knotcompat.CapPresent { 557 563 client, err := k.OAuth.ServiceClient( 558 564 r, 559 565 oauth.WithService(domain), ··· 660 666 return 661 667 } 662 668 663 - if knotcompat.KnotHasCapability(r.Context(), domain, k.Config.Core.Dev, consts.CapKnotACL) { 669 + capStatus := knotcompat.KnotCapability(r.Context(), domain, k.Config.Core.Dev, consts.CapKnotACL) 670 + if capStatus == knotcompat.CapUnknown { 671 + l.Error("knot capability probe failed") 672 + k.Pages.Notice(w, noticeId, "Could not reach the knot to remove the member. Try again later.") 673 + return 674 + } 675 + if capStatus == knotcompat.CapPresent { 664 676 client, err := k.OAuth.ServiceClient( 665 677 r, 666 678 oauth.WithService(domain),
+12 -2
appview/repo/repo.go
··· 759 759 l = l.With("collaborator", collaboratorIdent.Handle) 760 760 l = l.With("knot", f.Knot) 761 761 762 - if knotcompat.KnotHasCapability(r.Context(), f.Knot, rp.config.Core.Dev, consts.CapKnotACL) { 762 + capStatus := knotcompat.KnotCapability(r.Context(), f.Knot, rp.config.Core.Dev, consts.CapKnotACL) 763 + if capStatus == knotcompat.CapUnknown { 764 + fail("Could not reach the knot to add the collaborator. Try again later.", nil) 765 + return 766 + } 767 + if capStatus == knotcompat.CapPresent { 763 768 if f.RepoDid == "" { 764 769 fail("This repository is missing its DID and cannot manage collaborators.", nil) 765 770 return ··· 927 932 return 928 933 } 929 934 930 - if knotcompat.KnotHasCapability(r.Context(), f.Knot, rp.config.Core.Dev, consts.CapKnotACL) { 935 + capStatus := knotcompat.KnotCapability(r.Context(), f.Knot, rp.config.Core.Dev, consts.CapKnotACL) 936 + if capStatus == knotcompat.CapUnknown { 937 + fail("Could not reach the knot to remove the collaborator. Try again later.", nil) 938 + return 939 + } 940 + if capStatus == knotcompat.CapPresent { 931 941 if f.RepoDid == "" { 932 942 fail("This repository is missing its DID and cannot manage collaborators.", nil) 933 943 return