detached f3efd5c

Merge remote-tracking branch 'origin/main' into feat/control-api

commit
f3efd5c31f75aae4cef8184419fba308678d3c3d
author
brice · · 2026-07-24 04:50 UTC
merge of
6b53f65 + 3f7eeb6 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +406 -92
M docs/remote-resilience.md +2 -0
@@ -2,6 +2,8 @@
22
33 Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers.
44
5+The raw UDP AEAD key is never persisted by vev; the proxy registry stores only its SHA-256 ownership fingerprint. During bootstrap, the key briefly exists in memory in the proxy, the `_udp-bootstrap` relay, the client, and associated SSH/readiness I/O and string buffers. vev promptly clears reachable key buffers after handoff on a best-effort basis. Builds made with Go 1.26 and `GOEXPERIMENT=runtimesecret` additionally protect key-handling call trees with `runtime/secret` on linux/amd64 and linux/arm64; on unsupported platforms, `runtime/secret` calls the function directly. These protections do not cover globals, goroutines started by protected calls, copies retained by I/O implementations, or key material retained in cipher state.
6+
57 ## Operational model
68
79 - SSH bootstrap starts or finds the remote UDP endpoint.
@@ -2,6 +2,8 @@
2 2
3 Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers.3 Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers.
4 4
5+The raw UDP AEAD key is never persisted by vev; the proxy registry stores only its SHA-256 ownership fingerprint. During bootstrap, the key briefly exists in memory in the proxy, the `_udp-bootstrap` relay, the client, and associated SSH/readiness I/O and string buffers. vev promptly clears reachable key buffers after handoff on a best-effort basis. Builds made with Go 1.26 and `GOEXPERIMENT=runtimesecret` additionally protect key-handling call trees with `runtime/secret` on linux/amd64 and linux/arm64; on unsupported platforms, `runtime/secret` calls the function directly. These protections do not cover globals, goroutines started by protected calls, copies retained by I/O implementations, or key material retained in cipher state.
6+
5 ## Operational model7 ## Operational model
6 8
7 - SSH bootstrap starts or finds the remote UDP endpoint.9 - SSH bootstrap starts or finds the remote UDP endpoint.
M internal/adapters/dgram/proxy_registry.go +14 -8
@@ -1,10 +1,10 @@
11 package dgram
22
33 import (
4+ "crypto/sha256"
45 "encoding/base64"
56 "encoding/json"
67 "errors"
7- "fmt"
88 "os"
99 "path/filepath"
1010 "syscall"
@@ -14,14 +14,20 @@ import (
1414 )
1515
1616 type ProxyRecord struct {
17- Session string `json:"session"`
18- PID int `json:"pid"`
19- Port int `json:"port"`
20- Key string `json:"key"`
21- Created time.Time `json:"created"`
17+ Session string `json:"session"`
18+ PID int `json:"pid"`
19+ Port int `json:"port"`
20+ // KeyFingerprint is a non-secret ownership tag (SHA-256 of the AEAD key).
21+ // The raw key must never be persisted by vev.
22+ KeyFingerprint string `json:"key_fingerprint"`
23+ Created time.Time `json:"created"`
2224 }
2325
24-func (r ProxyRecord) ReadyLine() string { return fmt.Sprintf("VEV-UDP %d %s\n", r.Port, r.Key) }
26+// KeyFingerprint derives the non-secret registry ownership tag.
27+func KeyFingerprint(key []byte) string {
28+ sum := sha256.Sum256(key)
29+ return base64.StdEncoding.EncodeToString(sum[:])
30+}
2531
2632 type ProxyRegistry struct{ dir string }
2733
@@ -88,7 +94,7 @@ func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error {
8894 if err != nil {
8995 return err
9096 }
91- if cur.PID != rec.PID || cur.Port != rec.Port || cur.Key != rec.Key {
97+ if cur.PID != rec.PID || cur.Port != rec.Port || cur.KeyFingerprint != rec.KeyFingerprint {
9298 return nil
9399 }
94100 return r.removeLocked(rec.Session)
@@ -1,10 +1,10 @@
1 package dgram1 package dgram
2 2
3 import (3 import (
4+ "crypto/sha256"
4 "encoding/base64"5 "encoding/base64"
5 "encoding/json"6 "encoding/json"
6 "errors"7 "errors"
7- "fmt"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10 "syscall"10 "syscall"
@@ -14,14 +14,20 @@ import (
14 )14 )
15 15
16 type ProxyRecord struct {16 type ProxyRecord struct {
17- Session string `json:"session"`17+ Session string `json:"session"`
18- PID int `json:"pid"`18+ PID int `json:"pid"`
19- Port int `json:"port"`19+ Port int `json:"port"`
20- Key string `json:"key"`20+ // KeyFingerprint is a non-secret ownership tag (SHA-256 of the AEAD key).
21- Created time.Time `json:"created"`21+ // The raw key must never be persisted by vev.
22+ KeyFingerprint string `json:"key_fingerprint"`
23+ Created time.Time `json:"created"`
22 }24 }
23 25
24-func (r ProxyRecord) ReadyLine() string { return fmt.Sprintf("VEV-UDP %d %s\n", r.Port, r.Key) }26+// KeyFingerprint derives the non-secret registry ownership tag.
27+func KeyFingerprint(key []byte) string {
28+ sum := sha256.Sum256(key)
29+ return base64.StdEncoding.EncodeToString(sum[:])
30+}
25 31
26 type ProxyRegistry struct{ dir string }32 type ProxyRegistry struct{ dir string }
27 33
@@ -88,7 +94,7 @@ func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error {
88 if err != nil {94 if err != nil {
89 return err95 return err
90 }96 }
91- if cur.PID != rec.PID || cur.Port != rec.Port || cur.Key != rec.Key {97+ if cur.PID != rec.PID || cur.Port != rec.Port || cur.KeyFingerprint != rec.KeyFingerprint {
92 return nil98 return nil
93 }99 }
94 return r.removeLocked(rec.Session)100 return r.removeLocked(rec.Session)
M internal/adapters/dgram/proxy_registry_test.go +60 -11
@@ -1,6 +1,10 @@
11 package dgram
22
33 import (
4+ "bytes"
5+ "crypto/sha256"
6+ "encoding/base64"
7+ "encoding/json"
48 "os"
59 "path/filepath"
610 "testing"
@@ -11,10 +15,55 @@ func privateDir(t *testing.T) string {
1115 return filepath.Join(t.TempDir(), "vev")
1216 }
1317
18+func TestKeyFingerprint(t *testing.T) {
19+ key := bytes.Repeat([]byte{0xA5}, 32)
20+ sum := sha256.Sum256(key)
21+ want := base64.StdEncoding.EncodeToString(sum[:])
22+ if got := KeyFingerprint(key); got != want {
23+ t.Fatalf("KeyFingerprint()=%q, want %q", got, want)
24+ }
25+ other := bytes.Repeat([]byte{0xA6}, 32)
26+ if KeyFingerprint(key) == KeyFingerprint(other) {
27+ t.Fatal("distinct keys produced identical fingerprints")
28+ }
29+}
30+
31+func TestProxyRegistryFileHoldsNoKeyMaterial(t *testing.T) {
32+ r := NewProxyRegistry(privateDir(t))
33+ key := bytes.Repeat([]byte{0xA5}, 32)
34+ fingerprint := KeyFingerprint(key)
35+ rec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: fingerprint}
36+ if err := r.Publish(rec); err != nil {
37+ t.Fatal(err)
38+ }
39+
40+ data, err := os.ReadFile(r.path("work"))
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ if bytes.Contains(data, key) || bytes.Contains(data, []byte(base64.StdEncoding.EncodeToString(key))) {
45+ t.Fatalf("registry file contains raw key material: %s", data)
46+ }
47+ var fields map[string]json.RawMessage
48+ if err := json.Unmarshal(data, &fields); err != nil {
49+ t.Fatal(err)
50+ }
51+ if _, ok := fields["key"]; ok {
52+ t.Fatalf("registry file contains raw key field: %s", data)
53+ }
54+ var got string
55+ if err := json.Unmarshal(fields["key_fingerprint"], &got); err != nil {
56+ t.Fatal(err)
57+ }
58+ if got != fingerprint {
59+ t.Fatalf("key_fingerprint=%q, want %q", got, fingerprint)
60+ }
61+}
62+
1463 func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
1564 t.Run("reuses live matching record", func(t *testing.T) {
1665 r := NewProxyRegistry(privateDir(t))
17- want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}
66+ want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}
1867 if err := r.Publish(want); err != nil {
1968 t.Fatal(err)
2069 }
@@ -22,16 +71,16 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
2271 if !ok {
2372 t.Fatal("Lookup ok=false, want true")
2473 }
25- if got.Port != want.Port || got.Key != want.Key || got.PID != want.PID {
74+ if got.Port != want.Port || got.KeyFingerprint != want.KeyFingerprint || got.PID != want.PID {
2675 t.Fatalf("Lookup()=%+v, want %+v", got, want)
2776 }
2877 })
2978 t.Run("removes stale record", func(t *testing.T) {
3079 r := NewProxyRegistry(privateDir(t))
31- if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}); err != nil {
80+ if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}); err != nil {
3281 t.Fatal(err)
3382 }
34- if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key":"abc"}`), 0o600); err != nil {
83+ if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key_fingerprint":"abc"}`), 0o600); err != nil {
3584 t.Fatal(err)
3685 }
3786 if _, ok := r.Lookup("work"); ok {
@@ -43,11 +92,11 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
4392 })
4493 t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {
4594 r := NewProxyRegistry(privateDir(t))
46- old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "old"}
95+ old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "old"}
4796 if err := r.Publish(old); err != nil {
4897 t.Fatal(err)
4998 }
50- newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, Key: "new"}
99+ newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, KeyFingerprint: "new"}
51100 if err := r.Publish(newRec); err != nil {
52101 t.Fatal(err)
53102 }
@@ -58,7 +107,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
58107 if !ok {
59108 t.Fatal("new live record removed by stale owner")
60109 }
61- if got.Port != newRec.Port || got.Key != newRec.Key {
110+ if got.Port != newRec.Port || got.KeyFingerprint != newRec.KeyFingerprint {
62111 t.Fatalf("Lookup()=%+v, want %+v", got, newRec)
63112 }
64113 if err := r.RemoveOwned(newRec); err != nil {
@@ -84,8 +133,8 @@ func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
84133 if r.path(tt.a) == r.path(tt.b) {
85134 t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a))
86135 }
87- first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, Key: "first"}
88- second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, Key: "second"}
136+ first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, KeyFingerprint: "first"}
137+ second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, KeyFingerprint: "second"}
89138 if err := r.Publish(first); err != nil {
90139 t.Fatal(err)
91140 }
@@ -93,11 +142,11 @@ func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
93142 t.Fatal(err)
94143 }
95144 gotFirst, ok := r.Lookup(tt.a)
96- if !ok || gotFirst.Key != first.Key {
145+ if !ok || gotFirst.KeyFingerprint != first.KeyFingerprint {
97146 t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok)
98147 }
99148 gotSecond, ok := r.Lookup(tt.b)
100- if !ok || gotSecond.Key != second.Key {
149+ if !ok || gotSecond.KeyFingerprint != second.KeyFingerprint {
101150 t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok)
102151 }
103152 }
@@ -1,6 +1,10 @@
1 package dgram1 package dgram
2 2
3 import (3 import (
4+ "bytes"
5+ "crypto/sha256"
6+ "encoding/base64"
7+ "encoding/json"
4 "os"8 "os"
5 "path/filepath"9 "path/filepath"
6 "testing"10 "testing"
@@ -11,10 +15,55 @@ func privateDir(t *testing.T) string {
11 return filepath.Join(t.TempDir(), "vev")15 return filepath.Join(t.TempDir(), "vev")
12 }16 }
13 17
18+func TestKeyFingerprint(t *testing.T) {
19+ key := bytes.Repeat([]byte{0xA5}, 32)
20+ sum := sha256.Sum256(key)
21+ want := base64.StdEncoding.EncodeToString(sum[:])
22+ if got := KeyFingerprint(key); got != want {
23+ t.Fatalf("KeyFingerprint()=%q, want %q", got, want)
24+ }
25+ other := bytes.Repeat([]byte{0xA6}, 32)
26+ if KeyFingerprint(key) == KeyFingerprint(other) {
27+ t.Fatal("distinct keys produced identical fingerprints")
28+ }
29+}
30+
31+func TestProxyRegistryFileHoldsNoKeyMaterial(t *testing.T) {
32+ r := NewProxyRegistry(privateDir(t))
33+ key := bytes.Repeat([]byte{0xA5}, 32)
34+ fingerprint := KeyFingerprint(key)
35+ rec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: fingerprint}
36+ if err := r.Publish(rec); err != nil {
37+ t.Fatal(err)
38+ }
39+
40+ data, err := os.ReadFile(r.path("work"))
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ if bytes.Contains(data, key) || bytes.Contains(data, []byte(base64.StdEncoding.EncodeToString(key))) {
45+ t.Fatalf("registry file contains raw key material: %s", data)
46+ }
47+ var fields map[string]json.RawMessage
48+ if err := json.Unmarshal(data, &fields); err != nil {
49+ t.Fatal(err)
50+ }
51+ if _, ok := fields["key"]; ok {
52+ t.Fatalf("registry file contains raw key field: %s", data)
53+ }
54+ var got string
55+ if err := json.Unmarshal(fields["key_fingerprint"], &got); err != nil {
56+ t.Fatal(err)
57+ }
58+ if got != fingerprint {
59+ t.Fatalf("key_fingerprint=%q, want %q", got, fingerprint)
60+ }
61+}
62+
14 func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {63 func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
15 t.Run("reuses live matching record", func(t *testing.T) {64 t.Run("reuses live matching record", func(t *testing.T) {
16 r := NewProxyRegistry(privateDir(t))65 r := NewProxyRegistry(privateDir(t))
17- want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}66+ want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}
18 if err := r.Publish(want); err != nil {67 if err := r.Publish(want); err != nil {
19 t.Fatal(err)68 t.Fatal(err)
20 }69 }
@@ -22,16 +71,16 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
22 if !ok {71 if !ok {
23 t.Fatal("Lookup ok=false, want true")72 t.Fatal("Lookup ok=false, want true")
24 }73 }
25- if got.Port != want.Port || got.Key != want.Key || got.PID != want.PID {74+ if got.Port != want.Port || got.KeyFingerprint != want.KeyFingerprint || got.PID != want.PID {
26 t.Fatalf("Lookup()=%+v, want %+v", got, want)75 t.Fatalf("Lookup()=%+v, want %+v", got, want)
27 }76 }
28 })77 })
29 t.Run("removes stale record", func(t *testing.T) {78 t.Run("removes stale record", func(t *testing.T) {
30 r := NewProxyRegistry(privateDir(t))79 r := NewProxyRegistry(privateDir(t))
31- if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}); err != nil {80+ if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}); err != nil {
32 t.Fatal(err)81 t.Fatal(err)
33 }82 }
34- if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key":"abc"}`), 0o600); err != nil {83+ if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key_fingerprint":"abc"}`), 0o600); err != nil {
35 t.Fatal(err)84 t.Fatal(err)
36 }85 }
37 if _, ok := r.Lookup("work"); ok {86 if _, ok := r.Lookup("work"); ok {
@@ -43,11 +92,11 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
43 })92 })
44 t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {93 t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {
45 r := NewProxyRegistry(privateDir(t))94 r := NewProxyRegistry(privateDir(t))
46- old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "old"}95+ old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "old"}
47 if err := r.Publish(old); err != nil {96 if err := r.Publish(old); err != nil {
48 t.Fatal(err)97 t.Fatal(err)
49 }98 }
50- newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, Key: "new"}99+ newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, KeyFingerprint: "new"}
51 if err := r.Publish(newRec); err != nil {100 if err := r.Publish(newRec); err != nil {
52 t.Fatal(err)101 t.Fatal(err)
53 }102 }
@@ -58,7 +107,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
58 if !ok {107 if !ok {
59 t.Fatal("new live record removed by stale owner")108 t.Fatal("new live record removed by stale owner")
60 }109 }
61- if got.Port != newRec.Port || got.Key != newRec.Key {110+ if got.Port != newRec.Port || got.KeyFingerprint != newRec.KeyFingerprint {
62 t.Fatalf("Lookup()=%+v, want %+v", got, newRec)111 t.Fatalf("Lookup()=%+v, want %+v", got, newRec)
63 }112 }
64 if err := r.RemoveOwned(newRec); err != nil {113 if err := r.RemoveOwned(newRec); err != nil {
@@ -84,8 +133,8 @@ func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
84 if r.path(tt.a) == r.path(tt.b) {133 if r.path(tt.a) == r.path(tt.b) {
85 t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a))134 t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a))
86 }135 }
87- first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, Key: "first"}136+ first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, KeyFingerprint: "first"}
88- second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, Key: "second"}137+ second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, KeyFingerprint: "second"}
89 if err := r.Publish(first); err != nil {138 if err := r.Publish(first); err != nil {
90 t.Fatal(err)139 t.Fatal(err)
91 }140 }
@@ -93,11 +142,11 @@ func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
93 t.Fatal(err)142 t.Fatal(err)
94 }143 }
95 gotFirst, ok := r.Lookup(tt.a)144 gotFirst, ok := r.Lookup(tt.a)
96- if !ok || gotFirst.Key != first.Key {145+ if !ok || gotFirst.KeyFingerprint != first.KeyFingerprint {
97 t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok)146 t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok)
98 }147 }
99 gotSecond, ok := r.Lookup(tt.b)148 gotSecond, ok := r.Lookup(tt.b)
100- if !ok || gotSecond.Key != second.Key {149+ if !ok || gotSecond.KeyFingerprint != second.KeyFingerprint {
101 t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok)150 t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok)
102 }151 }
103 }152 }
M internal/adapters/dgram/ssh_dialer.go +56 -24
@@ -158,6 +158,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
158158 cleanup = false
159159 return nil, udpUnavailable("read bootstrap readiness", err, &stderr)
160160 }
161+ defer pdgram.Erase(ready.key)
161162 if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
162163 waited = true
163164 cleanup = false
@@ -182,6 +183,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
182183 return listenUDPPacket(ctx)
183184 },
184185 }, WithRuntimeObserver(d.RuntimeObserver))
186+ pdgram.Erase(ready.key)
185187 if err != nil {
186188 _ = pc.Close()
187189 return nil, udpUnavailable("create UDP transport", err, &stderr)
@@ -196,15 +198,24 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
196198 return t, nil
197199 }
198200
199-func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
200- type result struct {
201- ready udpReady
202- err error
201+type udpReadyResult struct {
202+ ready udpReady
203+ err error
204+}
205+
206+func deliverUDPReady(ctx context.Context, ch chan<- udpReadyResult, res udpReadyResult) {
207+ select {
208+ case ch <- res:
209+ case <-ctx.Done():
210+ pdgram.Erase(res.ready.key)
203211 }
204- ch := make(chan result, 1)
212+}
213+
214+func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
215+ ch := make(chan udpReadyResult)
205216 go func() {
206217 ready, err := readUDPReady(r)
207- ch <- result{ready: ready, err: err}
218+ deliverUDPReady(ctx, ch, udpReadyResult{ready: ready, err: err})
208219 }()
209220 select {
210221 case res := <-ch:
@@ -247,26 +258,47 @@ type udpReady struct {
247258 }
248259
249260 func readUDPReady(r io.Reader) (udpReady, error) {
250- line, err := bufio.NewReader(r).ReadString('\n')
251- if err != nil && line == "" {
252- return udpReady{}, err
253- }
254- fields := strings.Fields(line)
255- if len(fields) != 3 || fields[0] != "VEV-UDP" {
256- return udpReady{}, fmt.Errorf("malformed readiness line %q", strings.TrimSpace(line))
257- }
258- port, err := strconv.Atoi(fields[1])
259- if err != nil || port < 1 || port > 65535 {
260- return udpReady{}, fmt.Errorf("invalid UDP port %q", fields[1])
261- }
262- key, err := base64.StdEncoding.DecodeString(fields[2])
263- if err != nil {
264- return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err)
261+ out := make([]byte, pdgram.KeySize)
262+ var (
263+ port int
264+ keyLen int
265+ parseErr error
266+ )
267+ pdgram.SecretDo(func() {
268+ line, err := bufio.NewReader(r).ReadString('\n')
269+ if err != nil && line == "" {
270+ parseErr = err
271+ return
272+ }
273+
274+ fields := strings.Fields(line)
275+ if len(fields) != 3 || fields[0] != "VEV-UDP" {
276+ parseErr = fmt.Errorf("malformed UDP readiness line")
277+ return
278+ }
279+ port, parseErr = strconv.Atoi(fields[1])
280+ if parseErr != nil || port < 1 || port > 65535 {
281+ parseErr = fmt.Errorf("invalid UDP port %q", fields[1])
282+ return
283+ }
284+ key, decodeErr := base64.StdEncoding.DecodeString(fields[2])
285+ defer pdgram.Erase(key)
286+ if decodeErr != nil {
287+ parseErr = fmt.Errorf("invalid bootstrap key: %w", decodeErr)
288+ return
289+ }
290+ keyLen = len(key)
291+ copy(out, key)
292+ })
293+ if parseErr != nil {
294+ pdgram.Erase(out)
295+ return udpReady{}, parseErr
265296 }
266- if len(key) != pdgram.KeySize {
267- return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key))
297+ if keyLen != pdgram.KeySize {
298+ pdgram.Erase(out)
299+ return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", keyLen)
268300 }
269- return udpReady{port: port, key: key}, nil
301+ return udpReady{port: port, key: out}, nil
270302 }
271303
272304 func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
@@ -158,6 +158,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
158 cleanup = false158 cleanup = false
159 return nil, udpUnavailable("read bootstrap readiness", err, &stderr)159 return nil, udpUnavailable("read bootstrap readiness", err, &stderr)
160 }160 }
161+ defer pdgram.Erase(ready.key)
161 if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {162 if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
162 waited = true163 waited = true
163 cleanup = false164 cleanup = false
@@ -182,6 +183,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
182 return listenUDPPacket(ctx)183 return listenUDPPacket(ctx)
183 },184 },
184 }, WithRuntimeObserver(d.RuntimeObserver))185 }, WithRuntimeObserver(d.RuntimeObserver))
186+ pdgram.Erase(ready.key)
185 if err != nil {187 if err != nil {
186 _ = pc.Close()188 _ = pc.Close()
187 return nil, udpUnavailable("create UDP transport", err, &stderr)189 return nil, udpUnavailable("create UDP transport", err, &stderr)
@@ -196,15 +198,24 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
196 return t, nil198 return t, nil
197 }199 }
198 200
199-func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {201+type udpReadyResult struct {
200- type result struct {202+ ready udpReady
201- ready udpReady203+ err error
202- err error204+}
205+
206+func deliverUDPReady(ctx context.Context, ch chan<- udpReadyResult, res udpReadyResult) {
207+ select {
208+ case ch <- res:
209+ case <-ctx.Done():
210+ pdgram.Erase(res.ready.key)
203 }211 }
204- ch := make(chan result, 1)212+}
213+
214+func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
215+ ch := make(chan udpReadyResult)
205 go func() {216 go func() {
206 ready, err := readUDPReady(r)217 ready, err := readUDPReady(r)
207- ch <- result{ready: ready, err: err}218+ deliverUDPReady(ctx, ch, udpReadyResult{ready: ready, err: err})
208 }()219 }()
209 select {220 select {
210 case res := <-ch:221 case res := <-ch:
@@ -247,26 +258,47 @@ type udpReady struct {
247 }258 }
248 259
249 func readUDPReady(r io.Reader) (udpReady, error) {260 func readUDPReady(r io.Reader) (udpReady, error) {
250- line, err := bufio.NewReader(r).ReadString('\n')261+ out := make([]byte, pdgram.KeySize)
251- if err != nil && line == "" {262+ var (
252- return udpReady{}, err263+ port int
253- }264+ keyLen int
254- fields := strings.Fields(line)265+ parseErr error
255- if len(fields) != 3 || fields[0] != "VEV-UDP" {266+ )
256- return udpReady{}, fmt.Errorf("malformed readiness line %q", strings.TrimSpace(line))267+ pdgram.SecretDo(func() {
257- }268+ line, err := bufio.NewReader(r).ReadString('\n')
258- port, err := strconv.Atoi(fields[1])269+ if err != nil && line == "" {
259- if err != nil || port < 1 || port > 65535 {270+ parseErr = err
260- return udpReady{}, fmt.Errorf("invalid UDP port %q", fields[1])271+ return
261- }272+ }
262- key, err := base64.StdEncoding.DecodeString(fields[2])273+
263- if err != nil {274+ fields := strings.Fields(line)
264- return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err)275+ if len(fields) != 3 || fields[0] != "VEV-UDP" {
276+ parseErr = fmt.Errorf("malformed UDP readiness line")
277+ return
278+ }
279+ port, parseErr = strconv.Atoi(fields[1])
280+ if parseErr != nil || port < 1 || port > 65535 {
281+ parseErr = fmt.Errorf("invalid UDP port %q", fields[1])
282+ return
283+ }
284+ key, decodeErr := base64.StdEncoding.DecodeString(fields[2])
285+ defer pdgram.Erase(key)
286+ if decodeErr != nil {
287+ parseErr = fmt.Errorf("invalid bootstrap key: %w", decodeErr)
288+ return
289+ }
290+ keyLen = len(key)
291+ copy(out, key)
292+ })
293+ if parseErr != nil {
294+ pdgram.Erase(out)
295+ return udpReady{}, parseErr
265 }296 }
266- if len(key) != pdgram.KeySize {297+ if keyLen != pdgram.KeySize {
267- return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key))298+ pdgram.Erase(out)
299+ return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", keyLen)
268 }300 }
269- return udpReady{port: port, key: key}, nil301+ return udpReady{port: port, key: out}, nil
270 }302 }
271 303
272 func udpUnavailable(action string, err error, stderr fmt.Stringer) error {304 func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
M internal/adapters/dgram/ssh_dialer_test.go +33 -5
@@ -60,7 +60,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
6060 wantStdoutClosed bool
6161 }{
6262 {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
63- {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},
63+ {name: "malformed readiness", stdout: "hello\n", want: "malformed UDP readiness line", killed: true, waited: true, wantStdoutClosed: true},
6464 {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
6565 {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},
6666 }
@@ -174,24 +174,52 @@ type closeTrackPacketConn struct {
174174
175175 func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() }
176176
177+func TestDeliverUDPReadyErasesParsedKeyAfterCancellation(t *testing.T) {
178+ ctx, cancel := context.WithCancel(context.Background())
179+ cancel()
180+ key := make([]byte, pdgram.KeySize)
181+ for i := range key {
182+ key[i] = byte(i + 1)
183+ }
184+ ready, err := readUDPReady(strings.NewReader("VEV-UDP 1234 " + base64.StdEncoding.EncodeToString(key) + "\n"))
185+ if err != nil {
186+ t.Fatal(err)
187+ }
188+
189+ deliverUDPReady(ctx, make(chan udpReadyResult), udpReadyResult{ready: ready})
190+
191+ for i, b := range ready.key {
192+ if b != 0 {
193+ t.Fatalf("key[%d] = %d, want erased", i, b)
194+ }
195+ }
196+}
197+
177198 func TestReadUDPReady(t *testing.T) {
178199 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
179200 tests := []struct {
180201 name, line string
181202 wantPort int
182203 wantErr string
204+ exactErr bool
183205 }{
184206 {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},
185- {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed readiness"},
186- {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: "invalid UDP port"},
207+ {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed UDP readiness line", exactErr: true},
208+ {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: `invalid UDP port "99999"`, exactErr: true},
187209 {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},
188210 }
189211 for _, tt := range tests {
190212 t.Run(tt.name, func(t *testing.T) {
191213 got, err := readUDPReady(strings.NewReader(tt.line))
192214 if tt.wantErr != "" {
193- if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
194- t.Fatalf("err=%v, want %q", err, tt.wantErr)
215+ if err == nil {
216+ t.Fatalf("err=nil, want %q", tt.wantErr)
217+ }
218+ if tt.exactErr && err.Error() != tt.wantErr {
219+ t.Fatalf("err=%q, want exactly %q", err, tt.wantErr)
220+ }
221+ if !tt.exactErr && !strings.Contains(err.Error(), tt.wantErr) {
222+ t.Fatalf("err=%v, want containing %q", err, tt.wantErr)
195223 }
196224 return
197225 }
@@ -60,7 +60,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
60 wantStdoutClosed bool60 wantStdoutClosed bool
61 }{61 }{
62 {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},62 {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
63- {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},63+ {name: "malformed readiness", stdout: "hello\n", want: "malformed UDP readiness line", killed: true, waited: true, wantStdoutClosed: true},
64 {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},64 {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
65 {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},65 {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},
66 }66 }
@@ -174,24 +174,52 @@ type closeTrackPacketConn struct {
174 174
175 func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() }175 func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() }
176 176
177+func TestDeliverUDPReadyErasesParsedKeyAfterCancellation(t *testing.T) {
178+ ctx, cancel := context.WithCancel(context.Background())
179+ cancel()
180+ key := make([]byte, pdgram.KeySize)
181+ for i := range key {
182+ key[i] = byte(i + 1)
183+ }
184+ ready, err := readUDPReady(strings.NewReader("VEV-UDP 1234 " + base64.StdEncoding.EncodeToString(key) + "\n"))
185+ if err != nil {
186+ t.Fatal(err)
187+ }
188+
189+ deliverUDPReady(ctx, make(chan udpReadyResult), udpReadyResult{ready: ready})
190+
191+ for i, b := range ready.key {
192+ if b != 0 {
193+ t.Fatalf("key[%d] = %d, want erased", i, b)
194+ }
195+ }
196+}
197+
177 func TestReadUDPReady(t *testing.T) {198 func TestReadUDPReady(t *testing.T) {
178 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))199 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
179 tests := []struct {200 tests := []struct {
180 name, line string201 name, line string
181 wantPort int202 wantPort int
182 wantErr string203 wantErr string
204+ exactErr bool
183 }{205 }{
184 {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},206 {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},
185- {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed readiness"},207+ {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed UDP readiness line", exactErr: true},
186- {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: "invalid UDP port"},208+ {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: `invalid UDP port "99999"`, exactErr: true},
187 {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},209 {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},
188 }210 }
189 for _, tt := range tests {211 for _, tt := range tests {
190 t.Run(tt.name, func(t *testing.T) {212 t.Run(tt.name, func(t *testing.T) {
191 got, err := readUDPReady(strings.NewReader(tt.line))213 got, err := readUDPReady(strings.NewReader(tt.line))
192 if tt.wantErr != "" {214 if tt.wantErr != "" {
193- if err == nil || !strings.Contains(err.Error(), tt.wantErr) {215+ if err == nil {
194- t.Fatalf("err=%v, want %q", err, tt.wantErr)216+ t.Fatalf("err=nil, want %q", tt.wantErr)
217+ }
218+ if tt.exactErr && err.Error() != tt.wantErr {
219+ t.Fatalf("err=%q, want exactly %q", err, tt.wantErr)
220+ }
221+ if !tt.exactErr && !strings.Contains(err.Error(), tt.wantErr) {
222+ t.Fatalf("err=%v, want containing %q", err, tt.wantErr)
195 }223 }
196 return224 return
197 }225 }
M internal/app/run.go +42 -17
@@ -48,6 +48,7 @@ import (
4848 "github.com/bnema/vev/internal/usecase/client"
4949 "github.com/bnema/vev/internal/usecase/confirm"
5050 "github.com/bnema/vev/internal/usecase/daemon"
51+ pdgram "github.com/bnema/vev/pkg/dgram"
5152 "github.com/bnema/vev/pkg/kv"
5253 )
5354
@@ -940,10 +941,6 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
940941 return err
941942 }
942943 defer func() { _ = conn.Close() }()
943- key := make([]byte, 32)
944- if _, err := rand.Read(key); err != nil {
945- return err
946- }
947944 daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
948945 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
949946 }, realSpawn, defaultBackoff)
@@ -953,26 +950,54 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
953950 defer func() { _ = daemonTr.Close() }()
954951 udpOptions := udpProxyClientTransportOptions
955952 udpOptions.Observe = dgram.DiagnosticLogObserver(log)
956- dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer))
957- if err != nil {
958- return err
959- }
960- defer func() { _ = dg.Close() }()
961-
962953 addr, ok := conn.LocalAddr().(*net.UDPAddr)
963954 if !ok {
964955 return errors.New("vev: udp proxy did not get a UDP address")
965956 }
966- keyText := base64.StdEncoding.EncodeToString(key)
967957 registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies"))
968- record := dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, Key: keyText}
969- if err := registry.Publish(record); err != nil {
970- return err
958+
959+ var (
960+ dg *dgram.Transport
961+ record dgram.ProxyRecord
962+ published bool
963+ setupErr error
964+ )
965+ pdgram.SecretDo(func() {
966+ key := make([]byte, pdgram.KeySize)
967+ defer pdgram.Erase(key)
968+ if _, setupErr = rand.Read(key); setupErr != nil {
969+ return
970+ }
971+ dg, setupErr = dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer))
972+ if setupErr != nil {
973+ return
974+ }
975+ record = dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, KeyFingerprint: dgram.KeyFingerprint(key)}
976+ if setupErr = registry.Publish(record); setupErr != nil {
977+ return
978+ }
979+ published = true
980+
981+ readiness := append([]byte("VEV-UDP "), strconv.Itoa(addr.Port)...)
982+ readiness = append(readiness, ' ')
983+ readiness = base64.StdEncoding.AppendEncode(readiness, key)
984+ readiness = append(readiness, '\n')
985+ defer pdgram.Erase(readiness)
986+ _, setupErr = ready.Write(readiness)
987+ pdgram.Erase(readiness)
988+ pdgram.Erase(key)
989+ })
990+ if setupErr != nil {
991+ if dg != nil {
992+ _ = dg.Close()
993+ }
994+ if published {
995+ _ = registry.RemoveOwned(record)
996+ }
997+ return setupErr
971998 }
999+ defer func() { _ = dg.Close() }()
9721000 defer func() { _ = registry.RemoveOwned(record) }()
973- if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, keyText); err != nil {
974- return err
975- }
9761001
9771002 return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log)
9781003 }
@@ -48,6 +48,7 @@ import (
48 "github.com/bnema/vev/internal/usecase/client"48 "github.com/bnema/vev/internal/usecase/client"
49 "github.com/bnema/vev/internal/usecase/confirm"49 "github.com/bnema/vev/internal/usecase/confirm"
50 "github.com/bnema/vev/internal/usecase/daemon"50 "github.com/bnema/vev/internal/usecase/daemon"
51+ pdgram "github.com/bnema/vev/pkg/dgram"
51 "github.com/bnema/vev/pkg/kv"52 "github.com/bnema/vev/pkg/kv"
52 )53 )
53 54
@@ -940,10 +941,6 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
940 return err941 return err
941 }942 }
942 defer func() { _ = conn.Close() }()943 defer func() { _ = conn.Close() }()
943- key := make([]byte, 32)
944- if _, err := rand.Read(key); err != nil {
945- return err
946- }
947 daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {944 daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
948 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))945 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
949 }, realSpawn, defaultBackoff)946 }, realSpawn, defaultBackoff)
@@ -953,26 +950,54 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
953 defer func() { _ = daemonTr.Close() }()950 defer func() { _ = daemonTr.Close() }()
954 udpOptions := udpProxyClientTransportOptions951 udpOptions := udpProxyClientTransportOptions
955 udpOptions.Observe = dgram.DiagnosticLogObserver(log)952 udpOptions.Observe = dgram.DiagnosticLogObserver(log)
956- dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer))
957- if err != nil {
958- return err
959- }
960- defer func() { _ = dg.Close() }()
961-
962 addr, ok := conn.LocalAddr().(*net.UDPAddr)953 addr, ok := conn.LocalAddr().(*net.UDPAddr)
963 if !ok {954 if !ok {
964 return errors.New("vev: udp proxy did not get a UDP address")955 return errors.New("vev: udp proxy did not get a UDP address")
965 }956 }
966- keyText := base64.StdEncoding.EncodeToString(key)
967 registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies"))957 registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies"))
968- record := dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, Key: keyText}958+
969- if err := registry.Publish(record); err != nil {959+ var (
970- return err960+ dg *dgram.Transport
961+ record dgram.ProxyRecord
962+ published bool
963+ setupErr error
964+ )
965+ pdgram.SecretDo(func() {
966+ key := make([]byte, pdgram.KeySize)
967+ defer pdgram.Erase(key)
968+ if _, setupErr = rand.Read(key); setupErr != nil {
969+ return
970+ }
971+ dg, setupErr = dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer))
972+ if setupErr != nil {
973+ return
974+ }
975+ record = dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, KeyFingerprint: dgram.KeyFingerprint(key)}
976+ if setupErr = registry.Publish(record); setupErr != nil {
977+ return
978+ }
979+ published = true
980+
981+ readiness := append([]byte("VEV-UDP "), strconv.Itoa(addr.Port)...)
982+ readiness = append(readiness, ' ')
983+ readiness = base64.StdEncoding.AppendEncode(readiness, key)
984+ readiness = append(readiness, '\n')
985+ defer pdgram.Erase(readiness)
986+ _, setupErr = ready.Write(readiness)
987+ pdgram.Erase(readiness)
988+ pdgram.Erase(key)
989+ })
990+ if setupErr != nil {
991+ if dg != nil {
992+ _ = dg.Close()
993+ }
994+ if published {
995+ _ = registry.RemoveOwned(record)
996+ }
997+ return setupErr
971 }998 }
999+ defer func() { _ = dg.Close() }()
972 defer func() { _ = registry.RemoveOwned(record) }()1000 defer func() { _ = registry.RemoveOwned(record) }()
973- if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, keyText); err != nil {
974- return err
975- }
976 1001
977 return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log)1002 return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log)
978 }1003 }
M internal/usecase/daemon/daemon_test.go +18 -4
@@ -64,12 +64,19 @@ func (t *signalTimer) Reset(time.Duration) bool { return false }
6464 func (t *signalTimer) Stop() bool { return true }
6565
6666 func newTestDaemon(t testing.TB, ptys ports.PTYFactory, clk ports.Clock) *Daemon {
67+ t.Helper()
68+ return newTestDaemonWithCleanup(t, ptys, clk, true)
69+}
70+
71+func newTestDaemonWithCleanup(t testing.TB, ptys ports.PTYFactory, clk ports.Clock, registerCleanup bool) *Daemon {
6772 t.Helper()
6873 d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil)))
6974 d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
7075 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
71- t.Cleanup(d.serveCancel)
72- t.Cleanup(d.hardCancel)
76+ if registerCleanup {
77+ t.Cleanup(d.serveCancel)
78+ t.Cleanup(d.hardCancel)
79+ }
7380 return d
7481 }
7582
@@ -368,7 +375,12 @@ func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.
368375
369376 func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
370377 t.Helper()
371- d := newTestDaemon(t, nil, stubClock{})
378+ return newManualSessionWithPTYsCleanup(t, true, ptys...)
379+}
380+
381+func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
382+ t.Helper()
383+ d := newTestDaemonWithCleanup(t, nil, stubClock{}, registerCleanup)
372384 tr, sends := newCapturingTransport(t)
373385 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
374386 ac.initOverlays()
@@ -387,7 +399,9 @@ func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *sessio
387399 ac.setSession(sess)
388400 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
389401 d.sessions[sess.id] = sess
390- t.Cleanup(cancel)
402+ if registerCleanup {
403+ t.Cleanup(cancel)
404+ }
391405 return d, sess, ac, sends
392406 }
393407
@@ -64,12 +64,19 @@ func (t *signalTimer) Reset(time.Duration) bool { return false }
64 func (t *signalTimer) Stop() bool { return true }64 func (t *signalTimer) Stop() bool { return true }
65 65
66 func newTestDaemon(t testing.TB, ptys ports.PTYFactory, clk ports.Clock) *Daemon {66 func newTestDaemon(t testing.TB, ptys ports.PTYFactory, clk ports.Clock) *Daemon {
67+ t.Helper()
68+ return newTestDaemonWithCleanup(t, ptys, clk, true)
69+}
70+
71+func newTestDaemonWithCleanup(t testing.TB, ptys ports.PTYFactory, clk ports.Clock, registerCleanup bool) *Daemon {
67 t.Helper()72 t.Helper()
68 d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil)))73 d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil)))
69 d.serveCtx, d.serveCancel = context.WithCancel(context.Background())74 d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
70 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())75 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
71- t.Cleanup(d.serveCancel)76+ if registerCleanup {
72- t.Cleanup(d.hardCancel)77+ t.Cleanup(d.serveCancel)
78+ t.Cleanup(d.hardCancel)
79+ }
73 return d80 return d
74 }81 }
75 82
@@ -368,7 +375,12 @@ func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.
368 375
369 func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {376 func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
370 t.Helper()377 t.Helper()
371- d := newTestDaemon(t, nil, stubClock{})378+ return newManualSessionWithPTYsCleanup(t, true, ptys...)
379+}
380+
381+func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
382+ t.Helper()
383+ d := newTestDaemonWithCleanup(t, nil, stubClock{}, registerCleanup)
372 tr, sends := newCapturingTransport(t)384 tr, sends := newCapturingTransport(t)
373 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}385 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
374 ac.initOverlays()386 ac.initOverlays()
@@ -387,7 +399,9 @@ func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *sessio
387 ac.setSession(sess)399 ac.setSession(sess)
388 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)400 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
389 d.sessions[sess.id] = sess401 d.sessions[sess.id] = sess
390- t.Cleanup(cancel)402+ if registerCleanup {
403+ t.Cleanup(cancel)
404+ }
391 return d, sess, ac, sends405 return d, sess, ac, sends
392 }406 }
393 407
M internal/usecase/daemon/performance_benchmark_test.go +101 -23
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "io"
78 "sync"
@@ -149,6 +150,38 @@ func TestPerformanceFixtureCellLimitedHistory(t *testing.T) {
149150 require.LessOrEqual(t, cells, capCells)
150151 }
151152
153+func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) {
154+ fixture := newPerformanceFixtureWithCleanup(t, performanceConfig{}, false)
155+ d, sess := fixture.d, fixture.sess
156+ serveCtx, hardCtx := d.serveCtx, d.hardCtx
157+ killErr := errors.New("scripted kill failure")
158+ fixture.killSession = func(sess *session, reason uint8, purge bool) error {
159+ require.NoError(t, d.killSession(sess, reason, purge))
160+ return killErr
161+ }
162+
163+ err := fixture.close()
164+
165+ require.ErrorIs(t, err, killErr)
166+ require.Nil(t, fixture.d)
167+ require.Nil(t, fixture.sess)
168+ require.Nil(t, fixture.activePane)
169+ require.Error(t, sess.ctx.Err())
170+ require.Error(t, serveCtx.Err())
171+ require.Error(t, hardCtx.Err())
172+ d.mu.Lock()
173+ sessionIDs := make([]domain.SessionID, 0, len(d.sessions))
174+ for id := range d.sessions {
175+ sessionIDs = append(sessionIDs, id)
176+ }
177+ d.mu.Unlock()
178+ require.NotContains(t, sessionIDs, sess.id)
179+ d.snapshotWorkerMu.Lock()
180+ workerCancel := d.snapshotWorkerCancel
181+ d.snapshotWorkerMu.Unlock()
182+ require.Nil(t, workerCancel)
183+}
184+
152185 func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) {
153186 transport := &countingOutputTransport{}
154187
@@ -506,29 +539,40 @@ func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBench
506539 b.ReportAllocs()
507540 b.ResetTimer()
508541 for operation := 0; b.Loop(); operation++ {
509- // A new fixture is deliberately outside the timed region. It gives every
510- // iteration the same filesystem state: initial has no generation,
511- // unchanged/visible/tail/sealed begin after one persisted generation.
512- // Reusing one fixture would eventually turn a tail into a sealed chunk
513- // and dilute initial-write bytes over later idempotent publications.
514- b.StopTimer()
515- fixture := newPerformanceFixture(b, performanceConfig{size: scenario.size, panes: 1, historyRows: scenario.historyRows})
516- validateSnapshotBenchmarkFixture(b, fixture, scenario)
517- generation := uint64(1)
518- if scenario.baseline {
542+ func() {
543+ // A new fixture gives every iteration the same filesystem state: initial
544+ // has no generation, while changed scenarios begin after one persisted
545+ // generation. Keep its expensive 10k-row construction timed so benchmark
546+ // calibration accounts for real wall time instead of scheduling thousands
547+ // of hidden setup iterations.
548+ //
549+ // b.Cleanup retains callbacks until the sub-benchmark ends; deferring an
550+ // explicit close avoids retaining every fixture and OOM.
551+ fixture := newPerformanceFixtureWithCleanup(b, performanceConfig{size: scenario.size, panes: 1, historyRows: scenario.historyRows}, false)
552+ b.StopTimer()
553+ defer func() {
554+ b.StopTimer()
555+ if err := fixture.close(); err != nil {
556+ b.Fatalf("close performance fixture: %v", err)
557+ }
558+ }()
559+ validateSnapshotBenchmarkFixture(b, fixture, scenario)
560+ generation := uint64(1)
561+ if scenario.baseline {
562+ publishBenchmarkSnapshot(b, fixture, generation)
563+ generation++
564+ fixture.snapshots.reset()
565+ }
566+ b.StartTimer()
567+ if scenario.mutate != nil {
568+ scenario.mutate(fixture, operation)
569+ }
519570 publishBenchmarkSnapshot(b, fixture, generation)
520- generation++
521- fixture.snapshots.reset()
522- }
523- b.StartTimer()
524- if scenario.mutate != nil {
525- scenario.mutate(fixture, operation)
526- }
527- publishBenchmarkSnapshot(b, fixture, generation)
528- b.StopTimer()
571+ b.StopTimer()
529572
530- metrics = addCountingSnapshotMetrics(metrics, fixture.snapshots.metrics())
531- peakCacheBytes = max(peakCacheBytes, benchmarkSnapshotCacheBytes(fixture.sess))
573+ metrics = addCountingSnapshotMetrics(metrics, fixture.snapshots.metrics())
574+ peakCacheBytes = max(peakCacheBytes, benchmarkSnapshotCacheBytes(fixture.sess))
575+ }()
532576 // B.Loop requires the timer to be running at the next iteration.
533577 b.StartTimer()
534578 }
@@ -952,6 +996,7 @@ type performanceFixture struct {
952996 resizedSize domain.Size
953997 pty *scriptedPerformancePTY
954998 clock *performanceClock
999+ killSession func(*session, uint8, bool) error
9551000 resizeRequests uint64
9561001 resizeCommits uint64
9571002 resizeCommitNanos uint64
@@ -964,6 +1009,11 @@ type performanceFixture struct {
9641009 }
9651010
9661011 func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {
1012+ t.Helper()
1013+ return newPerformanceFixtureWithCleanup(t, config, true)
1014+}
1015+
1016+func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, registerCleanup bool) *performanceFixture {
9671017 t.Helper()
9681018 if !config.size.Valid() {
9691019 config.size = domain.Size{Cols: 80, Rows: 24}
@@ -980,7 +1030,7 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
9801030 pty := &scriptedPerformancePTY{}
9811031 ptys := make([]ports.PTY, config.tabs)
9821032 ptys[0] = pty
983- d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)
1033+ d, sess, ac, _ := newManualSessionWithPTYsCleanup(t, registerCleanup, ptys...)
9841034 clock := &performanceClock{}
9851035 d.clock = clock
9861036 output := &countingOutputTransport{}
@@ -999,12 +1049,15 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
9991049 snapshots: &countingSnapshotRepository{},
10001050 pty: pty,
10011051 clock: clock,
1052+ killSession: d.killSession,
10021053 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
10031054 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
10041055 }
10051056 WithSnapshotRepository(fixture.snapshots, nil)(d)
10061057 d.startSnapshotEncodeWorker()
1007- t.Cleanup(d.stopSnapshotEncodeWorker)
1058+ if registerCleanup {
1059+ t.Cleanup(d.stopSnapshotEncodeWorker)
1060+ }
10081061 for tabIndex, tb := range sess.tabs {
10091062 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
10101063 }
@@ -1029,6 +1082,31 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
10291082 return fixture
10301083 }
10311084
1085+func (f *performanceFixture) close() error {
1086+ if f.d == nil {
1087+ return nil
1088+ }
1089+ d, sess := f.d, f.sess
1090+ sess.mu.Lock()
1091+ sess.ephemeral = true
1092+ sess.mu.Unlock()
1093+ killErr := f.killSession(sess, ports.ReasonServerShutdown, false)
1094+ d.stopSnapshotEncodeWorker()
1095+ d.serveCancel()
1096+ d.hardCancel()
1097+
1098+ f.d = nil
1099+ f.sess = nil
1100+ f.ac = nil
1101+ f.output = nil
1102+ f.snapshots = nil
1103+ f.activePane = nil
1104+ f.pty = nil
1105+ f.clock = nil
1106+ f.killSession = nil
1107+ return killErr
1108+}
1109+
10321110 func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) {
10331111 f.t.Helper()
10341112 tb.mu.Lock()
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "io"7 "io"
7 "sync"8 "sync"
@@ -149,6 +150,38 @@ func TestPerformanceFixtureCellLimitedHistory(t *testing.T) {
149 require.LessOrEqual(t, cells, capCells)150 require.LessOrEqual(t, cells, capCells)
150 }151 }
151 152
153+func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) {
154+ fixture := newPerformanceFixtureWithCleanup(t, performanceConfig{}, false)
155+ d, sess := fixture.d, fixture.sess
156+ serveCtx, hardCtx := d.serveCtx, d.hardCtx
157+ killErr := errors.New("scripted kill failure")
158+ fixture.killSession = func(sess *session, reason uint8, purge bool) error {
159+ require.NoError(t, d.killSession(sess, reason, purge))
160+ return killErr
161+ }
162+
163+ err := fixture.close()
164+
165+ require.ErrorIs(t, err, killErr)
166+ require.Nil(t, fixture.d)
167+ require.Nil(t, fixture.sess)
168+ require.Nil(t, fixture.activePane)
169+ require.Error(t, sess.ctx.Err())
170+ require.Error(t, serveCtx.Err())
171+ require.Error(t, hardCtx.Err())
172+ d.mu.Lock()
173+ sessionIDs := make([]domain.SessionID, 0, len(d.sessions))
174+ for id := range d.sessions {
175+ sessionIDs = append(sessionIDs, id)
176+ }
177+ d.mu.Unlock()
178+ require.NotContains(t, sessionIDs, sess.id)
179+ d.snapshotWorkerMu.Lock()
180+ workerCancel := d.snapshotWorkerCancel
181+ d.snapshotWorkerMu.Unlock()
182+ require.Nil(t, workerCancel)
183+}
184+
152 func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) {185 func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) {
153 transport := &countingOutputTransport{}186 transport := &countingOutputTransport{}
154 187
@@ -506,29 +539,40 @@ func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBench
506 b.ReportAllocs()539 b.ReportAllocs()
507 b.ResetTimer()540 b.ResetTimer()
508 for operation := 0; b.Loop(); operation++ {541 for operation := 0; b.Loop(); operation++ {
509- // A new fixture is deliberately outside the timed region. It gives every542+ func() {
510- // iteration the same filesystem state: initial has no generation,543+ // A new fixture gives every iteration the same filesystem state: initial
511- // unchanged/visible/tail/sealed begin after one persisted generation.544+ // has no generation, while changed scenarios begin after one persisted
512- // Reusing one fixture would eventually turn a tail into a sealed chunk545+ // generation. Keep its expensive 10k-row construction timed so benchmark
513- // and dilute initial-write bytes over later idempotent publications.546+ // calibration accounts for real wall time instead of scheduling thousands
514- b.StopTimer()547+ // of hidden setup iterations.
515- fixture := newPerformanceFixture(b, performanceConfig{size: scenario.size, panes: 1, historyRows: scenario.historyRows})548+ //
516- validateSnapshotBenchmarkFixture(b, fixture, scenario)549+ // b.Cleanup retains callbacks until the sub-benchmark ends; deferring an
517- generation := uint64(1)550+ // explicit close avoids retaining every fixture and OOM.
518- if scenario.baseline {551+ fixture := newPerformanceFixtureWithCleanup(b, performanceConfig{size: scenario.size, panes: 1, historyRows: scenario.historyRows}, false)
552+ b.StopTimer()
553+ defer func() {
554+ b.StopTimer()
555+ if err := fixture.close(); err != nil {
556+ b.Fatalf("close performance fixture: %v", err)
557+ }
558+ }()
559+ validateSnapshotBenchmarkFixture(b, fixture, scenario)
560+ generation := uint64(1)
561+ if scenario.baseline {
562+ publishBenchmarkSnapshot(b, fixture, generation)
563+ generation++
564+ fixture.snapshots.reset()
565+ }
566+ b.StartTimer()
567+ if scenario.mutate != nil {
568+ scenario.mutate(fixture, operation)
569+ }
519 publishBenchmarkSnapshot(b, fixture, generation)570 publishBenchmarkSnapshot(b, fixture, generation)
520- generation++571+ b.StopTimer()
521- fixture.snapshots.reset()
522- }
523- b.StartTimer()
524- if scenario.mutate != nil {
525- scenario.mutate(fixture, operation)
526- }
527- publishBenchmarkSnapshot(b, fixture, generation)
528- b.StopTimer()
529 572
530- metrics = addCountingSnapshotMetrics(metrics, fixture.snapshots.metrics())573+ metrics = addCountingSnapshotMetrics(metrics, fixture.snapshots.metrics())
531- peakCacheBytes = max(peakCacheBytes, benchmarkSnapshotCacheBytes(fixture.sess))574+ peakCacheBytes = max(peakCacheBytes, benchmarkSnapshotCacheBytes(fixture.sess))
575+ }()
532 // B.Loop requires the timer to be running at the next iteration.576 // B.Loop requires the timer to be running at the next iteration.
533 b.StartTimer()577 b.StartTimer()
534 }578 }
@@ -952,6 +996,7 @@ type performanceFixture struct {
952 resizedSize domain.Size996 resizedSize domain.Size
953 pty *scriptedPerformancePTY997 pty *scriptedPerformancePTY
954 clock *performanceClock998 clock *performanceClock
999+ killSession func(*session, uint8, bool) error
955 resizeRequests uint641000 resizeRequests uint64
956 resizeCommits uint641001 resizeCommits uint64
957 resizeCommitNanos uint641002 resizeCommitNanos uint64
@@ -964,6 +1009,11 @@ type performanceFixture struct {
964 }1009 }
965 1010
966 func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {1011 func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {
1012+ t.Helper()
1013+ return newPerformanceFixtureWithCleanup(t, config, true)
1014+}
1015+
1016+func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, registerCleanup bool) *performanceFixture {
967 t.Helper()1017 t.Helper()
968 if !config.size.Valid() {1018 if !config.size.Valid() {
969 config.size = domain.Size{Cols: 80, Rows: 24}1019 config.size = domain.Size{Cols: 80, Rows: 24}
@@ -980,7 +1030,7 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
980 pty := &scriptedPerformancePTY{}1030 pty := &scriptedPerformancePTY{}
981 ptys := make([]ports.PTY, config.tabs)1031 ptys := make([]ports.PTY, config.tabs)
982 ptys[0] = pty1032 ptys[0] = pty
983- d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)1033+ d, sess, ac, _ := newManualSessionWithPTYsCleanup(t, registerCleanup, ptys...)
984 clock := &performanceClock{}1034 clock := &performanceClock{}
985 d.clock = clock1035 d.clock = clock
986 output := &countingOutputTransport{}1036 output := &countingOutputTransport{}
@@ -999,12 +1049,15 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
999 snapshots: &countingSnapshotRepository{},1049 snapshots: &countingSnapshotRepository{},
1000 pty: pty,1050 pty: pty,
1001 clock: clock,1051 clock: clock,
1052+ killSession: d.killSession,
1002 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},1053 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
1003 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},1054 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
1004 }1055 }
1005 WithSnapshotRepository(fixture.snapshots, nil)(d)1056 WithSnapshotRepository(fixture.snapshots, nil)(d)
1006 d.startSnapshotEncodeWorker()1057 d.startSnapshotEncodeWorker()
1007- t.Cleanup(d.stopSnapshotEncodeWorker)1058+ if registerCleanup {
1059+ t.Cleanup(d.stopSnapshotEncodeWorker)
1060+ }
1008 for tabIndex, tb := range sess.tabs {1061 for tabIndex, tb := range sess.tabs {
1009 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))1062 fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
1010 }1063 }
@@ -1029,6 +1082,31 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
1029 return fixture1082 return fixture
1030 }1083 }
1031 1084
1085+func (f *performanceFixture) close() error {
1086+ if f.d == nil {
1087+ return nil
1088+ }
1089+ d, sess := f.d, f.sess
1090+ sess.mu.Lock()
1091+ sess.ephemeral = true
1092+ sess.mu.Unlock()
1093+ killErr := f.killSession(sess, ports.ReasonServerShutdown, false)
1094+ d.stopSnapshotEncodeWorker()
1095+ d.serveCancel()
1096+ d.hardCancel()
1097+
1098+ f.d = nil
1099+ f.sess = nil
1100+ f.ac = nil
1101+ f.output = nil
1102+ f.snapshots = nil
1103+ f.activePane = nil
1104+ f.pty = nil
1105+ f.clock = nil
1106+ f.killSession = nil
1107+ return killErr
1108+}
1109+
1032 func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) {1110 func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) {
1033 f.t.Helper()1111 f.t.Helper()
1034 tb.mu.Lock()1112 tb.mu.Lock()
M pkg/dgram/dgram.go +6 -0
@@ -24,6 +24,12 @@ var (
2424 ErrFragment = errors.New("dgram: invalid fragment")
2525 )
2626
27+// Erase overwrites b with zeroes. This is best effort: copies made by the
28+// compiler, runtime, or called code may retain the original data.
29+func Erase(b []byte) {
30+ clear(b)
31+}
32+
2733 type Codec struct {
2834 gcm cipher.AEAD
2935 }
@@ -24,6 +24,12 @@ var (
24 ErrFragment = errors.New("dgram: invalid fragment")24 ErrFragment = errors.New("dgram: invalid fragment")
25 )25 )
26 26
27+// Erase overwrites b with zeroes. This is best effort: copies made by the
28+// compiler, runtime, or called code may retain the original data.
29+func Erase(b []byte) {
30+ clear(b)
31+}
32+
27 type Codec struct {33 type Codec struct {
28 gcm cipher.AEAD34 gcm cipher.AEAD
29 }35 }
A pkg/dgram/erase_test.go +27 -0
@@ -0,0 +1,27 @@
1+package dgram
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestErase(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ buf []byte
12+ }{
13+ {name: "nil", buf: nil},
14+ {name: "empty", buf: []byte{}},
15+ {name: "key", buf: bytes.Repeat([]byte{0xa5}, KeySize)},
16+ }
17+
18+ for _, tt := range tests {
19+ t.Run(tt.name, func(t *testing.T) {
20+ Erase(tt.buf)
21+
22+ if !bytes.Equal(tt.buf, make([]byte, len(tt.buf))) {
23+ t.Fatalf("Erase() left data in buffer: %x", tt.buf)
24+ }
25+ })
26+ }
27+}
@@ -0,0 +1,27 @@
1+package dgram
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestErase(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ buf []byte
12+ }{
13+ {name: "nil", buf: nil},
14+ {name: "empty", buf: []byte{}},
15+ {name: "key", buf: bytes.Repeat([]byte{0xa5}, KeySize)},
16+ }
17+
18+ for _, tt := range tests {
19+ t.Run(tt.name, func(t *testing.T) {
20+ Erase(tt.buf)
21+
22+ if !bytes.Equal(tt.buf, make([]byte, len(tt.buf))) {
23+ t.Fatalf("Erase() left data in buffer: %x", tt.buf)
24+ }
25+ })
26+ }
27+}
A pkg/dgram/secret_disabled.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build !goexperiment.runtimesecret
2+
3+package dgram
4+
5+// SecretDo invokes f directly when runtime/secret is not in this build.
6+// Callers still explicitly clear every reachable key buffer with Erase.
7+func SecretDo(f func()) { f() }
@@ -0,0 +1,7 @@
1+//go:build !goexperiment.runtimesecret
2+
3+package dgram
4+
5+// SecretDo invokes f directly when runtime/secret is not in this build.
6+// Callers still explicitly clear every reachable key buffer with Erase.
7+func SecretDo(f func()) { f() }
A pkg/dgram/secret_enabled.go +12 -0
@@ -0,0 +1,12 @@
1+//go:build goexperiment.runtimesecret
2+
3+package dgram
4+
5+import "runtime/secret"
6+
7+// SecretDo invokes f through runtime/secret.Do. On supported Go 1.26
8+// platforms this protects temporary stack/register storage for f's call tree
9+// and marks its allocations for clearing after they become unreachable and
10+// the GC notices. On unsupported platforms runtime/secret.Do invokes f
11+// directly. It does not protect globals or goroutines started by f.
12+func SecretDo(f func()) { secret.Do(f) }
@@ -0,0 +1,12 @@
1+//go:build goexperiment.runtimesecret
2+
3+package dgram
4+
5+import "runtime/secret"
6+
7+// SecretDo invokes f through runtime/secret.Do. On supported Go 1.26
8+// platforms this protects temporary stack/register storage for f's call tree
9+// and marks its allocations for clearing after they become unreachable and
10+// the GC notices. On unsupported platforms runtime/secret.Do invokes f
11+// directly. It does not protect globals or goroutines started by f.
12+func SecretDo(f func()) { secret.Do(f) }
A pkg/dgram/secret_test.go +28 -0
@@ -0,0 +1,28 @@
1+package dgram
2+
3+import "testing"
4+
5+func TestSecretDoRunsSynchronously(t *testing.T) {
6+ called := false
7+
8+ SecretDo(func() {
9+ called = true
10+ })
11+
12+ if !called {
13+ t.Fatal("SecretDo returned before callback ran")
14+ }
15+}
16+
17+func TestSecretDoPropagatesPanic(t *testing.T) {
18+ want := &struct{ message string }{message: "secret callback panic"}
19+ defer func() {
20+ if got := recover(); got != want {
21+ t.Fatalf("recover() = %#v, want %#v", got, want)
22+ }
23+ }()
24+
25+ SecretDo(func() {
26+ panic(want)
27+ })
28+}
@@ -0,0 +1,28 @@
1+package dgram
2+
3+import "testing"
4+
5+func TestSecretDoRunsSynchronously(t *testing.T) {
6+ called := false
7+
8+ SecretDo(func() {
9+ called = true
10+ })
11+
12+ if !called {
13+ t.Fatal("SecretDo returned before callback ran")
14+ }
15+}
16+
17+func TestSecretDoPropagatesPanic(t *testing.T) {
18+ want := &struct{ message string }{message: "secret callback panic"}
19+ defer func() {
20+ if got := recover(); got != want {
21+ t.Fatalf("recover() = %#v, want %#v", got, want)
22+ }
23+ }()
24+
25+ SecretDo(func() {
26+ panic(want)
27+ })
28+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close