feat(attach): let the daemon resolve attach-by-name (#291)
* feat(attach): let the daemon resolve attach-by-name After kill --all, vev a <name> offered to create an existing session and then failed to attach: both the create prompt and the attach resolved the name against the broker catalogue, which stays empty until a running daemon is observed, and observation never starts one. - Add the attach-named stream admission (wire code 4). The daemon resolves the name through its existing attach path, restoring the session if needed, and refuses an unknown name with ErrNoSuchSession. - Local and remote vev a <name> now open that admission instead of resolving an exact lifecycle from the catalogue. - The create prompt asks the destination daemon for its sessions with a list that may start it, instead of reading the catalogue. - Remove the local catalogue-based exact attach resolver. * feat(protocol): bump session protocol version to 61 for attach-named admission Older daemons refuse the new attach-named admission code, so peers must negotiate the version that carries it. * fix(picker): let a named attach start a remote host with no daemon * test(daemon): compare attachment membership by pointer to avoid racy deep equality
- commit
- 9386b87235373c20d1ede9141a7e596c343b4567
- author
- bnema · · 2026-09-26 00:59 UTC
- committer
- GitHub · · 2026-09-26 00:59 UTC
- parent
- b3c9196
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
33 files · +547 -237
M internal/adapters/brokerwire/codec.go +6 -2
| @@ -554,8 +554,8 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) { | |||
| 554 | 554 | Env: append([]string(nil), m.Env...), | |
| 555 | 555 | Policy: policyToWire(m.Policy), | |
| 556 | 556 | // The admission taxonomy mirrors the port values (0 none, 1 exact, | |
| 557 | - | // 2 create named, 3 create ephemeral); the name travels only for the | |
| 558 | - | // create-named variant. | |
| 557 | + | // 2 create named, 3 create ephemeral, 4 attach named); the name | |
| 558 | + | // travels only for the two named variants. | |
| 559 | 559 | Admission: admission, Name: m.Name, StartMode: startMode, | |
| 560 | 560 | } | |
| 561 | 561 | if !m.Local { | |
| @@ -581,6 +581,8 @@ func admissionToWire(admission ports.BrokerStreamAdmission) (uint32, error) { | |||
| 581 | 581 | return 2, nil | |
| 582 | 582 | case ports.BrokerAdmissionCreateEphemeral: | |
| 583 | 583 | return 3, nil | |
| 584 | + | case ports.BrokerAdmissionAttachNamed: | |
| 585 | + | return 4, nil | |
| 584 | 586 | default: | |
| 585 | 587 | return 0, errConvertRange | |
| 586 | 588 | } | |
| @@ -597,6 +599,8 @@ func admissionFromWire(value uint32) (ports.BrokerStreamAdmission, error) { | |||
| 597 | 599 | return ports.BrokerAdmissionCreateNamed, nil | |
| 598 | 600 | case 3: | |
| 599 | 601 | return ports.BrokerAdmissionCreateEphemeral, nil | |
| 602 | + | case 4: | |
| 603 | + | return ports.BrokerAdmissionAttachNamed, nil | |
| 600 | 604 | default: | |
| 601 | 605 | return 0, errConvertRange | |
| 602 | 606 | } | |
| @@ -554,8 +554,8 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) { | |||||
| 554 | Env: append([]string(nil), m.Env...), | 554 | Env: append([]string(nil), m.Env...), | ||
| 555 | Policy: policyToWire(m.Policy), | 555 | Policy: policyToWire(m.Policy), | ||
| 556 | // The admission taxonomy mirrors the port values (0 none, 1 exact, | 556 | // The admission taxonomy mirrors the port values (0 none, 1 exact, | ||
| 557 | - | // 2 create named, 3 create ephemeral); the name travels only for the | 557 | + | // 2 create named, 3 create ephemeral, 4 attach named); the name |
| 558 | - | // create-named variant. | 558 | + | // travels only for the two named variants. |
| 559 | Admission: admission, Name: m.Name, StartMode: startMode, | 559 | Admission: admission, Name: m.Name, StartMode: startMode, | ||
| 560 | } | 560 | } | ||
| 561 | if !m.Local { | 561 | if !m.Local { | ||
| @@ -581,6 +581,8 @@ func admissionToWire(admission ports.BrokerStreamAdmission) (uint32, error) { | |||||
| 581 | return 2, nil | 581 | return 2, nil | ||
| 582 | case ports.BrokerAdmissionCreateEphemeral: | 582 | case ports.BrokerAdmissionCreateEphemeral: | ||
| 583 | return 3, nil | 583 | return 3, nil | ||
| 584 | + | case ports.BrokerAdmissionAttachNamed: | |||
| 585 | + | return 4, nil | |||
| 584 | default: | 586 | default: | ||
| 585 | return 0, errConvertRange | 587 | return 0, errConvertRange | ||
| 586 | } | 588 | } | ||
| @@ -597,6 +599,8 @@ func admissionFromWire(value uint32) (ports.BrokerStreamAdmission, error) { | |||||
| 597 | return ports.BrokerAdmissionCreateNamed, nil | 599 | return ports.BrokerAdmissionCreateNamed, nil | ||
| 598 | case 3: | 600 | case 3: | ||
| 599 | return ports.BrokerAdmissionCreateEphemeral, nil | 601 | return ports.BrokerAdmissionCreateEphemeral, nil | ||
| 602 | + | case 4: | |||
| 603 | + | return ports.BrokerAdmissionAttachNamed, nil | |||
| 600 | default: | 604 | default: | ||
| 601 | return 0, errConvertRange | 605 | return 0, errConvertRange | ||
| 602 | } | 606 | } | ||
M internal/adapters/brokerwire/codec_test.go +19 -1
| @@ -412,6 +412,12 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) { | |||
| 412 | 412 | message.Admission = ports.BrokerAdmissionCreateEphemeral | |
| 413 | 413 | return message | |
| 414 | 414 | }()}, | |
| 415 | + | {"attach named", func() OpenStream { | |
| 416 | + | message := base() | |
| 417 | + | message.Admission = ports.BrokerAdmissionAttachNamed | |
| 418 | + | message.Name = "work" | |
| 419 | + | return message | |
| 420 | + | }()}, | |
| 415 | 421 | {"control carries none", OpenStream{ | |
| 416 | 422 | Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl, | |
| 417 | 423 | Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded, | |
| @@ -452,6 +458,18 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) { | |||
| 452 | 458 | m.Admission = ports.BrokerAdmissionCreateNamed | |
| 453 | 459 | return m | |
| 454 | 460 | }(), | |
| 461 | + | "attach named with target": func() OpenStream { | |
| 462 | + | m := base() | |
| 463 | + | m.Admission = ports.BrokerAdmissionAttachNamed | |
| 464 | + | m.Name = "work" | |
| 465 | + | m.Target = testTarget() | |
| 466 | + | return m | |
| 467 | + | }(), | |
| 468 | + | "attach named without name": func() OpenStream { | |
| 469 | + | m := base() | |
| 470 | + | m.Admission = ports.BrokerAdmissionAttachNamed | |
| 471 | + | return m | |
| 472 | + | }(), | |
| 455 | 473 | "ephemeral with name": func() OpenStream { | |
| 456 | 474 | m := base() | |
| 457 | 475 | m.Admission = ports.BrokerAdmissionCreateEphemeral | |
| @@ -484,7 +502,7 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) { | |||
| 484 | 502 | raw, err := EncodeClient(message, testEnvelopeCeiling, testChunkCeiling) | |
| 485 | 503 | require.NoError(t, err) | |
| 486 | 504 | _, err = DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling) | |
| 487 | - | if name == "named with target" { | |
| 505 | + | if name == "named with target" || name == "attach named with target" { | |
| 488 | 506 | // The encoder omits a target on creation; test the untrusted wire directly. | |
| 489 | 507 | envelope := &wire.BrokerClientEnvelope{} | |
| 490 | 508 | require.NoError(t, proto.Unmarshal(raw, envelope)) | |
| @@ -412,6 +412,12 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) { | |||||
| 412 | message.Admission = ports.BrokerAdmissionCreateEphemeral | 412 | message.Admission = ports.BrokerAdmissionCreateEphemeral | ||
| 413 | return message | 413 | return message | ||
| 414 | }()}, | 414 | }()}, | ||
| 415 | + | {"attach named", func() OpenStream { | |||
| 416 | + | message := base() | |||
| 417 | + | message.Admission = ports.BrokerAdmissionAttachNamed | |||
| 418 | + | message.Name = "work" | |||
| 419 | + | return message | |||
| 420 | + | }()}, | |||
| 415 | {"control carries none", OpenStream{ | 421 | {"control carries none", OpenStream{ | ||
| 416 | Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl, | 422 | Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl, | ||
| 417 | Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded, | 423 | Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded, | ||
| @@ -452,6 +458,18 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) { | |||||
| 452 | m.Admission = ports.BrokerAdmissionCreateNamed | 458 | m.Admission = ports.BrokerAdmissionCreateNamed | ||
| 453 | return m | 459 | return m | ||
| 454 | }(), | 460 | }(), | ||
| 461 | + | "attach named with target": func() OpenStream { | |||
| 462 | + | m := base() | |||
| 463 | + | m.Admission = ports.BrokerAdmissionAttachNamed | |||
| 464 | + | m.Name = "work" | |||
| 465 | + | m.Target = testTarget() | |||
| 466 | + | return m | |||
| 467 | + | }(), | |||
| 468 | + | "attach named without name": func() OpenStream { | |||
| 469 | + | m := base() | |||
| 470 | + | m.Admission = ports.BrokerAdmissionAttachNamed | |||
| 471 | + | return m | |||
| 472 | + | }(), | |||
| 455 | "ephemeral with name": func() OpenStream { | 473 | "ephemeral with name": func() OpenStream { | ||
| 456 | m := base() | 474 | m := base() | ||
| 457 | m.Admission = ports.BrokerAdmissionCreateEphemeral | 475 | m.Admission = ports.BrokerAdmissionCreateEphemeral | ||
| @@ -484,7 +502,7 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) { | |||||
| 484 | raw, err := EncodeClient(message, testEnvelopeCeiling, testChunkCeiling) | 502 | raw, err := EncodeClient(message, testEnvelopeCeiling, testChunkCeiling) | ||
| 485 | require.NoError(t, err) | 503 | require.NoError(t, err) | ||
| 486 | _, err = DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling) | 504 | _, err = DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling) | ||
| 487 | - | if name == "named with target" { | 505 | + | if name == "named with target" || name == "attach named with target" { |
| 488 | // The encoder omits a target on creation; test the untrusted wire directly. | 506 | // The encoder omits a target on creation; test the untrusted wire directly. | ||
| 489 | envelope := &wire.BrokerClientEnvelope{} | 507 | envelope := &wire.BrokerClientEnvelope{} | ||
| 490 | require.NoError(t, proto.Unmarshal(raw, envelope)) | 508 | require.NoError(t, proto.Unmarshal(raw, envelope)) | ||
M internal/adapters/daemonmux/admission_conversion_test.go +11 -0
| @@ -67,6 +67,17 @@ func TestOpenMessageCarriesAttachmentAdmission(t *testing.T) { | |||
| 67 | 67 | }(), | |
| 68 | 68 | admission: ports.BrokerAdmissionCreateEphemeral, | |
| 69 | 69 | }, | |
| 70 | + | { | |
| 71 | + | name: "named attach keeps its name", | |
| 72 | + | request: func() ports.BrokerOpenStreamRequest { | |
| 73 | + | r := base | |
| 74 | + | r.Admission = ports.BrokerAdmissionAttachNamed | |
| 75 | + | r.Name = "work" | |
| 76 | + | return r | |
| 77 | + | }(), | |
| 78 | + | admission: ports.BrokerAdmissionAttachNamed, | |
| 79 | + | createAs: "work", | |
| 80 | + | }, | |
| 70 | 81 | } | |
| 71 | 82 | ||
| 72 | 83 | for _, tc := range tests { | |
| @@ -67,6 +67,17 @@ func TestOpenMessageCarriesAttachmentAdmission(t *testing.T) { | |||||
| 67 | }(), | 67 | }(), | ||
| 68 | admission: ports.BrokerAdmissionCreateEphemeral, | 68 | admission: ports.BrokerAdmissionCreateEphemeral, | ||
| 69 | }, | 69 | }, | ||
| 70 | + | { | |||
| 71 | + | name: "named attach keeps its name", | |||
| 72 | + | request: func() ports.BrokerOpenStreamRequest { | |||
| 73 | + | r := base | |||
| 74 | + | r.Admission = ports.BrokerAdmissionAttachNamed | |||
| 75 | + | r.Name = "work" | |||
| 76 | + | return r | |||
| 77 | + | }(), | |||
| 78 | + | admission: ports.BrokerAdmissionAttachNamed, | |||
| 79 | + | createAs: "work", | |||
| 80 | + | }, | |||
| 70 | } | 81 | } | ||
| 71 | 82 | ||||
| 72 | for _, tc := range tests { | 83 | for _, tc := range tests { | ||
M internal/adapters/daemonmux/codec_test.go +14 -0
| @@ -717,6 +717,13 @@ func TestMuxAdmissionVariants(t *testing.T) { | |||
| 717 | 717 | named.Target = protocol.ExactSessionTarget{} | |
| 718 | 718 | require.Equal(t, named, roundTrip(t, named)) | |
| 719 | 719 | }) | |
| 720 | + | t.Run("named attach round-trips", func(t *testing.T) { | |
| 721 | + | named := testOpen() | |
| 722 | + | named.Admission = ports.BrokerAdmissionAttachNamed | |
| 723 | + | named.Name = "work" | |
| 724 | + | named.Target = protocol.ExactSessionTarget{} | |
| 725 | + | require.Equal(t, named, roundTrip(t, named)) | |
| 726 | + | }) | |
| 720 | 727 | t.Run("ephemeral creation round-trips", func(t *testing.T) { | |
| 721 | 728 | ephemeral := testOpen() | |
| 722 | 729 | ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral | |
| @@ -843,6 +850,10 @@ func TestMuxAdmissionWireRange(t *testing.T) { | |||
| 843 | 850 | ephemeral := testOpen() | |
| 844 | 851 | ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral | |
| 845 | 852 | ephemeral.Target = protocol.ExactSessionTarget{} | |
| 853 | + | attachNamed := testOpen() | |
| 854 | + | attachNamed.Admission = ports.BrokerAdmissionAttachNamed | |
| 855 | + | attachNamed.Name = "work" | |
| 856 | + | attachNamed.Target = protocol.ExactSessionTarget{} | |
| 846 | 857 | ||
| 847 | 858 | tests := []struct { | |
| 848 | 859 | name string | |
| @@ -854,6 +865,9 @@ func TestMuxAdmissionWireRange(t *testing.T) { | |||
| 854 | 865 | {"exact admission", testOpen(), uint32(ports.BrokerAdmissionExact), false}, | |
| 855 | 866 | {"create-named admission", named, uint32(ports.BrokerAdmissionCreateNamed), false}, | |
| 856 | 867 | {"create-ephemeral admission", ephemeral, uint32(ports.BrokerAdmissionCreateEphemeral), false}, | |
| 868 | + | {"attach-named admission", attachNamed, uint32(ports.BrokerAdmissionAttachNamed), false}, | |
| 869 | + | {"wire value 5 past the closed set", attachNamed, 5, true}, | |
| 870 | + | {"wire value 260 aliasing attach-named", attachNamed, 260, true}, | |
| 857 | 871 | {"wire value 257 aliasing exact", testOpen(), 257, true}, | |
| 858 | 872 | {"wire value 258 aliasing create-named", named, 258, true}, | |
| 859 | 873 | {"wire value 1024 aliasing no admission", control, 1024, true}, | |
| @@ -717,6 +717,13 @@ func TestMuxAdmissionVariants(t *testing.T) { | |||||
| 717 | named.Target = protocol.ExactSessionTarget{} | 717 | named.Target = protocol.ExactSessionTarget{} | ||
| 718 | require.Equal(t, named, roundTrip(t, named)) | 718 | require.Equal(t, named, roundTrip(t, named)) | ||
| 719 | }) | 719 | }) | ||
| 720 | + | t.Run("named attach round-trips", func(t *testing.T) { | |||
| 721 | + | named := testOpen() | |||
| 722 | + | named.Admission = ports.BrokerAdmissionAttachNamed | |||
| 723 | + | named.Name = "work" | |||
| 724 | + | named.Target = protocol.ExactSessionTarget{} | |||
| 725 | + | require.Equal(t, named, roundTrip(t, named)) | |||
| 726 | + | }) | |||
| 720 | t.Run("ephemeral creation round-trips", func(t *testing.T) { | 727 | t.Run("ephemeral creation round-trips", func(t *testing.T) { | ||
| 721 | ephemeral := testOpen() | 728 | ephemeral := testOpen() | ||
| 722 | ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral | 729 | ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral | ||
| @@ -843,6 +850,10 @@ func TestMuxAdmissionWireRange(t *testing.T) { | |||||
| 843 | ephemeral := testOpen() | 850 | ephemeral := testOpen() | ||
| 844 | ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral | 851 | ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral | ||
| 845 | ephemeral.Target = protocol.ExactSessionTarget{} | 852 | ephemeral.Target = protocol.ExactSessionTarget{} | ||
| 853 | + | attachNamed := testOpen() | |||
| 854 | + | attachNamed.Admission = ports.BrokerAdmissionAttachNamed | |||
| 855 | + | attachNamed.Name = "work" | |||
| 856 | + | attachNamed.Target = protocol.ExactSessionTarget{} | |||
| 846 | 857 | ||||
| 847 | tests := []struct { | 858 | tests := []struct { | ||
| 848 | name string | 859 | name string | ||
| @@ -854,6 +865,9 @@ func TestMuxAdmissionWireRange(t *testing.T) { | |||||
| 854 | {"exact admission", testOpen(), uint32(ports.BrokerAdmissionExact), false}, | 865 | {"exact admission", testOpen(), uint32(ports.BrokerAdmissionExact), false}, | ||
| 855 | {"create-named admission", named, uint32(ports.BrokerAdmissionCreateNamed), false}, | 866 | {"create-named admission", named, uint32(ports.BrokerAdmissionCreateNamed), false}, | ||
| 856 | {"create-ephemeral admission", ephemeral, uint32(ports.BrokerAdmissionCreateEphemeral), false}, | 867 | {"create-ephemeral admission", ephemeral, uint32(ports.BrokerAdmissionCreateEphemeral), false}, | ||
| 868 | + | {"attach-named admission", attachNamed, uint32(ports.BrokerAdmissionAttachNamed), false}, | |||
| 869 | + | {"wire value 5 past the closed set", attachNamed, 5, true}, | |||
| 870 | + | {"wire value 260 aliasing attach-named", attachNamed, 260, true}, | |||
| 857 | {"wire value 257 aliasing exact", testOpen(), 257, true}, | 871 | {"wire value 257 aliasing exact", testOpen(), 257, true}, | ||
| 858 | {"wire value 258 aliasing create-named", named, 258, true}, | 872 | {"wire value 258 aliasing create-named", named, 258, true}, | ||
| 859 | {"wire value 1024 aliasing no admission", control, 1024, true}, | 873 | {"wire value 1024 aliasing no admission", control, 1024, true}, | ||
M internal/app/attach_missing_prompt.go +44 -31
| @@ -7,8 +7,10 @@ import ( | |||
| 7 | 7 | "os" | |
| 8 | 8 | "sync" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 10 | 11 | "github.com/bnema/vev/internal/ports" | |
| 11 | 12 | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 12 | 14 | "github.com/bnema/vev/internal/usecase/confirm" | |
| 13 | 15 | "github.com/bnema/vev/pkg/rawterm" | |
| 14 | 16 | ) | |
| @@ -50,21 +52,19 @@ func (w flushingWriter) Write(data []byte) (int, error) { | |||
| 50 | 52 | return written, nil | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 53 | - | // resolveAttachCreationIntent decides the attach intent for one direct exact | |
| 55 | + | // resolveAttachCreationIntent decides the attach intent for one direct | |
| 54 | 56 | // `attach <name>` target (local when remoteTarget is empty, otherwise a | |
| 55 | 57 | // configured remote host) before the client owns any console reader or raw | |
| 56 | - | // mode. It reuses the broker's own attach resolution (the same exact-target | |
| 57 | - | // lookup `localExactAttachResolver`/`remoteExactAttachResolver` run later) so | |
| 58 | - | // existence is decided from one committed broker publication, never from | |
| 59 | - | // error-text matching. | |
| 58 | + | // mode. Existence is asked of the destination daemon itself through a list | |
| 59 | + | // that may start it, never read from the broker catalogue: right after | |
| 60 | + | // `kill --all` the catalogue is empty while the daemon still restores the | |
| 61 | + | // session from disk. | |
| 60 | 62 | // | |
| 61 | - | // When the session exists, the host is not configured, or existence cannot be | |
| 62 | - | // determined (the broker is unreachable), it keeps IntentAttach unchanged and | |
| 63 | - | // the resolver's own rejection, if any, surfaces unchanged later. When the | |
| 64 | - | // session is confirmed absent on a known host, it offers creation on an | |
| 65 | - | // interactive console and returns IntentNew on confirmation; a declined | |
| 66 | - | // answer, an unusable console, or a non-interactive console all keep | |
| 67 | - | // IntentAttach so the existing resolver reports its own clear refusal. | |
| 63 | + | // When the session exists, the host is not configured, or the daemon cannot | |
| 64 | + | // answer, it keeps IntentAttach; the named attach then lets the daemon decide | |
| 65 | + | // and report its own refusal. Only a daemon listing without the name offers | |
| 66 | + | // creation on an interactive console, returning IntentNew on confirmation; a | |
| 67 | + | // declined answer or a non-interactive console keeps IntentAttach. | |
| 68 | 68 | // | |
| 69 | 69 | // The check runs before Supervisor.Run enters raw mode, so the create prompt | |
| 70 | 70 | // is always the sole console reader: no attachment worker can leave a | |
| @@ -85,8 +85,7 @@ func resolveAttachCreationIntent(ctx context.Context, intent uint8, name, remote | |||
| 85 | 85 | // cadence) decide, exactly as if this preflight had never run. | |
| 86 | 86 | return protocol.IntentAttach, nil, nil | |
| 87 | 87 | } | |
| 88 | - | exists, hostKnown := attachTargetSessionExists(service.Snapshot(), remoteTarget, name) | |
| 89 | - | if exists || !hostKnown { | |
| 88 | + | if !daemonReportsSessionMissing(ctx, service, remoteTarget, name) { | |
| 90 | 89 | return protocol.IntentAttach, service, nil | |
| 91 | 90 | } | |
| 92 | 91 | resolved, err := confirmMissingSessionCreate(ctx, name, terminal) | |
| @@ -97,25 +96,39 @@ func resolveAttachCreationIntent(ctx context.Context, intent uint8, name, remote | |||
| 97 | 96 | return resolved, service, nil | |
| 98 | 97 | } | |
| 99 | 98 | ||
| 100 | - | // attachTargetSessionExists reports whether one exact-name attach target is | |
| 101 | - | // present in the committed publication, and whether the target's daemon (the | |
| 102 | - | // local daemon, or the named remote host) is even a configured observation. | |
| 103 | - | // hostKnown is false only when the daemon itself is absent from the snapshot; | |
| 104 | - | // a known daemon with no matching session name reports exists=false, | |
| 105 | - | // hostKnown=true. | |
| 106 | - | func attachTargetSessionExists(snapshot ports.BrokerSnapshot, remoteTarget, name string) (exists, hostKnown bool) { | |
| 107 | - | var observation ports.BrokerDaemonObservation | |
| 108 | - | var ok bool | |
| 109 | - | if remoteTarget == "" { | |
| 110 | - | observation, ok = localBrokerObservation(snapshot) | |
| 111 | - | } else { | |
| 112 | - | observation, ok = snapshot.Find(remoteTarget) | |
| 99 | + | // attachSessionLister is the daemon listing seam; production starts the | |
| 100 | + | // destination daemon if needed so restored sessions are included. | |
| 101 | + | var attachSessionLister = func(ctx context.Context, service ports.BrokerService, route client.BrokerOperationRoute) ([]protocol.SessionInfo, error) { | |
| 102 | + | operations, err := client.NewBrokerOperations(service, clock.New()) | |
| 103 | + | if err != nil { | |
| 104 | + | return nil, err | |
| 105 | + | } | |
| 106 | + | return operations.ListStarting(ctx, route) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // daemonReportsSessionMissing reports true only when the destination daemon | |
| 110 | + | // answered a listing that does not carry name. An unconfigured host or a | |
| 111 | + | // failed listing proves nothing, so it reports false. | |
| 112 | + | func daemonReportsSessionMissing(ctx context.Context, service ports.BrokerService, remoteTarget, name string) bool { | |
| 113 | + | snapshot := service.Snapshot() | |
| 114 | + | route := client.BrokerOperationRoute{Epoch: snapshot.Epoch, Destination: ports.BrokerEndpointFence{Local: true}} | |
| 115 | + | if remoteTarget != "" { | |
| 116 | + | observation, ok := snapshot.Find(remoteTarget) | |
| 117 | + | if !ok { | |
| 118 | + | return false | |
| 119 | + | } | |
| 120 | + | route.Destination = ports.BrokerEndpointFence{Registration: observation.Registration} | |
| 113 | 121 | } | |
| 114 | - | if !ok { | |
| 115 | - | return false, false | |
| 122 | + | sessions, err := attachSessionLister(ctx, service, route) | |
| 123 | + | if err != nil { | |
| 124 | + | return false | |
| 125 | + | } | |
| 126 | + | for _, session := range sessions { | |
| 127 | + | if session.Name == name { | |
| 128 | + | return false | |
| 129 | + | } | |
| 116 | 130 | } | |
| 117 | - | _, found := brokerObservationExactTarget(observation, name) | |
| 118 | - | return found, true | |
| 131 | + | return true | |
| 119 | 132 | } | |
| 120 | 133 | ||
| 121 | 134 | // confirmMissingSessionCreate prompts to create an absent session and maps | |
| @@ -7,8 +7,10 @@ import ( | |||||
| 7 | "os" | 7 | "os" | ||
| 8 | "sync" | 8 | "sync" | ||
| 9 | 9 | ||||
| 10 | + | "github.com/bnema/vev/internal/adapters/clock" | |||
| 10 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 11 | "github.com/bnema/vev/internal/protocol" | 12 | "github.com/bnema/vev/internal/protocol" | ||
| 13 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 12 | "github.com/bnema/vev/internal/usecase/confirm" | 14 | "github.com/bnema/vev/internal/usecase/confirm" | ||
| 13 | "github.com/bnema/vev/pkg/rawterm" | 15 | "github.com/bnema/vev/pkg/rawterm" | ||
| 14 | ) | 16 | ) | ||
| @@ -50,21 +52,19 @@ func (w flushingWriter) Write(data []byte) (int, error) { | |||||
| 50 | return written, nil | 52 | return written, nil | ||
| 51 | } | 53 | } | ||
| 52 | 54 | ||||
| 53 | - | // resolveAttachCreationIntent decides the attach intent for one direct exact | 55 | + | // resolveAttachCreationIntent decides the attach intent for one direct |
| 54 | // `attach <name>` target (local when remoteTarget is empty, otherwise a | 56 | // `attach <name>` target (local when remoteTarget is empty, otherwise a | ||
| 55 | // configured remote host) before the client owns any console reader or raw | 57 | // configured remote host) before the client owns any console reader or raw | ||
| 56 | - | // mode. It reuses the broker's own attach resolution (the same exact-target | 58 | + | // mode. Existence is asked of the destination daemon itself through a list |
| 57 | - | // lookup `localExactAttachResolver`/`remoteExactAttachResolver` run later) so | 59 | + | // that may start it, never read from the broker catalogue: right after |
| 58 | - | // existence is decided from one committed broker publication, never from | 60 | + | // `kill --all` the catalogue is empty while the daemon still restores the |
| 59 | - | // error-text matching. | 61 | + | // session from disk. |
| 60 | // | 62 | // | ||
| 61 | - | // When the session exists, the host is not configured, or existence cannot be | 63 | + | // When the session exists, the host is not configured, or the daemon cannot |
| 62 | - | // determined (the broker is unreachable), it keeps IntentAttach unchanged and | 64 | + | // answer, it keeps IntentAttach; the named attach then lets the daemon decide |
| 63 | - | // the resolver's own rejection, if any, surfaces unchanged later. When the | 65 | + | // and report its own refusal. Only a daemon listing without the name offers |
| 64 | - | // session is confirmed absent on a known host, it offers creation on an | 66 | + | // creation on an interactive console, returning IntentNew on confirmation; a |
| 65 | - | // interactive console and returns IntentNew on confirmation; a declined | 67 | + | // declined answer or a non-interactive console keeps IntentAttach. |
| 66 | - | // answer, an unusable console, or a non-interactive console all keep | |||
| 67 | - | // IntentAttach so the existing resolver reports its own clear refusal. | |||
| 68 | // | 68 | // | ||
| 69 | // The check runs before Supervisor.Run enters raw mode, so the create prompt | 69 | // The check runs before Supervisor.Run enters raw mode, so the create prompt | ||
| 70 | // is always the sole console reader: no attachment worker can leave a | 70 | // is always the sole console reader: no attachment worker can leave a | ||
| @@ -85,8 +85,7 @@ func resolveAttachCreationIntent(ctx context.Context, intent uint8, name, remote | |||||
| 85 | // cadence) decide, exactly as if this preflight had never run. | 85 | // cadence) decide, exactly as if this preflight had never run. | ||
| 86 | return protocol.IntentAttach, nil, nil | 86 | return protocol.IntentAttach, nil, nil | ||
| 87 | } | 87 | } | ||
| 88 | - | exists, hostKnown := attachTargetSessionExists(service.Snapshot(), remoteTarget, name) | 88 | + | if !daemonReportsSessionMissing(ctx, service, remoteTarget, name) { |
| 89 | - | if exists || !hostKnown { | |||
| 90 | return protocol.IntentAttach, service, nil | 89 | return protocol.IntentAttach, service, nil | ||
| 91 | } | 90 | } | ||
| 92 | resolved, err := confirmMissingSessionCreate(ctx, name, terminal) | 91 | resolved, err := confirmMissingSessionCreate(ctx, name, terminal) | ||
| @@ -97,25 +96,39 @@ func resolveAttachCreationIntent(ctx context.Context, intent uint8, name, remote | |||||
| 97 | return resolved, service, nil | 96 | return resolved, service, nil | ||
| 98 | } | 97 | } | ||
| 99 | 98 | ||||
| 100 | - | // attachTargetSessionExists reports whether one exact-name attach target is | 99 | + | // attachSessionLister is the daemon listing seam; production starts the |
| 101 | - | // present in the committed publication, and whether the target's daemon (the | 100 | + | // destination daemon if needed so restored sessions are included. |
| 102 | - | // local daemon, or the named remote host) is even a configured observation. | 101 | + | var attachSessionLister = func(ctx context.Context, service ports.BrokerService, route client.BrokerOperationRoute) ([]protocol.SessionInfo, error) { |
| 103 | - | // hostKnown is false only when the daemon itself is absent from the snapshot; | 102 | + | operations, err := client.NewBrokerOperations(service, clock.New()) |
| 104 | - | // a known daemon with no matching session name reports exists=false, | 103 | + | if err != nil { |
| 105 | - | // hostKnown=true. | 104 | + | return nil, err |
| 106 | - | func attachTargetSessionExists(snapshot ports.BrokerSnapshot, remoteTarget, name string) (exists, hostKnown bool) { | 105 | + | } |
| 107 | - | var observation ports.BrokerDaemonObservation | 106 | + | return operations.ListStarting(ctx, route) |
| 108 | - | var ok bool | 107 | + | } |
| 109 | - | if remoteTarget == "" { | 108 | + | |
| 110 | - | observation, ok = localBrokerObservation(snapshot) | 109 | + | // daemonReportsSessionMissing reports true only when the destination daemon |
| 111 | - | } else { | 110 | + | // answered a listing that does not carry name. An unconfigured host or a |
| 112 | - | observation, ok = snapshot.Find(remoteTarget) | 111 | + | // failed listing proves nothing, so it reports false. |
| 112 | + | func daemonReportsSessionMissing(ctx context.Context, service ports.BrokerService, remoteTarget, name string) bool { | |||
| 113 | + | snapshot := service.Snapshot() | |||
| 114 | + | route := client.BrokerOperationRoute{Epoch: snapshot.Epoch, Destination: ports.BrokerEndpointFence{Local: true}} | |||
| 115 | + | if remoteTarget != "" { | |||
| 116 | + | observation, ok := snapshot.Find(remoteTarget) | |||
| 117 | + | if !ok { | |||
| 118 | + | return false | |||
| 119 | + | } | |||
| 120 | + | route.Destination = ports.BrokerEndpointFence{Registration: observation.Registration} | |||
| 113 | } | 121 | } | ||
| 114 | - | if !ok { | 122 | + | sessions, err := attachSessionLister(ctx, service, route) |
| 115 | - | return false, false | 123 | + | if err != nil { |
| 124 | + | return false | |||
| 125 | + | } | |||
| 126 | + | for _, session := range sessions { | |||
| 127 | + | if session.Name == name { | |||
| 128 | + | return false | |||
| 129 | + | } | |||
| 116 | } | 130 | } | ||
| 117 | - | _, found := brokerObservationExactTarget(observation, name) | 131 | + | return true |
| 118 | - | return found, true | |||
| 119 | } | 132 | } | ||
| 120 | 133 | ||||
| 121 | // confirmMissingSessionCreate prompts to create an absent session and maps | 134 | // confirmMissingSessionCreate prompts to create an absent session and maps | ||
M internal/app/attach_missing_prompt_test.go +67 -62
| @@ -6,11 +6,10 @@ package app | |||
| 6 | 6 | // | |
| 7 | 7 | // resolveAttachCreationIntent restores the `vev attach <missing>` create | |
| 8 | 8 | // prompt main once ran through the direct-dial preflight, but through the | |
| 9 | - | // broker: it peeks the first committed publication over the same | |
| 10 | - | // connectProductionClientBroker seam the ordinary terminal composition uses, | |
| 11 | - | // decides existence with the exact lookup the later resolver also runs | |
| 12 | - | // (attachTargetSessionExists / brokerObservationExactTarget), and on a | |
| 13 | - | // confirmed create hands its already-open connection back so the run still | |
| 9 | + | // broker: it connects over the same connectProductionClientBroker seam the | |
| 10 | + | // ordinary terminal composition uses, asks the destination daemon for its | |
| 11 | + | // session list (daemonReportsSessionMissing, starting it if needed), and on a | |
| 12 | + | // definite answer hands its already-open connection back so the run still | |
| 14 | 13 | // opens exactly one broker connection. | |
| 15 | 14 | ||
| 16 | 15 | import ( | |
| @@ -30,6 +29,7 @@ import ( | |||
| 30 | 29 | "github.com/bnema/vev/internal/ports" | |
| 31 | 30 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 32 | 31 | "github.com/bnema/vev/internal/protocol" | |
| 32 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 33 | 33 | "github.com/bnema/vev/pkg/rawterm" | |
| 34 | 34 | ) | |
| 35 | 35 | ||
| @@ -59,66 +59,68 @@ func withPreflightBroker(t *testing.T, connect func(context.Context) (ports.Brok | |||
| 59 | 59 | t.Cleanup(func() { connectProductionClientBroker = previous }) | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | - | func TestAttachTargetSessionExists(t *testing.T) { | |
| 62 | + | // withDaemonSessions installs a scripted daemon listing: the destination named | |
| 63 | + | // by the route answers with sessions, or listErr when set. The broker | |
| 64 | + | // catalogue is deliberately not consulted, matching production. | |
| 65 | + | func withDaemonSessions(t *testing.T, sessions map[string][]string, listErr error) *[]client.BrokerOperationRoute { | |
| 66 | + | t.Helper() | |
| 67 | + | var routes []client.BrokerOperationRoute | |
| 68 | + | previous := attachSessionLister | |
| 69 | + | attachSessionLister = func(_ context.Context, _ ports.BrokerService, route client.BrokerOperationRoute) ([]protocol.SessionInfo, error) { | |
| 70 | + | routes = append(routes, route) | |
| 71 | + | if listErr != nil { | |
| 72 | + | return nil, listErr | |
| 73 | + | } | |
| 74 | + | key := route.Destination.Registration.Endpoint | |
| 75 | + | var out []protocol.SessionInfo | |
| 76 | + | for _, name := range sessions[key] { | |
| 77 | + | out = append(out, protocol.SessionInfo{Name: name}) | |
| 78 | + | } | |
| 79 | + | return out, nil | |
| 80 | + | } | |
| 81 | + | t.Cleanup(func() { attachSessionLister = previous }) | |
| 82 | + | return &routes | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestDaemonReportsSessionMissing(t *testing.T) { | |
| 86 | + | remote := "user@example.test" | |
| 63 | 87 | tests := []struct { | |
| 64 | - | name string | |
| 65 | - | snapshot ports.BrokerSnapshot | |
| 66 | - | remote string | |
| 67 | - | target string | |
| 68 | - | wantExists bool | |
| 69 | - | wantHostKnow bool | |
| 88 | + | name string | |
| 89 | + | snapshot ports.BrokerSnapshot | |
| 90 | + | daemon map[string][]string // listing per endpoint; "" is local | |
| 91 | + | listErr error | |
| 92 | + | remote string | |
| 93 | + | target string | |
| 94 | + | wantMissing bool | |
| 95 | + | wantListed bool | |
| 70 | 96 | }{ | |
| 71 | - | { | |
| 72 | - | name: "local session present", | |
| 73 | - | snapshot: terminalCompositionSnapshot([]string{"work"}, nil), | |
| 74 | - | target: "work", | |
| 75 | - | wantExists: true, | |
| 76 | - | wantHostKnow: true, | |
| 77 | - | }, | |
| 78 | - | { | |
| 79 | - | name: "local session absent", | |
| 80 | - | snapshot: terminalCompositionSnapshot([]string{"work"}, nil), | |
| 81 | - | target: "gone", | |
| 82 | - | wantExists: false, | |
| 83 | - | wantHostKnow: true, | |
| 84 | - | }, | |
| 85 | - | { | |
| 86 | - | name: "local daemon absent from snapshot", | |
| 87 | - | snapshot: ports.BrokerSnapshot{Epoch: 1, Revision: 1}, | |
| 88 | - | target: "gone", | |
| 89 | - | wantExists: false, | |
| 90 | - | wantHostKnow: false, | |
| 91 | - | }, | |
| 92 | - | { | |
| 93 | - | name: "remote session present", | |
| 94 | - | snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}), | |
| 95 | - | remote: "user@example.test", | |
| 96 | - | target: "work", | |
| 97 | - | wantExists: true, | |
| 98 | - | wantHostKnow: true, | |
| 99 | - | }, | |
| 100 | - | { | |
| 101 | - | name: "remote session absent on known host", | |
| 102 | - | snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}), | |
| 103 | - | remote: "user@example.test", | |
| 104 | - | target: "gone", | |
| 105 | - | wantExists: false, | |
| 106 | - | wantHostKnow: true, | |
| 107 | - | }, | |
| 108 | - | { | |
| 109 | - | name: "remote host not configured", | |
| 110 | - | snapshot: terminalCompositionSnapshot([]string{"work"}, nil), | |
| 111 | - | remote: "user@elsewhere.test", | |
| 112 | - | target: "work", | |
| 113 | - | wantExists: false, | |
| 114 | - | wantHostKnow: false, | |
| 115 | - | }, | |
| 97 | + | {name: "local session present", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"work"}}, target: "work", wantListed: true}, | |
| 98 | + | {name: "local session absent", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"work"}}, target: "gone", wantMissing: true, wantListed: true}, | |
| 99 | + | {name: "catalogue empty but daemon restored it", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"vev"}}, target: "vev", wantListed: true}, | |
| 100 | + | {name: "daemon listing failed proves nothing", snapshot: terminalCompositionSnapshot(nil, nil), listErr: errors.New("unreachable"), target: "gone", wantListed: true}, | |
| 101 | + | {name: "remote session present", snapshot: terminalCompositionSnapshot(nil, map[string][]string{remote: nil}), daemon: map[string][]string{remote: {"work"}}, remote: remote, target: "work", wantListed: true}, | |
| 102 | + | {name: "remote session absent", snapshot: terminalCompositionSnapshot(nil, map[string][]string{remote: nil}), daemon: map[string][]string{remote: {"work"}}, remote: remote, target: "gone", wantMissing: true, wantListed: true}, | |
| 103 | + | {name: "remote host not configured", snapshot: terminalCompositionSnapshot(nil, nil), remote: "user@elsewhere.test", target: "work"}, | |
| 116 | 104 | } | |
| 117 | 105 | for _, tc := range tests { | |
| 118 | 106 | t.Run(tc.name, func(t *testing.T) { | |
| 119 | - | exists, hostKnown := attachTargetSessionExists(tc.snapshot, tc.remote, tc.target) | |
| 120 | - | require.Equal(t, tc.wantExists, exists) | |
| 121 | - | require.Equal(t, tc.wantHostKnow, hostKnown) | |
| 107 | + | routes := withDaemonSessions(t, tc.daemon, tc.listErr) | |
| 108 | + | service := portsmocks.NewMockBrokerService(t) | |
| 109 | + | service.EXPECT().Snapshot().Return(tc.snapshot).Once() | |
| 110 | + | ||
| 111 | + | require.Equal(t, tc.wantMissing, daemonReportsSessionMissing(context.Background(), service, tc.remote, tc.target)) | |
| 112 | + | if !tc.wantListed { | |
| 113 | + | require.Empty(t, *routes, "an unconfigured host is never listed") | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | require.Len(t, *routes, 1) | |
| 117 | + | route := (*routes)[0] | |
| 118 | + | require.Equal(t, tc.snapshot.Epoch, route.Epoch) | |
| 119 | + | require.Equal(t, tc.remote == "", route.Destination.Local) | |
| 120 | + | if tc.remote != "" { | |
| 121 | + | observation, _ := tc.snapshot.Find(tc.remote) | |
| 122 | + | require.True(t, route.Destination.Registration.Equal(observation.Registration)) | |
| 123 | + | } | |
| 122 | 124 | }) | |
| 123 | 125 | } | |
| 124 | 126 | } | |
| @@ -160,7 +162,8 @@ func TestResolveAttachCreationIntentBrokerUnavailableKeepsAttach(t *testing.T) { | |||
| 160 | 162 | ||
| 161 | 163 | func TestResolveAttachCreationIntentExistingSessionAttachesWithoutPrompt(t *testing.T) { | |
| 162 | 164 | service := portsmocks.NewMockBrokerService(t) | |
| 163 | - | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once() | |
| 165 | + | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot(nil, nil)).Once() | |
| 166 | + | withDaemonSessions(t, map[string][]string{"": {"work"}}, nil) | |
| 164 | 167 | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | |
| 165 | 168 | withAttachInteractiveConsole(t, true) // must not even be consulted | |
| 166 | 169 | ||
| @@ -209,6 +212,7 @@ func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) { | |||
| 209 | 212 | } | |
| 210 | 213 | service := portsmocks.NewMockBrokerService(t) | |
| 211 | 214 | service.EXPECT().Snapshot().Return(snapshot).Once() | |
| 215 | + | withDaemonSessions(t, map[string][]string{tc.remote: {"work"}}, nil) | |
| 212 | 216 | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | |
| 213 | 217 | withAttachInteractiveConsole(t, tc.interactive) | |
| 214 | 218 | ||
| @@ -234,8 +238,9 @@ func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) { | |||
| 234 | 238 | ||
| 235 | 239 | func TestResolveAttachCreationIntentCancelledPromptClosesThePeek(t *testing.T) { | |
| 236 | 240 | service := portsmocks.NewMockBrokerService(t) | |
| 237 | - | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once() | |
| 241 | + | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot(nil, nil)).Once() | |
| 238 | 242 | service.EXPECT().Close().Return(nil).Once() | |
| 243 | + | withDaemonSessions(t, nil, nil) | |
| 239 | 244 | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | |
| 240 | 245 | withAttachInteractiveConsole(t, true) | |
| 241 | 246 | ||
| @@ -6,11 +6,10 @@ package app | |||||
| 6 | // | 6 | // | ||
| 7 | // resolveAttachCreationIntent restores the `vev attach <missing>` create | 7 | // resolveAttachCreationIntent restores the `vev attach <missing>` create | ||
| 8 | // prompt main once ran through the direct-dial preflight, but through the | 8 | // prompt main once ran through the direct-dial preflight, but through the | ||
| 9 | - | // broker: it peeks the first committed publication over the same | 9 | + | // broker: it connects over the same connectProductionClientBroker seam the |
| 10 | - | // connectProductionClientBroker seam the ordinary terminal composition uses, | 10 | + | // ordinary terminal composition uses, asks the destination daemon for its |
| 11 | - | // decides existence with the exact lookup the later resolver also runs | 11 | + | // session list (daemonReportsSessionMissing, starting it if needed), and on a |
| 12 | - | // (attachTargetSessionExists / brokerObservationExactTarget), and on a | 12 | + | // definite answer hands its already-open connection back so the run still |
| 13 | - | // confirmed create hands its already-open connection back so the run still | |||
| 14 | // opens exactly one broker connection. | 13 | // opens exactly one broker connection. | ||
| 15 | 14 | ||||
| 16 | import ( | 15 | import ( | ||
| @@ -30,6 +29,7 @@ import ( | |||||
| 30 | "github.com/bnema/vev/internal/ports" | 29 | "github.com/bnema/vev/internal/ports" | ||
| 31 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 30 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 32 | "github.com/bnema/vev/internal/protocol" | 31 | "github.com/bnema/vev/internal/protocol" | ||
| 32 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 33 | "github.com/bnema/vev/pkg/rawterm" | 33 | "github.com/bnema/vev/pkg/rawterm" | ||
| 34 | ) | 34 | ) | ||
| 35 | 35 | ||||
| @@ -59,66 +59,68 @@ func withPreflightBroker(t *testing.T, connect func(context.Context) (ports.Brok | |||||
| 59 | t.Cleanup(func() { connectProductionClientBroker = previous }) | 59 | t.Cleanup(func() { connectProductionClientBroker = previous }) | ||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | - | func TestAttachTargetSessionExists(t *testing.T) { | 62 | + | // withDaemonSessions installs a scripted daemon listing: the destination named |
| 63 | + | // by the route answers with sessions, or listErr when set. The broker | |||
| 64 | + | // catalogue is deliberately not consulted, matching production. | |||
| 65 | + | func withDaemonSessions(t *testing.T, sessions map[string][]string, listErr error) *[]client.BrokerOperationRoute { | |||
| 66 | + | t.Helper() | |||
| 67 | + | var routes []client.BrokerOperationRoute | |||
| 68 | + | previous := attachSessionLister | |||
| 69 | + | attachSessionLister = func(_ context.Context, _ ports.BrokerService, route client.BrokerOperationRoute) ([]protocol.SessionInfo, error) { | |||
| 70 | + | routes = append(routes, route) | |||
| 71 | + | if listErr != nil { | |||
| 72 | + | return nil, listErr | |||
| 73 | + | } | |||
| 74 | + | key := route.Destination.Registration.Endpoint | |||
| 75 | + | var out []protocol.SessionInfo | |||
| 76 | + | for _, name := range sessions[key] { | |||
| 77 | + | out = append(out, protocol.SessionInfo{Name: name}) | |||
| 78 | + | } | |||
| 79 | + | return out, nil | |||
| 80 | + | } | |||
| 81 | + | t.Cleanup(func() { attachSessionLister = previous }) | |||
| 82 | + | return &routes | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestDaemonReportsSessionMissing(t *testing.T) { | |||
| 86 | + | remote := "user@example.test" | |||
| 63 | tests := []struct { | 87 | tests := []struct { | ||
| 64 | - | name string | 88 | + | name string |
| 65 | - | snapshot ports.BrokerSnapshot | 89 | + | snapshot ports.BrokerSnapshot |
| 66 | - | remote string | 90 | + | daemon map[string][]string // listing per endpoint; "" is local |
| 67 | - | target string | 91 | + | listErr error |
| 68 | - | wantExists bool | 92 | + | remote string |
| 69 | - | wantHostKnow bool | 93 | + | target string |
| 94 | + | wantMissing bool | |||
| 95 | + | wantListed bool | |||
| 70 | }{ | 96 | }{ | ||
| 71 | - | { | 97 | + | {name: "local session present", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"work"}}, target: "work", wantListed: true}, |
| 72 | - | name: "local session present", | 98 | + | {name: "local session absent", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"work"}}, target: "gone", wantMissing: true, wantListed: true}, |
| 73 | - | snapshot: terminalCompositionSnapshot([]string{"work"}, nil), | 99 | + | {name: "catalogue empty but daemon restored it", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"vev"}}, target: "vev", wantListed: true}, |
| 74 | - | target: "work", | 100 | + | {name: "daemon listing failed proves nothing", snapshot: terminalCompositionSnapshot(nil, nil), listErr: errors.New("unreachable"), target: "gone", wantListed: true}, |
| 75 | - | wantExists: true, | 101 | + | {name: "remote session present", snapshot: terminalCompositionSnapshot(nil, map[string][]string{remote: nil}), daemon: map[string][]string{remote: {"work"}}, remote: remote, target: "work", wantListed: true}, |
| 76 | - | wantHostKnow: true, | 102 | + | {name: "remote session absent", snapshot: terminalCompositionSnapshot(nil, map[string][]string{remote: nil}), daemon: map[string][]string{remote: {"work"}}, remote: remote, target: "gone", wantMissing: true, wantListed: true}, |
| 77 | - | }, | 103 | + | {name: "remote host not configured", snapshot: terminalCompositionSnapshot(nil, nil), remote: "user@elsewhere.test", target: "work"}, |
| 78 | - | { | |||
| 79 | - | name: "local session absent", | |||
| 80 | - | snapshot: terminalCompositionSnapshot([]string{"work"}, nil), | |||
| 81 | - | target: "gone", | |||
| 82 | - | wantExists: false, | |||
| 83 | - | wantHostKnow: true, | |||
| 84 | - | }, | |||
| 85 | - | { | |||
| 86 | - | name: "local daemon absent from snapshot", | |||
| 87 | - | snapshot: ports.BrokerSnapshot{Epoch: 1, Revision: 1}, | |||
| 88 | - | target: "gone", | |||
| 89 | - | wantExists: false, | |||
| 90 | - | wantHostKnow: false, | |||
| 91 | - | }, | |||
| 92 | - | { | |||
| 93 | - | name: "remote session present", | |||
| 94 | - | snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}), | |||
| 95 | - | remote: "user@example.test", | |||
| 96 | - | target: "work", | |||
| 97 | - | wantExists: true, | |||
| 98 | - | wantHostKnow: true, | |||
| 99 | - | }, | |||
| 100 | - | { | |||
| 101 | - | name: "remote session absent on known host", | |||
| 102 | - | snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}), | |||
| 103 | - | remote: "user@example.test", | |||
| 104 | - | target: "gone", | |||
| 105 | - | wantExists: false, | |||
| 106 | - | wantHostKnow: true, | |||
| 107 | - | }, | |||
| 108 | - | { | |||
| 109 | - | name: "remote host not configured", | |||
| 110 | - | snapshot: terminalCompositionSnapshot([]string{"work"}, nil), | |||
| 111 | - | remote: "user@elsewhere.test", | |||
| 112 | - | target: "work", | |||
| 113 | - | wantExists: false, | |||
| 114 | - | wantHostKnow: false, | |||
| 115 | - | }, | |||
| 116 | } | 104 | } | ||
| 117 | for _, tc := range tests { | 105 | for _, tc := range tests { | ||
| 118 | t.Run(tc.name, func(t *testing.T) { | 106 | t.Run(tc.name, func(t *testing.T) { | ||
| 119 | - | exists, hostKnown := attachTargetSessionExists(tc.snapshot, tc.remote, tc.target) | 107 | + | routes := withDaemonSessions(t, tc.daemon, tc.listErr) |
| 120 | - | require.Equal(t, tc.wantExists, exists) | 108 | + | service := portsmocks.NewMockBrokerService(t) |
| 121 | - | require.Equal(t, tc.wantHostKnow, hostKnown) | 109 | + | service.EXPECT().Snapshot().Return(tc.snapshot).Once() |
| 110 | + | ||||
| 111 | + | require.Equal(t, tc.wantMissing, daemonReportsSessionMissing(context.Background(), service, tc.remote, tc.target)) | |||
| 112 | + | if !tc.wantListed { | |||
| 113 | + | require.Empty(t, *routes, "an unconfigured host is never listed") | |||
| 114 | + | return | |||
| 115 | + | } | |||
| 116 | + | require.Len(t, *routes, 1) | |||
| 117 | + | route := (*routes)[0] | |||
| 118 | + | require.Equal(t, tc.snapshot.Epoch, route.Epoch) | |||
| 119 | + | require.Equal(t, tc.remote == "", route.Destination.Local) | |||
| 120 | + | if tc.remote != "" { | |||
| 121 | + | observation, _ := tc.snapshot.Find(tc.remote) | |||
| 122 | + | require.True(t, route.Destination.Registration.Equal(observation.Registration)) | |||
| 123 | + | } | |||
| 122 | }) | 124 | }) | ||
| 123 | } | 125 | } | ||
| 124 | } | 126 | } | ||
| @@ -160,7 +162,8 @@ func TestResolveAttachCreationIntentBrokerUnavailableKeepsAttach(t *testing.T) { | |||||
| 160 | 162 | ||||
| 161 | func TestResolveAttachCreationIntentExistingSessionAttachesWithoutPrompt(t *testing.T) { | 163 | func TestResolveAttachCreationIntentExistingSessionAttachesWithoutPrompt(t *testing.T) { | ||
| 162 | service := portsmocks.NewMockBrokerService(t) | 164 | service := portsmocks.NewMockBrokerService(t) | ||
| 163 | - | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once() | 165 | + | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot(nil, nil)).Once() |
| 166 | + | withDaemonSessions(t, map[string][]string{"": {"work"}}, nil) | |||
| 164 | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | 167 | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | ||
| 165 | withAttachInteractiveConsole(t, true) // must not even be consulted | 168 | withAttachInteractiveConsole(t, true) // must not even be consulted | ||
| 166 | 169 | ||||
| @@ -209,6 +212,7 @@ func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) { | |||||
| 209 | } | 212 | } | ||
| 210 | service := portsmocks.NewMockBrokerService(t) | 213 | service := portsmocks.NewMockBrokerService(t) | ||
| 211 | service.EXPECT().Snapshot().Return(snapshot).Once() | 214 | service.EXPECT().Snapshot().Return(snapshot).Once() | ||
| 215 | + | withDaemonSessions(t, map[string][]string{tc.remote: {"work"}}, nil) | |||
| 212 | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | 216 | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | ||
| 213 | withAttachInteractiveConsole(t, tc.interactive) | 217 | withAttachInteractiveConsole(t, tc.interactive) | ||
| 214 | 218 | ||||
| @@ -234,8 +238,9 @@ func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) { | |||||
| 234 | 238 | ||||
| 235 | func TestResolveAttachCreationIntentCancelledPromptClosesThePeek(t *testing.T) { | 239 | func TestResolveAttachCreationIntentCancelledPromptClosesThePeek(t *testing.T) { | ||
| 236 | service := portsmocks.NewMockBrokerService(t) | 240 | service := portsmocks.NewMockBrokerService(t) | ||
| 237 | - | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once() | 241 | + | service.EXPECT().Snapshot().Return(terminalCompositionSnapshot(nil, nil)).Once() |
| 238 | service.EXPECT().Close().Return(nil).Once() | 242 | service.EXPECT().Close().Return(nil).Once() | ||
| 243 | + | withDaemonSessions(t, nil, nil) | |||
| 239 | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | 244 | withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil }) | ||
| 240 | withAttachInteractiveConsole(t, true) | 245 | withAttachInteractiveConsole(t, true) | ||
| 241 | 246 | ||||
M internal/app/broker_client.go +25 -27
| @@ -334,23 +334,22 @@ func localEphemeralNavigation() client.InitialNavigation { | |||
| 334 | 334 | } | |
| 335 | 335 | } | |
| 336 | 336 | ||
| 337 | - | // errTerminalNavigationUnresolved reports that a CLI target could not be | |
| 338 | - | // translated into the exact identity a snapshot carries. It is a local refusal: | |
| 337 | + | // errTerminalNavigationUnresolved reports that a CLI target names a host the | |
| 338 | + | // broker catalogue does not carry. It is a local refusal: | |
| 339 | 339 | // no destination was dialed, so the supervisor surfaces it as a bounded | |
| 340 | 340 | // selection-unavailable notice and returns to the picker instead of attaching a | |
| 341 | 341 | // fallback or creating implicitly. | |
| 342 | - | var errTerminalNavigationUnresolved = errors.New("vev: attach target is not in the broker catalogue") | |
| 342 | + | var errTerminalNavigationUnresolved = errors.New("vev: attach host is not in the broker catalogue") | |
| 343 | 343 | ||
| 344 | 344 | // terminalBrokerNavigation translates the parsed terminal CLI intent into the | |
| 345 | 345 | // closed initial-navigation union. An intent that is fully determined before | |
| 346 | - | // the connection (local creation) is a value; an intent whose exact identity | |
| 347 | - | // only exists in a broker publication (`attach`, and any remote target) is a | |
| 348 | - | // single-shot resolver that runs once against the first committed snapshot. | |
| 346 | + | // the connection (local creation, local named attach) is a value; a remote | |
| 347 | + | // target is a single-shot resolver that runs once against the first committed | |
| 348 | + | // snapshot to fence the host registration. | |
| 349 | 349 | // | |
| 350 | - | // The translation never invents identity: a remote destination is only ever the | |
| 351 | - | // complete registration a snapshot carries, and an exact target is only ever | |
| 352 | - | // the lifecycle/name pair the snapshot publishes. A target the snapshot does not | |
| 353 | - | // carry is a refusal, never an implicit create and never a same-name fallback. | |
| 350 | + | // Named attach never consults the catalogue for session existence: the daemon | |
| 351 | + | // restores or refuses the name. A remote destination is only ever the complete | |
| 352 | + | // registration a snapshot carries; an unknown host is a refusal. | |
| 354 | 353 | func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.InitialNavigation, client.InitialNavigationResolver, error) { | |
| 355 | 354 | if remoteTarget == "" { | |
| 356 | 355 | switch intent { | |
| @@ -369,7 +368,13 @@ func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.I | |||
| 369 | 368 | if err := domain.ValidateSessionName(name); err != nil { | |
| 370 | 369 | return client.InitialNavigation{}, nil, err | |
| 371 | 370 | } | |
| 372 | - | return client.InitialNavigation{}, localExactAttachResolver(name), nil | |
| 371 | + | // The daemon resolves the name, so a catalogue that has not | |
| 372 | + | // observed the session yet cannot refuse it. | |
| 373 | + | return client.InitialNavigation{ | |
| 374 | + | Kind: client.InitialNavigationAttachNamed, | |
| 375 | + | Destination: ports.BrokerEndpointFence{Local: true}, | |
| 376 | + | Name: name, | |
| 377 | + | }, nil, nil | |
| 373 | 378 | default: | |
| 374 | 379 | return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent) | |
| 375 | 380 | } | |
| @@ -391,32 +396,25 @@ func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.I | |||
| 391 | 396 | if err := domain.ValidateSessionName(name); err != nil { | |
| 392 | 397 | return client.InitialNavigation{}, nil, err | |
| 393 | 398 | } | |
| 394 | - | return client.InitialNavigation{}, remoteExactAttachResolver(remoteTarget, name), nil | |
| 399 | + | return client.InitialNavigation{}, remoteNamedAttachResolver(remoteTarget, name), nil | |
| 395 | 400 | default: | |
| 396 | 401 | return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent) | |
| 397 | 402 | } | |
| 398 | 403 | } | |
| 399 | 404 | ||
| 400 | - | // localExactAttachResolver resolves one local session name to its exact | |
| 401 | - | // lifecycle in the committed publication. `attach <name>` is a target | |
| 402 | - | // translation only: a name the local observation does not carry is refused, so | |
| 403 | - | // the client returns to the picker with a notice instead of creating a session | |
| 404 | - | // implicitly. | |
| 405 | - | func localExactAttachResolver(name string) client.InitialNavigationResolver { | |
| 405 | + | // remoteNamedAttachResolver binds a remote named attach to the endpoint's | |
| 406 | + | // committed registration; the remote daemon resolves the name. | |
| 407 | + | func remoteNamedAttachResolver(endpoint, name string) client.InitialNavigationResolver { | |
| 406 | 408 | return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) { | |
| 407 | - | observation, ok := localBrokerObservation(snapshot) | |
| 408 | - | if !ok { | |
| 409 | - | return client.InitialNavigation{}, fmt.Errorf("%w: no local daemon observation", errTerminalNavigationUnresolved) | |
| 410 | - | } | |
| 411 | - | target, ok := brokerObservationExactTarget(observation, name) | |
| 409 | + | observation, ok := snapshot.Find(endpoint) | |
| 412 | 410 | if !ok { | |
| 413 | - | return client.InitialNavigation{}, fmt.Errorf("%w: %q", errTerminalNavigationUnresolved, name) | |
| 411 | + | return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint) | |
| 414 | 412 | } | |
| 415 | 413 | return client.InitialNavigation{ | |
| 416 | - | Kind: client.InitialNavigationAttachExact, | |
| 414 | + | Kind: client.InitialNavigationAttachNamed, | |
| 417 | 415 | Epoch: snapshot.Epoch, | |
| 418 | - | Destination: ports.BrokerEndpointFence{Local: true}, | |
| 419 | - | Target: target, | |
| 416 | + | Destination: ports.BrokerEndpointFence{Registration: observation.Registration}, | |
| 417 | + | Name: name, | |
| 420 | 418 | }, nil | |
| 421 | 419 | } | |
| 422 | 420 | } | |
| @@ -334,23 +334,22 @@ func localEphemeralNavigation() client.InitialNavigation { | |||||
| 334 | } | 334 | } | ||
| 335 | } | 335 | } | ||
| 336 | 336 | ||||
| 337 | - | // errTerminalNavigationUnresolved reports that a CLI target could not be | 337 | + | // errTerminalNavigationUnresolved reports that a CLI target names a host the |
| 338 | - | // translated into the exact identity a snapshot carries. It is a local refusal: | 338 | + | // broker catalogue does not carry. It is a local refusal: |
| 339 | // no destination was dialed, so the supervisor surfaces it as a bounded | 339 | // no destination was dialed, so the supervisor surfaces it as a bounded | ||
| 340 | // selection-unavailable notice and returns to the picker instead of attaching a | 340 | // selection-unavailable notice and returns to the picker instead of attaching a | ||
| 341 | // fallback or creating implicitly. | 341 | // fallback or creating implicitly. | ||
| 342 | - | var errTerminalNavigationUnresolved = errors.New("vev: attach target is not in the broker catalogue") | 342 | + | var errTerminalNavigationUnresolved = errors.New("vev: attach host is not in the broker catalogue") |
| 343 | 343 | ||||
| 344 | // terminalBrokerNavigation translates the parsed terminal CLI intent into the | 344 | // terminalBrokerNavigation translates the parsed terminal CLI intent into the | ||
| 345 | // closed initial-navigation union. An intent that is fully determined before | 345 | // closed initial-navigation union. An intent that is fully determined before | ||
| 346 | - | // the connection (local creation) is a value; an intent whose exact identity | 346 | + | // the connection (local creation, local named attach) is a value; a remote |
| 347 | - | // only exists in a broker publication (`attach`, and any remote target) is a | 347 | + | // target is a single-shot resolver that runs once against the first committed |
| 348 | - | // single-shot resolver that runs once against the first committed snapshot. | 348 | + | // snapshot to fence the host registration. |
| 349 | // | 349 | // | ||
| 350 | - | // The translation never invents identity: a remote destination is only ever the | 350 | + | // Named attach never consults the catalogue for session existence: the daemon |
| 351 | - | // complete registration a snapshot carries, and an exact target is only ever | 351 | + | // restores or refuses the name. A remote destination is only ever the complete |
| 352 | - | // the lifecycle/name pair the snapshot publishes. A target the snapshot does not | 352 | + | // registration a snapshot carries; an unknown host is a refusal. |
| 353 | - | // carry is a refusal, never an implicit create and never a same-name fallback. | |||
| 354 | func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.InitialNavigation, client.InitialNavigationResolver, error) { | 353 | func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.InitialNavigation, client.InitialNavigationResolver, error) { | ||
| 355 | if remoteTarget == "" { | 354 | if remoteTarget == "" { | ||
| 356 | switch intent { | 355 | switch intent { | ||
| @@ -369,7 +368,13 @@ func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.I | |||||
| 369 | if err := domain.ValidateSessionName(name); err != nil { | 368 | if err := domain.ValidateSessionName(name); err != nil { | ||
| 370 | return client.InitialNavigation{}, nil, err | 369 | return client.InitialNavigation{}, nil, err | ||
| 371 | } | 370 | } | ||
| 372 | - | return client.InitialNavigation{}, localExactAttachResolver(name), nil | 371 | + | // The daemon resolves the name, so a catalogue that has not |
| 372 | + | // observed the session yet cannot refuse it. | |||
| 373 | + | return client.InitialNavigation{ | |||
| 374 | + | Kind: client.InitialNavigationAttachNamed, | |||
| 375 | + | Destination: ports.BrokerEndpointFence{Local: true}, | |||
| 376 | + | Name: name, | |||
| 377 | + | }, nil, nil | |||
| 373 | default: | 378 | default: | ||
| 374 | return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent) | 379 | return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent) | ||
| 375 | } | 380 | } | ||
| @@ -391,32 +396,25 @@ func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.I | |||||
| 391 | if err := domain.ValidateSessionName(name); err != nil { | 396 | if err := domain.ValidateSessionName(name); err != nil { | ||
| 392 | return client.InitialNavigation{}, nil, err | 397 | return client.InitialNavigation{}, nil, err | ||
| 393 | } | 398 | } | ||
| 394 | - | return client.InitialNavigation{}, remoteExactAttachResolver(remoteTarget, name), nil | 399 | + | return client.InitialNavigation{}, remoteNamedAttachResolver(remoteTarget, name), nil |
| 395 | default: | 400 | default: | ||
| 396 | return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent) | 401 | return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent) | ||
| 397 | } | 402 | } | ||
| 398 | } | 403 | } | ||
| 399 | 404 | ||||
| 400 | - | // localExactAttachResolver resolves one local session name to its exact | 405 | + | // remoteNamedAttachResolver binds a remote named attach to the endpoint's |
| 401 | - | // lifecycle in the committed publication. `attach <name>` is a target | 406 | + | // committed registration; the remote daemon resolves the name. |
| 402 | - | // translation only: a name the local observation does not carry is refused, so | 407 | + | func remoteNamedAttachResolver(endpoint, name string) client.InitialNavigationResolver { |
| 403 | - | // the client returns to the picker with a notice instead of creating a session | |||
| 404 | - | // implicitly. | |||
| 405 | - | func localExactAttachResolver(name string) client.InitialNavigationResolver { | |||
| 406 | return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) { | 408 | return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) { | ||
| 407 | - | observation, ok := localBrokerObservation(snapshot) | 409 | + | observation, ok := snapshot.Find(endpoint) |
| 408 | - | if !ok { | |||
| 409 | - | return client.InitialNavigation{}, fmt.Errorf("%w: no local daemon observation", errTerminalNavigationUnresolved) | |||
| 410 | - | } | |||
| 411 | - | target, ok := brokerObservationExactTarget(observation, name) | |||
| 412 | if !ok { | 410 | if !ok { | ||
| 413 | - | return client.InitialNavigation{}, fmt.Errorf("%w: %q", errTerminalNavigationUnresolved, name) | 411 | + | return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint) |
| 414 | } | 412 | } | ||
| 415 | return client.InitialNavigation{ | 413 | return client.InitialNavigation{ | ||
| 416 | - | Kind: client.InitialNavigationAttachExact, | 414 | + | Kind: client.InitialNavigationAttachNamed, |
| 417 | Epoch: snapshot.Epoch, | 415 | Epoch: snapshot.Epoch, | ||
| 418 | - | Destination: ports.BrokerEndpointFence{Local: true}, | 416 | + | Destination: ports.BrokerEndpointFence{Registration: observation.Registration}, |
| 419 | - | Target: target, | 417 | + | Name: name, |
| 420 | }, nil | 418 | }, nil | ||
| 421 | } | 419 | } | ||
| 422 | } | 420 | } | ||
M internal/app/broker_client_test.go +135 -26
| @@ -127,12 +127,15 @@ func sortStrings(values []string) { | |||
| 127 | 127 | type terminalCompositionService struct { | |
| 128 | 128 | mu sync.Mutex | |
| 129 | 129 | snapshot ports.BrokerSnapshot | |
| 130 | - | closed bool | |
| 131 | - | next ports.BrokerStreamID | |
| 132 | - | opened []ports.BrokerOpenStreamRequest | |
| 133 | - | streams []*terminalCompositionStream | |
| 134 | - | done chan struct{} | |
| 135 | - | changed chan struct{} | |
| 130 | + | // daemonSessions, when set, is what the local daemon owns regardless of | |
| 131 | + | // what the broker catalogue has observed. | |
| 132 | + | daemonSessions []string | |
| 133 | + | closed bool | |
| 134 | + | next ports.BrokerStreamID | |
| 135 | + | opened []ports.BrokerOpenStreamRequest | |
| 136 | + | streams []*terminalCompositionStream | |
| 137 | + | done chan struct{} | |
| 138 | + | changed chan struct{} | |
| 136 | 139 | } | |
| 137 | 140 | ||
| 138 | 141 | func newTerminalCompositionService(snapshot ports.BrokerSnapshot) *terminalCompositionService { | |
| @@ -177,7 +180,11 @@ func (s *terminalCompositionService) OpenStream(_ context.Context, request ports | |||
| 177 | 180 | return nil, ports.BrokerAdmissionClosed | |
| 178 | 181 | } | |
| 179 | 182 | s.opened = append(s.opened, request) | |
| 180 | - | stream := newTerminalCompositionStream(request) | |
| 183 | + | sessions := terminalCompositionDaemonSessions(s.snapshot, request) | |
| 184 | + | if request.Local && s.daemonSessions != nil { | |
| 185 | + | sessions = terminalCompositionSessions(s.daemonSessions) | |
| 186 | + | } | |
| 187 | + | stream := newTerminalCompositionStream(request, sessions) | |
| 181 | 188 | s.streams = append(s.streams, stream) | |
| 182 | 189 | s.mu.Unlock() | |
| 183 | 190 | return stream, nil | |
| @@ -211,10 +218,26 @@ func (s *terminalCompositionService) Close() error { | |||
| 211 | 218 | return nil | |
| 212 | 219 | } | |
| 213 | 220 | ||
| 221 | + | // openedRequests returns the attachment streams opened; the preflight's | |
| 222 | + | // control listings are reported by openedControls. | |
| 214 | 223 | func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRequest { | |
| 224 | + | return s.openedWithPurpose(ports.BrokerStreamAttachment) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func (s *terminalCompositionService) openedControls() []ports.BrokerOpenStreamRequest { | |
| 228 | + | return s.openedWithPurpose(ports.BrokerStreamControl) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func (s *terminalCompositionService) openedWithPurpose(purpose ports.BrokerStreamPurpose) []ports.BrokerOpenStreamRequest { | |
| 215 | 232 | s.mu.Lock() | |
| 216 | 233 | defer s.mu.Unlock() | |
| 217 | - | return append([]ports.BrokerOpenStreamRequest(nil), s.opened...) | |
| 234 | + | var out []ports.BrokerOpenStreamRequest | |
| 235 | + | for _, request := range s.opened { | |
| 236 | + | if request.Purpose == purpose { | |
| 237 | + | out = append(out, request) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | return out | |
| 218 | 241 | } | |
| 219 | 242 | ||
| 220 | 243 | // streamHello returns the Hello the supervisor sent on the single admitted | |
| @@ -223,7 +246,12 @@ func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRe | |||
| 223 | 246 | func (s *terminalCompositionService) streamHello(t *testing.T) protocol.Hello { | |
| 224 | 247 | t.Helper() | |
| 225 | 248 | s.mu.Lock() | |
| 226 | - | streams := append([]*terminalCompositionStream(nil), s.streams...) | |
| 249 | + | var streams []*terminalCompositionStream | |
| 250 | + | for _, stream := range s.streams { | |
| 251 | + | if stream.request.Purpose == ports.BrokerStreamAttachment { | |
| 252 | + | streams = append(streams, stream) | |
| 253 | + | } | |
| 254 | + | } | |
| 227 | 255 | s.mu.Unlock() | |
| 228 | 256 | require.Len(t, streams, 1) | |
| 229 | 257 | for _, message := range streams[0].sentMessages() { | |
| @@ -253,8 +281,33 @@ type terminalCompositionStream struct { | |||
| 253 | 281 | done chan struct{} | |
| 254 | 282 | } | |
| 255 | 283 | ||
| 256 | - | func newTerminalCompositionStream(request ports.BrokerOpenStreamRequest) *terminalCompositionStream { | |
| 284 | + | // terminalCompositionDaemonSessions is the session list the scripted | |
| 285 | + | // destination daemon owns: the sessions its observation publishes. | |
| 286 | + | func terminalCompositionDaemonSessions(snapshot ports.BrokerSnapshot, request ports.BrokerOpenStreamRequest) []catalogue.RemoteCatalogSession { | |
| 287 | + | for _, observation := range snapshot.Daemons { | |
| 288 | + | if observation.Local == request.Local && (request.Local || observation.Endpoint == request.Endpoint) { | |
| 289 | + | return observation.Sessions | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | return nil | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func newTerminalCompositionStream(request ports.BrokerOpenStreamRequest, sessions []catalogue.RemoteCatalogSession) *terminalCompositionStream { | |
| 257 | 296 | stream := &terminalCompositionStream{request: request, done: make(chan struct{})} | |
| 297 | + | if request.Purpose == ports.BrokerStreamControl { | |
| 298 | + | // The scripted daemon answers a List with its own sessions. | |
| 299 | + | infos := make([]protocol.SessionInfo, 0, len(sessions)) | |
| 300 | + | for _, session := range sessions { | |
| 301 | + | infos = append(infos, protocol.SessionInfo{Name: session.Name}) | |
| 302 | + | } | |
| 303 | + | stream.queue = append(stream.queue, protocol.Sessions{Sessions: infos}) | |
| 304 | + | return stream | |
| 305 | + | } | |
| 306 | + | if request.Admission == ports.BrokerAdmissionAttachNamed && !slices.ContainsFunc(sessions, func(session catalogue.RemoteCatalogSession) bool { return session.Name == request.Name }) { | |
| 307 | + | // Like the real daemon, an unknown name is refused, never created. | |
| 308 | + | stream.queue = append(stream.queue, protocol.ErrorMsg{Code: protocol.ErrNoSuchSession, Text: "no such resumable session: " + request.Name}) | |
| 309 | + | return stream | |
| 310 | + | } | |
| 258 | 311 | target := terminalCompositionCommittedTarget(request) | |
| 259 | 312 | stream.queue = append(stream.queue, protocol.Welcome{ | |
| 260 | 313 | SessionID: "sess", SessionName: request.Name, | |
| @@ -465,12 +518,20 @@ func (r *terminalCompositionRun) wait(t *testing.T) error { | |||
| 465 | 518 | } | |
| 466 | 519 | ||
| 467 | 520 | func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot) *terminalCompositionRun { | |
| 521 | + | t.Helper() | |
| 522 | + | return startTerminalCompositionWithDaemon(t, intent, name, remoteTarget, snapshot, nil) | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | // startTerminalCompositionWithDaemon is startTerminalComposition whose local | |
| 526 | + | // daemon owns daemonSessions even when the catalogue has not observed them. | |
| 527 | + | func startTerminalCompositionWithDaemon(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot, daemonSessions []string) *terminalCompositionRun { | |
| 468 | 528 | t.Helper() | |
| 469 | 529 | // The ordinary terminal path reads the nested-session and remote-transport | |
| 470 | 530 | // environment; a test drives the broker composition with both unset. | |
| 471 | 531 | t.Setenv("VEV", "") | |
| 472 | 532 | t.Setenv(envRemoteTransport, "") | |
| 473 | 533 | service := newTerminalCompositionService(snapshot) | |
| 534 | + | service.daemonSessions = daemonSessions | |
| 474 | 535 | terminal := newTerminalCompositionTerminal() | |
| 475 | 536 | previousConnect := connectProductionClientBroker | |
| 476 | 537 | previousTerminal := terminalForAttach | |
| @@ -582,8 +643,8 @@ func TestTerminalNavigationTranslationClosesTheIntent(t *testing.T) { | |||
| 582 | 643 | }{ | |
| 583 | 644 | {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, wantKind: client.InitialNavigationCreateEphemeral, wantLocal: true}, | |
| 584 | 645 | {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", wantKind: client.InitialNavigationCreateNamed, wantLocal: true, wantName: "scratch"}, | |
| 585 | - | {name: "attach resolves a local exact target", intent: protocol.IntentAttach, session: "work", wantResolver: true}, | |
| 586 | - | {name: "remote attach resolves an exact remote target", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", wantResolver: true}, | |
| 646 | + | {name: "attach names a local session for the daemon to resolve", intent: protocol.IntentAttach, session: "work", wantKind: client.InitialNavigationAttachNamed, wantLocal: true, wantName: "work"}, | |
| 647 | + | {name: "remote attach resolves the remote registration", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", wantResolver: true}, | |
| 587 | 648 | {name: "remote new resolves a named remote creation", intent: protocol.IntentNew, session: "work", remote: "user@example.test", wantResolver: true}, | |
| 588 | 649 | {name: "remote ephemeral attach resolves a remote creation", intent: protocol.IntentEphemeral, remote: "user@example.test", wantResolver: true}, | |
| 589 | 650 | {name: "invalid local name is refused", intent: protocol.IntentNew, session: "bad name", wantError: true}, | |
| @@ -624,24 +685,26 @@ func TestTerminalCompositionOpensTheExactBrokerStream(t *testing.T) { | |||
| 624 | 685 | return terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}) | |
| 625 | 686 | } | |
| 626 | 687 | tests := []struct { | |
| 627 | - | name string | |
| 628 | - | intent uint8 | |
| 629 | - | session string | |
| 630 | - | remote string | |
| 631 | - | snapshot ports.BrokerSnapshot | |
| 632 | - | wantAdmission ports.BrokerStreamAdmission | |
| 633 | - | wantLocal bool | |
| 634 | - | wantName string | |
| 635 | - | wantHello uint8 | |
| 688 | + | name string | |
| 689 | + | intent uint8 | |
| 690 | + | session string | |
| 691 | + | remote string | |
| 692 | + | snapshot ports.BrokerSnapshot | |
| 693 | + | daemonSessions []string | |
| 694 | + | wantAdmission ports.BrokerStreamAdmission | |
| 695 | + | wantLocal bool | |
| 696 | + | wantName string | |
| 697 | + | wantHello uint8 | |
| 636 | 698 | }{ | |
| 637 | 699 | {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateEphemeral, wantLocal: true, wantHello: protocol.IntentEphemeral}, | |
| 638 | 700 | {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateNamed, wantLocal: true, wantName: "scratch", wantHello: protocol.IntentNew}, | |
| 639 | - | {name: "attach resolves the local session lifecycle", intent: protocol.IntentAttach, session: "work", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionExact, wantLocal: true, wantHello: protocol.IntentAttach}, | |
| 640 | - | {name: "remote attach resolves the remote session lifecycle", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", snapshot: remoteSnapshot(), wantAdmission: ports.BrokerAdmissionExact, wantHello: protocol.IntentAttach}, | |
| 701 | + | {name: "attach names the local session", intent: protocol.IntentAttach, session: "work", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionAttachNamed, wantLocal: true, wantName: "work", wantHello: protocol.IntentAttach}, | |
| 702 | + | {name: "remote attach names the remote session", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", snapshot: remoteSnapshot(), wantAdmission: ports.BrokerAdmissionAttachNamed, wantName: "work", wantHello: protocol.IntentAttach}, | |
| 703 | + | {name: "attach reaches a session the catalogue has not observed yet", intent: protocol.IntentAttach, session: "work", snapshot: terminalCompositionUnobservedLocal(), daemonSessions: []string{"work"}, wantAdmission: ports.BrokerAdmissionAttachNamed, wantLocal: true, wantName: "work", wantHello: protocol.IntentAttach}, | |
| 641 | 704 | } | |
| 642 | 705 | for _, tc := range tests { | |
| 643 | 706 | t.Run(tc.name, func(t *testing.T) { | |
| 644 | - | run := startTerminalComposition(t, tc.intent, tc.session, tc.remote, tc.snapshot) | |
| 707 | + | run := startTerminalCompositionWithDaemon(t, tc.intent, tc.session, tc.remote, tc.snapshot, tc.daemonSessions) | |
| 645 | 708 | awaitTerminalCompositionAttachment(t, run) | |
| 646 | 709 | opened := run.service.openedRequests() | |
| 647 | 710 | require.Len(t, opened, 1, "one CLI target opens exactly one broker stream") | |
| @@ -674,10 +737,25 @@ func TestTerminalCompositionOpensTheExactBrokerStream(t *testing.T) { | |||
| 674 | 737 | require.NotNil(t, hello.ExactTarget) | |
| 675 | 738 | require.Equal(t, request.Target, *hello.ExactTarget) | |
| 676 | 739 | } | |
| 740 | + | if tc.wantAdmission == ports.BrokerAdmissionAttachNamed { | |
| 741 | + | require.Nil(t, hello.ExactTarget, "a named attach leaves identity to the daemon") | |
| 742 | + | controls := run.service.openedControls() | |
| 743 | + | require.Len(t, controls, 1, "the preflight asks the daemon once") | |
| 744 | + | require.Equal(t, ports.BrokerDaemonStartIfNeeded, controls[0].StartMode, "the preflight may start a stopped daemon") | |
| 745 | + | } | |
| 677 | 746 | }) | |
| 678 | 747 | } | |
| 679 | 748 | } | |
| 680 | 749 | ||
| 750 | + | // terminalCompositionUnobservedLocal is the publication a freshly started | |
| 751 | + | // broker commits right after `kill --all`: the local daemon is known but its | |
| 752 | + | // sessions are not observed yet, while the daemon itself restores them. | |
| 753 | + | func terminalCompositionUnobservedLocal() ports.BrokerSnapshot { | |
| 754 | + | snapshot := terminalCompositionSnapshot(nil, nil) | |
| 755 | + | snapshot.Daemons[0].InventoryKnown = false | |
| 756 | + | return snapshot | |
| 757 | + | } | |
| 758 | + | ||
| 681 | 759 | // TestTerminalCompositionPublishesRealSessionOutput proves an admitted | |
| 682 | 760 | // attachment is a committed publication: the daemon's initial full frame reached | |
| 683 | 761 | // the terminal through a committed UI output transaction, and the supervisor | |
| @@ -717,10 +795,8 @@ func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testin | |||
| 717 | 795 | remote string | |
| 718 | 796 | snapshot ports.BrokerSnapshot | |
| 719 | 797 | }{ | |
| 720 | - | {name: "missing local session", intent: protocol.IntentAttach, session: "gone", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)}, | |
| 721 | 798 | {name: "local daemon absent", intent: protocol.IntentEphemeral, snapshot: ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1}}, | |
| 722 | 799 | {name: "unconfigured remote host", intent: protocol.IntentAttach, session: "work", remote: "user@elsewhere.test", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)}, | |
| 723 | - | {name: "missing remote session", intent: protocol.IntentAttach, session: "gone", remote: "user@example.test", snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})}, | |
| 724 | 800 | } | |
| 725 | 801 | for _, tc := range tests { | |
| 726 | 802 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -754,6 +830,39 @@ func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testin | |||
| 754 | 830 | } | |
| 755 | 831 | } | |
| 756 | 832 | ||
| 833 | + | // TestTerminalCompositionDaemonRefusesMissingName pins that existence is the | |
| 834 | + | // daemon's decision: a declined (non-interactive) create prompt keeps the | |
| 835 | + | // named attach, the daemon answers ErrNoSuchSession, and the client reports | |
| 836 | + | // that refusal without attaching or creating. | |
| 837 | + | func TestTerminalCompositionDaemonRefusesMissingName(t *testing.T) { | |
| 838 | + | tests := []struct { | |
| 839 | + | name string | |
| 840 | + | remote string | |
| 841 | + | snapshot ports.BrokerSnapshot | |
| 842 | + | }{ | |
| 843 | + | {name: "local", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)}, | |
| 844 | + | {name: "remote", remote: "user@example.test", snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})}, | |
| 845 | + | } | |
| 846 | + | for _, tc := range tests { | |
| 847 | + | t.Run(tc.name, func(t *testing.T) { | |
| 848 | + | withAttachInteractiveConsole(t, false) | |
| 849 | + | run := startTerminalComposition(t, protocol.IntentAttach, "gone", tc.remote, tc.snapshot) | |
| 850 | + | require.Equal(t, client.LifecycleNoticeDestinationFailed, awaitTerminalCompositionNotice(t, run).Kind) | |
| 851 | + | select { | |
| 852 | + | case failure := <-run.failures: | |
| 853 | + | var protocolErr *client.ProtocolError | |
| 854 | + | require.ErrorAs(t, failure, &protocolErr) | |
| 855 | + | require.Equal(t, protocol.ErrNoSuchSession, protocolErr.Code) | |
| 856 | + | case <-time.After(brokerTestWait): | |
| 857 | + | t.Fatal("the daemon refusal was not reported") | |
| 858 | + | } | |
| 859 | + | opened := run.service.openedRequests() | |
| 860 | + | require.Len(t, opened, 1) | |
| 861 | + | require.Equal(t, ports.BrokerAdmissionAttachNamed, opened[0].Admission, "a missing name is never created implicitly") | |
| 862 | + | }) | |
| 863 | + | } | |
| 864 | + | } | |
| 865 | + | ||
| 757 | 866 | // TestTerminalCompositionClosesRawModeOnTerminalEnd pins that the shared | |
| 758 | 867 | // composition restores the terminal exactly once when the run ends, however the | |
| 759 | 868 | // attachment settled. | |
| @@ -127,12 +127,15 @@ func sortStrings(values []string) { | |||||
| 127 | type terminalCompositionService struct { | 127 | type terminalCompositionService struct { | ||
| 128 | mu sync.Mutex | 128 | mu sync.Mutex | ||
| 129 | snapshot ports.BrokerSnapshot | 129 | snapshot ports.BrokerSnapshot | ||
| 130 | - | closed bool | 130 | + | // daemonSessions, when set, is what the local daemon owns regardless of |
| 131 | - | next ports.BrokerStreamID | 131 | + | // what the broker catalogue has observed. |
| 132 | - | opened []ports.BrokerOpenStreamRequest | 132 | + | daemonSessions []string |
| 133 | - | streams []*terminalCompositionStream | 133 | + | closed bool |
| 134 | - | done chan struct{} | 134 | + | next ports.BrokerStreamID |
| 135 | - | changed chan struct{} | 135 | + | opened []ports.BrokerOpenStreamRequest |
| 136 | + | streams []*terminalCompositionStream | |||
| 137 | + | done chan struct{} | |||
| 138 | + | changed chan struct{} | |||
| 136 | } | 139 | } | ||
| 137 | 140 | ||||
| 138 | func newTerminalCompositionService(snapshot ports.BrokerSnapshot) *terminalCompositionService { | 141 | func newTerminalCompositionService(snapshot ports.BrokerSnapshot) *terminalCompositionService { | ||
| @@ -177,7 +180,11 @@ func (s *terminalCompositionService) OpenStream(_ context.Context, request ports | |||||
| 177 | return nil, ports.BrokerAdmissionClosed | 180 | return nil, ports.BrokerAdmissionClosed | ||
| 178 | } | 181 | } | ||
| 179 | s.opened = append(s.opened, request) | 182 | s.opened = append(s.opened, request) | ||
| 180 | - | stream := newTerminalCompositionStream(request) | 183 | + | sessions := terminalCompositionDaemonSessions(s.snapshot, request) |
| 184 | + | if request.Local && s.daemonSessions != nil { | |||
| 185 | + | sessions = terminalCompositionSessions(s.daemonSessions) | |||
| 186 | + | } | |||
| 187 | + | stream := newTerminalCompositionStream(request, sessions) | |||
| 181 | s.streams = append(s.streams, stream) | 188 | s.streams = append(s.streams, stream) | ||
| 182 | s.mu.Unlock() | 189 | s.mu.Unlock() | ||
| 183 | return stream, nil | 190 | return stream, nil | ||
| @@ -211,10 +218,26 @@ func (s *terminalCompositionService) Close() error { | |||||
| 211 | return nil | 218 | return nil | ||
| 212 | } | 219 | } | ||
| 213 | 220 | ||||
| 221 | + | // openedRequests returns the attachment streams opened; the preflight's | |||
| 222 | + | // control listings are reported by openedControls. | |||
| 214 | func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRequest { | 223 | func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRequest { | ||
| 224 | + | return s.openedWithPurpose(ports.BrokerStreamAttachment) | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func (s *terminalCompositionService) openedControls() []ports.BrokerOpenStreamRequest { | |||
| 228 | + | return s.openedWithPurpose(ports.BrokerStreamControl) | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func (s *terminalCompositionService) openedWithPurpose(purpose ports.BrokerStreamPurpose) []ports.BrokerOpenStreamRequest { | |||
| 215 | s.mu.Lock() | 232 | s.mu.Lock() | ||
| 216 | defer s.mu.Unlock() | 233 | defer s.mu.Unlock() | ||
| 217 | - | return append([]ports.BrokerOpenStreamRequest(nil), s.opened...) | 234 | + | var out []ports.BrokerOpenStreamRequest |
| 235 | + | for _, request := range s.opened { | |||
| 236 | + | if request.Purpose == purpose { | |||
| 237 | + | out = append(out, request) | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | return out | |||
| 218 | } | 241 | } | ||
| 219 | 242 | ||||
| 220 | // streamHello returns the Hello the supervisor sent on the single admitted | 243 | // streamHello returns the Hello the supervisor sent on the single admitted | ||
| @@ -223,7 +246,12 @@ func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRe | |||||
| 223 | func (s *terminalCompositionService) streamHello(t *testing.T) protocol.Hello { | 246 | func (s *terminalCompositionService) streamHello(t *testing.T) protocol.Hello { | ||
| 224 | t.Helper() | 247 | t.Helper() | ||
| 225 | s.mu.Lock() | 248 | s.mu.Lock() | ||
| 226 | - | streams := append([]*terminalCompositionStream(nil), s.streams...) | 249 | + | var streams []*terminalCompositionStream |
| 250 | + | for _, stream := range s.streams { | |||
| 251 | + | if stream.request.Purpose == ports.BrokerStreamAttachment { | |||
| 252 | + | streams = append(streams, stream) | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 227 | s.mu.Unlock() | 255 | s.mu.Unlock() | ||
| 228 | require.Len(t, streams, 1) | 256 | require.Len(t, streams, 1) | ||
| 229 | for _, message := range streams[0].sentMessages() { | 257 | for _, message := range streams[0].sentMessages() { | ||
| @@ -253,8 +281,33 @@ type terminalCompositionStream struct { | |||||
| 253 | done chan struct{} | 281 | done chan struct{} | ||
| 254 | } | 282 | } | ||
| 255 | 283 | ||||
| 256 | - | func newTerminalCompositionStream(request ports.BrokerOpenStreamRequest) *terminalCompositionStream { | 284 | + | // terminalCompositionDaemonSessions is the session list the scripted |
| 285 | + | // destination daemon owns: the sessions its observation publishes. | |||
| 286 | + | func terminalCompositionDaemonSessions(snapshot ports.BrokerSnapshot, request ports.BrokerOpenStreamRequest) []catalogue.RemoteCatalogSession { | |||
| 287 | + | for _, observation := range snapshot.Daemons { | |||
| 288 | + | if observation.Local == request.Local && (request.Local || observation.Endpoint == request.Endpoint) { | |||
| 289 | + | return observation.Sessions | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | return nil | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func newTerminalCompositionStream(request ports.BrokerOpenStreamRequest, sessions []catalogue.RemoteCatalogSession) *terminalCompositionStream { | |||
| 257 | stream := &terminalCompositionStream{request: request, done: make(chan struct{})} | 296 | stream := &terminalCompositionStream{request: request, done: make(chan struct{})} | ||
| 297 | + | if request.Purpose == ports.BrokerStreamControl { | |||
| 298 | + | // The scripted daemon answers a List with its own sessions. | |||
| 299 | + | infos := make([]protocol.SessionInfo, 0, len(sessions)) | |||
| 300 | + | for _, session := range sessions { | |||
| 301 | + | infos = append(infos, protocol.SessionInfo{Name: session.Name}) | |||
| 302 | + | } | |||
| 303 | + | stream.queue = append(stream.queue, protocol.Sessions{Sessions: infos}) | |||
| 304 | + | return stream | |||
| 305 | + | } | |||
| 306 | + | if request.Admission == ports.BrokerAdmissionAttachNamed && !slices.ContainsFunc(sessions, func(session catalogue.RemoteCatalogSession) bool { return session.Name == request.Name }) { | |||
| 307 | + | // Like the real daemon, an unknown name is refused, never created. | |||
| 308 | + | stream.queue = append(stream.queue, protocol.ErrorMsg{Code: protocol.ErrNoSuchSession, Text: "no such resumable session: " + request.Name}) | |||
| 309 | + | return stream | |||
| 310 | + | } | |||
| 258 | target := terminalCompositionCommittedTarget(request) | 311 | target := terminalCompositionCommittedTarget(request) | ||
| 259 | stream.queue = append(stream.queue, protocol.Welcome{ | 312 | stream.queue = append(stream.queue, protocol.Welcome{ | ||
| 260 | SessionID: "sess", SessionName: request.Name, | 313 | SessionID: "sess", SessionName: request.Name, | ||
| @@ -465,12 +518,20 @@ func (r *terminalCompositionRun) wait(t *testing.T) error { | |||||
| 465 | } | 518 | } | ||
| 466 | 519 | ||||
| 467 | func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot) *terminalCompositionRun { | 520 | func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot) *terminalCompositionRun { | ||
| 521 | + | t.Helper() | |||
| 522 | + | return startTerminalCompositionWithDaemon(t, intent, name, remoteTarget, snapshot, nil) | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | // startTerminalCompositionWithDaemon is startTerminalComposition whose local | |||
| 526 | + | // daemon owns daemonSessions even when the catalogue has not observed them. | |||
| 527 | + | func startTerminalCompositionWithDaemon(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot, daemonSessions []string) *terminalCompositionRun { | |||
| 468 | t.Helper() | 528 | t.Helper() | ||
| 469 | // The ordinary terminal path reads the nested-session and remote-transport | 529 | // The ordinary terminal path reads the nested-session and remote-transport | ||
| 470 | // environment; a test drives the broker composition with both unset. | 530 | // environment; a test drives the broker composition with both unset. | ||
| 471 | t.Setenv("VEV", "") | 531 | t.Setenv("VEV", "") | ||
| 472 | t.Setenv(envRemoteTransport, "") | 532 | t.Setenv(envRemoteTransport, "") | ||
| 473 | service := newTerminalCompositionService(snapshot) | 533 | service := newTerminalCompositionService(snapshot) | ||
| 534 | + | service.daemonSessions = daemonSessions | |||
| 474 | terminal := newTerminalCompositionTerminal() | 535 | terminal := newTerminalCompositionTerminal() | ||
| 475 | previousConnect := connectProductionClientBroker | 536 | previousConnect := connectProductionClientBroker | ||
| 476 | previousTerminal := terminalForAttach | 537 | previousTerminal := terminalForAttach | ||
| @@ -582,8 +643,8 @@ func TestTerminalNavigationTranslationClosesTheIntent(t *testing.T) { | |||||
| 582 | }{ | 643 | }{ | ||
| 583 | {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, wantKind: client.InitialNavigationCreateEphemeral, wantLocal: true}, | 644 | {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, wantKind: client.InitialNavigationCreateEphemeral, wantLocal: true}, | ||
| 584 | {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", wantKind: client.InitialNavigationCreateNamed, wantLocal: true, wantName: "scratch"}, | 645 | {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", wantKind: client.InitialNavigationCreateNamed, wantLocal: true, wantName: "scratch"}, | ||
| 585 | - | {name: "attach resolves a local exact target", intent: protocol.IntentAttach, session: "work", wantResolver: true}, | 646 | + | {name: "attach names a local session for the daemon to resolve", intent: protocol.IntentAttach, session: "work", wantKind: client.InitialNavigationAttachNamed, wantLocal: true, wantName: "work"}, |
| 586 | - | {name: "remote attach resolves an exact remote target", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", wantResolver: true}, | 647 | + | {name: "remote attach resolves the remote registration", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", wantResolver: true}, |
| 587 | {name: "remote new resolves a named remote creation", intent: protocol.IntentNew, session: "work", remote: "user@example.test", wantResolver: true}, | 648 | {name: "remote new resolves a named remote creation", intent: protocol.IntentNew, session: "work", remote: "user@example.test", wantResolver: true}, | ||
| 588 | {name: "remote ephemeral attach resolves a remote creation", intent: protocol.IntentEphemeral, remote: "user@example.test", wantResolver: true}, | 649 | {name: "remote ephemeral attach resolves a remote creation", intent: protocol.IntentEphemeral, remote: "user@example.test", wantResolver: true}, | ||
| 589 | {name: "invalid local name is refused", intent: protocol.IntentNew, session: "bad name", wantError: true}, | 650 | {name: "invalid local name is refused", intent: protocol.IntentNew, session: "bad name", wantError: true}, | ||
| @@ -624,24 +685,26 @@ func TestTerminalCompositionOpensTheExactBrokerStream(t *testing.T) { | |||||
| 624 | return terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}) | 685 | return terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}) | ||
| 625 | } | 686 | } | ||
| 626 | tests := []struct { | 687 | tests := []struct { | ||
| 627 | - | name string | 688 | + | name string |
| 628 | - | intent uint8 | 689 | + | intent uint8 |
| 629 | - | session string | 690 | + | session string |
| 630 | - | remote string | 691 | + | remote string |
| 631 | - | snapshot ports.BrokerSnapshot | 692 | + | snapshot ports.BrokerSnapshot |
| 632 | - | wantAdmission ports.BrokerStreamAdmission | 693 | + | daemonSessions []string |
| 633 | - | wantLocal bool | 694 | + | wantAdmission ports.BrokerStreamAdmission |
| 634 | - | wantName string | 695 | + | wantLocal bool |
| 635 | - | wantHello uint8 | 696 | + | wantName string |
| 697 | + | wantHello uint8 | |||
| 636 | }{ | 698 | }{ | ||
| 637 | {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateEphemeral, wantLocal: true, wantHello: protocol.IntentEphemeral}, | 699 | {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateEphemeral, wantLocal: true, wantHello: protocol.IntentEphemeral}, | ||
| 638 | {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateNamed, wantLocal: true, wantName: "scratch", wantHello: protocol.IntentNew}, | 700 | {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateNamed, wantLocal: true, wantName: "scratch", wantHello: protocol.IntentNew}, | ||
| 639 | - | {name: "attach resolves the local session lifecycle", intent: protocol.IntentAttach, session: "work", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionExact, wantLocal: true, wantHello: protocol.IntentAttach}, | 701 | + | {name: "attach names the local session", intent: protocol.IntentAttach, session: "work", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionAttachNamed, wantLocal: true, wantName: "work", wantHello: protocol.IntentAttach}, |
| 640 | - | {name: "remote attach resolves the remote session lifecycle", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", snapshot: remoteSnapshot(), wantAdmission: ports.BrokerAdmissionExact, wantHello: protocol.IntentAttach}, | 702 | + | {name: "remote attach names the remote session", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", snapshot: remoteSnapshot(), wantAdmission: ports.BrokerAdmissionAttachNamed, wantName: "work", wantHello: protocol.IntentAttach}, |
| 703 | + | {name: "attach reaches a session the catalogue has not observed yet", intent: protocol.IntentAttach, session: "work", snapshot: terminalCompositionUnobservedLocal(), daemonSessions: []string{"work"}, wantAdmission: ports.BrokerAdmissionAttachNamed, wantLocal: true, wantName: "work", wantHello: protocol.IntentAttach}, | |||
| 641 | } | 704 | } | ||
| 642 | for _, tc := range tests { | 705 | for _, tc := range tests { | ||
| 643 | t.Run(tc.name, func(t *testing.T) { | 706 | t.Run(tc.name, func(t *testing.T) { | ||
| 644 | - | run := startTerminalComposition(t, tc.intent, tc.session, tc.remote, tc.snapshot) | 707 | + | run := startTerminalCompositionWithDaemon(t, tc.intent, tc.session, tc.remote, tc.snapshot, tc.daemonSessions) |
| 645 | awaitTerminalCompositionAttachment(t, run) | 708 | awaitTerminalCompositionAttachment(t, run) | ||
| 646 | opened := run.service.openedRequests() | 709 | opened := run.service.openedRequests() | ||
| 647 | require.Len(t, opened, 1, "one CLI target opens exactly one broker stream") | 710 | require.Len(t, opened, 1, "one CLI target opens exactly one broker stream") | ||
| @@ -674,10 +737,25 @@ func TestTerminalCompositionOpensTheExactBrokerStream(t *testing.T) { | |||||
| 674 | require.NotNil(t, hello.ExactTarget) | 737 | require.NotNil(t, hello.ExactTarget) | ||
| 675 | require.Equal(t, request.Target, *hello.ExactTarget) | 738 | require.Equal(t, request.Target, *hello.ExactTarget) | ||
| 676 | } | 739 | } | ||
| 740 | + | if tc.wantAdmission == ports.BrokerAdmissionAttachNamed { | |||
| 741 | + | require.Nil(t, hello.ExactTarget, "a named attach leaves identity to the daemon") | |||
| 742 | + | controls := run.service.openedControls() | |||
| 743 | + | require.Len(t, controls, 1, "the preflight asks the daemon once") | |||
| 744 | + | require.Equal(t, ports.BrokerDaemonStartIfNeeded, controls[0].StartMode, "the preflight may start a stopped daemon") | |||
| 745 | + | } | |||
| 677 | }) | 746 | }) | ||
| 678 | } | 747 | } | ||
| 679 | } | 748 | } | ||
| 680 | 749 | ||||
| 750 | + | // terminalCompositionUnobservedLocal is the publication a freshly started | |||
| 751 | + | // broker commits right after `kill --all`: the local daemon is known but its | |||
| 752 | + | // sessions are not observed yet, while the daemon itself restores them. | |||
| 753 | + | func terminalCompositionUnobservedLocal() ports.BrokerSnapshot { | |||
| 754 | + | snapshot := terminalCompositionSnapshot(nil, nil) | |||
| 755 | + | snapshot.Daemons[0].InventoryKnown = false | |||
| 756 | + | return snapshot | |||
| 757 | + | } | |||
| 758 | + | ||||
| 681 | // TestTerminalCompositionPublishesRealSessionOutput proves an admitted | 759 | // TestTerminalCompositionPublishesRealSessionOutput proves an admitted | ||
| 682 | // attachment is a committed publication: the daemon's initial full frame reached | 760 | // attachment is a committed publication: the daemon's initial full frame reached | ||
| 683 | // the terminal through a committed UI output transaction, and the supervisor | 761 | // the terminal through a committed UI output transaction, and the supervisor | ||
| @@ -717,10 +795,8 @@ func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testin | |||||
| 717 | remote string | 795 | remote string | ||
| 718 | snapshot ports.BrokerSnapshot | 796 | snapshot ports.BrokerSnapshot | ||
| 719 | }{ | 797 | }{ | ||
| 720 | - | {name: "missing local session", intent: protocol.IntentAttach, session: "gone", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)}, | |||
| 721 | {name: "local daemon absent", intent: protocol.IntentEphemeral, snapshot: ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1}}, | 798 | {name: "local daemon absent", intent: protocol.IntentEphemeral, snapshot: ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1}}, | ||
| 722 | {name: "unconfigured remote host", intent: protocol.IntentAttach, session: "work", remote: "user@elsewhere.test", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)}, | 799 | {name: "unconfigured remote host", intent: protocol.IntentAttach, session: "work", remote: "user@elsewhere.test", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)}, | ||
| 723 | - | {name: "missing remote session", intent: protocol.IntentAttach, session: "gone", remote: "user@example.test", snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})}, | |||
| 724 | } | 800 | } | ||
| 725 | for _, tc := range tests { | 801 | for _, tc := range tests { | ||
| 726 | t.Run(tc.name, func(t *testing.T) { | 802 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -754,6 +830,39 @@ func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testin | |||||
| 754 | } | 830 | } | ||
| 755 | } | 831 | } | ||
| 756 | 832 | ||||
| 833 | + | // TestTerminalCompositionDaemonRefusesMissingName pins that existence is the | |||
| 834 | + | // daemon's decision: a declined (non-interactive) create prompt keeps the | |||
| 835 | + | // named attach, the daemon answers ErrNoSuchSession, and the client reports | |||
| 836 | + | // that refusal without attaching or creating. | |||
| 837 | + | func TestTerminalCompositionDaemonRefusesMissingName(t *testing.T) { | |||
| 838 | + | tests := []struct { | |||
| 839 | + | name string | |||
| 840 | + | remote string | |||
| 841 | + | snapshot ports.BrokerSnapshot | |||
| 842 | + | }{ | |||
| 843 | + | {name: "local", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)}, | |||
| 844 | + | {name: "remote", remote: "user@example.test", snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})}, | |||
| 845 | + | } | |||
| 846 | + | for _, tc := range tests { | |||
| 847 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 848 | + | withAttachInteractiveConsole(t, false) | |||
| 849 | + | run := startTerminalComposition(t, protocol.IntentAttach, "gone", tc.remote, tc.snapshot) | |||
| 850 | + | require.Equal(t, client.LifecycleNoticeDestinationFailed, awaitTerminalCompositionNotice(t, run).Kind) | |||
| 851 | + | select { | |||
| 852 | + | case failure := <-run.failures: | |||
| 853 | + | var protocolErr *client.ProtocolError | |||
| 854 | + | require.ErrorAs(t, failure, &protocolErr) | |||
| 855 | + | require.Equal(t, protocol.ErrNoSuchSession, protocolErr.Code) | |||
| 856 | + | case <-time.After(brokerTestWait): | |||
| 857 | + | t.Fatal("the daemon refusal was not reported") | |||
| 858 | + | } | |||
| 859 | + | opened := run.service.openedRequests() | |||
| 860 | + | require.Len(t, opened, 1) | |||
| 861 | + | require.Equal(t, ports.BrokerAdmissionAttachNamed, opened[0].Admission, "a missing name is never created implicitly") | |||
| 862 | + | }) | |||
| 863 | + | } | |||
| 864 | + | } | |||
| 865 | + | ||||
| 757 | // TestTerminalCompositionClosesRawModeOnTerminalEnd pins that the shared | 866 | // TestTerminalCompositionClosesRawModeOnTerminalEnd pins that the shared | ||
| 758 | // composition restores the terminal exactly once when the run ends, however the | 867 | // composition restores the terminal exactly once when the run ends, however the | ||
| 759 | // attachment settled. | 868 | // attachment settled. | ||
M internal/app/ui_driver_acceptance_test.go +10 -10
| @@ -116,16 +116,16 @@ func TestUIDriverCreationRoutes(t *testing.T) { | |||
| 116 | 116 | require.Equal(t, "work", namedAttached.Context.Route.Target.SessionName) | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | - | // TestUIDriverExactAttachResolvesTheCommittedLifecycle pins the attach route | |
| 120 | - | // deterministically against the scripted broker: `attach work` resolves the | |
| 121 | - | // committed local lifecycle into exactly one exact admission whose target is | |
| 122 | - | // that published identity, and the run dials no daemon itself. | |
| 123 | - | func TestUIDriverExactAttachResolvesTheCommittedLifecycle(t *testing.T) { | |
| 119 | + | // TestUIDriverNamedAttachLetsTheDaemonResolve pins the attach route | |
| 120 | + | // deterministically against the scripted broker: `attach work` opens exactly | |
| 121 | + | // one named admission, the daemon commits the identity, and the run dials no | |
| 122 | + | // daemon itself. | |
| 123 | + | func TestUIDriverNamedAttachLetsTheDaemonResolve(t *testing.T) { | |
| 124 | 124 | snapshot := terminalCompositionSnapshot([]string{"work"}, nil) | |
| 125 | 125 | navigation, resolver, err := terminalBrokerNavigation(protocol.IntentAttach, "work", "") | |
| 126 | 126 | require.NoError(t, err) | |
| 127 | - | require.Equal(t, client.InitialNavigation{}, navigation, "a name is resolved against the committed publication") | |
| 128 | - | require.NotNil(t, resolver) | |
| 127 | + | require.Nil(t, resolver, "a local name needs no catalogue resolution") | |
| 128 | + | require.Equal(t, client.InitialNavigationAttachNamed, navigation.Kind) | |
| 129 | 129 | ||
| 130 | 130 | service := newTerminalCompositionService(snapshot) | |
| 131 | 131 | run := startScriptedUIDriverRun(t, service, navigation, resolver) | |
| @@ -135,10 +135,10 @@ func TestUIDriverExactAttachResolvesTheCommittedLifecycle(t *testing.T) { | |||
| 135 | 135 | require.Len(t, opened, 1, "attach work opens exactly one broker stream") | |
| 136 | 136 | request := opened[0] | |
| 137 | 137 | require.NoError(t, request.Validate()) | |
| 138 | - | require.Equal(t, ports.BrokerAdmissionExact, request.Admission) | |
| 138 | + | require.Equal(t, ports.BrokerAdmissionAttachNamed, request.Admission) | |
| 139 | 139 | require.True(t, request.Local) | |
| 140 | - | require.Equal(t, "work", request.Target.SessionName) | |
| 141 | - | require.Equal(t, terminalCompositionSessions([]string{"work"})[0].LifecycleID, request.Target.LifecycleID) | |
| 140 | + | require.Equal(t, "work", request.Name) | |
| 141 | + | require.Equal(t, protocol.ExactSessionTarget{}, request.Target) | |
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | 144 | // TestUIDriverRefusedTargetStaysPickerAndKeepsServing pins the local-refusal | |
| @@ -116,16 +116,16 @@ func TestUIDriverCreationRoutes(t *testing.T) { | |||||
| 116 | require.Equal(t, "work", namedAttached.Context.Route.Target.SessionName) | 116 | require.Equal(t, "work", namedAttached.Context.Route.Target.SessionName) | ||
| 117 | } | 117 | } | ||
| 118 | 118 | ||||
| 119 | - | // TestUIDriverExactAttachResolvesTheCommittedLifecycle pins the attach route | 119 | + | // TestUIDriverNamedAttachLetsTheDaemonResolve pins the attach route |
| 120 | - | // deterministically against the scripted broker: `attach work` resolves the | 120 | + | // deterministically against the scripted broker: `attach work` opens exactly |
| 121 | - | // committed local lifecycle into exactly one exact admission whose target is | 121 | + | // one named admission, the daemon commits the identity, and the run dials no |
| 122 | - | // that published identity, and the run dials no daemon itself. | 122 | + | // daemon itself. |
| 123 | - | func TestUIDriverExactAttachResolvesTheCommittedLifecycle(t *testing.T) { | 123 | + | func TestUIDriverNamedAttachLetsTheDaemonResolve(t *testing.T) { |
| 124 | snapshot := terminalCompositionSnapshot([]string{"work"}, nil) | 124 | snapshot := terminalCompositionSnapshot([]string{"work"}, nil) | ||
| 125 | navigation, resolver, err := terminalBrokerNavigation(protocol.IntentAttach, "work", "") | 125 | navigation, resolver, err := terminalBrokerNavigation(protocol.IntentAttach, "work", "") | ||
| 126 | require.NoError(t, err) | 126 | require.NoError(t, err) | ||
| 127 | - | require.Equal(t, client.InitialNavigation{}, navigation, "a name is resolved against the committed publication") | 127 | + | require.Nil(t, resolver, "a local name needs no catalogue resolution") |
| 128 | - | require.NotNil(t, resolver) | 128 | + | require.Equal(t, client.InitialNavigationAttachNamed, navigation.Kind) |
| 129 | 129 | ||||
| 130 | service := newTerminalCompositionService(snapshot) | 130 | service := newTerminalCompositionService(snapshot) | ||
| 131 | run := startScriptedUIDriverRun(t, service, navigation, resolver) | 131 | run := startScriptedUIDriverRun(t, service, navigation, resolver) | ||
| @@ -135,10 +135,10 @@ func TestUIDriverExactAttachResolvesTheCommittedLifecycle(t *testing.T) { | |||||
| 135 | require.Len(t, opened, 1, "attach work opens exactly one broker stream") | 135 | require.Len(t, opened, 1, "attach work opens exactly one broker stream") | ||
| 136 | request := opened[0] | 136 | request := opened[0] | ||
| 137 | require.NoError(t, request.Validate()) | 137 | require.NoError(t, request.Validate()) | ||
| 138 | - | require.Equal(t, ports.BrokerAdmissionExact, request.Admission) | 138 | + | require.Equal(t, ports.BrokerAdmissionAttachNamed, request.Admission) |
| 139 | require.True(t, request.Local) | 139 | require.True(t, request.Local) | ||
| 140 | - | require.Equal(t, "work", request.Target.SessionName) | 140 | + | require.Equal(t, "work", request.Name) |
| 141 | - | require.Equal(t, terminalCompositionSessions([]string{"work"})[0].LifecycleID, request.Target.LifecycleID) | 141 | + | require.Equal(t, protocol.ExactSessionTarget{}, request.Target) |
| 142 | } | 142 | } | ||
| 143 | 143 | ||||
| 144 | // TestUIDriverRefusedTargetStaysPickerAndKeepsServing pins the local-refusal | 144 | // TestUIDriverRefusedTargetStaysPickerAndKeepsServing pins the local-refusal | ||
M internal/ports/broker.go +12 -5
| @@ -351,6 +351,10 @@ const ( | |||
| 351 | 351 | // BrokerAdmissionCreateEphemeral creates one ephemeral session when the | |
| 352 | 352 | // stream attaches. | |
| 353 | 353 | BrokerAdmissionCreateEphemeral | |
| 354 | + | // BrokerAdmissionAttachNamed attaches to the session the daemon knows by | |
| 355 | + | // this validated name, restoring it if needed. The daemon, not a broker | |
| 356 | + | // catalogue, decides existence and refuses an unknown name. | |
| 357 | + | BrokerAdmissionAttachNamed | |
| 354 | 358 | ) | |
| 355 | 359 | ||
| 356 | 360 | func (a BrokerStreamAdmission) String() string { | |
| @@ -361,6 +365,8 @@ func (a BrokerStreamAdmission) String() string { | |||
| 361 | 365 | return "create_named" | |
| 362 | 366 | case BrokerAdmissionCreateEphemeral: | |
| 363 | 367 | return "create_ephemeral" | |
| 368 | + | case BrokerAdmissionAttachNamed: | |
| 369 | + | return "attach_named" | |
| 364 | 370 | default: | |
| 365 | 371 | return fmt.Sprintf("invalid(%d)", uint8(a)) | |
| 366 | 372 | } | |
| @@ -368,7 +374,7 @@ func (a BrokerStreamAdmission) String() string { | |||
| 368 | 374 | ||
| 369 | 375 | func (a BrokerStreamAdmission) Validate() error { | |
| 370 | 376 | switch a { | |
| 371 | - | case BrokerAdmissionExact, BrokerAdmissionCreateNamed, BrokerAdmissionCreateEphemeral: | |
| 377 | + | case BrokerAdmissionExact, BrokerAdmissionCreateNamed, BrokerAdmissionCreateEphemeral, BrokerAdmissionAttachNamed: | |
| 372 | 378 | return nil | |
| 373 | 379 | default: | |
| 374 | 380 | return errors.New("ports: invalid broker stream admission") | |
| @@ -379,7 +385,8 @@ func (a BrokerStreamAdmission) Validate() error { | |||
| 379 | 385 | // cancellable logical stream to the owning daemon. The daemon revalidates | |
| 380 | 386 | // the exact session identity before attachment. Admission selects the | |
| 381 | 387 | // attachment admission variant; Name is the validated session name for | |
| 382 | - | // BrokerAdmissionCreateNamed and is empty for every other variant. StartMode | |
| 388 | + | // BrokerAdmissionCreateNamed and BrokerAdmissionAttachNamed and is empty for | |
| 389 | + | // every other variant. StartMode | |
| 383 | 390 | // is the explicit daemon-start authorization this request carries to the | |
| 384 | 391 | // transport; Pending acquisition keys on it so an ExistingOnly request is | |
| 385 | 392 | // never coalesced with a dial that may start the target. Env is the | |
| @@ -442,12 +449,12 @@ func (r BrokerOpenStreamRequest) Validate() error { | |||
| 442 | 449 | if r.Name != "" { | |
| 443 | 450 | return errors.New("ports: exact admission carries a creation name") | |
| 444 | 451 | } | |
| 445 | - | case BrokerAdmissionCreateNamed: | |
| 452 | + | case BrokerAdmissionCreateNamed, BrokerAdmissionAttachNamed: | |
| 446 | 453 | if r.Target != (protocol.ExactSessionTarget{}) { | |
| 447 | - | return errors.New("ports: named creation carries an exact target") | |
| 454 | + | return errors.New("ports: named admission carries an exact target") | |
| 448 | 455 | } | |
| 449 | 456 | if err := domain.ValidateSessionName(r.Name); err != nil { | |
| 450 | - | return fmt.Errorf("ports: invalid creation session name: %w", err) | |
| 457 | + | return fmt.Errorf("ports: invalid admission session name: %w", err) | |
| 451 | 458 | } | |
| 452 | 459 | case BrokerAdmissionCreateEphemeral: | |
| 453 | 460 | if r.Target != (protocol.ExactSessionTarget{}) { | |
| @@ -351,6 +351,10 @@ const ( | |||||
| 351 | // BrokerAdmissionCreateEphemeral creates one ephemeral session when the | 351 | // BrokerAdmissionCreateEphemeral creates one ephemeral session when the | ||
| 352 | // stream attaches. | 352 | // stream attaches. | ||
| 353 | BrokerAdmissionCreateEphemeral | 353 | BrokerAdmissionCreateEphemeral | ||
| 354 | + | // BrokerAdmissionAttachNamed attaches to the session the daemon knows by | |||
| 355 | + | // this validated name, restoring it if needed. The daemon, not a broker | |||
| 356 | + | // catalogue, decides existence and refuses an unknown name. | |||
| 357 | + | BrokerAdmissionAttachNamed | |||
| 354 | ) | 358 | ) | ||
| 355 | 359 | ||||
| 356 | func (a BrokerStreamAdmission) String() string { | 360 | func (a BrokerStreamAdmission) String() string { | ||
| @@ -361,6 +365,8 @@ func (a BrokerStreamAdmission) String() string { | |||||
| 361 | return "create_named" | 365 | return "create_named" | ||
| 362 | case BrokerAdmissionCreateEphemeral: | 366 | case BrokerAdmissionCreateEphemeral: | ||
| 363 | return "create_ephemeral" | 367 | return "create_ephemeral" | ||
| 368 | + | case BrokerAdmissionAttachNamed: | |||
| 369 | + | return "attach_named" | |||
| 364 | default: | 370 | default: | ||
| 365 | return fmt.Sprintf("invalid(%d)", uint8(a)) | 371 | return fmt.Sprintf("invalid(%d)", uint8(a)) | ||
| 366 | } | 372 | } | ||
| @@ -368,7 +374,7 @@ func (a BrokerStreamAdmission) String() string { | |||||
| 368 | 374 | ||||
| 369 | func (a BrokerStreamAdmission) Validate() error { | 375 | func (a BrokerStreamAdmission) Validate() error { | ||
| 370 | switch a { | 376 | switch a { | ||
| 371 | - | case BrokerAdmissionExact, BrokerAdmissionCreateNamed, BrokerAdmissionCreateEphemeral: | 377 | + | case BrokerAdmissionExact, BrokerAdmissionCreateNamed, BrokerAdmissionCreateEphemeral, BrokerAdmissionAttachNamed: |
| 372 | return nil | 378 | return nil | ||
| 373 | default: | 379 | default: | ||
| 374 | return errors.New("ports: invalid broker stream admission") | 380 | return errors.New("ports: invalid broker stream admission") | ||
| @@ -379,7 +385,8 @@ func (a BrokerStreamAdmission) Validate() error { | |||||
| 379 | // cancellable logical stream to the owning daemon. The daemon revalidates | 385 | // cancellable logical stream to the owning daemon. The daemon revalidates | ||
| 380 | // the exact session identity before attachment. Admission selects the | 386 | // the exact session identity before attachment. Admission selects the | ||
| 381 | // attachment admission variant; Name is the validated session name for | 387 | // attachment admission variant; Name is the validated session name for | ||
| 382 | - | // BrokerAdmissionCreateNamed and is empty for every other variant. StartMode | 388 | + | // BrokerAdmissionCreateNamed and BrokerAdmissionAttachNamed and is empty for |
| 389 | + | // every other variant. StartMode | |||
| 383 | // is the explicit daemon-start authorization this request carries to the | 390 | // is the explicit daemon-start authorization this request carries to the | ||
| 384 | // transport; Pending acquisition keys on it so an ExistingOnly request is | 391 | // transport; Pending acquisition keys on it so an ExistingOnly request is | ||
| 385 | // never coalesced with a dial that may start the target. Env is the | 392 | // never coalesced with a dial that may start the target. Env is the | ||
| @@ -442,12 +449,12 @@ func (r BrokerOpenStreamRequest) Validate() error { | |||||
| 442 | if r.Name != "" { | 449 | if r.Name != "" { | ||
| 443 | return errors.New("ports: exact admission carries a creation name") | 450 | return errors.New("ports: exact admission carries a creation name") | ||
| 444 | } | 451 | } | ||
| 445 | - | case BrokerAdmissionCreateNamed: | 452 | + | case BrokerAdmissionCreateNamed, BrokerAdmissionAttachNamed: |
| 446 | if r.Target != (protocol.ExactSessionTarget{}) { | 453 | if r.Target != (protocol.ExactSessionTarget{}) { | ||
| 447 | - | return errors.New("ports: named creation carries an exact target") | 454 | + | return errors.New("ports: named admission carries an exact target") |
| 448 | } | 455 | } | ||
| 449 | if err := domain.ValidateSessionName(r.Name); err != nil { | 456 | if err := domain.ValidateSessionName(r.Name); err != nil { | ||
| 450 | - | return fmt.Errorf("ports: invalid creation session name: %w", err) | 457 | + | return fmt.Errorf("ports: invalid admission session name: %w", err) |
| 451 | } | 458 | } | ||
| 452 | case BrokerAdmissionCreateEphemeral: | 459 | case BrokerAdmissionCreateEphemeral: | ||
| 453 | if r.Target != (protocol.ExactSessionTarget{}) { | 460 | if r.Target != (protocol.ExactSessionTarget{}) { | ||
M internal/ports/session_admission.go +5 -5
| @@ -77,8 +77,8 @@ type SessionAdmission struct { | |||
| 77 | 77 | // Admission is the closed attachment-admission variant. It is nonzero only | |
| 78 | 78 | // for BrokerStreamAttachment; control and observation carry none. | |
| 79 | 79 | Admission BrokerStreamAdmission | |
| 80 | - | // Name is the validated creation session name for | |
| 81 | - | // BrokerAdmissionCreateNamed and is empty for every other variant. | |
| 80 | + | // Name is the validated session name for BrokerAdmissionCreateNamed and | |
| 81 | + | // BrokerAdmissionAttachNamed and is empty for every other variant. | |
| 82 | 82 | Name string | |
| 83 | 83 | // Target is the exact session lifecycle target for BrokerAdmissionExact and | |
| 84 | 84 | // is zero for every other variant. | |
| @@ -117,12 +117,12 @@ func (a SessionAdmission) Validate() error { | |||
| 117 | 117 | if a.Name != "" { | |
| 118 | 118 | return errors.New("ports: exact admission carries a creation name") | |
| 119 | 119 | } | |
| 120 | - | case BrokerAdmissionCreateNamed: | |
| 120 | + | case BrokerAdmissionCreateNamed, BrokerAdmissionAttachNamed: | |
| 121 | 121 | if a.Target != (protocol.ExactSessionTarget{}) { | |
| 122 | - | return errors.New("ports: named creation carries an exact target") | |
| 122 | + | return errors.New("ports: named admission carries an exact target") | |
| 123 | 123 | } | |
| 124 | 124 | if err := domain.ValidateSessionName(a.Name); err != nil { | |
| 125 | - | return fmt.Errorf("ports: invalid creation session name: %w", err) | |
| 125 | + | return fmt.Errorf("ports: invalid admission session name: %w", err) | |
| 126 | 126 | } | |
| 127 | 127 | case BrokerAdmissionCreateEphemeral: | |
| 128 | 128 | if a.Target != (protocol.ExactSessionTarget{}) { | |
| @@ -77,8 +77,8 @@ type SessionAdmission struct { | |||||
| 77 | // Admission is the closed attachment-admission variant. It is nonzero only | 77 | // Admission is the closed attachment-admission variant. It is nonzero only | ||
| 78 | // for BrokerStreamAttachment; control and observation carry none. | 78 | // for BrokerStreamAttachment; control and observation carry none. | ||
| 79 | Admission BrokerStreamAdmission | 79 | Admission BrokerStreamAdmission | ||
| 80 | - | // Name is the validated creation session name for | 80 | + | // Name is the validated session name for BrokerAdmissionCreateNamed and |
| 81 | - | // BrokerAdmissionCreateNamed and is empty for every other variant. | 81 | + | // BrokerAdmissionAttachNamed and is empty for every other variant. |
| 82 | Name string | 82 | Name string | ||
| 83 | // Target is the exact session lifecycle target for BrokerAdmissionExact and | 83 | // Target is the exact session lifecycle target for BrokerAdmissionExact and | ||
| 84 | // is zero for every other variant. | 84 | // is zero for every other variant. | ||
| @@ -117,12 +117,12 @@ func (a SessionAdmission) Validate() error { | |||||
| 117 | if a.Name != "" { | 117 | if a.Name != "" { | ||
| 118 | return errors.New("ports: exact admission carries a creation name") | 118 | return errors.New("ports: exact admission carries a creation name") | ||
| 119 | } | 119 | } | ||
| 120 | - | case BrokerAdmissionCreateNamed: | 120 | + | case BrokerAdmissionCreateNamed, BrokerAdmissionAttachNamed: |
| 121 | if a.Target != (protocol.ExactSessionTarget{}) { | 121 | if a.Target != (protocol.ExactSessionTarget{}) { | ||
| 122 | - | return errors.New("ports: named creation carries an exact target") | 122 | + | return errors.New("ports: named admission carries an exact target") |
| 123 | } | 123 | } | ||
| 124 | if err := domain.ValidateSessionName(a.Name); err != nil { | 124 | if err := domain.ValidateSessionName(a.Name); err != nil { | ||
| 125 | - | return fmt.Errorf("ports: invalid creation session name: %w", err) | 125 | + | return fmt.Errorf("ports: invalid admission session name: %w", err) |
| 126 | } | 126 | } | ||
| 127 | case BrokerAdmissionCreateEphemeral: | 127 | case BrokerAdmissionCreateEphemeral: | ||
| 128 | if a.Target != (protocol.ExactSessionTarget{}) { | 128 | if a.Target != (protocol.ExactSessionTarget{}) { | ||
M internal/protocol/session.go +1 -1
| @@ -16,7 +16,7 @@ import ( | |||
| 16 | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | |
| 17 | 17 | // negotiated ceilings and accepted daemon binding ride the same wire | |
| 18 | 18 | // version as the session and broker conversations. | |
| 19 | - | const Version uint16 = 60 | |
| 19 | + | const Version uint16 = 61 | |
| 20 | 20 | ||
| 21 | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 22 | 22 | // first committed publication. It excludes the preceding client-local | |
| @@ -16,7 +16,7 @@ import ( | |||||
| 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | ||
| 17 | // negotiated ceilings and accepted daemon binding ride the same wire | 17 | // negotiated ceilings and accepted daemon binding ride the same wire | ||
| 18 | // version as the session and broker conversations. | 18 | // version as the session and broker conversations. | ||
| 19 | - | const Version uint16 = 60 | 19 | + | const Version uint16 = 61 |
| 20 | 20 | ||||
| 21 | // HandshakeTimeout bounds every transport handshake from connect through the | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 22 | // first committed publication. It excludes the preceding client-local | 22 | // first committed publication. It excludes the preceding client-local | ||
M internal/protocol/session_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(60), Version) | |
| 13 | + | require.Equal(t, uint16(61), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | ||
| 13 | - | require.Equal(t, uint16(60), Version) | 13 | + | require.Equal(t, uint16(61), Version) |
| 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
M internal/protocol/wire/broker.pb.go +5 -3 generated file: collapsed
M internal/protocol/wire/envelope_inventory_test.go +2 -2
| @@ -15,8 +15,8 @@ import ( | |||
| 15 | 15 | // shares a payload type except route navigation failure, and the preamble | |
| 16 | 16 | // constants agree with the schema authority. | |
| 17 | 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | |
| 18 | - | if protocol.Version != 60 { | |
| 19 | - | t.Fatalf("protocol.Version = %d, want 60", protocol.Version) | |
| 18 | + | if protocol.Version != 61 { | |
| 19 | + | t.Fatalf("protocol.Version = %d, want 61", protocol.Version) | |
| 20 | 20 | } | |
| 21 | 21 | if ProtocolEpoch != 1 { | |
| 22 | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | |
| @@ -15,8 +15,8 @@ import ( | |||||
| 15 | // shares a payload type except route navigation failure, and the preamble | 15 | // shares a payload type except route navigation failure, and the preamble | ||
| 16 | // constants agree with the schema authority. | 16 | // constants agree with the schema authority. | ||
| 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | 17 | func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | ||
| 18 | - | if protocol.Version != 60 { | 18 | + | if protocol.Version != 61 { |
| 19 | - | t.Fatalf("protocol.Version = %d, want 60", protocol.Version) | 19 | + | t.Fatalf("protocol.Version = %d, want 61", protocol.Version) |
| 20 | } | 20 | } | ||
| 21 | if ProtocolEpoch != 1 { | 21 | if ProtocolEpoch != 1 { | ||
| 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | ||
M internal/protocol/wire/multiplex.pb.go +2 -1 generated file: collapsed
M internal/protocol/wire/schema/broker.proto +5 -3
| @@ -181,7 +181,8 @@ message Reconcile { | |||
| 181 | 181 | // admission is the closed attachment-admission taxonomy: an attachment | |
| 182 | 182 | // stream names exactly how the daemon must admit it, while control and | |
| 183 | 183 | // observation streams carry none (0). name is validated session-name | |
| 184 | - | // authority for create-named and is empty for every other variant. | |
| 184 | + | // authority for create-named and attach-named and is empty for every other | |
| 185 | + | // variant. | |
| 185 | 186 | // start_mode is the explicit daemon-start authorization carried to the | |
| 186 | 187 | // transport: observation and daemon-stop must be existing_only, while | |
| 187 | 188 | // attach/creation and the explicit list/mutate controls may be | |
| @@ -199,9 +200,10 @@ message OpenStream { | |||
| 199 | 200 | repeated string env = 7; | |
| 200 | 201 | BrokerWirePolicy policy = 8; | |
| 201 | 202 | // Closed admission taxonomy: 0 = none (control/observation), 1 = exact | |
| 202 | - | // attach/resume, 2 = create named, 3 = create ephemeral. | |
| 203 | + | // attach/resume, 2 = create named, 3 = create ephemeral, 4 = attach named | |
| 204 | + | // (the daemon resolves the name and refuses an unknown one). | |
| 203 | 205 | uint32 admission = 9; | |
| 204 | - | // Validated session name for create-named; empty otherwise. | |
| 206 | + | // Validated session name for create-named and attach-named; empty otherwise. | |
| 205 | 207 | string name = 10; | |
| 206 | 208 | // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 = | |
| 207 | 209 | // existing_only, 2 = start_if_needed. Always present; zero is refused. | |
| @@ -181,7 +181,8 @@ message Reconcile { | |||||
| 181 | // admission is the closed attachment-admission taxonomy: an attachment | 181 | // admission is the closed attachment-admission taxonomy: an attachment | ||
| 182 | // stream names exactly how the daemon must admit it, while control and | 182 | // stream names exactly how the daemon must admit it, while control and | ||
| 183 | // observation streams carry none (0). name is validated session-name | 183 | // observation streams carry none (0). name is validated session-name | ||
| 184 | - | // authority for create-named and is empty for every other variant. | 184 | + | // authority for create-named and attach-named and is empty for every other |
| 185 | + | // variant. | |||
| 185 | // start_mode is the explicit daemon-start authorization carried to the | 186 | // start_mode is the explicit daemon-start authorization carried to the | ||
| 186 | // transport: observation and daemon-stop must be existing_only, while | 187 | // transport: observation and daemon-stop must be existing_only, while | ||
| 187 | // attach/creation and the explicit list/mutate controls may be | 188 | // attach/creation and the explicit list/mutate controls may be | ||
| @@ -199,9 +200,10 @@ message OpenStream { | |||||
| 199 | repeated string env = 7; | 200 | repeated string env = 7; | ||
| 200 | BrokerWirePolicy policy = 8; | 201 | BrokerWirePolicy policy = 8; | ||
| 201 | // Closed admission taxonomy: 0 = none (control/observation), 1 = exact | 202 | // Closed admission taxonomy: 0 = none (control/observation), 1 = exact | ||
| 202 | - | // attach/resume, 2 = create named, 3 = create ephemeral. | 203 | + | // attach/resume, 2 = create named, 3 = create ephemeral, 4 = attach named |
| 204 | + | // (the daemon resolves the name and refuses an unknown one). | |||
| 203 | uint32 admission = 9; | 205 | uint32 admission = 9; | ||
| 204 | - | // Validated session name for create-named; empty otherwise. | 206 | + | // Validated session name for create-named and attach-named; empty otherwise. |
| 205 | string name = 10; | 207 | string name = 10; | ||
| 206 | // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 = | 208 | // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 = | ||
| 207 | // existing_only, 2 = start_if_needed. Always present; zero is refused. | 209 | // existing_only, 2 = start_if_needed. Always present; zero is refused. | ||
M internal/protocol/wire/schema/multiplex.proto +2 -1
| @@ -55,7 +55,8 @@ message MuxOpen { | |||
| 55 | 55 | BrokerWirePolicy policy = 8; | |
| 56 | 56 | // Closed attachment-admission taxonomy (ports.BrokerStreamAdmission): | |
| 57 | 57 | // 0 = none (control/observation), 1 = exact attach/resume carrying | |
| 58 | - | // target, 2 = create named carrying name, 3 = create ephemeral. | |
| 58 | + | // target, 2 = create named carrying name, 3 = create ephemeral, 4 = attach | |
| 59 | + | // named carrying name. | |
| 59 | 60 | uint32 admission = 9; | |
| 60 | 61 | string name = 10; | |
| 61 | 62 | // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 = | |
| @@ -55,7 +55,8 @@ message MuxOpen { | |||||
| 55 | BrokerWirePolicy policy = 8; | 55 | BrokerWirePolicy policy = 8; | ||
| 56 | // Closed attachment-admission taxonomy (ports.BrokerStreamAdmission): | 56 | // Closed attachment-admission taxonomy (ports.BrokerStreamAdmission): | ||
| 57 | // 0 = none (control/observation), 1 = exact attach/resume carrying | 57 | // 0 = none (control/observation), 1 = exact attach/resume carrying | ||
| 58 | - | // target, 2 = create named carrying name, 3 = create ephemeral. | 58 | + | // target, 2 = create named carrying name, 3 = create ephemeral, 4 = attach |
| 59 | + | // named carrying name. | |||
| 59 | uint32 admission = 9; | 60 | uint32 admission = 9; | ||
| 60 | string name = 10; | 61 | string name = 10; | ||
| 61 | // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 = | 62 | // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 = | ||
M internal/usecase/client/attachment_session.go +6 -2
| @@ -652,7 +652,7 @@ func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) erro | |||
| 652 | 652 | if welcome.CommittedIdentity != nil { | |
| 653 | 653 | return w.validateIdentity("welcome", welcome.CommittedIdentity.Target) | |
| 654 | 654 | } | |
| 655 | - | if w.cfg.Request.Admission == ports.BrokerAdmissionCreateNamed && welcome.SessionName != w.cfg.Request.Name { | |
| 655 | + | if (w.cfg.Request.Admission == ports.BrokerAdmissionCreateNamed || w.cfg.Request.Admission == ports.BrokerAdmissionAttachNamed) && welcome.SessionName != w.cfg.Request.Name { | |
| 656 | 656 | return &AttachmentIdentityError{Stage: "welcome", Got: protocol.ExactSessionTarget{SessionName: welcome.SessionName}, Name: w.cfg.Request.Name} | |
| 657 | 657 | } | |
| 658 | 658 | return nil | |
| @@ -672,7 +672,7 @@ func (w *sessionAttachmentWorker) validateIdentity(stage string, got protocol.Ex | |||
| 672 | 672 | if got != request.Target { | |
| 673 | 673 | return &AttachmentIdentityError{Stage: stage, Want: request.Target, Got: got} | |
| 674 | 674 | } | |
| 675 | - | case ports.BrokerAdmissionCreateNamed: | |
| 675 | + | case ports.BrokerAdmissionCreateNamed, ports.BrokerAdmissionAttachNamed: | |
| 676 | 676 | if got.SessionName != request.Name { | |
| 677 | 677 | return &AttachmentIdentityError{Stage: stage, Got: got, Name: request.Name} | |
| 678 | 678 | } | |
| @@ -740,6 +740,10 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr | |||
| 740 | 740 | case ports.BrokerAdmissionCreateNamed: | |
| 741 | 741 | hello.Intent = protocol.IntentNew | |
| 742 | 742 | hello.Name = request.Name | |
| 743 | + | case ports.BrokerAdmissionAttachNamed: | |
| 744 | + | // The daemon resolves the name, restoring it if needed. | |
| 745 | + | hello.Intent = protocol.IntentAttach | |
| 746 | + | hello.Name = request.Name | |
| 743 | 747 | case ports.BrokerAdmissionCreateEphemeral: | |
| 744 | 748 | hello.Intent = protocol.IntentEphemeral | |
| 745 | 749 | } | |
| @@ -652,7 +652,7 @@ func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) erro | |||||
| 652 | if welcome.CommittedIdentity != nil { | 652 | if welcome.CommittedIdentity != nil { | ||
| 653 | return w.validateIdentity("welcome", welcome.CommittedIdentity.Target) | 653 | return w.validateIdentity("welcome", welcome.CommittedIdentity.Target) | ||
| 654 | } | 654 | } | ||
| 655 | - | if w.cfg.Request.Admission == ports.BrokerAdmissionCreateNamed && welcome.SessionName != w.cfg.Request.Name { | 655 | + | if (w.cfg.Request.Admission == ports.BrokerAdmissionCreateNamed || w.cfg.Request.Admission == ports.BrokerAdmissionAttachNamed) && welcome.SessionName != w.cfg.Request.Name { |
| 656 | return &AttachmentIdentityError{Stage: "welcome", Got: protocol.ExactSessionTarget{SessionName: welcome.SessionName}, Name: w.cfg.Request.Name} | 656 | return &AttachmentIdentityError{Stage: "welcome", Got: protocol.ExactSessionTarget{SessionName: welcome.SessionName}, Name: w.cfg.Request.Name} | ||
| 657 | } | 657 | } | ||
| 658 | return nil | 658 | return nil | ||
| @@ -672,7 +672,7 @@ func (w *sessionAttachmentWorker) validateIdentity(stage string, got protocol.Ex | |||||
| 672 | if got != request.Target { | 672 | if got != request.Target { | ||
| 673 | return &AttachmentIdentityError{Stage: stage, Want: request.Target, Got: got} | 673 | return &AttachmentIdentityError{Stage: stage, Want: request.Target, Got: got} | ||
| 674 | } | 674 | } | ||
| 675 | - | case ports.BrokerAdmissionCreateNamed: | 675 | + | case ports.BrokerAdmissionCreateNamed, ports.BrokerAdmissionAttachNamed: |
| 676 | if got.SessionName != request.Name { | 676 | if got.SessionName != request.Name { | ||
| 677 | return &AttachmentIdentityError{Stage: stage, Got: got, Name: request.Name} | 677 | return &AttachmentIdentityError{Stage: stage, Got: got, Name: request.Name} | ||
| 678 | } | 678 | } | ||
| @@ -740,6 +740,10 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr | |||||
| 740 | case ports.BrokerAdmissionCreateNamed: | 740 | case ports.BrokerAdmissionCreateNamed: | ||
| 741 | hello.Intent = protocol.IntentNew | 741 | hello.Intent = protocol.IntentNew | ||
| 742 | hello.Name = request.Name | 742 | hello.Name = request.Name | ||
| 743 | + | case ports.BrokerAdmissionAttachNamed: | |||
| 744 | + | // The daemon resolves the name, restoring it if needed. | |||
| 745 | + | hello.Intent = protocol.IntentAttach | |||
| 746 | + | hello.Name = request.Name | |||
| 743 | case ports.BrokerAdmissionCreateEphemeral: | 747 | case ports.BrokerAdmissionCreateEphemeral: | ||
| 744 | hello.Intent = protocol.IntentEphemeral | 748 | hello.Intent = protocol.IntentEphemeral | ||
| 745 | } | 749 | } | ||
M internal/usecase/client/broker_operations.go +12 -1
| @@ -122,7 +122,18 @@ func NewBrokerOperations(service ports.BrokerService, clock ports.Clock) (*Broke | |||
| 122 | 122 | // unreachable: a refused or lost listing is an ordinary error. A new explicit | |
| 123 | 123 | // list is always safe, so the implementation never retries on its own. | |
| 124 | 124 | func (o *BrokerOperations) List(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) { | |
| 125 | - | reply, err := o.exchange(ctx, route, ports.BrokerDaemonExistingOnly, protocol.List{}) | |
| 125 | + | return o.list(ctx, route, ports.BrokerDaemonExistingOnly) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // ListStarting is List that may start the destination daemon first, so the | |
| 129 | + | // reply includes sessions it restores from disk. Use it when the answer | |
| 130 | + | // decides an attach, where a stopped daemon must not read as "no sessions". | |
| 131 | + | func (o *BrokerOperations) ListStarting(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) { | |
| 132 | + | return o.list(ctx, route, ports.BrokerDaemonStartIfNeeded) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (o *BrokerOperations) list(ctx context.Context, route BrokerOperationRoute, mode ports.BrokerDaemonStartMode) ([]protocol.SessionInfo, error) { | |
| 136 | + | reply, err := o.exchange(ctx, route, mode, protocol.List{}) | |
| 126 | 137 | if err != nil { | |
| 127 | 138 | if errors.Is(err, errBrokerOperationLost) { | |
| 128 | 139 | return nil, fmt.Errorf("vev: reading session list: %w", err) | |
| @@ -122,7 +122,18 @@ func NewBrokerOperations(service ports.BrokerService, clock ports.Clock) (*Broke | |||||
| 122 | // unreachable: a refused or lost listing is an ordinary error. A new explicit | 122 | // unreachable: a refused or lost listing is an ordinary error. A new explicit | ||
| 123 | // list is always safe, so the implementation never retries on its own. | 123 | // list is always safe, so the implementation never retries on its own. | ||
| 124 | func (o *BrokerOperations) List(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) { | 124 | func (o *BrokerOperations) List(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) { | ||
| 125 | - | reply, err := o.exchange(ctx, route, ports.BrokerDaemonExistingOnly, protocol.List{}) | 125 | + | return o.list(ctx, route, ports.BrokerDaemonExistingOnly) |
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // ListStarting is List that may start the destination daemon first, so the | |||
| 129 | + | // reply includes sessions it restores from disk. Use it when the answer | |||
| 130 | + | // decides an attach, where a stopped daemon must not read as "no sessions". | |||
| 131 | + | func (o *BrokerOperations) ListStarting(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) { | |||
| 132 | + | return o.list(ctx, route, ports.BrokerDaemonStartIfNeeded) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (o *BrokerOperations) list(ctx context.Context, route BrokerOperationRoute, mode ports.BrokerDaemonStartMode) ([]protocol.SessionInfo, error) { | |||
| 136 | + | reply, err := o.exchange(ctx, route, mode, protocol.List{}) | |||
| 126 | if err != nil { | 137 | if err != nil { | ||
| 127 | if errors.Is(err, errBrokerOperationLost) { | 138 | if errors.Is(err, errBrokerOperationLost) { | ||
| 128 | return nil, fmt.Errorf("vev: reading session list: %w", err) | 139 | return nil, fmt.Errorf("vev: reading session list: %w", err) | ||
M internal/usecase/client/broker_operations_test.go +36 -24
| @@ -464,33 +464,45 @@ func TestBrokerOperationOpenFailureIsNotSent(t *testing.T) { | |||
| 464 | 464 | // control stream, sends exactly one List, and returns the daemon's own | |
| 465 | 465 | // sessions rather than a fabricated or cached value. | |
| 466 | 466 | func TestBrokerOperationsListUsesOneControlStream(t *testing.T) { | |
| 467 | - | stream := newBrokerOpsTestStream() | |
| 468 | - | stream.deliver(protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}}) | |
| 469 | - | ops, service, _ := brokerOpsTestOperation(t, brokerOpsTestLocalSnapshot(), stream) | |
| 467 | + | tests := []struct { | |
| 468 | + | name string | |
| 469 | + | list func(*BrokerOperations, context.Context, BrokerOperationRoute) ([]protocol.SessionInfo, error) | |
| 470 | + | startMode ports.BrokerDaemonStartMode | |
| 471 | + | }{ | |
| 472 | + | {name: "list never starts the daemon", list: (*BrokerOperations).List, startMode: ports.BrokerDaemonExistingOnly}, | |
| 473 | + | {name: "list starting may start the daemon", list: (*BrokerOperations).ListStarting, startMode: ports.BrokerDaemonStartIfNeeded}, | |
| 474 | + | } | |
| 475 | + | for _, tt := range tests { | |
| 476 | + | t.Run(tt.name, func(t *testing.T) { | |
| 477 | + | stream := newBrokerOpsTestStream() | |
| 478 | + | stream.deliver(protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}}) | |
| 479 | + | ops, service, _ := brokerOpsTestOperation(t, brokerOpsTestLocalSnapshot(), stream) | |
| 470 | 480 | ||
| 471 | - | sessions, err := ops.List(context.Background(), brokerOpsTestLocalRoute()) | |
| 472 | - | require.NoError(t, err) | |
| 473 | - | require.Equal(t, []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}, sessions) | |
| 481 | + | sessions, err := tt.list(ops, context.Background(), brokerOpsTestLocalRoute()) | |
| 482 | + | require.NoError(t, err) | |
| 483 | + | require.Equal(t, []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}, sessions) | |
| 474 | 484 | ||
| 475 | - | requests := service.openedRequests() | |
| 476 | - | require.Len(t, requests, 1) | |
| 477 | - | request := requests[0] | |
| 478 | - | require.Equal(t, ports.BrokerStreamControl, request.Purpose) | |
| 479 | - | require.Equal(t, ports.BrokerStreamAdmission(0), request.Admission) | |
| 480 | - | require.Empty(t, request.Name) | |
| 481 | - | require.Equal(t, protocol.ExactSessionTarget{}, request.Target) | |
| 482 | - | require.Empty(t, request.Env) | |
| 483 | - | require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode) | |
| 484 | - | require.True(t, request.Local) | |
| 485 | - | require.Equal(t, service.ConnectionID(), request.Connection) | |
| 486 | - | require.NotZero(t, request.Stream) | |
| 487 | - | require.Equal(t, brokerOpsTestEpoch, request.Epoch) | |
| 485 | + | requests := service.openedRequests() | |
| 486 | + | require.Len(t, requests, 1) | |
| 487 | + | request := requests[0] | |
| 488 | + | require.Equal(t, ports.BrokerStreamControl, request.Purpose) | |
| 489 | + | require.Equal(t, ports.BrokerStreamAdmission(0), request.Admission) | |
| 490 | + | require.Empty(t, request.Name) | |
| 491 | + | require.Equal(t, protocol.ExactSessionTarget{}, request.Target) | |
| 492 | + | require.Empty(t, request.Env) | |
| 493 | + | require.Equal(t, tt.startMode, request.StartMode) | |
| 494 | + | require.True(t, request.Local) | |
| 495 | + | require.Equal(t, service.ConnectionID(), request.Connection) | |
| 496 | + | require.NotZero(t, request.Stream) | |
| 497 | + | require.Equal(t, brokerOpsTestEpoch, request.Epoch) | |
| 488 | 498 | ||
| 489 | - | sent := stream.messages() | |
| 490 | - | require.Len(t, sent, 1) | |
| 491 | - | require.Equal(t, protocol.List{}, sent[0]) | |
| 492 | - | require.Equal(t, 1, stream.closeCount(), "the owned stream is closed exactly once") | |
| 493 | - | require.Equal(t, 0, service.closeCount(), "the borrowed service is never closed") | |
| 499 | + | sent := stream.messages() | |
| 500 | + | require.Len(t, sent, 1) | |
| 501 | + | require.Equal(t, protocol.List{}, sent[0]) | |
| 502 | + | require.Equal(t, 1, stream.closeCount(), "the owned stream is closed exactly once") | |
| 503 | + | require.Equal(t, 0, service.closeCount(), "the borrowed service is never closed") | |
| 504 | + | }) | |
| 505 | + | } | |
| 494 | 506 | } | |
| 495 | 507 | ||
| 496 | 508 | // TestBrokerOperationsListLostReplyIsError proves a lost or malformed listing | |
| @@ -464,33 +464,45 @@ func TestBrokerOperationOpenFailureIsNotSent(t *testing.T) { | |||||
| 464 | // control stream, sends exactly one List, and returns the daemon's own | 464 | // control stream, sends exactly one List, and returns the daemon's own | ||
| 465 | // sessions rather than a fabricated or cached value. | 465 | // sessions rather than a fabricated or cached value. | ||
| 466 | func TestBrokerOperationsListUsesOneControlStream(t *testing.T) { | 466 | func TestBrokerOperationsListUsesOneControlStream(t *testing.T) { | ||
| 467 | - | stream := newBrokerOpsTestStream() | 467 | + | tests := []struct { |
| 468 | - | stream.deliver(protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}}) | 468 | + | name string |
| 469 | - | ops, service, _ := brokerOpsTestOperation(t, brokerOpsTestLocalSnapshot(), stream) | 469 | + | list func(*BrokerOperations, context.Context, BrokerOperationRoute) ([]protocol.SessionInfo, error) |
| 470 | + | startMode ports.BrokerDaemonStartMode | |||
| 471 | + | }{ | |||
| 472 | + | {name: "list never starts the daemon", list: (*BrokerOperations).List, startMode: ports.BrokerDaemonExistingOnly}, | |||
| 473 | + | {name: "list starting may start the daemon", list: (*BrokerOperations).ListStarting, startMode: ports.BrokerDaemonStartIfNeeded}, | |||
| 474 | + | } | |||
| 475 | + | for _, tt := range tests { | |||
| 476 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 477 | + | stream := newBrokerOpsTestStream() | |||
| 478 | + | stream.deliver(protocol.Sessions{Sessions: []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}}) | |||
| 479 | + | ops, service, _ := brokerOpsTestOperation(t, brokerOpsTestLocalSnapshot(), stream) | |||
| 470 | 480 | ||||
| 471 | - | sessions, err := ops.List(context.Background(), brokerOpsTestLocalRoute()) | 481 | + | sessions, err := tt.list(ops, context.Background(), brokerOpsTestLocalRoute()) |
| 472 | - | require.NoError(t, err) | 482 | + | require.NoError(t, err) |
| 473 | - | require.Equal(t, []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}, sessions) | 483 | + | require.Equal(t, []protocol.SessionInfo{{Name: "alpha", State: protocol.SessionUp}}, sessions) |
| 474 | 484 | ||||
| 475 | - | requests := service.openedRequests() | 485 | + | requests := service.openedRequests() |
| 476 | - | require.Len(t, requests, 1) | 486 | + | require.Len(t, requests, 1) |
| 477 | - | request := requests[0] | 487 | + | request := requests[0] |
| 478 | - | require.Equal(t, ports.BrokerStreamControl, request.Purpose) | 488 | + | require.Equal(t, ports.BrokerStreamControl, request.Purpose) |
| 479 | - | require.Equal(t, ports.BrokerStreamAdmission(0), request.Admission) | 489 | + | require.Equal(t, ports.BrokerStreamAdmission(0), request.Admission) |
| 480 | - | require.Empty(t, request.Name) | 490 | + | require.Empty(t, request.Name) |
| 481 | - | require.Equal(t, protocol.ExactSessionTarget{}, request.Target) | 491 | + | require.Equal(t, protocol.ExactSessionTarget{}, request.Target) |
| 482 | - | require.Empty(t, request.Env) | 492 | + | require.Empty(t, request.Env) |
| 483 | - | require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode) | 493 | + | require.Equal(t, tt.startMode, request.StartMode) |
| 484 | - | require.True(t, request.Local) | 494 | + | require.True(t, request.Local) |
| 485 | - | require.Equal(t, service.ConnectionID(), request.Connection) | 495 | + | require.Equal(t, service.ConnectionID(), request.Connection) |
| 486 | - | require.NotZero(t, request.Stream) | 496 | + | require.NotZero(t, request.Stream) |
| 487 | - | require.Equal(t, brokerOpsTestEpoch, request.Epoch) | 497 | + | require.Equal(t, brokerOpsTestEpoch, request.Epoch) |
| 488 | 498 | ||||
| 489 | - | sent := stream.messages() | 499 | + | sent := stream.messages() |
| 490 | - | require.Len(t, sent, 1) | 500 | + | require.Len(t, sent, 1) |
| 491 | - | require.Equal(t, protocol.List{}, sent[0]) | 501 | + | require.Equal(t, protocol.List{}, sent[0]) |
| 492 | - | require.Equal(t, 1, stream.closeCount(), "the owned stream is closed exactly once") | 502 | + | require.Equal(t, 1, stream.closeCount(), "the owned stream is closed exactly once") |
| 493 | - | require.Equal(t, 0, service.closeCount(), "the borrowed service is never closed") | 503 | + | require.Equal(t, 0, service.closeCount(), "the borrowed service is never closed") |
| 504 | + | }) | |||
| 505 | + | } | |||
| 494 | } | 506 | } | ||
| 495 | 507 | ||||
| 496 | // TestBrokerOperationsListLostReplyIsError proves a lost or malformed listing | 508 | // TestBrokerOperationsListLostReplyIsError proves a lost or malformed listing | ||
M internal/usecase/client/initial_navigation.go +14 -4
| @@ -31,6 +31,8 @@ import ( | |||
| 31 | 31 | // only for the named variant, and no target. | |
| 32 | 32 | // - AttachExact carries a valid endpoint fence, a nonzero epoch, no name, and | |
| 33 | 33 | // a validated protocol.ExactSessionTarget. | |
| 34 | + | // - AttachNamed carries a valid endpoint fence, a name, and no target; the | |
| 35 | + | // daemon resolves the name and refuses an unknown one. | |
| 34 | 36 | // - A local creation may state epoch zero, meaning "bind to the first adopted | |
| 35 | 37 | // publication". Every remote intent, and every exact attach even locally, | |
| 36 | 38 | // requires the epoch of the snapshot that resolved it, and a nonzero epoch | |
| @@ -48,6 +50,9 @@ const ( | |||
| 48 | 50 | InitialNavigationCreateNamed | |
| 49 | 51 | // InitialNavigationAttachExact attaches to one exact session lifecycle. | |
| 50 | 52 | InitialNavigationAttachExact | |
| 53 | + | // InitialNavigationAttachNamed attaches to the session the daemon knows by | |
| 54 | + | // name; existence is the daemon's decision, not the broker catalogue's. | |
| 55 | + | InitialNavigationAttachNamed | |
| 51 | 56 | ) | |
| 52 | 57 | ||
| 53 | 58 | func (k InitialNavigationKind) String() string { | |
| @@ -60,6 +65,8 @@ func (k InitialNavigationKind) String() string { | |||
| 60 | 65 | return "create_named" | |
| 61 | 66 | case InitialNavigationAttachExact: | |
| 62 | 67 | return "attach_exact" | |
| 68 | + | case InitialNavigationAttachNamed: | |
| 69 | + | return "attach_named" | |
| 63 | 70 | default: | |
| 64 | 71 | return fmt.Sprintf("invalid(%d)", uint8(k)) | |
| 65 | 72 | } | |
| @@ -104,7 +111,7 @@ func (n InitialNavigation) Validate() error { | |||
| 104 | 111 | return errors.New("vev: picker navigation carries an exact target") | |
| 105 | 112 | } | |
| 106 | 113 | return nil | |
| 107 | - | case InitialNavigationCreateEphemeral, InitialNavigationCreateNamed, InitialNavigationAttachExact: | |
| 114 | + | case InitialNavigationCreateEphemeral, InitialNavigationCreateNamed, InitialNavigationAttachExact, InitialNavigationAttachNamed: | |
| 108 | 115 | default: | |
| 109 | 116 | return fmt.Errorf("vev: unknown initial navigation kind %d", uint8(n.Kind)) | |
| 110 | 117 | } | |
| @@ -124,12 +131,12 @@ func (n InitialNavigation) Validate() error { | |||
| 124 | 131 | if n.Target != (protocol.ExactSessionTarget{}) { | |
| 125 | 132 | return errors.New("vev: ephemeral creation navigation carries an exact target") | |
| 126 | 133 | } | |
| 127 | - | case InitialNavigationCreateNamed: | |
| 134 | + | case InitialNavigationCreateNamed, InitialNavigationAttachNamed: | |
| 128 | 135 | if err := domain.ValidateSessionName(n.Name); err != nil { | |
| 129 | 136 | return fmt.Errorf("vev: initial navigation session name: %w", err) | |
| 130 | 137 | } | |
| 131 | 138 | if n.Target != (protocol.ExactSessionTarget{}) { | |
| 132 | - | return errors.New("vev: named creation navigation carries an exact target") | |
| 139 | + | return errors.New("vev: named navigation carries an exact target") | |
| 133 | 140 | } | |
| 134 | 141 | case InitialNavigationAttachExact: | |
| 135 | 142 | if n.Epoch == 0 { | |
| @@ -202,6 +209,9 @@ func pickerSelectionFromNavigation(n InitialNavigation) pickerSelectionRef { | |||
| 202 | 209 | ref.kind = pickerSelectionExact | |
| 203 | 210 | ref.lifecycle = n.Target.LifecycleID | |
| 204 | 211 | ref.name = n.Target.SessionName | |
| 212 | + | case InitialNavigationAttachNamed: | |
| 213 | + | ref.kind = pickerSelectionAttachNamed | |
| 214 | + | ref.name = n.Name | |
| 205 | 215 | } | |
| 206 | 216 | return ref | |
| 207 | 217 | } | |
| @@ -211,7 +221,7 @@ func pickerSelectionFromNavigation(n InitialNavigation) pickerSelectionRef { | |||
| 211 | 221 | // creation name keeps its own code, and every other contradiction is an | |
| 212 | 222 | // unknown-kind refusal. | |
| 213 | 223 | func initialNavigationRefusal(n InitialNavigation) pickerCatalogueErrorCode { | |
| 214 | - | if n.Kind == InitialNavigationCreateNamed { | |
| 224 | + | if n.Kind == InitialNavigationCreateNamed || n.Kind == InitialNavigationAttachNamed { | |
| 215 | 225 | if err := domain.ValidateSessionName(n.Name); err != nil { | |
| 216 | 226 | return pickerCatalogueInvalidName | |
| 217 | 227 | } | |
| @@ -31,6 +31,8 @@ import ( | |||||
| 31 | // only for the named variant, and no target. | 31 | // only for the named variant, and no target. | ||
| 32 | // - AttachExact carries a valid endpoint fence, a nonzero epoch, no name, and | 32 | // - AttachExact carries a valid endpoint fence, a nonzero epoch, no name, and | ||
| 33 | // a validated protocol.ExactSessionTarget. | 33 | // a validated protocol.ExactSessionTarget. | ||
| 34 | + | // - AttachNamed carries a valid endpoint fence, a name, and no target; the | |||
| 35 | + | // daemon resolves the name and refuses an unknown one. | |||
| 34 | // - A local creation may state epoch zero, meaning "bind to the first adopted | 36 | // - A local creation may state epoch zero, meaning "bind to the first adopted | ||
| 35 | // publication". Every remote intent, and every exact attach even locally, | 37 | // publication". Every remote intent, and every exact attach even locally, | ||
| 36 | // requires the epoch of the snapshot that resolved it, and a nonzero epoch | 38 | // requires the epoch of the snapshot that resolved it, and a nonzero epoch | ||
| @@ -48,6 +50,9 @@ const ( | |||||
| 48 | InitialNavigationCreateNamed | 50 | InitialNavigationCreateNamed | ||
| 49 | // InitialNavigationAttachExact attaches to one exact session lifecycle. | 51 | // InitialNavigationAttachExact attaches to one exact session lifecycle. | ||
| 50 | InitialNavigationAttachExact | 52 | InitialNavigationAttachExact | ||
| 53 | + | // InitialNavigationAttachNamed attaches to the session the daemon knows by | |||
| 54 | + | // name; existence is the daemon's decision, not the broker catalogue's. | |||
| 55 | + | InitialNavigationAttachNamed | |||
| 51 | ) | 56 | ) | ||
| 52 | 57 | ||||
| 53 | func (k InitialNavigationKind) String() string { | 58 | func (k InitialNavigationKind) String() string { | ||
| @@ -60,6 +65,8 @@ func (k InitialNavigationKind) String() string { | |||||
| 60 | return "create_named" | 65 | return "create_named" | ||
| 61 | case InitialNavigationAttachExact: | 66 | case InitialNavigationAttachExact: | ||
| 62 | return "attach_exact" | 67 | return "attach_exact" | ||
| 68 | + | case InitialNavigationAttachNamed: | |||
| 69 | + | return "attach_named" | |||
| 63 | default: | 70 | default: | ||
| 64 | return fmt.Sprintf("invalid(%d)", uint8(k)) | 71 | return fmt.Sprintf("invalid(%d)", uint8(k)) | ||
| 65 | } | 72 | } | ||
| @@ -104,7 +111,7 @@ func (n InitialNavigation) Validate() error { | |||||
| 104 | return errors.New("vev: picker navigation carries an exact target") | 111 | return errors.New("vev: picker navigation carries an exact target") | ||
| 105 | } | 112 | } | ||
| 106 | return nil | 113 | return nil | ||
| 107 | - | case InitialNavigationCreateEphemeral, InitialNavigationCreateNamed, InitialNavigationAttachExact: | 114 | + | case InitialNavigationCreateEphemeral, InitialNavigationCreateNamed, InitialNavigationAttachExact, InitialNavigationAttachNamed: |
| 108 | default: | 115 | default: | ||
| 109 | return fmt.Errorf("vev: unknown initial navigation kind %d", uint8(n.Kind)) | 116 | return fmt.Errorf("vev: unknown initial navigation kind %d", uint8(n.Kind)) | ||
| 110 | } | 117 | } | ||
| @@ -124,12 +131,12 @@ func (n InitialNavigation) Validate() error { | |||||
| 124 | if n.Target != (protocol.ExactSessionTarget{}) { | 131 | if n.Target != (protocol.ExactSessionTarget{}) { | ||
| 125 | return errors.New("vev: ephemeral creation navigation carries an exact target") | 132 | return errors.New("vev: ephemeral creation navigation carries an exact target") | ||
| 126 | } | 133 | } | ||
| 127 | - | case InitialNavigationCreateNamed: | 134 | + | case InitialNavigationCreateNamed, InitialNavigationAttachNamed: |
| 128 | if err := domain.ValidateSessionName(n.Name); err != nil { | 135 | if err := domain.ValidateSessionName(n.Name); err != nil { | ||
| 129 | return fmt.Errorf("vev: initial navigation session name: %w", err) | 136 | return fmt.Errorf("vev: initial navigation session name: %w", err) | ||
| 130 | } | 137 | } | ||
| 131 | if n.Target != (protocol.ExactSessionTarget{}) { | 138 | if n.Target != (protocol.ExactSessionTarget{}) { | ||
| 132 | - | return errors.New("vev: named creation navigation carries an exact target") | 139 | + | return errors.New("vev: named navigation carries an exact target") |
| 133 | } | 140 | } | ||
| 134 | case InitialNavigationAttachExact: | 141 | case InitialNavigationAttachExact: | ||
| 135 | if n.Epoch == 0 { | 142 | if n.Epoch == 0 { | ||
| @@ -202,6 +209,9 @@ func pickerSelectionFromNavigation(n InitialNavigation) pickerSelectionRef { | |||||
| 202 | ref.kind = pickerSelectionExact | 209 | ref.kind = pickerSelectionExact | ||
| 203 | ref.lifecycle = n.Target.LifecycleID | 210 | ref.lifecycle = n.Target.LifecycleID | ||
| 204 | ref.name = n.Target.SessionName | 211 | ref.name = n.Target.SessionName | ||
| 212 | + | case InitialNavigationAttachNamed: | |||
| 213 | + | ref.kind = pickerSelectionAttachNamed | |||
| 214 | + | ref.name = n.Name | |||
| 205 | } | 215 | } | ||
| 206 | return ref | 216 | return ref | ||
| 207 | } | 217 | } | ||
| @@ -211,7 +221,7 @@ func pickerSelectionFromNavigation(n InitialNavigation) pickerSelectionRef { | |||||
| 211 | // creation name keeps its own code, and every other contradiction is an | 221 | // creation name keeps its own code, and every other contradiction is an | ||
| 212 | // unknown-kind refusal. | 222 | // unknown-kind refusal. | ||
| 213 | func initialNavigationRefusal(n InitialNavigation) pickerCatalogueErrorCode { | 223 | func initialNavigationRefusal(n InitialNavigation) pickerCatalogueErrorCode { | ||
| 214 | - | if n.Kind == InitialNavigationCreateNamed { | 224 | + | if n.Kind == InitialNavigationCreateNamed || n.Kind == InitialNavigationAttachNamed { |
| 215 | if err := domain.ValidateSessionName(n.Name); err != nil { | 225 | if err := domain.ValidateSessionName(n.Name); err != nil { | ||
| 216 | return pickerCatalogueInvalidName | 226 | return pickerCatalogueInvalidName | ||
| 217 | } | 227 | } | ||
M internal/usecase/client/picker_catalogue.go +12 -1
| @@ -136,6 +136,9 @@ const ( | |||
| 136 | 136 | pickerSelectionCreateNamed | |
| 137 | 137 | // pickerSelectionCreateEphemeral creates one ephemeral session. | |
| 138 | 138 | pickerSelectionCreateEphemeral | |
| 139 | + | // pickerSelectionAttachNamed attaches to a session the daemon resolves by | |
| 140 | + | // name. It is produced only by an initial CLI navigation, never a row. | |
| 141 | + | pickerSelectionAttachNamed | |
| 139 | 142 | ) | |
| 140 | 143 | ||
| 141 | 144 | // pickerSelectionRef is the client's exact selection identity, captured when a | |
| @@ -599,7 +602,9 @@ func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthori | |||
| 599 | 602 | if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind == pickerSelectionExact { | |
| 600 | 603 | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote host has no vev daemon; choose create session"}) | |
| 601 | 604 | } | |
| 602 | - | if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind != pickerSelectionCreateNamed && ref.kind != pickerSelectionCreateEphemeral { | |
| 605 | + | if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind != pickerSelectionCreateNamed && ref.kind != pickerSelectionCreateEphemeral && ref.kind != pickerSelectionAttachNamed { | |
| 606 | + | // A named attach carries StartIfNeeded and lets the started daemon | |
| 607 | + | // restore or refuse the name, so a no-daemon observation is no refusal. | |
| 603 | 608 | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "no daemon exists on this host; choose create session"}) | |
| 604 | 609 | } | |
| 605 | 610 | if refuseFailing && !ref.local && pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityNoDaemon { | |
| @@ -658,6 +663,12 @@ func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthori | |||
| 658 | 663 | } | |
| 659 | 664 | request.Admission = ports.BrokerAdmissionCreateNamed | |
| 660 | 665 | request.Name = ref.createName | |
| 666 | + | case pickerSelectionAttachNamed: | |
| 667 | + | if err := domain.ValidateSessionName(ref.name); err != nil { | |
| 668 | + | return fail(pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"}) | |
| 669 | + | } | |
| 670 | + | request.Admission = ports.BrokerAdmissionAttachNamed | |
| 671 | + | request.Name = ref.name | |
| 661 | 672 | case pickerSelectionCreateEphemeral: | |
| 662 | 673 | request.Admission = ports.BrokerAdmissionCreateEphemeral | |
| 663 | 674 | default: | |
| @@ -136,6 +136,9 @@ const ( | |||||
| 136 | pickerSelectionCreateNamed | 136 | pickerSelectionCreateNamed | ||
| 137 | // pickerSelectionCreateEphemeral creates one ephemeral session. | 137 | // pickerSelectionCreateEphemeral creates one ephemeral session. | ||
| 138 | pickerSelectionCreateEphemeral | 138 | pickerSelectionCreateEphemeral | ||
| 139 | + | // pickerSelectionAttachNamed attaches to a session the daemon resolves by | |||
| 140 | + | // name. It is produced only by an initial CLI navigation, never a row. | |||
| 141 | + | pickerSelectionAttachNamed | |||
| 139 | ) | 142 | ) | ||
| 140 | 143 | ||||
| 141 | // pickerSelectionRef is the client's exact selection identity, captured when a | 144 | // pickerSelectionRef is the client's exact selection identity, captured when a | ||
| @@ -599,7 +602,9 @@ func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthori | |||||
| 599 | if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind == pickerSelectionExact { | 602 | if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind == pickerSelectionExact { | ||
| 600 | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote host has no vev daemon; choose create session"}) | 603 | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote host has no vev daemon; choose create session"}) | ||
| 601 | } | 604 | } | ||
| 602 | - | if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind != pickerSelectionCreateNamed && ref.kind != pickerSelectionCreateEphemeral { | 605 | + | if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind != pickerSelectionCreateNamed && ref.kind != pickerSelectionCreateEphemeral && ref.kind != pickerSelectionAttachNamed { |
| 606 | + | // A named attach carries StartIfNeeded and lets the started daemon | |||
| 607 | + | // restore or refuse the name, so a no-daemon observation is no refusal. | |||
| 603 | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "no daemon exists on this host; choose create session"}) | 608 | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "no daemon exists on this host; choose create session"}) | ||
| 604 | } | 609 | } | ||
| 605 | if refuseFailing && !ref.local && pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityNoDaemon { | 610 | if refuseFailing && !ref.local && pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityNoDaemon { | ||
| @@ -658,6 +663,12 @@ func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthori | |||||
| 658 | } | 663 | } | ||
| 659 | request.Admission = ports.BrokerAdmissionCreateNamed | 664 | request.Admission = ports.BrokerAdmissionCreateNamed | ||
| 660 | request.Name = ref.createName | 665 | request.Name = ref.createName | ||
| 666 | + | case pickerSelectionAttachNamed: | |||
| 667 | + | if err := domain.ValidateSessionName(ref.name); err != nil { | |||
| 668 | + | return fail(pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"}) | |||
| 669 | + | } | |||
| 670 | + | request.Admission = ports.BrokerAdmissionAttachNamed | |||
| 671 | + | request.Name = ref.name | |||
| 661 | case pickerSelectionCreateEphemeral: | 672 | case pickerSelectionCreateEphemeral: | ||
| 662 | request.Admission = ports.BrokerAdmissionCreateEphemeral | 673 | request.Admission = ports.BrokerAdmissionCreateEphemeral | ||
| 663 | default: | 674 | default: | ||
M internal/usecase/client/picker_catalogue_test.go +23 -0
| @@ -849,6 +849,7 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) { | |||
| 849 | 849 | hostRow bool | |
| 850 | 850 | create bool | |
| 851 | 851 | exact bool | |
| 852 | + | named bool | |
| 852 | 853 | }{ | |
| 853 | 854 | { | |
| 854 | 855 | name: "unobserved unknown stays attemptable", | |
| @@ -915,6 +916,19 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) { | |||
| 915 | 916 | }(), | |
| 916 | 917 | wantErr: pickerCatalogueUnavailable, | |
| 917 | 918 | }, | |
| 919 | + | { | |
| 920 | + | name: "no daemon lets the daemon decide a named attach", named: true, | |
| 921 | + | daemon: func() ports.BrokerDaemonObservation { | |
| 922 | + | o := pickerTestRemoteObservation("user@arch", 1, 1, now) | |
| 923 | + | o.Identity = "" | |
| 924 | + | o.Incarnation = ports.BrokerDaemonIncarnation{} | |
| 925 | + | o.ProtocolVersion = 0 | |
| 926 | + | o.Availability = domain.RemoteAvailabilityNoDaemon | |
| 927 | + | o.LastSuccess = time.Time{} | |
| 928 | + | o.InventoryKnown = false | |
| 929 | + | return o | |
| 930 | + | }(), | |
| 931 | + | }, | |
| 918 | 932 | { | |
| 919 | 933 | name: "reachable mismatch refuses incompatible", | |
| 920 | 934 | daemon: func() ports.BrokerDaemonObservation { | |
| @@ -953,6 +967,15 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) { | |||
| 953 | 967 | require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode) | |
| 954 | 968 | return | |
| 955 | 969 | } | |
| 970 | + | if tt.named { | |
| 971 | + | ref := pickerSelectionRef{kind: pickerSelectionAttachNamed, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint, | |
| 972 | + | registration: tt.daemon.Registration, name: "restored"} | |
| 973 | + | request, err := resolvePickerRequest(catalogue.snapshot.Epoch, pickerAuthorityInSnapshot(catalogue.snapshot, ref), ref, pickerTestBase()) | |
| 974 | + | require.NoError(t, err) | |
| 975 | + | require.Equal(t, ports.BrokerAdmissionAttachNamed, request.Admission) | |
| 976 | + | require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode) | |
| 977 | + | return | |
| 978 | + | } | |
| 956 | 979 | if tt.exact { | |
| 957 | 980 | ref := pickerSelectionRef{kind: pickerSelectionExact, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint, | |
| 958 | 981 | registration: tt.daemon.Registration, lifecycle: domain.SessionLifecycleID{1}, name: "absent"} | |
| @@ -849,6 +849,7 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) { | |||||
| 849 | hostRow bool | 849 | hostRow bool | ||
| 850 | create bool | 850 | create bool | ||
| 851 | exact bool | 851 | exact bool | ||
| 852 | + | named bool | |||
| 852 | }{ | 853 | }{ | ||
| 853 | { | 854 | { | ||
| 854 | name: "unobserved unknown stays attemptable", | 855 | name: "unobserved unknown stays attemptable", | ||
| @@ -915,6 +916,19 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) { | |||||
| 915 | }(), | 916 | }(), | ||
| 916 | wantErr: pickerCatalogueUnavailable, | 917 | wantErr: pickerCatalogueUnavailable, | ||
| 917 | }, | 918 | }, | ||
| 919 | + | { | |||
| 920 | + | name: "no daemon lets the daemon decide a named attach", named: true, | |||
| 921 | + | daemon: func() ports.BrokerDaemonObservation { | |||
| 922 | + | o := pickerTestRemoteObservation("user@arch", 1, 1, now) | |||
| 923 | + | o.Identity = "" | |||
| 924 | + | o.Incarnation = ports.BrokerDaemonIncarnation{} | |||
| 925 | + | o.ProtocolVersion = 0 | |||
| 926 | + | o.Availability = domain.RemoteAvailabilityNoDaemon | |||
| 927 | + | o.LastSuccess = time.Time{} | |||
| 928 | + | o.InventoryKnown = false | |||
| 929 | + | return o | |||
| 930 | + | }(), | |||
| 931 | + | }, | |||
| 918 | { | 932 | { | ||
| 919 | name: "reachable mismatch refuses incompatible", | 933 | name: "reachable mismatch refuses incompatible", | ||
| 920 | daemon: func() ports.BrokerDaemonObservation { | 934 | daemon: func() ports.BrokerDaemonObservation { | ||
| @@ -953,6 +967,15 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) { | |||||
| 953 | require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode) | 967 | require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode) | ||
| 954 | return | 968 | return | ||
| 955 | } | 969 | } | ||
| 970 | + | if tt.named { | |||
| 971 | + | ref := pickerSelectionRef{kind: pickerSelectionAttachNamed, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint, | |||
| 972 | + | registration: tt.daemon.Registration, name: "restored"} | |||
| 973 | + | request, err := resolvePickerRequest(catalogue.snapshot.Epoch, pickerAuthorityInSnapshot(catalogue.snapshot, ref), ref, pickerTestBase()) | |||
| 974 | + | require.NoError(t, err) | |||
| 975 | + | require.Equal(t, ports.BrokerAdmissionAttachNamed, request.Admission) | |||
| 976 | + | require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode) | |||
| 977 | + | return | |||
| 978 | + | } | |||
| 956 | if tt.exact { | 979 | if tt.exact { | ||
| 957 | ref := pickerSelectionRef{kind: pickerSelectionExact, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint, | 980 | ref := pickerSelectionRef{kind: pickerSelectionExact, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint, | ||
| 958 | registration: tt.daemon.Registration, lifecycle: domain.SessionLifecycleID{1}, name: "absent"} | 981 | registration: tt.daemon.Registration, lifecycle: domain.SessionLifecycleID{1}, name: "absent"} | ||
M internal/usecase/daemon/attachment_effect_gate_test.go +3 -2
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "io" | |
| 6 | + | "slices" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| @@ -523,8 +524,8 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat | |||
| 523 | 524 | t.Fatal("replacement publication remained blocked behind the old render send") | |
| 524 | 525 | } | |
| 525 | 526 | require.NoError(t, <-transitionErr) | |
| 526 | - | require.Contains(t, sess.snapshotAttachments(), next) | |
| 527 | - | require.Contains(t, sess.snapshotAttachments(), old) | |
| 527 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), next)) | |
| 528 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), old)) | |
| 528 | 529 | require.False(t, newTransport.Closed(), "new attachment transport was affected by publication") | |
| 529 | 530 | ||
| 530 | 531 | // Generic attachment publication does not interrupt or retire another | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | + | "slices" | |||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "time" | 9 | "time" | ||
| @@ -523,8 +524,8 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat | |||||
| 523 | t.Fatal("replacement publication remained blocked behind the old render send") | 524 | t.Fatal("replacement publication remained blocked behind the old render send") | ||
| 524 | } | 525 | } | ||
| 525 | require.NoError(t, <-transitionErr) | 526 | require.NoError(t, <-transitionErr) | ||
| 526 | - | require.Contains(t, sess.snapshotAttachments(), next) | 527 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), next)) |
| 527 | - | require.Contains(t, sess.snapshotAttachments(), old) | 528 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), old)) |
| 528 | require.False(t, newTransport.Closed(), "new attachment transport was affected by publication") | 529 | require.False(t, newTransport.Closed(), "new attachment transport was affected by publication") | ||
| 529 | 530 | ||||
| 530 | // Generic attachment publication does not interrupt or retire another | 531 | // Generic attachment publication does not interrupt or retire another | ||
M internal/usecase/daemon/attachment_multiplex_semantics_test.go +4 -3
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "slices" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| @@ -179,7 +180,7 @@ func TestMultiplexReconnectGenerationFencesOldEffects(t *testing.T) { | |||
| 179 | 180 | t.Fatal("stale generation emitted a frame") | |
| 180 | 181 | default: | |
| 181 | 182 | } | |
| 182 | - | require.Contains(t, sess.snapshotAttachments(), peer) | |
| 183 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), peer)) | |
| 183 | 184 | } | |
| 184 | 185 | ||
| 185 | 186 | // TestMultiplexSingleStreamErrorIsolation proves one failed attachment is | |
| @@ -197,8 +198,8 @@ func TestMultiplexSingleStreamErrorIsolation(t *testing.T) { | |||
| 197 | 198 | failed.installTestAttachmentCapability(failedToken) | |
| 198 | 199 | ||
| 199 | 200 | d.detachOnAttachmentSendError(failedToken, failedTransport) | |
| 200 | - | require.NotContains(t, sess.snapshotAttachments(), failed) | |
| 201 | - | require.Contains(t, sess.snapshotAttachments(), peer) | |
| 201 | + | require.False(t, slices.Contains(sess.snapshotAttachments(), failed)) | |
| 202 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), peer)) | |
| 202 | 203 | require.Same(t, sess, peer.currentSession()) | |
| 203 | 204 | d.mu.Lock() | |
| 204 | 205 | require.Same(t, sess, d.sessions[sess.id]) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "slices" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| @@ -179,7 +180,7 @@ func TestMultiplexReconnectGenerationFencesOldEffects(t *testing.T) { | |||||
| 179 | t.Fatal("stale generation emitted a frame") | 180 | t.Fatal("stale generation emitted a frame") | ||
| 180 | default: | 181 | default: | ||
| 181 | } | 182 | } | ||
| 182 | - | require.Contains(t, sess.snapshotAttachments(), peer) | 183 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), peer)) |
| 183 | } | 184 | } | ||
| 184 | 185 | ||||
| 185 | // TestMultiplexSingleStreamErrorIsolation proves one failed attachment is | 186 | // TestMultiplexSingleStreamErrorIsolation proves one failed attachment is | ||
| @@ -197,8 +198,8 @@ func TestMultiplexSingleStreamErrorIsolation(t *testing.T) { | |||||
| 197 | failed.installTestAttachmentCapability(failedToken) | 198 | failed.installTestAttachmentCapability(failedToken) | ||
| 198 | 199 | ||||
| 199 | d.detachOnAttachmentSendError(failedToken, failedTransport) | 200 | d.detachOnAttachmentSendError(failedToken, failedTransport) | ||
| 200 | - | require.NotContains(t, sess.snapshotAttachments(), failed) | 201 | + | require.False(t, slices.Contains(sess.snapshotAttachments(), failed)) |
| 201 | - | require.Contains(t, sess.snapshotAttachments(), peer) | 202 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), peer)) |
| 202 | require.Same(t, sess, peer.currentSession()) | 203 | require.Same(t, sess, peer.currentSession()) | ||
| 203 | d.mu.Lock() | 204 | d.mu.Lock() | ||
| 204 | require.Same(t, sess, d.sessions[sess.id]) | 205 | require.Same(t, sess, d.sessions[sess.id]) | ||
M internal/usecase/daemon/attachment_parking_test.go +2 -1
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "slices" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/stretchr/testify/require" | |
| @@ -42,7 +43,7 @@ func TestAttachmentLossParksOnlyThatAttachment(t *testing.T) { | |||
| 42 | 43 | sess.mu.Unlock() | |
| 43 | 44 | require.False(t, firstAttached) | |
| 44 | 45 | require.True(t, secondAttached) | |
| 45 | - | require.Contains(t, sess.snapshotAttachments(), second) | |
| 46 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), second)) | |
| 46 | 47 | require.False(t, secondTransport.Closed()) | |
| 47 | 48 | } | |
| 48 | 49 | ||
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "slices" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| @@ -42,7 +43,7 @@ func TestAttachmentLossParksOnlyThatAttachment(t *testing.T) { | |||||
| 42 | sess.mu.Unlock() | 43 | sess.mu.Unlock() | ||
| 43 | require.False(t, firstAttached) | 44 | require.False(t, firstAttached) | ||
| 44 | require.True(t, secondAttached) | 45 | require.True(t, secondAttached) | ||
| 45 | - | require.Contains(t, sess.snapshotAttachments(), second) | 46 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), second)) |
| 46 | require.False(t, secondTransport.Closed()) | 47 | require.False(t, secondTransport.Closed()) | ||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
M internal/usecase/daemon/attachment_suspension_expiry_test.go +3 -2
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | "io" | |
| 6 | 6 | "log/slog" | |
| 7 | + | "slices" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "sync/atomic" | |
| 9 | 10 | "testing" | |
| @@ -112,7 +113,7 @@ func TestSuspendedAttachmentSafetyExpiryEvictsAfterTimer(t *testing.T) { | |||
| 112 | 113 | // Suspended-only presence is not observable, but membership and transport | |
| 113 | 114 | // ownership are retained until expiry. | |
| 114 | 115 | require.False(t, sess.snapshotView(viewOptions{}).attached, "suspended-only session must not be observably attached") | |
| 115 | - | require.Contains(t, sess.snapshotAttachments(), ac, "suspension retains session membership") | |
| 116 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "suspension retains session membership") | |
| 116 | 117 | d.mu.Lock() | |
| 117 | 118 | require.NotNil(t, d.suspended[ac], "suspension arms the daemon-owned safety expiry") | |
| 118 | 119 | d.mu.Unlock() | |
| @@ -438,7 +439,7 @@ func TestHandleListAttachedExcludesSuspendedOnly(t *testing.T) { | |||
| 438 | 439 | ac.installTestAttachmentCapability(token) | |
| 439 | 440 | require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1})) | |
| 440 | 441 | require.False(t, listedAttached(t, d, sess.name), "a suspended-only session is not attached in the listing") | |
| 441 | - | require.Contains(t, sess.snapshotAttachments(), ac, "suspension retains membership for activation") | |
| 442 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "suspension retains membership for activation") | |
| 442 | 443 | ||
| 443 | 444 | require.NoError(t, d.activateAttachment(ac, ac.transportSnapshot(), protocol.ActivateAttachment{ | |
| 444 | 445 | RequestID: 2, | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | "log/slog" | 6 | "log/slog" | ||
| 7 | + | "slices" | |||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "sync/atomic" | 9 | "sync/atomic" | ||
| 9 | "testing" | 10 | "testing" | ||
| @@ -112,7 +113,7 @@ func TestSuspendedAttachmentSafetyExpiryEvictsAfterTimer(t *testing.T) { | |||||
| 112 | // Suspended-only presence is not observable, but membership and transport | 113 | // Suspended-only presence is not observable, but membership and transport | ||
| 113 | // ownership are retained until expiry. | 114 | // ownership are retained until expiry. | ||
| 114 | require.False(t, sess.snapshotView(viewOptions{}).attached, "suspended-only session must not be observably attached") | 115 | require.False(t, sess.snapshotView(viewOptions{}).attached, "suspended-only session must not be observably attached") | ||
| 115 | - | require.Contains(t, sess.snapshotAttachments(), ac, "suspension retains session membership") | 116 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "suspension retains session membership") |
| 116 | d.mu.Lock() | 117 | d.mu.Lock() | ||
| 117 | require.NotNil(t, d.suspended[ac], "suspension arms the daemon-owned safety expiry") | 118 | require.NotNil(t, d.suspended[ac], "suspension arms the daemon-owned safety expiry") | ||
| 118 | d.mu.Unlock() | 119 | d.mu.Unlock() | ||
| @@ -438,7 +439,7 @@ func TestHandleListAttachedExcludesSuspendedOnly(t *testing.T) { | |||||
| 438 | ac.installTestAttachmentCapability(token) | 439 | ac.installTestAttachmentCapability(token) | ||
| 439 | require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1})) | 440 | require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1})) | ||
| 440 | require.False(t, listedAttached(t, d, sess.name), "a suspended-only session is not attached in the listing") | 441 | require.False(t, listedAttached(t, d, sess.name), "a suspended-only session is not attached in the listing") | ||
| 441 | - | require.Contains(t, sess.snapshotAttachments(), ac, "suspension retains membership for activation") | 442 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "suspension retains membership for activation") |
| 442 | 443 | ||||
| 443 | require.NoError(t, d.activateAttachment(ac, ac.transportSnapshot(), protocol.ActivateAttachment{ | 444 | require.NoError(t, d.activateAttachment(ac, ac.transportSnapshot(), protocol.ActivateAttachment{ | ||
| 444 | RequestID: 2, | 445 | RequestID: 2, | ||
M internal/usecase/daemon/clipboard_test.go +4 -3
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "io" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | + | "slices" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "sync" | |
| 12 | 13 | "testing" | |
| @@ -366,7 +367,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) { | |||
| 366 | 367 | d.clipboardWorker(sess) | |
| 367 | 368 | ||
| 368 | 369 | require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client") | |
| 369 | - | require.Contains(t, sess.snapshotAttachments(), ac, "stale clipboard send handling detached the former owner's client") | |
| 370 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale clipboard send handling detached the former owner's client") | |
| 370 | 371 | } | |
| 371 | 372 | ||
| 372 | 373 | type movingClipboardErrorTransport struct { | |
| @@ -435,7 +436,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing | |||
| 435 | 436 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | |
| 436 | 437 | ||
| 437 | 438 | require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O") | |
| 438 | - | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 439 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) | |
| 439 | 440 | } | |
| 440 | 441 | ||
| 441 | 442 | func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |
| @@ -464,7 +465,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |||
| 464 | 465 | close(oldTransport.release) | |
| 465 | 466 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | |
| 466 | 467 | ||
| 467 | - | require.Contains(t, sess.snapshotAttachments(), ac, "a send error from the pane's retired owner detached its client") | |
| 468 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "a send error from the pane's retired owner detached its client") | |
| 468 | 469 | select { | |
| 469 | 470 | case <-oldTransport.closed: | |
| 470 | 471 | t.Fatal("a send error from the pane's retired owner closed its client transport") | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "io" | 7 | "io" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | + | "slices" | |||
| 10 | "strings" | 11 | "strings" | ||
| 11 | "sync" | 12 | "sync" | ||
| 12 | "testing" | 13 | "testing" | ||
| @@ -366,7 +367,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) { | |||||
| 366 | d.clipboardWorker(sess) | 367 | d.clipboardWorker(sess) | ||
| 367 | 368 | ||||
| 368 | require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client") | 369 | require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client") | ||
| 369 | - | require.Contains(t, sess.snapshotAttachments(), ac, "stale clipboard send handling detached the former owner's client") | 370 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale clipboard send handling detached the former owner's client") |
| 370 | } | 371 | } | ||
| 371 | 372 | ||||
| 372 | type movingClipboardErrorTransport struct { | 373 | type movingClipboardErrorTransport struct { | ||
| @@ -435,7 +436,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing | |||||
| 435 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | 436 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | ||
| 436 | 437 | ||||
| 437 | require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O") | 438 | require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O") | ||
| 438 | - | require.Contains(t, sess.snapshotAttachments(), ac) | 439 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) |
| 439 | } | 440 | } | ||
| 440 | 441 | ||||
| 441 | func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | 442 | func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | ||
| @@ -464,7 +465,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |||||
| 464 | close(oldTransport.release) | 465 | close(oldTransport.release) | ||
| 465 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | 466 | awaitTestCompletion(t, workerDone, "clipboard worker did not finish") | ||
| 466 | 467 | ||||
| 467 | - | require.Contains(t, sess.snapshotAttachments(), ac, "a send error from the pane's retired owner detached its client") | 468 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "a send error from the pane's retired owner detached its client") |
| 468 | select { | 469 | select { | ||
| 469 | case <-oldTransport.closed: | 470 | case <-oldTransport.closed: | ||
| 470 | t.Fatal("a send error from the pane's retired owner closed its client transport") | 471 | t.Fatal("a send error from the pane's retired owner closed its client transport") | ||
M internal/usecase/daemon/copymode_test.go +2 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "io" | |
| 8 | + | "slices" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "testing" | |
| @@ -61,7 +62,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin | |||
| 61 | 62 | out := unmarshalTestOutput(t, (<-failed.sent).Payload) | |
| 62 | 63 | require.Equal(t, []byte("copy"), out.Data) | |
| 63 | 64 | d.detachOnSendError(sess, ac, used) | |
| 64 | - | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 65 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) | |
| 65 | 66 | require.False(t, replacement.Closed()) | |
| 66 | 67 | } | |
| 67 | 68 | ||
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | + | "slices" | |||
| 8 | "strings" | 9 | "strings" | ||
| 9 | "sync" | 10 | "sync" | ||
| 10 | "testing" | 11 | "testing" | ||
| @@ -61,7 +62,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin | |||||
| 61 | out := unmarshalTestOutput(t, (<-failed.sent).Payload) | 62 | out := unmarshalTestOutput(t, (<-failed.sent).Payload) | ||
| 62 | require.Equal(t, []byte("copy"), out.Data) | 63 | require.Equal(t, []byte("copy"), out.Data) | ||
| 63 | d.detachOnSendError(sess, ac, used) | 64 | d.detachOnSendError(sess, ac, used) | ||
| 64 | - | require.Contains(t, sess.snapshotAttachments(), ac) | 65 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) |
| 65 | require.False(t, replacement.Closed()) | 66 | require.False(t, replacement.Closed()) | ||
| 66 | } | 67 | } | ||
| 67 | 68 | ||||
M internal/usecase/daemon/resume_test.go +13 -12
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "slices" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "testing" | |
| @@ -413,7 +414,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) { | |||
| 413 | 414 | require.Same(t, sess, resumedSess) | |
| 414 | 415 | require.Same(t, ac, resumedAC) | |
| 415 | 416 | require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token") | |
| 416 | - | require.Contains(t, sess.snapshotAttachments(), resumedAC) | |
| 417 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), resumedAC)) | |
| 417 | 418 | d.firstPaint(sess, resumedAC) | |
| 418 | 419 | require.Equal(t, domain.Size{Cols: 100, Rows: 30}, peerAC.sizeSnapshot()) | |
| 419 | 420 | tb.mu.Lock() | |
| @@ -508,7 +509,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) { | |||
| 508 | 509 | require.NoError(t, err) | |
| 509 | 510 | token := ac.resumeToken | |
| 510 | 511 | require.NotZero(t, token) | |
| 511 | - | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 512 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) | |
| 512 | 513 | d.mu.Lock() | |
| 513 | 514 | _, parkedAtStart := d.parked[token] | |
| 514 | 515 | sessionsBefore := len(d.sessions) | |
| @@ -581,7 +582,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||
| 581 | 582 | require.NoError(t, err) | |
| 582 | 583 | token := ac.resumeToken | |
| 583 | 584 | require.NotZero(t, token) | |
| 584 | - | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 585 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) | |
| 585 | 586 | ||
| 586 | 587 | reachedGap := make(chan struct{}) | |
| 587 | 588 | releaseGap := make(chan struct{}) | |
| @@ -647,7 +648,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||
| 647 | 648 | require.NoError(t, got.err, "same-client token must recover across detach-before-park") | |
| 648 | 649 | require.Same(t, sess, got.sess) | |
| 649 | 650 | require.Same(t, ac, got.ac) | |
| 650 | - | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 651 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) | |
| 651 | 652 | require.Same(t, newTr, ac.transport()) | |
| 652 | 653 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | |
| 653 | 654 | require.True(t, d.commitResumeClaim(ac), "successful resume must consume its parked credential") | |
| @@ -661,7 +662,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||
| 661 | 662 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | |
| 662 | 663 | ||
| 663 | 664 | d.clientGone(sess, ac, oldTr, false) | |
| 664 | - | require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment") | |
| 665 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale old-link cleanup must not detach the rebound attachment") | |
| 665 | 666 | require.Same(t, newTr, ac.transport()) | |
| 666 | 667 | require.False(t, newTr.Closed()) | |
| 667 | 668 | } | |
| @@ -680,7 +681,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 680 | 681 | require.NoError(t, err) | |
| 681 | 682 | token := ac.resumeToken | |
| 682 | 683 | require.NotZero(t, token) | |
| 683 | - | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 684 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) | |
| 684 | 685 | ||
| 685 | 686 | reachedGap := make(chan struct{}) | |
| 686 | 687 | releaseGap := make(chan struct{}) | |
| @@ -750,7 +751,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 750 | 751 | ||
| 751 | 752 | require.Same(t, sess, winner.sess) | |
| 752 | 753 | require.Same(t, ac, winner.ac) | |
| 753 | - | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 754 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) | |
| 754 | 755 | require.Same(t, winnerTr, ac.transport()) | |
| 755 | 756 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | |
| 756 | 757 | require.True(t, oldTr.Closed(), "winning resume must retire the old transport") | |
| @@ -771,7 +772,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||
| 771 | 772 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | |
| 772 | 773 | ||
| 773 | 774 | d.clientGone(sess, ac, oldTr, false) | |
| 774 | - | require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment") | |
| 775 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale old-link cleanup must not detach the rebound attachment") | |
| 775 | 776 | require.Same(t, winnerTr, ac.transport()) | |
| 776 | 777 | require.False(t, winnerTr.Closed()) | |
| 777 | 778 | } | |
| @@ -902,7 +903,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||
| 902 | 903 | require.NoError(t, err) | |
| 903 | 904 | token := ac.resumeToken | |
| 904 | 905 | require.NotZero(t, token) | |
| 905 | - | require.Contains(t, sess.snapshotAttachments(), ac) | |
| 906 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) | |
| 906 | 907 | ||
| 907 | 908 | validated := make(chan struct{}) | |
| 908 | 909 | releaseMark := make(chan struct{}) | |
| @@ -1018,7 +1019,7 @@ func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *tes | |||
| 1018 | 1019 | releaseOnce.Do(func() { close(releaseDetach) }) | |
| 1019 | 1020 | awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound") | |
| 1020 | 1021 | ||
| 1021 | - | require.Contains(t, sess.snapshotAttachments(), ac, "stale cleanup must not detach the rebound attachment") | |
| 1022 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale cleanup must not detach the rebound attachment") | |
| 1022 | 1023 | require.Same(t, newTr, ac.transport()) | |
| 1023 | 1024 | require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup") | |
| 1024 | 1025 | require.True(t, oldTr.Closed(), "live resume retires the captured old transport") | |
| @@ -1075,7 +1076,7 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) { | |||
| 1075 | 1076 | require.Same(t, sess, resumedSess) | |
| 1076 | 1077 | require.Same(t, ac, resumedAC) | |
| 1077 | 1078 | require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token") | |
| 1078 | - | require.Contains(t, sess.snapshotAttachments(), resumedAC) | |
| 1079 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), resumedAC)) | |
| 1079 | 1080 | } | |
| 1080 | 1081 | ||
| 1081 | 1082 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |
| @@ -1523,7 +1524,7 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) { | |||
| 1523 | 1524 | ||
| 1524 | 1525 | d.clientGone(sess, ac, oldTr, false) | |
| 1525 | 1526 | ||
| 1526 | - | require.Contains(t, sess.snapshotAttachments(), ac, "stale connection must not detach current client") | |
| 1527 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale connection must not detach current client") | |
| 1527 | 1528 | require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler") | |
| 1528 | 1529 | require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop") | |
| 1529 | 1530 | } | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "slices" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "testing" | 9 | "testing" | ||
| @@ -413,7 +414,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) { | |||||
| 413 | require.Same(t, sess, resumedSess) | 414 | require.Same(t, sess, resumedSess) | ||
| 414 | require.Same(t, ac, resumedAC) | 415 | require.Same(t, ac, resumedAC) | ||
| 415 | require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token") | 416 | require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token") | ||
| 416 | - | require.Contains(t, sess.snapshotAttachments(), resumedAC) | 417 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), resumedAC)) |
| 417 | d.firstPaint(sess, resumedAC) | 418 | d.firstPaint(sess, resumedAC) | ||
| 418 | require.Equal(t, domain.Size{Cols: 100, Rows: 30}, peerAC.sizeSnapshot()) | 419 | require.Equal(t, domain.Size{Cols: 100, Rows: 30}, peerAC.sizeSnapshot()) | ||
| 419 | tb.mu.Lock() | 420 | tb.mu.Lock() | ||
| @@ -508,7 +509,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) { | |||||
| 508 | require.NoError(t, err) | 509 | require.NoError(t, err) | ||
| 509 | token := ac.resumeToken | 510 | token := ac.resumeToken | ||
| 510 | require.NotZero(t, token) | 511 | require.NotZero(t, token) | ||
| 511 | - | require.Contains(t, sess.snapshotAttachments(), ac) | 512 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) |
| 512 | d.mu.Lock() | 513 | d.mu.Lock() | ||
| 513 | _, parkedAtStart := d.parked[token] | 514 | _, parkedAtStart := d.parked[token] | ||
| 514 | sessionsBefore := len(d.sessions) | 515 | sessionsBefore := len(d.sessions) | ||
| @@ -581,7 +582,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||||
| 581 | require.NoError(t, err) | 582 | require.NoError(t, err) | ||
| 582 | token := ac.resumeToken | 583 | token := ac.resumeToken | ||
| 583 | require.NotZero(t, token) | 584 | require.NotZero(t, token) | ||
| 584 | - | require.Contains(t, sess.snapshotAttachments(), ac) | 585 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) |
| 585 | 586 | ||||
| 586 | reachedGap := make(chan struct{}) | 587 | reachedGap := make(chan struct{}) | ||
| 587 | releaseGap := make(chan struct{}) | 588 | releaseGap := make(chan struct{}) | ||
| @@ -647,7 +648,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||||
| 647 | require.NoError(t, got.err, "same-client token must recover across detach-before-park") | 648 | require.NoError(t, got.err, "same-client token must recover across detach-before-park") | ||
| 648 | require.Same(t, sess, got.sess) | 649 | require.Same(t, sess, got.sess) | ||
| 649 | require.Same(t, ac, got.ac) | 650 | require.Same(t, ac, got.ac) | ||
| 650 | - | require.Contains(t, sess.snapshotAttachments(), ac) | 651 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) |
| 651 | require.Same(t, newTr, ac.transport()) | 652 | require.Same(t, newTr, ac.transport()) | ||
| 652 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | 653 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | ||
| 653 | require.True(t, d.commitResumeClaim(ac), "successful resume must consume its parked credential") | 654 | require.True(t, d.commitResumeClaim(ac), "successful resume must consume its parked credential") | ||
| @@ -661,7 +662,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) { | |||||
| 661 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | 662 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | ||
| 662 | 663 | ||||
| 663 | d.clientGone(sess, ac, oldTr, false) | 664 | d.clientGone(sess, ac, oldTr, false) | ||
| 664 | - | require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment") | 665 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale old-link cleanup must not detach the rebound attachment") |
| 665 | require.Same(t, newTr, ac.transport()) | 666 | require.Same(t, newTr, ac.transport()) | ||
| 666 | require.False(t, newTr.Closed()) | 667 | require.False(t, newTr.Closed()) | ||
| 667 | } | 668 | } | ||
| @@ -680,7 +681,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||||
| 680 | require.NoError(t, err) | 681 | require.NoError(t, err) | ||
| 681 | token := ac.resumeToken | 682 | token := ac.resumeToken | ||
| 682 | require.NotZero(t, token) | 683 | require.NotZero(t, token) | ||
| 683 | - | require.Contains(t, sess.snapshotAttachments(), ac) | 684 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) |
| 684 | 685 | ||||
| 685 | reachedGap := make(chan struct{}) | 686 | reachedGap := make(chan struct{}) | ||
| 686 | releaseGap := make(chan struct{}) | 687 | releaseGap := make(chan struct{}) | ||
| @@ -750,7 +751,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||||
| 750 | 751 | ||||
| 751 | require.Same(t, sess, winner.sess) | 752 | require.Same(t, sess, winner.sess) | ||
| 752 | require.Same(t, ac, winner.ac) | 753 | require.Same(t, ac, winner.ac) | ||
| 753 | - | require.Contains(t, sess.snapshotAttachments(), ac) | 754 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) |
| 754 | require.Same(t, winnerTr, ac.transport()) | 755 | require.Same(t, winnerTr, ac.transport()) | ||
| 755 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | 756 | require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential") | ||
| 756 | require.True(t, oldTr.Closed(), "winning resume must retire the old transport") | 757 | require.True(t, oldTr.Closed(), "winning resume must retire the old transport") | ||
| @@ -771,7 +772,7 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) { | |||||
| 771 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | 772 | require.False(t, stillParking, "parking marker must be consumed after park/resume") | ||
| 772 | 773 | ||||
| 773 | d.clientGone(sess, ac, oldTr, false) | 774 | d.clientGone(sess, ac, oldTr, false) | ||
| 774 | - | require.Contains(t, sess.snapshotAttachments(), ac, "stale old-link cleanup must not detach the rebound attachment") | 775 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale old-link cleanup must not detach the rebound attachment") |
| 775 | require.Same(t, winnerTr, ac.transport()) | 776 | require.Same(t, winnerTr, ac.transport()) | ||
| 776 | require.False(t, winnerTr.Closed()) | 777 | require.False(t, winnerTr.Closed()) | ||
| 777 | } | 778 | } | ||
| @@ -902,7 +903,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||||
| 902 | require.NoError(t, err) | 903 | require.NoError(t, err) | ||
| 903 | token := ac.resumeToken | 904 | token := ac.resumeToken | ||
| 904 | require.NotZero(t, token) | 905 | require.NotZero(t, token) | ||
| 905 | - | require.Contains(t, sess.snapshotAttachments(), ac) | 906 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac)) |
| 906 | 907 | ||||
| 907 | validated := make(chan struct{}) | 908 | validated := make(chan struct{}) | ||
| 908 | releaseMark := make(chan struct{}) | 909 | releaseMark := make(chan struct{}) | ||
| @@ -1018,7 +1019,7 @@ func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *tes | |||||
| 1018 | releaseOnce.Do(func() { close(releaseDetach) }) | 1019 | releaseOnce.Do(func() { close(releaseDetach) }) | ||
| 1019 | awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound") | 1020 | awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound") | ||
| 1020 | 1021 | ||||
| 1021 | - | require.Contains(t, sess.snapshotAttachments(), ac, "stale cleanup must not detach the rebound attachment") | 1022 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale cleanup must not detach the rebound attachment") |
| 1022 | require.Same(t, newTr, ac.transport()) | 1023 | require.Same(t, newTr, ac.transport()) | ||
| 1023 | require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup") | 1024 | require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup") | ||
| 1024 | require.True(t, oldTr.Closed(), "live resume retires the captured old transport") | 1025 | require.True(t, oldTr.Closed(), "live resume retires the captured old transport") | ||
| @@ -1075,7 +1076,7 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) { | |||||
| 1075 | require.Same(t, sess, resumedSess) | 1076 | require.Same(t, sess, resumedSess) | ||
| 1076 | require.Same(t, ac, resumedAC) | 1077 | require.Same(t, ac, resumedAC) | ||
| 1077 | require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token") | 1078 | require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token") | ||
| 1078 | - | require.Contains(t, sess.snapshotAttachments(), resumedAC) | 1079 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), resumedAC)) |
| 1079 | } | 1080 | } | ||
| 1080 | 1081 | ||||
| 1081 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | 1082 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | ||
| @@ -1523,7 +1524,7 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) { | |||||
| 1523 | 1524 | ||||
| 1524 | d.clientGone(sess, ac, oldTr, false) | 1525 | d.clientGone(sess, ac, oldTr, false) | ||
| 1525 | 1526 | ||||
| 1526 | - | require.Contains(t, sess.snapshotAttachments(), ac, "stale connection must not detach current client") | 1527 | + | require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale connection must not detach current client") |
| 1527 | require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler") | 1528 | require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler") | ||
| 1528 | require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop") | 1529 | require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop") | ||
| 1529 | } | 1530 | } | ||
M internal/usecase/daemon/session_admission.go +10 -0
| @@ -108,6 +108,16 @@ func validateSessionHelloAdmission(h protocol.Hello, admission ports.SessionAdmi | |||
| 108 | 108 | if h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 { | |
| 109 | 109 | return admissionError("named admission forbids a target or resume token") | |
| 110 | 110 | } | |
| 111 | + | case ports.BrokerAdmissionAttachNamed: | |
| 112 | + | if h.Intent != protocol.IntentAttach { | |
| 113 | + | return admissionError("named attach admission requires an attach hello") | |
| 114 | + | } | |
| 115 | + | if h.Name != admission.Name { | |
| 116 | + | return admissionError("hello name contradicts the admitted session name") | |
| 117 | + | } | |
| 118 | + | if h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 { | |
| 119 | + | return admissionError("named attach admission forbids a target or resume token") | |
| 120 | + | } | |
| 111 | 121 | case ports.BrokerAdmissionExact: | |
| 112 | 122 | if h.ExactTarget == nil || *h.ExactTarget != admission.Target { | |
| 113 | 123 | return admissionError("hello exact target contradicts the admitted target") | |
| @@ -108,6 +108,16 @@ func validateSessionHelloAdmission(h protocol.Hello, admission ports.SessionAdmi | |||||
| 108 | if h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 { | 108 | if h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 { | ||
| 109 | return admissionError("named admission forbids a target or resume token") | 109 | return admissionError("named admission forbids a target or resume token") | ||
| 110 | } | 110 | } | ||
| 111 | + | case ports.BrokerAdmissionAttachNamed: | |||
| 112 | + | if h.Intent != protocol.IntentAttach { | |||
| 113 | + | return admissionError("named attach admission requires an attach hello") | |||
| 114 | + | } | |||
| 115 | + | if h.Name != admission.Name { | |||
| 116 | + | return admissionError("hello name contradicts the admitted session name") | |||
| 117 | + | } | |||
| 118 | + | if h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 { | |||
| 119 | + | return admissionError("named attach admission forbids a target or resume token") | |||
| 120 | + | } | |||
| 111 | case ports.BrokerAdmissionExact: | 121 | case ports.BrokerAdmissionExact: | ||
| 112 | if h.ExactTarget == nil || *h.ExactTarget != admission.Target { | 122 | if h.ExactTarget == nil || *h.ExactTarget != admission.Target { | ||
| 113 | return admissionError("hello exact target contradicts the admitted target") | 123 | return admissionError("hello exact target contradicts the admitted target") | ||
M internal/usecase/daemon/session_admission_test.go +37 -0
| @@ -96,6 +96,43 @@ func TestValidateSessionHelloAdmissionMatrix(t *testing.T) { | |||
| 96 | 96 | }, | |
| 97 | 97 | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work", Env: localEnv}), | |
| 98 | 98 | }, | |
| 99 | + | { | |
| 100 | + | name: "local named attach", | |
| 101 | + | hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", Cwd: "/client/cwd", Env: localEnv}, | |
| 102 | + | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work", Env: localEnv}), | |
| 103 | + | }, | |
| 104 | + | { | |
| 105 | + | name: "remote named attach", | |
| 106 | + | hello: protocol.Hello{ | |
| 107 | + | Intent: protocol.IntentAttach, Name: "work", Remote: true, | |
| 108 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 109 | + | }, | |
| 110 | + | admission: remote(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}), | |
| 111 | + | }, | |
| 112 | + | { | |
| 113 | + | name: "named attach cannot create", | |
| 114 | + | hello: protocol.Hello{Intent: protocol.IntentNew, Name: "work"}, | |
| 115 | + | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}), | |
| 116 | + | wantErr: true, | |
| 117 | + | }, | |
| 118 | + | { | |
| 119 | + | name: "liar name on a named attach", | |
| 120 | + | hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "other"}, | |
| 121 | + | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}), | |
| 122 | + | wantErr: true, | |
| 123 | + | }, | |
| 124 | + | { | |
| 125 | + | name: "named attach smuggles an exact target", | |
| 126 | + | hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target}, | |
| 127 | + | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}), | |
| 128 | + | wantErr: true, | |
| 129 | + | }, | |
| 130 | + | { | |
| 131 | + | name: "named attach smuggles a resume token", | |
| 132 | + | hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ResumeToken: 4}, | |
| 133 | + | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}), | |
| 134 | + | wantErr: true, | |
| 135 | + | }, | |
| 99 | 136 | { | |
| 100 | 137 | name: "local fresh exact attach", | |
| 101 | 138 | hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target}, | |
| @@ -96,6 +96,43 @@ func TestValidateSessionHelloAdmissionMatrix(t *testing.T) { | |||||
| 96 | }, | 96 | }, | ||
| 97 | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work", Env: localEnv}), | 97 | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work", Env: localEnv}), | ||
| 98 | }, | 98 | }, | ||
| 99 | + | { | |||
| 100 | + | name: "local named attach", | |||
| 101 | + | hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", Cwd: "/client/cwd", Env: localEnv}, | |||
| 102 | + | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work", Env: localEnv}), | |||
| 103 | + | }, | |||
| 104 | + | { | |||
| 105 | + | name: "remote named attach", | |||
| 106 | + | hello: protocol.Hello{ | |||
| 107 | + | Intent: protocol.IntentAttach, Name: "work", Remote: true, | |||
| 108 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |||
| 109 | + | }, | |||
| 110 | + | admission: remote(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}), | |||
| 111 | + | }, | |||
| 112 | + | { | |||
| 113 | + | name: "named attach cannot create", | |||
| 114 | + | hello: protocol.Hello{Intent: protocol.IntentNew, Name: "work"}, | |||
| 115 | + | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}), | |||
| 116 | + | wantErr: true, | |||
| 117 | + | }, | |||
| 118 | + | { | |||
| 119 | + | name: "liar name on a named attach", | |||
| 120 | + | hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "other"}, | |||
| 121 | + | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}), | |||
| 122 | + | wantErr: true, | |||
| 123 | + | }, | |||
| 124 | + | { | |||
| 125 | + | name: "named attach smuggles an exact target", | |||
| 126 | + | hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target}, | |||
| 127 | + | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}), | |||
| 128 | + | wantErr: true, | |||
| 129 | + | }, | |||
| 130 | + | { | |||
| 131 | + | name: "named attach smuggles a resume token", | |||
| 132 | + | hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ResumeToken: 4}, | |||
| 133 | + | admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}), | |||
| 134 | + | wantErr: true, | |||
| 135 | + | }, | |||
| 99 | { | 136 | { | ||
| 100 | name: "local fresh exact attach", | 137 | name: "local fresh exact attach", | ||
| 101 | hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target}, | 138 | hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target}, | ||