detached 004db2e

refactor(client): unify session connections

commit
004db2e8c3293f3e8a67c5d0d4ed4452ac3ce7d4
author
brice · · 2026-08-03 10:49 UTC
committer
brice · · 2026-08-04 07:16 UTC
parent
ee0f20d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +228 -14
M internal/app/run.go +11 -1
@@ -208,6 +208,14 @@ func parseArgs(args []string) (command, error) {
208208 }
209209 cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]}
210210 if target, session, ok := parseRemoteAttachTarget(args[1]); ok {
211+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
212+ return command{}, err
213+ }
214+ if session != "" {
215+ if err := domain.ValidateSessionName(session); err != nil {
216+ return command{}, err
217+ }
218+ }
211219 cmd.remoteTarget = target
212220 cmd.name = session
213221 if session == "" {
@@ -840,7 +848,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
840848 Logger: log,
841849 RuntimeObserver: deps.runtimeObserver,
842850 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
843- }, client.AttachRequest{Intent: intent, SessionName: name, Remote: true})
851+ Remote: true,
852+ }, client.AttachRequest{Intent: intent, SessionName: name})
844853 }
845854
846855 localDialer := deps.localDialer
@@ -859,6 +868,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
859868 Clock: clock.New(),
860869 Logger: log,
861870 RuntimeObserver: deps.runtimeObserver,
871+ Remote: false,
862872 }, client.AttachRequest{Intent: intent, SessionName: name})
863873 },
864874 killDaemon: requestDaemonStop,
@@ -208,6 +208,14 @@ func parseArgs(args []string) (command, error) {
208 }208 }
209 cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]}209 cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]}
210 if target, session, ok := parseRemoteAttachTarget(args[1]); ok {210 if target, session, ok := parseRemoteAttachTarget(args[1]); ok {
211+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
212+ return command{}, err
213+ }
214+ if session != "" {
215+ if err := domain.ValidateSessionName(session); err != nil {
216+ return command{}, err
217+ }
218+ }
211 cmd.remoteTarget = target219 cmd.remoteTarget = target
212 cmd.name = session220 cmd.name = session
213 if session == "" {221 if session == "" {
@@ -840,7 +848,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
840 Logger: log,848 Logger: log,
841 RuntimeObserver: deps.runtimeObserver,849 RuntimeObserver: deps.runtimeObserver,
842 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),850 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
843- }, client.AttachRequest{Intent: intent, SessionName: name, Remote: true})851+ Remote: true,
852+ }, client.AttachRequest{Intent: intent, SessionName: name})
844 }853 }
845 854
846 localDialer := deps.localDialer855 localDialer := deps.localDialer
@@ -859,6 +868,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
859 Clock: clock.New(),868 Clock: clock.New(),
860 Logger: log,869 Logger: log,
861 RuntimeObserver: deps.runtimeObserver,870 RuntimeObserver: deps.runtimeObserver,
871+ Remote: false,
862 }, client.AttachRequest{Intent: intent, SessionName: name})872 }, client.AttachRequest{Intent: intent, SessionName: name})
863 },873 },
864 killDaemon: requestDaemonStop,874 killDaemon: requestDaemonStop,
M internal/app/run_test.go +39 -5
@@ -579,9 +579,12 @@ func TestParseArgs(t *testing.T) {
579579 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
580580 {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
581581 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
582- {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
583- {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
584- {name: "attach remote with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
582+ {name: "attach remote host uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
583+ {name: "attach remote host with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
584+ {name: "attach remote host with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
585+ {name: "attach remote ipv4 with session", args: []string{"attach", "user@192.0.2.10:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@192.0.2.10"},
586+ {name: "attach remote ipv6 with session", args: []string{"attach", "user@[2001:db8::1]:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@[2001:db8::1]"},
587+ {name: "attach remote invalid session", args: []string{"attach", "user@example.com:my work"}, wantErr: true, nonUsageErr: true},
585588 {name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true},
586589 {name: "attach without name", args: []string{"attach"}, wantErr: true},
587590 {name: "ls", args: []string{"ls"}, wantKind: kindList},
@@ -689,6 +692,31 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
689692 }
690693 }
691694
695+func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
696+ tests := []struct {
697+ name string
698+ arg string
699+ want client.AttachRequest
700+ }{
701+ {name: "local host", arg: "work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
702+ {name: "remote host", arg: "user@example.com:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
703+ {name: "remote ipv4", arg: "user@192.0.2.10:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
704+ {name: "remote ipv6", arg: "user@[2001:db8::1]:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
705+ }
706+ for _, tt := range tests {
707+ t.Run(tt.name, func(t *testing.T) {
708+ cmd, err := parseArgs([]string{"attach", tt.arg})
709+ require.NoError(t, err)
710+ connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{
711+ Intent: cmd.intent,
712+ SessionName: cmd.name,
713+ })
714+ require.NoError(t, err)
715+ require.Equal(t, tt.want, connection.AttachRequest())
716+ })
717+ }
718+}
719+
692720 func TestListShowsBroken(t *testing.T) {
693721 var out bytes.Buffer
694722 printSessions(&out, []ports.SessionInfo{
@@ -1073,7 +1101,10 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
10731101 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
10741102 }
10751103 gotDialer = nd.name
1076- gotRemote = request.Remote
1104+ gotRemote = deps.Remote
1105+ if request.Remote {
1106+ t.Fatal("remote carriage metadata must not be in the attach request")
1107+ }
10771108 gotClipboard = deps.Clipboard
10781109 if request.Intent != ports.IntentAttach || request.SessionName != "work" {
10791110 t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)
@@ -1153,7 +1184,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
11531184 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
11541185 }
11551186 gotDialer = nd.name
1156- gotRemote = request.Remote
1187+ gotRemote = deps.Remote
1188+ if request.Remote {
1189+ t.Fatal("local carriage metadata must not be in the attach request")
1190+ }
11571191 gotClipboard = deps.Clipboard
11581192 if request.Intent != ports.IntentEphemeral || request.SessionName != "" {
11591193 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)
@@ -579,9 +579,12 @@ func TestParseArgs(t *testing.T) {
579 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},579 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
580 {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},580 {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
581 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},581 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
582- {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},582+ {name: "attach remote host uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
583- {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},583+ {name: "attach remote host with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
584- {name: "attach remote with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"},584+ {name: "attach remote host with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
585+ {name: "attach remote ipv4 with session", args: []string{"attach", "user@192.0.2.10:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@192.0.2.10"},
586+ {name: "attach remote ipv6 with session", args: []string{"attach", "user@[2001:db8::1]:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@[2001:db8::1]"},
587+ {name: "attach remote invalid session", args: []string{"attach", "user@example.com:my work"}, wantErr: true, nonUsageErr: true},
585 {name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true},588 {name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true},
586 {name: "attach without name", args: []string{"attach"}, wantErr: true},589 {name: "attach without name", args: []string{"attach"}, wantErr: true},
587 {name: "ls", args: []string{"ls"}, wantKind: kindList},590 {name: "ls", args: []string{"ls"}, wantKind: kindList},
@@ -689,6 +692,31 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
689 }692 }
690 }693 }
691 694
695+func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
696+ tests := []struct {
697+ name string
698+ arg string
699+ want client.AttachRequest
700+ }{
701+ {name: "local host", arg: "work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
702+ {name: "remote host", arg: "user@example.com:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
703+ {name: "remote ipv4", arg: "user@192.0.2.10:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
704+ {name: "remote ipv6", arg: "user@[2001:db8::1]:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
705+ }
706+ for _, tt := range tests {
707+ t.Run(tt.name, func(t *testing.T) {
708+ cmd, err := parseArgs([]string{"attach", tt.arg})
709+ require.NoError(t, err)
710+ connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{
711+ Intent: cmd.intent,
712+ SessionName: cmd.name,
713+ })
714+ require.NoError(t, err)
715+ require.Equal(t, tt.want, connection.AttachRequest())
716+ })
717+ }
718+}
719+
692 func TestListShowsBroken(t *testing.T) {720 func TestListShowsBroken(t *testing.T) {
693 var out bytes.Buffer721 var out bytes.Buffer
694 printSessions(&out, []ports.SessionInfo{722 printSessions(&out, []ports.SessionInfo{
@@ -1073,7 +1101,10 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
1073 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)1101 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
1074 }1102 }
1075 gotDialer = nd.name1103 gotDialer = nd.name
1076- gotRemote = request.Remote1104+ gotRemote = deps.Remote
1105+ if request.Remote {
1106+ t.Fatal("remote carriage metadata must not be in the attach request")
1107+ }
1077 gotClipboard = deps.Clipboard1108 gotClipboard = deps.Clipboard
1078 if request.Intent != ports.IntentAttach || request.SessionName != "work" {1109 if request.Intent != ports.IntentAttach || request.SessionName != "work" {
1079 t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)1110 t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)
@@ -1153,7 +1184,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
1153 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)1184 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
1154 }1185 }
1155 gotDialer = nd.name1186 gotDialer = nd.name
1156- gotRemote = request.Remote1187+ gotRemote = deps.Remote
1188+ if request.Remote {
1189+ t.Fatal("local carriage metadata must not be in the attach request")
1190+ }
1157 gotClipboard = deps.Clipboard1191 gotClipboard = deps.Clipboard
1158 if request.Intent != ports.IntentEphemeral || request.SessionName != "" {1192 if request.Intent != ports.IntentEphemeral || request.SessionName != "" {
1159 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)1193 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)
M internal/usecase/client/client.go +31 -8
@@ -155,9 +155,13 @@ type Dependencies struct {
155155 Logger *slog.Logger
156156 RuntimeObserver ports.SerializedRuntimeObserver
157157 RemoteHostLearner ports.RemoteHostLearner
158+ // Remote selects client-side carriage presentation only; it never enters
159+ // the daemon-facing session request.
160+ Remote bool
158161 }
159162
160-// AttachRequest identifies the session and transport mode for one client run.
163+// AttachRequest identifies the session for one client run. Remote is
164+// client-only presentation metadata; it is never serialized into Hello.
161165 type AttachRequest struct {
162166 Intent uint8
163167 SessionName string
@@ -173,6 +177,7 @@ type Runner struct {
173177 logger *slog.Logger
174178 runtimeObserver ports.SerializedRuntimeObserver
175179 remoteHostLearner ports.RemoteHostLearner
180+ remote bool
176181
177182 inputMu sync.Mutex
178183 input *terminalInputPump
@@ -192,6 +197,7 @@ func NewRunner(deps Dependencies) *Runner {
192197 logger: log,
193198 runtimeObserver: deps.RuntimeObserver,
194199 remoteHostLearner: deps.RemoteHostLearner,
200+ remote: deps.Remote,
195201 }
196202 }
197203
@@ -282,9 +288,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
282288 }
283289 }
284290 }()
291+ remote := request.Remote || r.remote
285292 reconnect := &reconnectUI{
286293 term: r.term,
287- remote: request.Remote,
294+ remote: remote,
288295 rawEntered: &rawEntered,
289296 stage: reconnectStageOfflineRetrying,
290297 }
@@ -333,14 +340,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
333340 continue
334341 }
335342 ms.dialed = true
343+ connection, err := NewSessionConnection(transport, SessionTarget{
344+ Intent: attemptRequest.Intent,
345+ SessionName: attemptRequest.SessionName,
346+ })
347+ if err != nil {
348+ _ = transport.Close()
349+ return err
350+ }
336351
337352 var linkEvents <-chan ports.LinkEvent
338- if reporter, ok := transport.(ports.LinkStateReporter); ok {
353+ if reporter, ok := connection.Transport().(ports.LinkStateReporter); ok {
339354 linkEvents = reporter.LinkEvents()
340355 }
341356 result := (&attachAttempt{
342357 runner: r,
343- transport: transport,
358+ connection: connection,
359+ remote: remote,
344360 request: attemptRequest,
345361 resumeToken: resumeToken,
346362 clientID: processClientID,
@@ -358,7 +374,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
358374 backoff = defaultReconnectBackoff.initial
359375 }
360376 if !result.transportClosed {
361- _ = transport.Close()
377+ _ = connection.Close()
362378 }
363379 if result.resumeToken != 0 {
364380 resumeToken = result.resumeToken
@@ -515,6 +531,8 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
515531
516532 type attachAttempt struct {
517533 runner *Runner
534+ connection *SessionConnection
535+ remote bool
518536 transport ports.Transport
519537 request AttachRequest
520538 resumeToken uint64
@@ -601,10 +619,15 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
601619
602620 func (a *attachAttempt) run(ctx context.Context) attachResult {
603621 transport := a.transport
622+ request := a.request
623+ if a.connection != nil {
624+ transport = a.connection.Transport()
625+ request = a.connection.AttachRequest()
626+ }
604627 term := a.runner.term
605628 clk := a.runner.clock
606- intent := a.request.Intent
607- name := a.request.SessionName
629+ intent := request.Intent
630+ name := request.SessionName
608631 resumeToken := a.resumeToken
609632 clientID := a.clientID
610633 ms := a.milestones
@@ -612,7 +635,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
612635 enterRaw := a.enterRaw
613636 reconnect := a.reconnect
614637 linkEvents := a.linkEvents
615- remote := a.request.Remote
638+ remote := a.remote || a.request.Remote
616639 clipboard := a.runner.clipboard
617640 log := a.runner.logger
618641 observer := a.runner.runtimeObserver
@@ -155,9 +155,13 @@ type Dependencies struct {
155 Logger *slog.Logger155 Logger *slog.Logger
156 RuntimeObserver ports.SerializedRuntimeObserver156 RuntimeObserver ports.SerializedRuntimeObserver
157 RemoteHostLearner ports.RemoteHostLearner157 RemoteHostLearner ports.RemoteHostLearner
158+ // Remote selects client-side carriage presentation only; it never enters
159+ // the daemon-facing session request.
160+ Remote bool
158 }161 }
159 162
160-// AttachRequest identifies the session and transport mode for one client run.163+// AttachRequest identifies the session for one client run. Remote is
164+// client-only presentation metadata; it is never serialized into Hello.
161 type AttachRequest struct {165 type AttachRequest struct {
162 Intent uint8166 Intent uint8
163 SessionName string167 SessionName string
@@ -173,6 +177,7 @@ type Runner struct {
173 logger *slog.Logger177 logger *slog.Logger
174 runtimeObserver ports.SerializedRuntimeObserver178 runtimeObserver ports.SerializedRuntimeObserver
175 remoteHostLearner ports.RemoteHostLearner179 remoteHostLearner ports.RemoteHostLearner
180+ remote bool
176 181
177 inputMu sync.Mutex182 inputMu sync.Mutex
178 input *terminalInputPump183 input *terminalInputPump
@@ -192,6 +197,7 @@ func NewRunner(deps Dependencies) *Runner {
192 logger: log,197 logger: log,
193 runtimeObserver: deps.RuntimeObserver,198 runtimeObserver: deps.RuntimeObserver,
194 remoteHostLearner: deps.RemoteHostLearner,199 remoteHostLearner: deps.RemoteHostLearner,
200+ remote: deps.Remote,
195 }201 }
196 }202 }
197 203
@@ -282,9 +288,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
282 }288 }
283 }289 }
284 }()290 }()
291+ remote := request.Remote || r.remote
285 reconnect := &reconnectUI{292 reconnect := &reconnectUI{
286 term: r.term,293 term: r.term,
287- remote: request.Remote,294+ remote: remote,
288 rawEntered: &rawEntered,295 rawEntered: &rawEntered,
289 stage: reconnectStageOfflineRetrying,296 stage: reconnectStageOfflineRetrying,
290 }297 }
@@ -333,14 +340,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
333 continue340 continue
334 }341 }
335 ms.dialed = true342 ms.dialed = true
343+ connection, err := NewSessionConnection(transport, SessionTarget{
344+ Intent: attemptRequest.Intent,
345+ SessionName: attemptRequest.SessionName,
346+ })
347+ if err != nil {
348+ _ = transport.Close()
349+ return err
350+ }
336 351
337 var linkEvents <-chan ports.LinkEvent352 var linkEvents <-chan ports.LinkEvent
338- if reporter, ok := transport.(ports.LinkStateReporter); ok {353+ if reporter, ok := connection.Transport().(ports.LinkStateReporter); ok {
339 linkEvents = reporter.LinkEvents()354 linkEvents = reporter.LinkEvents()
340 }355 }
341 result := (&attachAttempt{356 result := (&attachAttempt{
342 runner: r,357 runner: r,
343- transport: transport,358+ connection: connection,
359+ remote: remote,
344 request: attemptRequest,360 request: attemptRequest,
345 resumeToken: resumeToken,361 resumeToken: resumeToken,
346 clientID: processClientID,362 clientID: processClientID,
@@ -358,7 +374,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
358 backoff = defaultReconnectBackoff.initial374 backoff = defaultReconnectBackoff.initial
359 }375 }
360 if !result.transportClosed {376 if !result.transportClosed {
361- _ = transport.Close()377+ _ = connection.Close()
362 }378 }
363 if result.resumeToken != 0 {379 if result.resumeToken != 0 {
364 resumeToken = result.resumeToken380 resumeToken = result.resumeToken
@@ -515,6 +531,8 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
515 531
516 type attachAttempt struct {532 type attachAttempt struct {
517 runner *Runner533 runner *Runner
534+ connection *SessionConnection
535+ remote bool
518 transport ports.Transport536 transport ports.Transport
519 request AttachRequest537 request AttachRequest
520 resumeToken uint64538 resumeToken uint64
@@ -601,10 +619,15 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
601 619
602 func (a *attachAttempt) run(ctx context.Context) attachResult {620 func (a *attachAttempt) run(ctx context.Context) attachResult {
603 transport := a.transport621 transport := a.transport
622+ request := a.request
623+ if a.connection != nil {
624+ transport = a.connection.Transport()
625+ request = a.connection.AttachRequest()
626+ }
604 term := a.runner.term627 term := a.runner.term
605 clk := a.runner.clock628 clk := a.runner.clock
606- intent := a.request.Intent629+ intent := request.Intent
607- name := a.request.SessionName630+ name := request.SessionName
608 resumeToken := a.resumeToken631 resumeToken := a.resumeToken
609 clientID := a.clientID632 clientID := a.clientID
610 ms := a.milestones633 ms := a.milestones
@@ -612,7 +635,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
612 enterRaw := a.enterRaw635 enterRaw := a.enterRaw
613 reconnect := a.reconnect636 reconnect := a.reconnect
614 linkEvents := a.linkEvents637 linkEvents := a.linkEvents
615- remote := a.request.Remote638+ remote := a.remote || a.request.Remote
616 clipboard := a.runner.clipboard639 clipboard := a.runner.clipboard
617 log := a.runner.logger640 log := a.runner.logger
618 observer := a.runner.runtimeObserver641 observer := a.runner.runtimeObserver
A internal/usecase/client/session_connection.go +89 -0
@@ -0,0 +1,89 @@
1+package client
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+var errNilSessionTransport = errors.New("client: nil session transport")
12+
13+// SessionTarget is the validated daemon-facing session selection. Endpoint
14+// details are resolved before a transport is opened and never enter this type.
15+type SessionTarget struct {
16+ Intent uint8
17+ SessionName string
18+}
19+
20+func (t SessionTarget) validate() error {
21+ switch t.Intent {
22+ case ports.IntentEphemeral:
23+ if t.SessionName == "" {
24+ return nil
25+ }
26+ case ports.IntentNew, ports.IntentAttach, ports.IntentResume:
27+ if t.SessionName == "" {
28+ return domain.ErrInvalidSessionName
29+ }
30+ default:
31+ return fmt.Errorf("invalid session intent %d", t.Intent)
32+ }
33+ return domain.ValidateSessionName(t.SessionName)
34+}
35+
36+// SessionConnection owns one already-open transport and its daemon-facing
37+// session target. Local sockets and remote carriages use this same owner.
38+type SessionConnection struct {
39+ transport ports.Transport
40+ target SessionTarget
41+}
42+
43+// NewSessionConnection creates a connection for an already-selected transport.
44+// Target validation happens before the connection is used for a Hello.
45+func NewSessionConnection(transport ports.Transport, target SessionTarget) (*SessionConnection, error) {
46+ if transport == nil {
47+ return nil, errNilSessionTransport
48+ }
49+ if err := target.validate(); err != nil {
50+ return nil, fmt.Errorf("invalid session target: %w", err)
51+ }
52+ return &SessionConnection{transport: transport, target: target}, nil
53+}
54+
55+// Transport returns the owned carriage for this connection.
56+func (c *SessionConnection) Transport() ports.Transport {
57+ if c == nil {
58+ return nil
59+ }
60+ return c.transport
61+}
62+
63+// Target returns the immutable daemon-facing session selection.
64+func (c *SessionConnection) Target() SessionTarget {
65+ if c == nil {
66+ return SessionTarget{}
67+ }
68+ return c.target
69+}
70+
71+// AttachRequest returns the common request shape used after transport
72+// selection. It contains no remote/proxy semantic.
73+func (c *SessionConnection) AttachRequest() AttachRequest {
74+ if c == nil {
75+ return AttachRequest{}
76+ }
77+ return AttachRequest{Intent: c.target.Intent, SessionName: c.target.SessionName}
78+}
79+
80+// Request is the short form of AttachRequest for connection callers.
81+func (c *SessionConnection) Request() AttachRequest { return c.AttachRequest() }
82+
83+// Close releases the owned transport.
84+func (c *SessionConnection) Close() error {
85+ if c == nil || c.transport == nil {
86+ return nil
87+ }
88+ return c.transport.Close()
89+}
@@ -0,0 +1,89 @@
1+package client
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+var errNilSessionTransport = errors.New("client: nil session transport")
12+
13+// SessionTarget is the validated daemon-facing session selection. Endpoint
14+// details are resolved before a transport is opened and never enter this type.
15+type SessionTarget struct {
16+ Intent uint8
17+ SessionName string
18+}
19+
20+func (t SessionTarget) validate() error {
21+ switch t.Intent {
22+ case ports.IntentEphemeral:
23+ if t.SessionName == "" {
24+ return nil
25+ }
26+ case ports.IntentNew, ports.IntentAttach, ports.IntentResume:
27+ if t.SessionName == "" {
28+ return domain.ErrInvalidSessionName
29+ }
30+ default:
31+ return fmt.Errorf("invalid session intent %d", t.Intent)
32+ }
33+ return domain.ValidateSessionName(t.SessionName)
34+}
35+
36+// SessionConnection owns one already-open transport and its daemon-facing
37+// session target. Local sockets and remote carriages use this same owner.
38+type SessionConnection struct {
39+ transport ports.Transport
40+ target SessionTarget
41+}
42+
43+// NewSessionConnection creates a connection for an already-selected transport.
44+// Target validation happens before the connection is used for a Hello.
45+func NewSessionConnection(transport ports.Transport, target SessionTarget) (*SessionConnection, error) {
46+ if transport == nil {
47+ return nil, errNilSessionTransport
48+ }
49+ if err := target.validate(); err != nil {
50+ return nil, fmt.Errorf("invalid session target: %w", err)
51+ }
52+ return &SessionConnection{transport: transport, target: target}, nil
53+}
54+
55+// Transport returns the owned carriage for this connection.
56+func (c *SessionConnection) Transport() ports.Transport {
57+ if c == nil {
58+ return nil
59+ }
60+ return c.transport
61+}
62+
63+// Target returns the immutable daemon-facing session selection.
64+func (c *SessionConnection) Target() SessionTarget {
65+ if c == nil {
66+ return SessionTarget{}
67+ }
68+ return c.target
69+}
70+
71+// AttachRequest returns the common request shape used after transport
72+// selection. It contains no remote/proxy semantic.
73+func (c *SessionConnection) AttachRequest() AttachRequest {
74+ if c == nil {
75+ return AttachRequest{}
76+ }
77+ return AttachRequest{Intent: c.target.Intent, SessionName: c.target.SessionName}
78+}
79+
80+// Request is the short form of AttachRequest for connection callers.
81+func (c *SessionConnection) Request() AttachRequest { return c.AttachRequest() }
82+
83+// Close releases the owned transport.
84+func (c *SessionConnection) Close() error {
85+ if c == nil || c.transport == nil {
86+ return nil
87+ }
88+ return c.transport.Close()
89+}
A internal/usecase/client/session_connection_test.go +58 -0
@@ -0,0 +1,58 @@
1+package client_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
8+ "github.com/bnema/vev/internal/usecase/client"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestSessionConnectionUsesOneAttachShapeForLocalAndRemoteTransports(t *testing.T) {
13+ target := client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}
14+ local := portsmocks.NewMockTransport(t)
15+ remote := portsmocks.NewMockTransport(t)
16+
17+ localConnection, err := client.NewSessionConnection(local, target)
18+ require.NoError(t, err)
19+ remoteConnection, err := client.NewSessionConnection(remote, target)
20+ require.NoError(t, err)
21+
22+ require.Equal(t, localConnection.AttachRequest(), remoteConnection.AttachRequest())
23+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, localConnection.AttachRequest())
24+ require.Same(t, local, localConnection.Transport())
25+ require.Same(t, remote, remoteConnection.Transport())
26+}
27+
28+func TestSessionTargetValidation(t *testing.T) {
29+ tests := []struct {
30+ name string
31+ target client.SessionTarget
32+ wantErr bool
33+ }{
34+ {name: "ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral}},
35+ {name: "new", target: client.SessionTarget{Intent: ports.IntentNew, SessionName: "work"}},
36+ {name: "attach", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}},
37+ {name: "resume", target: client.SessionTarget{Intent: ports.IntentResume, SessionName: "work"}},
38+ {name: "missing named session", target: client.SessionTarget{Intent: ports.IntentAttach}, wantErr: true},
39+ {name: "unsafe session", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
40+ {name: "unknown intent", target: client.SessionTarget{Intent: 99, SessionName: "work"}, wantErr: true},
41+ }
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ transport := portsmocks.NewMockTransport(t)
45+ _, err := client.NewSessionConnection(transport, tt.target)
46+ if tt.wantErr {
47+ require.Error(t, err)
48+ return
49+ }
50+ require.NoError(t, err)
51+ })
52+ }
53+}
54+
55+func TestSessionConnectionRequiresTransport(t *testing.T) {
56+ _, err := client.NewSessionConnection(nil, client.SessionTarget{Intent: ports.IntentEphemeral})
57+ require.Error(t, err)
58+}
@@ -0,0 +1,58 @@
1+package client_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
8+ "github.com/bnema/vev/internal/usecase/client"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestSessionConnectionUsesOneAttachShapeForLocalAndRemoteTransports(t *testing.T) {
13+ target := client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}
14+ local := portsmocks.NewMockTransport(t)
15+ remote := portsmocks.NewMockTransport(t)
16+
17+ localConnection, err := client.NewSessionConnection(local, target)
18+ require.NoError(t, err)
19+ remoteConnection, err := client.NewSessionConnection(remote, target)
20+ require.NoError(t, err)
21+
22+ require.Equal(t, localConnection.AttachRequest(), remoteConnection.AttachRequest())
23+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, localConnection.AttachRequest())
24+ require.Same(t, local, localConnection.Transport())
25+ require.Same(t, remote, remoteConnection.Transport())
26+}
27+
28+func TestSessionTargetValidation(t *testing.T) {
29+ tests := []struct {
30+ name string
31+ target client.SessionTarget
32+ wantErr bool
33+ }{
34+ {name: "ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral}},
35+ {name: "new", target: client.SessionTarget{Intent: ports.IntentNew, SessionName: "work"}},
36+ {name: "attach", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}},
37+ {name: "resume", target: client.SessionTarget{Intent: ports.IntentResume, SessionName: "work"}},
38+ {name: "missing named session", target: client.SessionTarget{Intent: ports.IntentAttach}, wantErr: true},
39+ {name: "unsafe session", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
40+ {name: "unknown intent", target: client.SessionTarget{Intent: 99, SessionName: "work"}, wantErr: true},
41+ }
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ transport := portsmocks.NewMockTransport(t)
45+ _, err := client.NewSessionConnection(transport, tt.target)
46+ if tt.wantErr {
47+ require.Error(t, err)
48+ return
49+ }
50+ require.NoError(t, err)
51+ })
52+ }
53+}
54+
55+func TestSessionConnectionRequiresTransport(t *testing.T) {
56+ _, err := client.NewSessionConnection(nil, client.SessionTarget{Intent: ports.IntentEphemeral})
57+ require.Error(t, err)
58+}

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 2

Tags 13

↑↓ moveenter openesc close