feat(control): classify command outcomes explicitly
- commit
- b337f41f91efd5c2c29485a865070b84d2b1b20d
- author
- brice · · 2026-09-20 10:34 UTC
- parent
- 69cc15b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
34 files · +1291 -159
M buf.yaml +5 -0
| @@ -15,3 +15,8 @@ breaking: | |||
| 15 | 15 | # One approved clean break at the migration baseline: the Protobuf | |
| 16 | 16 | # schema epoch starts here and replaces the custom MsgType codecs. | |
| 17 | 17 | - EXTENSION_NO_DELETE | |
| 18 | + | ignore_only: | |
| 19 | + | # Protocol v58 intentionally replaces CommandResult.ok with a closed | |
| 20 | + | # outcome. Strict version equality prevents cross-version decoding. | |
| 21 | + | FIELD_NO_DELETE: | |
| 22 | + | - internal/protocol/wire/schema/command.proto | |
| @@ -15,3 +15,8 @@ breaking: | |||||
| 15 | # One approved clean break at the migration baseline: the Protobuf | 15 | # One approved clean break at the migration baseline: the Protobuf | ||
| 16 | # schema epoch starts here and replaces the custom MsgType codecs. | 16 | # schema epoch starts here and replaces the custom MsgType codecs. | ||
| 17 | - EXTENSION_NO_DELETE | 17 | - EXTENSION_NO_DELETE | ||
| 18 | + | ignore_only: | |||
| 19 | + | # Protocol v58 intentionally replaces CommandResult.ok with a closed | |||
| 20 | + | # outcome. Strict version equality prevents cross-version decoding. | |||
| 21 | + | FIELD_NO_DELETE: | |||
| 22 | + | - internal/protocol/wire/schema/command.proto | |||
M docs/architecture.md +12 -2
| @@ -1,6 +1,16 @@ | |||
| 1 | 1 | # Architecture | |
| 2 | 2 | ||
| 3 | - | vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases. | |
| 3 | + | vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases. The current strict protocol version is 58. | |
| 4 | + | ||
| 5 | + | `CommandResult` has a closed explicit outcome contract: | |
| 6 | + | ||
| 7 | + | | Outcome | Meaning | Fields | | |
| 8 | + | | --- | --- | --- | | |
| 9 | + | | `succeeded` | The command completed successfully. | code and text are empty; output may be present. | | |
| 10 | + | | `failed` | A definite failure, including validation/cancellation before dispatch. | code is nonzero; output is empty. | | |
| 11 | + | | `outcome_unknown` | Dispatch was attempted but no final outcome can be established (timeout, cancellation, EOF, or lost reply). | code and output are empty; callers must not replay automatically. | | |
| 12 | + | ||
| 13 | + | Every result retains the request ID for strict correlation. Version negotiation requires exact equality, so protocol 58 intentionally has no compatibility fallback for the deleted legacy `ok` field. | |
| 4 | 14 | ||
| 5 | 15 | ## Package ownership | |
| 6 | 16 | ||
| @@ -197,7 +207,7 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. | |||
| 197 | 207 | `go tool buf generate`, and handle the new variant in `sessionwire`. | |
| 198 | 208 | Never edit generated `*.pb.go`; never add manual IDs or dispatch tables. | |
| 199 | 209 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | |
| 200 | - | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `57`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break. | |
| 210 | + | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `58`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break. | |
| 201 | 211 | ||
| 202 | 212 | ## Broker wire core (Plan 001 P3.1, not activated) | |
| 203 | 213 | ||
| @@ -1,6 +1,16 @@ | |||||
| 1 | # Architecture | 1 | # Architecture | ||
| 2 | 2 | ||||
| 3 | - | vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases. | 3 | + | vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases. The current strict protocol version is 58. |
| 4 | + | ||||
| 5 | + | `CommandResult` has a closed explicit outcome contract: | |||
| 6 | + | ||||
| 7 | + | | Outcome | Meaning | Fields | | |||
| 8 | + | | --- | --- | --- | | |||
| 9 | + | | `succeeded` | The command completed successfully. | code and text are empty; output may be present. | | |||
| 10 | + | | `failed` | A definite failure, including validation/cancellation before dispatch. | code is nonzero; output is empty. | | |||
| 11 | + | | `outcome_unknown` | Dispatch was attempted but no final outcome can be established (timeout, cancellation, EOF, or lost reply). | code and output are empty; callers must not replay automatically. | | |||
| 12 | + | ||||
| 13 | + | Every result retains the request ID for strict correlation. Version negotiation requires exact equality, so protocol 58 intentionally has no compatibility fallback for the deleted legacy `ok` field. | |||
| 4 | 14 | ||||
| 5 | ## Package ownership | 15 | ## Package ownership | ||
| 6 | 16 | ||||
| @@ -197,7 +207,7 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. | |||||
| 197 | `go tool buf generate`, and handle the new variant in `sessionwire`. | 207 | `go tool buf generate`, and handle the new variant in `sessionwire`. | ||
| 198 | Never edit generated `*.pb.go`; never add manual IDs or dispatch tables. | 208 | Never edit generated `*.pb.go`; never add manual IDs or dispatch tables. | ||
| 199 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | 209 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | ||
| 200 | - | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `57`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break. | 210 | + | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `58`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break. |
| 201 | 211 | ||||
| 202 | ## Broker wire core (Plan 001 P3.1, not activated) | 212 | ## Broker wire core (Plan 001 P3.1, not activated) | ||
| 203 | 213 | ||||
M docs/protocol.md +15 -12
| @@ -77,7 +77,7 @@ mutation: | |||
| 77 | 77 | |---|---|---| | |
| 78 | 78 | | `DecodeMessageHello`, version mismatch | `ErrorMsg{ErrVersionMismatch}` | no | | |
| 79 | 79 | | `DecodeMessageHello`, malformed | `ErrorMsg{ErrInternal}` | no | | |
| 80 | - | | `DecodeMessageCommand` | `CommandResult{Code, Text}` | yes, else silent close | | |
| 80 | + | | `DecodeMessageCommand` | `CommandResult{Outcome, Code, Text}` | yes, else silent close | | |
| 81 | 81 | | `DecodeMessageKill` | `ErrorMsg{ErrInternal}` | no — a malformed kill has no reliable RequestID to correlate a `KillResult` to | | |
| 82 | 82 | | `DecodeMessageRemotePreview` | `RemotePreview{Status: Malformed}` | no | | |
| 83 | 83 | | `DecodeMessageNavigationInventory` | `NavigationInventoryResponse{Invalid / VersionMismatch}` | yes, else silent close | | |
| @@ -92,7 +92,9 @@ negotiation: equality is mandatory. | |||
| 92 | 92 | ||
| 93 | 93 | Command requests additionally carry a 10-second result deadline | |
| 94 | 94 | (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per | |
| 95 | - | connection and correlated by `RequestID`. | |
| 95 | + | connection and correlated by `RequestID`. Every dispatched request produces | |
| 96 | + | one closed `CommandResult` outcome: succeeded, failed, or outcome unknown. | |
| 97 | + | Timeout or reply loss after dispatch is outcome unknown and is never replayed. | |
| 96 | 98 | ||
| 97 | 99 | ## Broker negotiation | |
| 98 | 100 | ||
| @@ -196,16 +198,17 @@ answers every normally decoded kill request with exactly one correlated | |||
| 196 | 198 | `KillOutcomeUnknown`. The client consumes that result; a close without a | |
| 197 | 199 | result, a mismatched `RequestID`, or a wrong-typed reply is `outcome unknown` | |
| 198 | 200 | and is never replayed, so a control failure whose response cannot be delivered | |
| 199 | - | is never mistaken for success. The remaining one-shot controls (`List`, | |
| 200 | - | `CommandRequest`, remote preview, inventory, and picker control) have no | |
| 201 | - | explicit per-request result. They do not treat close as success: the daemon | |
| 202 | - | attempts one response and then closes, and a client that receives no valid | |
| 203 | - | response reports a receive error rather than a silent success. Because no | |
| 204 | - | typed result exists, an undeliverable response is logged daemon-side only where | |
| 205 | - | the handler has a send-failure path (`CommandRequest`, remote preview) and is | |
| 206 | - | otherwise unrecorded, so the client is left without any outcome at all; these | |
| 207 | - | paths stay weaker than the explicit `KillResult`, and replacing them with | |
| 208 | - | explicit per-request results belongs to the P7 cutover (GO-005). | |
| 201 | + | is never mistaken for success. `CommandRequest` follows the same correlated | |
| 202 | + | model through `CommandResult`: succeeded, failed, or outcome unknown. A timeout, | |
| 203 | + | close, cancellation, malformed reply, or mismatched `RequestID` after send is | |
| 204 | + | outcome unknown and is never replayed. | |
| 205 | + | ||
| 206 | + | The remaining one-shot controls (`List`, remote preview, inventory, and picker | |
| 207 | + | control) do not treat close as success, but they have no closed outcome field. | |
| 208 | + | The daemon attempts one response and then closes; a client that receives no | |
| 209 | + | valid response reports a receive error. An undeliverable response is logged | |
| 210 | + | only where the handler has a send-failure path, so these controls remain weaker | |
| 211 | + | than `KillResult` and `CommandResult`. | |
| 209 | 212 | ||
| 210 | 213 | ## Limits | |
| 211 | 214 | ||
| @@ -77,7 +77,7 @@ mutation: | |||||
| 77 | |---|---|---| | 77 | |---|---|---| | ||
| 78 | | `DecodeMessageHello`, version mismatch | `ErrorMsg{ErrVersionMismatch}` | no | | 78 | | `DecodeMessageHello`, version mismatch | `ErrorMsg{ErrVersionMismatch}` | no | | ||
| 79 | | `DecodeMessageHello`, malformed | `ErrorMsg{ErrInternal}` | no | | 79 | | `DecodeMessageHello`, malformed | `ErrorMsg{ErrInternal}` | no | | ||
| 80 | - | | `DecodeMessageCommand` | `CommandResult{Code, Text}` | yes, else silent close | | 80 | + | | `DecodeMessageCommand` | `CommandResult{Outcome, Code, Text}` | yes, else silent close | |
| 81 | | `DecodeMessageKill` | `ErrorMsg{ErrInternal}` | no — a malformed kill has no reliable RequestID to correlate a `KillResult` to | | 81 | | `DecodeMessageKill` | `ErrorMsg{ErrInternal}` | no — a malformed kill has no reliable RequestID to correlate a `KillResult` to | | ||
| 82 | | `DecodeMessageRemotePreview` | `RemotePreview{Status: Malformed}` | no | | 82 | | `DecodeMessageRemotePreview` | `RemotePreview{Status: Malformed}` | no | | ||
| 83 | | `DecodeMessageNavigationInventory` | `NavigationInventoryResponse{Invalid / VersionMismatch}` | yes, else silent close | | 83 | | `DecodeMessageNavigationInventory` | `NavigationInventoryResponse{Invalid / VersionMismatch}` | yes, else silent close | | ||
| @@ -92,7 +92,9 @@ negotiation: equality is mandatory. | |||||
| 92 | 92 | ||||
| 93 | Command requests additionally carry a 10-second result deadline | 93 | Command requests additionally carry a 10-second result deadline | ||
| 94 | (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per | 94 | (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per | ||
| 95 | - | connection and correlated by `RequestID`. | 95 | + | connection and correlated by `RequestID`. Every dispatched request produces |
| 96 | + | one closed `CommandResult` outcome: succeeded, failed, or outcome unknown. | |||
| 97 | + | Timeout or reply loss after dispatch is outcome unknown and is never replayed. | |||
| 96 | 98 | ||||
| 97 | ## Broker negotiation | 99 | ## Broker negotiation | ||
| 98 | 100 | ||||
| @@ -196,16 +198,17 @@ answers every normally decoded kill request with exactly one correlated | |||||
| 196 | `KillOutcomeUnknown`. The client consumes that result; a close without a | 198 | `KillOutcomeUnknown`. The client consumes that result; a close without a | ||
| 197 | result, a mismatched `RequestID`, or a wrong-typed reply is `outcome unknown` | 199 | result, a mismatched `RequestID`, or a wrong-typed reply is `outcome unknown` | ||
| 198 | and is never replayed, so a control failure whose response cannot be delivered | 200 | and is never replayed, so a control failure whose response cannot be delivered | ||
| 199 | - | is never mistaken for success. The remaining one-shot controls (`List`, | 201 | + | is never mistaken for success. `CommandRequest` follows the same correlated |
| 200 | - | `CommandRequest`, remote preview, inventory, and picker control) have no | 202 | + | model through `CommandResult`: succeeded, failed, or outcome unknown. A timeout, |
| 201 | - | explicit per-request result. They do not treat close as success: the daemon | 203 | + | close, cancellation, malformed reply, or mismatched `RequestID` after send is |
| 202 | - | attempts one response and then closes, and a client that receives no valid | 204 | + | outcome unknown and is never replayed. |
| 203 | - | response reports a receive error rather than a silent success. Because no | 205 | + | |
| 204 | - | typed result exists, an undeliverable response is logged daemon-side only where | 206 | + | The remaining one-shot controls (`List`, remote preview, inventory, and picker |
| 205 | - | the handler has a send-failure path (`CommandRequest`, remote preview) and is | 207 | + | control) do not treat close as success, but they have no closed outcome field. |
| 206 | - | otherwise unrecorded, so the client is left without any outcome at all; these | 208 | + | The daemon attempts one response and then closes; a client that receives no |
| 207 | - | paths stay weaker than the explicit `KillResult`, and replacing them with | 209 | + | valid response reports a receive error. An undeliverable response is logged |
| 208 | - | explicit per-request results belongs to the P7 cutover (GO-005). | 210 | + | only where the handler has a send-failure path, so these controls remain weaker |
| 211 | + | than `KillResult` and `CommandResult`. | |||
| 209 | 212 | ||||
| 210 | ## Limits | 213 | ## Limits | ||
| 211 | 214 | ||||
M internal/adapters/sessionwire/client_test.go +1 -1
| @@ -72,7 +72,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) { | |||
| 72 | 72 | {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}}, | |
| 73 | 73 | {name: "pong", message: protocol.Pong{}}, | |
| 74 | 74 | {name: "sessions", message: protocol.Sessions{}}, | |
| 75 | - | {name: "command result", message: protocol.CommandResult{RequestID: 1, OK: true}}, | |
| 75 | + | {name: "command result", message: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}}, | |
| 76 | 76 | {name: "kill result", message: protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}}, | |
| 77 | 77 | {name: "kill result partial failures", message: protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}}}, | |
| 78 | 78 | {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}}, | |
| @@ -72,7 +72,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) { | |||||
| 72 | {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}}, | 72 | {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}}, | ||
| 73 | {name: "pong", message: protocol.Pong{}}, | 73 | {name: "pong", message: protocol.Pong{}}, | ||
| 74 | {name: "sessions", message: protocol.Sessions{}}, | 74 | {name: "sessions", message: protocol.Sessions{}}, | ||
| 75 | - | {name: "command result", message: protocol.CommandResult{RequestID: 1, OK: true}}, | 75 | + | {name: "command result", message: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}}, |
| 76 | {name: "kill result", message: protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}}, | 76 | {name: "kill result", message: protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}}, | ||
| 77 | {name: "kill result partial failures", message: protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}}}, | 77 | {name: "kill result partial failures", message: protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}}}, | ||
| 78 | {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}}, | 78 | {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}}, | ||
A internal/adapters/sessionwire/command_result_test.go +238 -0
| @@ -0,0 +1,238 @@ | |||
| 1 | + | package sessionwire | |
| 2 | + | ||
| 3 | + | // CommandResult wire contract: one command request carries exactly one closed | |
| 4 | + | // terminal outcome (Succeeded, Failed, or Unknown), correlation is preserved | |
| 5 | + | // byte-for-byte, and the decode boundary refuses a malformed, unspecified, or | |
| 6 | + | // out-of-range outcome instead of aliasing it into a valid one. The result is a | |
| 7 | + | // bounded control envelope: a failure's Code and Text can never ride the | |
| 8 | + | // Output/bulk ceiling exemption. | |
| 9 | + | ||
| 10 | + | import ( | |
| 11 | + | "encoding/hex" | |
| 12 | + | "math" | |
| 13 | + | "testing" | |
| 14 | + | ||
| 15 | + | "google.golang.org/protobuf/proto" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/vev/internal/protocol" | |
| 18 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 19 | + | "github.com/stretchr/testify/require" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | func testCommandResults() []protocol.CommandResult { | |
| 23 | + | return []protocol.CommandResult{ | |
| 24 | + | {RequestID: 0x1122334455, Outcome: protocol.CommandSucceeded, Output: "work\tsh\n"}, | |
| 25 | + | {RequestID: 7, Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"}, | |
| 26 | + | {RequestID: 8, Outcome: protocol.CommandOutcomeUnknown, Text: "command request timed out"}, | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // commandResultPinnedBytes pins the exact serialized bytes of each fixture in | |
| 31 | + | // testCommandResults order, so a schema, encoder, or envelope-variant change | |
| 32 | + | // that silently re-lays-out a CommandResult fails loudly here. | |
| 33 | + | var commandResultPinnedBytes = []string{ | |
| 34 | + | "3a1308d588cd9192022a08776f726b0973680a3001", | |
| 35 | + | "3a180807180922106e6f206c6976652073657373696f6e733002", | |
| 36 | + | "3a1f08082219636f6d6d616e6420726571756573742074696d6564206f75743003", | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // TestCommandResultByteRoundTrip proves every closed outcome has pinned | |
| 40 | + | // serialized bytes and re-encodes to byte-identical bytes after a strict scan + | |
| 41 | + | // generated unmarshal + semantic decode, with the request correlation id and the | |
| 42 | + | // bounded text/output intact. | |
| 43 | + | func TestCommandResultByteRoundTrip(t *testing.T) { | |
| 44 | + | for index, message := range testCommandResults() { | |
| 45 | + | require.True(t, message.Valid(), "fixture %+v must obey the outcome contract", message) | |
| 46 | + | raw, err := EncodeServerMessage(message) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Equal(t, commandResultPinnedBytes[index], hex.EncodeToString(raw), "CommandResult %d must keep its pinned bytes", index) | |
| 49 | + | require.Equal(t, categoryControl, serverEnvelopeCategory(raw), "a CommandResult is a control envelope") | |
| 50 | + | require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, raw)) | |
| 51 | + | ||
| 52 | + | decoded, err := DecodeServerEnvelope(raw) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | require.Equal(t, message, decoded) | |
| 55 | + | ||
| 56 | + | reencoded, err := EncodeServerMessage(decoded) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | require.Equal(t, raw, reencoded, "CommandResult must re-encode byte-for-byte") | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // TestCommandResultEnvelopeStrictScan proves the scan boundary rejects a | |
| 63 | + | // truncated prefix, a truncated payload, trailing garbage, and a concatenated | |
| 64 | + | // duplicate before any semantic decode, and that each rejected frame surfaces | |
| 65 | + | // as a malformed decode failure rather than a valid result. | |
| 66 | + | func TestCommandResultEnvelopeStrictScan(t *testing.T) { | |
| 67 | + | valid, err := EncodeServerMessage(testCommandResults()[0]) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, valid)) | |
| 70 | + | ||
| 71 | + | clone := func(raw []byte) []byte { return append([]byte(nil), raw...) } | |
| 72 | + | tests := []struct { | |
| 73 | + | name string | |
| 74 | + | raw []byte | |
| 75 | + | wantErr error | |
| 76 | + | }{ | |
| 77 | + | {name: "truncated tag prefix", raw: valid[:1], wantErr: wire.ErrScanTruncated}, | |
| 78 | + | {name: "truncated length prefix", raw: valid[:3], wantErr: wire.ErrScanTruncated}, | |
| 79 | + | {name: "truncated payload", raw: valid[:len(valid)-1], wantErr: wire.ErrScanTruncated}, | |
| 80 | + | {name: "trailing garbage", raw: append(clone(valid), 0xFF), wantErr: wire.ErrScanTruncated}, | |
| 81 | + | {name: "concatenated duplicate", raw: append(clone(valid), valid...), wantErr: wire.ErrScanDuplicate}, | |
| 82 | + | } | |
| 83 | + | for _, tt := range tests { | |
| 84 | + | t.Run(tt.name, func(t *testing.T) { | |
| 85 | + | require.ErrorIs(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, tt.raw), tt.wantErr) | |
| 86 | + | ||
| 87 | + | _, decodeErr := DecodeServerEnvelope(tt.raw) | |
| 88 | + | var failure *protocol.DecodeFailure | |
| 89 | + | require.ErrorAs(t, decodeErr, &failure) | |
| 90 | + | require.Equal(t, protocol.DecodeMalformed, failure.Category, "a corrupt CommandResult frame is malformed, never a result") | |
| 91 | + | }) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Every proper prefix of a valid frame is a truncated prefix in one of the | |
| 95 | + | // leading tag, length, or nested-payload bytes; none may scan or decode. | |
| 96 | + | t.Run("every truncated prefix", func(t *testing.T) { | |
| 97 | + | for length := 1; length < len(valid); length++ { | |
| 98 | + | require.ErrorIs(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, valid[:length]), wire.ErrScanTruncated, "prefix %d", length) | |
| 99 | + | _, decodeErr := DecodeServerEnvelope(valid[:length]) | |
| 100 | + | var failure *protocol.DecodeFailure | |
| 101 | + | require.ErrorAs(t, decodeErr, &failure, "prefix %d", length) | |
| 102 | + | require.Equal(t, protocol.DecodeMalformed, failure.Category, "prefix %d", length) | |
| 103 | + | } | |
| 104 | + | }) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // TestCommandResultWrongDirectionFailsClosed proves a server-only CommandResult | |
| 108 | + | // payload is refused on the client direction and the server decoder rejects a | |
| 109 | + | // nil envelope, so a result can never be consumed as a request. | |
| 110 | + | func TestCommandResultWrongDirectionFailsClosed(t *testing.T) { | |
| 111 | + | converted, err := commandResultToWire(testCommandResults()[0]) | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | raw, err := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{ | |
| 114 | + | CommandResult: converted, | |
| 115 | + | }}) | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | ||
| 118 | + | var clientEnvelope wire.ClientEnvelope | |
| 119 | + | err = wire.ScanEnvelope(&clientEnvelope, raw) | |
| 120 | + | require.Error(t, err, "a server CommandResult must not scan as a client envelope") | |
| 121 | + | ||
| 122 | + | _, err = decodeProtoClient(&wire.ClientEnvelope{Payload: nil}) | |
| 123 | + | require.ErrorIs(t, err, ErrWrongDirection) | |
| 124 | + | _, err = decodeProtoServer(nil) | |
| 125 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // TestCommandResultRejectsMalformedZeroAndOutOfRangeOutcome proves the decode | |
| 129 | + | // boundary refuses an unspecified (zero) outcome, an outcome outside the closed | |
| 130 | + | // enumeration, and every outcome/code/text combination the contract forbids. | |
| 131 | + | // The out-of-range cases include values whose low byte would otherwise truncate | |
| 132 | + | // into a valid outcome (257 -> 1, 259 -> 3, 65537 -> 1), so a hostile or corrupt | |
| 133 | + | // frame can never alias success or unknown. | |
| 134 | + | func TestCommandResultRejectsMalformedZeroAndOutOfRangeOutcome(t *testing.T) { | |
| 135 | + | failed := wire.CommandOutcome_COMMAND_OUTCOME_FAILED | |
| 136 | + | tests := []struct { | |
| 137 | + | name string | |
| 138 | + | raw *wire.CommandResult | |
| 139 | + | }{ | |
| 140 | + | {name: "nil result"}, | |
| 141 | + | {name: "unspecified zero outcome", raw: &wire.CommandResult{RequestId: 1}}, | |
| 142 | + | {name: "outcome one above range", raw: &wire.CommandResult{RequestId: 1, Outcome: failed + 2}}, | |
| 143 | + | {name: "outcome far above range", raw: &wire.CommandResult{RequestId: 1, Outcome: 255}}, | |
| 144 | + | {name: "outcome truncating into succeeded", raw: &wire.CommandResult{RequestId: 1, Outcome: 257}}, | |
| 145 | + | {name: "outcome truncating into unknown", raw: &wire.CommandResult{RequestId: 1, Outcome: 259}}, | |
| 146 | + | {name: "outcome truncating at 16 bits", raw: &wire.CommandResult{RequestId: 1, Outcome: 65537}}, | |
| 147 | + | {name: "outcome at int32 maximum", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome(math.MaxInt32)}}, | |
| 148 | + | {name: "negative outcome", raw: &wire.CommandResult{RequestId: 1, Outcome: -1}}, | |
| 149 | + | {name: "code overflows uint16", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Code: 1 << 16}}, | |
| 150 | + | {name: "succeeded with a code", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Code: uint32(protocol.ErrInternal)}}, | |
| 151 | + | {name: "succeeded with text", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Text: "boom"}}, | |
| 152 | + | {name: "failed without a code", raw: &wire.CommandResult{RequestId: 1, Outcome: failed}}, | |
| 153 | + | {name: "failed with output", raw: &wire.CommandResult{RequestId: 1, Outcome: failed, Code: uint32(protocol.ErrInternal), Output: "leak"}}, | |
| 154 | + | {name: "unknown with a code", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN, Code: uint32(protocol.ErrInternal)}}, | |
| 155 | + | {name: "unknown with output", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN, Output: "leak"}}, | |
| 156 | + | } | |
| 157 | + | for _, tt := range tests { | |
| 158 | + | t.Run(tt.name, func(t *testing.T) { | |
| 159 | + | _, err := commandResultFromWire(tt.raw) | |
| 160 | + | require.ErrorIs(t, err, errProtoConvertRange) | |
| 161 | + | ||
| 162 | + | if tt.raw == nil { | |
| 163 | + | return | |
| 164 | + | } | |
| 165 | + | // The serialized boundary must refuse the same frame end to end: | |
| 166 | + | // a well-formed protobuf with a forbidden value is still refused. | |
| 167 | + | payload, marshalErr := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: tt.raw}}) | |
| 168 | + | require.NoError(t, marshalErr) | |
| 169 | + | _, decodeErr := DecodeServerEnvelope(payload) | |
| 170 | + | require.Error(t, decodeErr, "a forbidden outcome must never decode into a semantic result") | |
| 171 | + | var failure *protocol.DecodeFailure | |
| 172 | + | require.ErrorAs(t, decodeErr, &failure) | |
| 173 | + | }) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // TestCommandResultConversionArmInventory is the sessionwire arm inventory for | |
| 178 | + | // the explicit command result: value and pointer arms share the value arm's | |
| 179 | + | // bytes, and a typed nil pointer arm fails closed with ErrInvalidMessage instead | |
| 180 | + | // of panicking. A new arm added without coverage fails here. | |
| 181 | + | func TestCommandResultConversionArmInventory(t *testing.T) { | |
| 182 | + | message := testCommandResults()[0] | |
| 183 | + | valueRaw, err := EncodeServerMessage(message) | |
| 184 | + | require.NoError(t, err) | |
| 185 | + | ||
| 186 | + | tests := []struct { | |
| 187 | + | name string | |
| 188 | + | run func() ([]byte, error) | |
| 189 | + | wantRaw []byte | |
| 190 | + | wantErr error | |
| 191 | + | }{ | |
| 192 | + | {name: "value arm", run: func() ([]byte, error) { return EncodeServerMessage(message) }, wantRaw: valueRaw}, | |
| 193 | + | {name: "pointer arm", run: func() ([]byte, error) { return EncodeServerMessage(&message) }, wantRaw: valueRaw}, | |
| 194 | + | {name: "typed nil pointer arm", run: func() ([]byte, error) { return EncodeServerMessage((*protocol.CommandResult)(nil)) }, wantErr: ErrInvalidMessage}, | |
| 195 | + | } | |
| 196 | + | for _, tt := range tests { | |
| 197 | + | t.Run(tt.name, func(t *testing.T) { | |
| 198 | + | raw, err := tt.run() | |
| 199 | + | if tt.wantErr != nil { | |
| 200 | + | require.ErrorIs(t, err, tt.wantErr) | |
| 201 | + | return | |
| 202 | + | } | |
| 203 | + | require.NoError(t, err) | |
| 204 | + | require.Equal(t, tt.wantRaw, raw, "every arm must share the value arm's bytes") | |
| 205 | + | }) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | decoded, err := DecodeServerEnvelope(valueRaw) | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | require.Equal(t, message, decoded) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // TestCommandResultIsBoundedControlEnvelope proves a CommandResult rides the | |
| 214 | + | // strict control ceiling, never the bulk exemption, on send and receive, so a | |
| 215 | + | // failure's error text stays inside the documented 1 MiB control bound. | |
| 216 | + | func TestCommandResultIsBoundedControlEnvelope(t *testing.T) { | |
| 217 | + | message := testCommandResults()[1] | |
| 218 | + | ||
| 219 | + | oversize := protocol.CommandResult{ | |
| 220 | + | RequestID: 1, | |
| 221 | + | Outcome: protocol.CommandFailed, | |
| 222 | + | Code: protocol.ErrInternal, | |
| 223 | + | Text: string(make([]byte, wire.ControlEnvelopeLimit)), | |
| 224 | + | } | |
| 225 | + | oversizeRaw, err := EncodeServerMessage(oversize) | |
| 226 | + | require.NoError(t, err) | |
| 227 | + | require.ErrorIs(t, checkCategoryCeiling(oversizeRaw, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength, | |
| 228 | + | "a CommandResult above the control ceiling must fail even under an absolute negotiated ceiling") | |
| 229 | + | ||
| 230 | + | send := &serverConnection{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()} | |
| 231 | + | require.NoError(t, send.SendServer(message)) | |
| 232 | + | require.ErrorIs(t, send.SendServer(oversize), wire.ErrScanLength) | |
| 233 | + | ||
| 234 | + | recv := &clientConnection{raw: &scriptedTransport{recv: []wire.Envelope{{Payload: oversizeRaw}}}, ceilings: defaultProtoCeilings()} | |
| 235 | + | recv.preambleOnce.Do(func() {}) | |
| 236 | + | _, err = recv.ReceiveServer() | |
| 237 | + | require.Error(t, err) | |
| 238 | + | } | |
| @@ -0,0 +1,238 @@ | |||||
| 1 | + | package sessionwire | |||
| 2 | + | ||||
| 3 | + | // CommandResult wire contract: one command request carries exactly one closed | |||
| 4 | + | // terminal outcome (Succeeded, Failed, or Unknown), correlation is preserved | |||
| 5 | + | // byte-for-byte, and the decode boundary refuses a malformed, unspecified, or | |||
| 6 | + | // out-of-range outcome instead of aliasing it into a valid one. The result is a | |||
| 7 | + | // bounded control envelope: a failure's Code and Text can never ride the | |||
| 8 | + | // Output/bulk ceiling exemption. | |||
| 9 | + | ||||
| 10 | + | import ( | |||
| 11 | + | "encoding/hex" | |||
| 12 | + | "math" | |||
| 13 | + | "testing" | |||
| 14 | + | ||||
| 15 | + | "google.golang.org/protobuf/proto" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/vev/internal/protocol" | |||
| 18 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 19 | + | "github.com/stretchr/testify/require" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | func testCommandResults() []protocol.CommandResult { | |||
| 23 | + | return []protocol.CommandResult{ | |||
| 24 | + | {RequestID: 0x1122334455, Outcome: protocol.CommandSucceeded, Output: "work\tsh\n"}, | |||
| 25 | + | {RequestID: 7, Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"}, | |||
| 26 | + | {RequestID: 8, Outcome: protocol.CommandOutcomeUnknown, Text: "command request timed out"}, | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // commandResultPinnedBytes pins the exact serialized bytes of each fixture in | |||
| 31 | + | // testCommandResults order, so a schema, encoder, or envelope-variant change | |||
| 32 | + | // that silently re-lays-out a CommandResult fails loudly here. | |||
| 33 | + | var commandResultPinnedBytes = []string{ | |||
| 34 | + | "3a1308d588cd9192022a08776f726b0973680a3001", | |||
| 35 | + | "3a180807180922106e6f206c6976652073657373696f6e733002", | |||
| 36 | + | "3a1f08082219636f6d6d616e6420726571756573742074696d6564206f75743003", | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // TestCommandResultByteRoundTrip proves every closed outcome has pinned | |||
| 40 | + | // serialized bytes and re-encodes to byte-identical bytes after a strict scan + | |||
| 41 | + | // generated unmarshal + semantic decode, with the request correlation id and the | |||
| 42 | + | // bounded text/output intact. | |||
| 43 | + | func TestCommandResultByteRoundTrip(t *testing.T) { | |||
| 44 | + | for index, message := range testCommandResults() { | |||
| 45 | + | require.True(t, message.Valid(), "fixture %+v must obey the outcome contract", message) | |||
| 46 | + | raw, err := EncodeServerMessage(message) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.Equal(t, commandResultPinnedBytes[index], hex.EncodeToString(raw), "CommandResult %d must keep its pinned bytes", index) | |||
| 49 | + | require.Equal(t, categoryControl, serverEnvelopeCategory(raw), "a CommandResult is a control envelope") | |||
| 50 | + | require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, raw)) | |||
| 51 | + | ||||
| 52 | + | decoded, err := DecodeServerEnvelope(raw) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | require.Equal(t, message, decoded) | |||
| 55 | + | ||||
| 56 | + | reencoded, err := EncodeServerMessage(decoded) | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | require.Equal(t, raw, reencoded, "CommandResult must re-encode byte-for-byte") | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // TestCommandResultEnvelopeStrictScan proves the scan boundary rejects a | |||
| 63 | + | // truncated prefix, a truncated payload, trailing garbage, and a concatenated | |||
| 64 | + | // duplicate before any semantic decode, and that each rejected frame surfaces | |||
| 65 | + | // as a malformed decode failure rather than a valid result. | |||
| 66 | + | func TestCommandResultEnvelopeStrictScan(t *testing.T) { | |||
| 67 | + | valid, err := EncodeServerMessage(testCommandResults()[0]) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | require.NoError(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, valid)) | |||
| 70 | + | ||||
| 71 | + | clone := func(raw []byte) []byte { return append([]byte(nil), raw...) } | |||
| 72 | + | tests := []struct { | |||
| 73 | + | name string | |||
| 74 | + | raw []byte | |||
| 75 | + | wantErr error | |||
| 76 | + | }{ | |||
| 77 | + | {name: "truncated tag prefix", raw: valid[:1], wantErr: wire.ErrScanTruncated}, | |||
| 78 | + | {name: "truncated length prefix", raw: valid[:3], wantErr: wire.ErrScanTruncated}, | |||
| 79 | + | {name: "truncated payload", raw: valid[:len(valid)-1], wantErr: wire.ErrScanTruncated}, | |||
| 80 | + | {name: "trailing garbage", raw: append(clone(valid), 0xFF), wantErr: wire.ErrScanTruncated}, | |||
| 81 | + | {name: "concatenated duplicate", raw: append(clone(valid), valid...), wantErr: wire.ErrScanDuplicate}, | |||
| 82 | + | } | |||
| 83 | + | for _, tt := range tests { | |||
| 84 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 85 | + | require.ErrorIs(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, tt.raw), tt.wantErr) | |||
| 86 | + | ||||
| 87 | + | _, decodeErr := DecodeServerEnvelope(tt.raw) | |||
| 88 | + | var failure *protocol.DecodeFailure | |||
| 89 | + | require.ErrorAs(t, decodeErr, &failure) | |||
| 90 | + | require.Equal(t, protocol.DecodeMalformed, failure.Category, "a corrupt CommandResult frame is malformed, never a result") | |||
| 91 | + | }) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // Every proper prefix of a valid frame is a truncated prefix in one of the | |||
| 95 | + | // leading tag, length, or nested-payload bytes; none may scan or decode. | |||
| 96 | + | t.Run("every truncated prefix", func(t *testing.T) { | |||
| 97 | + | for length := 1; length < len(valid); length++ { | |||
| 98 | + | require.ErrorIs(t, wire.ScanEnvelope(&wire.ServerEnvelope{}, valid[:length]), wire.ErrScanTruncated, "prefix %d", length) | |||
| 99 | + | _, decodeErr := DecodeServerEnvelope(valid[:length]) | |||
| 100 | + | var failure *protocol.DecodeFailure | |||
| 101 | + | require.ErrorAs(t, decodeErr, &failure, "prefix %d", length) | |||
| 102 | + | require.Equal(t, protocol.DecodeMalformed, failure.Category, "prefix %d", length) | |||
| 103 | + | } | |||
| 104 | + | }) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // TestCommandResultWrongDirectionFailsClosed proves a server-only CommandResult | |||
| 108 | + | // payload is refused on the client direction and the server decoder rejects a | |||
| 109 | + | // nil envelope, so a result can never be consumed as a request. | |||
| 110 | + | func TestCommandResultWrongDirectionFailsClosed(t *testing.T) { | |||
| 111 | + | converted, err := commandResultToWire(testCommandResults()[0]) | |||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | raw, err := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{ | |||
| 114 | + | CommandResult: converted, | |||
| 115 | + | }}) | |||
| 116 | + | require.NoError(t, err) | |||
| 117 | + | ||||
| 118 | + | var clientEnvelope wire.ClientEnvelope | |||
| 119 | + | err = wire.ScanEnvelope(&clientEnvelope, raw) | |||
| 120 | + | require.Error(t, err, "a server CommandResult must not scan as a client envelope") | |||
| 121 | + | ||||
| 122 | + | _, err = decodeProtoClient(&wire.ClientEnvelope{Payload: nil}) | |||
| 123 | + | require.ErrorIs(t, err, ErrWrongDirection) | |||
| 124 | + | _, err = decodeProtoServer(nil) | |||
| 125 | + | require.ErrorIs(t, err, ErrInvalidMessage) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // TestCommandResultRejectsMalformedZeroAndOutOfRangeOutcome proves the decode | |||
| 129 | + | // boundary refuses an unspecified (zero) outcome, an outcome outside the closed | |||
| 130 | + | // enumeration, and every outcome/code/text combination the contract forbids. | |||
| 131 | + | // The out-of-range cases include values whose low byte would otherwise truncate | |||
| 132 | + | // into a valid outcome (257 -> 1, 259 -> 3, 65537 -> 1), so a hostile or corrupt | |||
| 133 | + | // frame can never alias success or unknown. | |||
| 134 | + | func TestCommandResultRejectsMalformedZeroAndOutOfRangeOutcome(t *testing.T) { | |||
| 135 | + | failed := wire.CommandOutcome_COMMAND_OUTCOME_FAILED | |||
| 136 | + | tests := []struct { | |||
| 137 | + | name string | |||
| 138 | + | raw *wire.CommandResult | |||
| 139 | + | }{ | |||
| 140 | + | {name: "nil result"}, | |||
| 141 | + | {name: "unspecified zero outcome", raw: &wire.CommandResult{RequestId: 1}}, | |||
| 142 | + | {name: "outcome one above range", raw: &wire.CommandResult{RequestId: 1, Outcome: failed + 2}}, | |||
| 143 | + | {name: "outcome far above range", raw: &wire.CommandResult{RequestId: 1, Outcome: 255}}, | |||
| 144 | + | {name: "outcome truncating into succeeded", raw: &wire.CommandResult{RequestId: 1, Outcome: 257}}, | |||
| 145 | + | {name: "outcome truncating into unknown", raw: &wire.CommandResult{RequestId: 1, Outcome: 259}}, | |||
| 146 | + | {name: "outcome truncating at 16 bits", raw: &wire.CommandResult{RequestId: 1, Outcome: 65537}}, | |||
| 147 | + | {name: "outcome at int32 maximum", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome(math.MaxInt32)}}, | |||
| 148 | + | {name: "negative outcome", raw: &wire.CommandResult{RequestId: 1, Outcome: -1}}, | |||
| 149 | + | {name: "code overflows uint16", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Code: 1 << 16}}, | |||
| 150 | + | {name: "succeeded with a code", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Code: uint32(protocol.ErrInternal)}}, | |||
| 151 | + | {name: "succeeded with text", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, Text: "boom"}}, | |||
| 152 | + | {name: "failed without a code", raw: &wire.CommandResult{RequestId: 1, Outcome: failed}}, | |||
| 153 | + | {name: "failed with output", raw: &wire.CommandResult{RequestId: 1, Outcome: failed, Code: uint32(protocol.ErrInternal), Output: "leak"}}, | |||
| 154 | + | {name: "unknown with a code", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN, Code: uint32(protocol.ErrInternal)}}, | |||
| 155 | + | {name: "unknown with output", raw: &wire.CommandResult{RequestId: 1, Outcome: wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN, Output: "leak"}}, | |||
| 156 | + | } | |||
| 157 | + | for _, tt := range tests { | |||
| 158 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 159 | + | _, err := commandResultFromWire(tt.raw) | |||
| 160 | + | require.ErrorIs(t, err, errProtoConvertRange) | |||
| 161 | + | ||||
| 162 | + | if tt.raw == nil { | |||
| 163 | + | return | |||
| 164 | + | } | |||
| 165 | + | // The serialized boundary must refuse the same frame end to end: | |||
| 166 | + | // a well-formed protobuf with a forbidden value is still refused. | |||
| 167 | + | payload, marshalErr := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: tt.raw}}) | |||
| 168 | + | require.NoError(t, marshalErr) | |||
| 169 | + | _, decodeErr := DecodeServerEnvelope(payload) | |||
| 170 | + | require.Error(t, decodeErr, "a forbidden outcome must never decode into a semantic result") | |||
| 171 | + | var failure *protocol.DecodeFailure | |||
| 172 | + | require.ErrorAs(t, decodeErr, &failure) | |||
| 173 | + | }) | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // TestCommandResultConversionArmInventory is the sessionwire arm inventory for | |||
| 178 | + | // the explicit command result: value and pointer arms share the value arm's | |||
| 179 | + | // bytes, and a typed nil pointer arm fails closed with ErrInvalidMessage instead | |||
| 180 | + | // of panicking. A new arm added without coverage fails here. | |||
| 181 | + | func TestCommandResultConversionArmInventory(t *testing.T) { | |||
| 182 | + | message := testCommandResults()[0] | |||
| 183 | + | valueRaw, err := EncodeServerMessage(message) | |||
| 184 | + | require.NoError(t, err) | |||
| 185 | + | ||||
| 186 | + | tests := []struct { | |||
| 187 | + | name string | |||
| 188 | + | run func() ([]byte, error) | |||
| 189 | + | wantRaw []byte | |||
| 190 | + | wantErr error | |||
| 191 | + | }{ | |||
| 192 | + | {name: "value arm", run: func() ([]byte, error) { return EncodeServerMessage(message) }, wantRaw: valueRaw}, | |||
| 193 | + | {name: "pointer arm", run: func() ([]byte, error) { return EncodeServerMessage(&message) }, wantRaw: valueRaw}, | |||
| 194 | + | {name: "typed nil pointer arm", run: func() ([]byte, error) { return EncodeServerMessage((*protocol.CommandResult)(nil)) }, wantErr: ErrInvalidMessage}, | |||
| 195 | + | } | |||
| 196 | + | for _, tt := range tests { | |||
| 197 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 198 | + | raw, err := tt.run() | |||
| 199 | + | if tt.wantErr != nil { | |||
| 200 | + | require.ErrorIs(t, err, tt.wantErr) | |||
| 201 | + | return | |||
| 202 | + | } | |||
| 203 | + | require.NoError(t, err) | |||
| 204 | + | require.Equal(t, tt.wantRaw, raw, "every arm must share the value arm's bytes") | |||
| 205 | + | }) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | decoded, err := DecodeServerEnvelope(valueRaw) | |||
| 209 | + | require.NoError(t, err) | |||
| 210 | + | require.Equal(t, message, decoded) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // TestCommandResultIsBoundedControlEnvelope proves a CommandResult rides the | |||
| 214 | + | // strict control ceiling, never the bulk exemption, on send and receive, so a | |||
| 215 | + | // failure's error text stays inside the documented 1 MiB control bound. | |||
| 216 | + | func TestCommandResultIsBoundedControlEnvelope(t *testing.T) { | |||
| 217 | + | message := testCommandResults()[1] | |||
| 218 | + | ||||
| 219 | + | oversize := protocol.CommandResult{ | |||
| 220 | + | RequestID: 1, | |||
| 221 | + | Outcome: protocol.CommandFailed, | |||
| 222 | + | Code: protocol.ErrInternal, | |||
| 223 | + | Text: string(make([]byte, wire.ControlEnvelopeLimit)), | |||
| 224 | + | } | |||
| 225 | + | oversizeRaw, err := EncodeServerMessage(oversize) | |||
| 226 | + | require.NoError(t, err) | |||
| 227 | + | require.ErrorIs(t, checkCategoryCeiling(oversizeRaw, categoryControl, wire.AbsoluteEnvelopeLimit), wire.ErrScanLength, | |||
| 228 | + | "a CommandResult above the control ceiling must fail even under an absolute negotiated ceiling") | |||
| 229 | + | ||||
| 230 | + | send := &serverConnection{raw: &scriptedTransport{}, ceilings: defaultProtoCeilings()} | |||
| 231 | + | require.NoError(t, send.SendServer(message)) | |||
| 232 | + | require.ErrorIs(t, send.SendServer(oversize), wire.ErrScanLength) | |||
| 233 | + | ||||
| 234 | + | recv := &clientConnection{raw: &scriptedTransport{recv: []wire.Envelope{{Payload: oversizeRaw}}}, ceilings: defaultProtoCeilings()} | |||
| 235 | + | recv.preambleOnce.Do(func() {}) | |||
| 236 | + | _, err = recv.ReceiveServer() | |||
| 237 | + | require.Error(t, err) | |||
| 238 | + | } | |||
M internal/adapters/sessionwire/proto_convert_misc.go +14 -4
| @@ -289,19 +289,29 @@ func errorFromWire(message *wire.ErrorMsg) (protocol.ErrorMsg, error) { | |||
| 289 | 289 | return protocol.ErrorMsg{Code: code, Text: message.GetText()}, nil | |
| 290 | 290 | } | |
| 291 | 291 | ||
| 292 | - | func commandResultToWire(message protocol.CommandResult) *wire.CommandResult { | |
| 293 | - | return &wire.CommandResult{RequestId: message.RequestID, Ok: message.OK, Code: uint32(message.Code), Text: message.Text, Output: message.Output} | |
| 292 | + | func commandResultToWire(message protocol.CommandResult) (*wire.CommandResult, error) { | |
| 293 | + | if !message.Valid() { | |
| 294 | + | return nil, errProtoConvertRange | |
| 295 | + | } | |
| 296 | + | return &wire.CommandResult{RequestId: message.RequestID, Outcome: wire.CommandOutcome(message.Outcome), Code: uint32(message.Code), Text: message.Text, Output: message.Output}, nil | |
| 294 | 297 | } | |
| 295 | 298 | ||
| 296 | 299 | func commandResultFromWire(message *wire.CommandResult) (protocol.CommandResult, error) { | |
| 297 | - | if message == nil { | |
| 300 | + | // The outcome is narrowed through a closed-range check before the cast: | |
| 301 | + | // a 32-bit wire value whose low byte would otherwise truncate into a valid | |
| 302 | + | // outcome (257 -> Succeeded, 259 -> Unknown) is refused, not aliased. | |
| 303 | + | if message == nil || message.GetOutcome() < wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED || message.GetOutcome() > wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN { | |
| 298 | 304 | return protocol.CommandResult{}, errProtoConvertRange | |
| 299 | 305 | } | |
| 300 | 306 | code, err := mustUint16(message.GetCode()) | |
| 301 | 307 | if err != nil { | |
| 302 | 308 | return protocol.CommandResult{}, err | |
| 303 | 309 | } | |
| 304 | - | return protocol.CommandResult{RequestID: message.GetRequestId(), OK: message.GetOk(), Code: code, Text: message.GetText(), Output: message.GetOutput()}, nil | |
| 310 | + | result := protocol.CommandResult{RequestID: message.GetRequestId(), Outcome: protocol.CommandOutcome(message.GetOutcome()), Code: code, Text: message.GetText(), Output: message.GetOutput()} | |
| 311 | + | if !result.Valid() { | |
| 312 | + | return protocol.CommandResult{}, errProtoConvertRange | |
| 313 | + | } | |
| 314 | + | return result, nil | |
| 305 | 315 | } | |
| 306 | 316 | ||
| 307 | 317 | func uiFenceToWire(message protocol.UIFence) *wire.UIFence { | |
| @@ -289,19 +289,29 @@ func errorFromWire(message *wire.ErrorMsg) (protocol.ErrorMsg, error) { | |||||
| 289 | return protocol.ErrorMsg{Code: code, Text: message.GetText()}, nil | 289 | return protocol.ErrorMsg{Code: code, Text: message.GetText()}, nil | ||
| 290 | } | 290 | } | ||
| 291 | 291 | ||||
| 292 | - | func commandResultToWire(message protocol.CommandResult) *wire.CommandResult { | 292 | + | func commandResultToWire(message protocol.CommandResult) (*wire.CommandResult, error) { |
| 293 | - | return &wire.CommandResult{RequestId: message.RequestID, Ok: message.OK, Code: uint32(message.Code), Text: message.Text, Output: message.Output} | 293 | + | if !message.Valid() { |
| 294 | + | return nil, errProtoConvertRange | |||
| 295 | + | } | |||
| 296 | + | return &wire.CommandResult{RequestId: message.RequestID, Outcome: wire.CommandOutcome(message.Outcome), Code: uint32(message.Code), Text: message.Text, Output: message.Output}, nil | |||
| 294 | } | 297 | } | ||
| 295 | 298 | ||||
| 296 | func commandResultFromWire(message *wire.CommandResult) (protocol.CommandResult, error) { | 299 | func commandResultFromWire(message *wire.CommandResult) (protocol.CommandResult, error) { | ||
| 297 | - | if message == nil { | 300 | + | // The outcome is narrowed through a closed-range check before the cast: |
| 301 | + | // a 32-bit wire value whose low byte would otherwise truncate into a valid | |||
| 302 | + | // outcome (257 -> Succeeded, 259 -> Unknown) is refused, not aliased. | |||
| 303 | + | if message == nil || message.GetOutcome() < wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED || message.GetOutcome() > wire.CommandOutcome_COMMAND_OUTCOME_UNKNOWN { | |||
| 298 | return protocol.CommandResult{}, errProtoConvertRange | 304 | return protocol.CommandResult{}, errProtoConvertRange | ||
| 299 | } | 305 | } | ||
| 300 | code, err := mustUint16(message.GetCode()) | 306 | code, err := mustUint16(message.GetCode()) | ||
| 301 | if err != nil { | 307 | if err != nil { | ||
| 302 | return protocol.CommandResult{}, err | 308 | return protocol.CommandResult{}, err | ||
| 303 | } | 309 | } | ||
| 304 | - | return protocol.CommandResult{RequestID: message.GetRequestId(), OK: message.GetOk(), Code: code, Text: message.GetText(), Output: message.GetOutput()}, nil | 310 | + | result := protocol.CommandResult{RequestID: message.GetRequestId(), Outcome: protocol.CommandOutcome(message.GetOutcome()), Code: code, Text: message.GetText(), Output: message.GetOutput()} |
| 311 | + | if !result.Valid() { | |||
| 312 | + | return protocol.CommandResult{}, errProtoConvertRange | |||
| 313 | + | } | |||
| 314 | + | return result, nil | |||
| 305 | } | 315 | } | ||
| 306 | 316 | ||||
| 307 | func uiFenceToWire(message protocol.UIFence) *wire.UIFence { | 317 | func uiFenceToWire(message protocol.UIFence) *wire.UIFence { | ||
M internal/adapters/sessionwire/proto_convert_test.go +1 -1
| @@ -93,7 +93,7 @@ func TestProtoServerRoundTrips(t *testing.T) { | |||
| 93 | 93 | protocol.Detached{Reason: protocol.ReasonDetach}, | |
| 94 | 94 | protocol.Pong{}, | |
| 95 | 95 | protocol.Sessions{}, | |
| 96 | - | protocol.CommandResult{RequestID: 1, OK: true}, | |
| 96 | + | protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}, | |
| 97 | 97 | protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}, | |
| 98 | 98 | protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: work", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "delete failed"}}}, | |
| 99 | 99 | protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}, | |
| @@ -93,7 +93,7 @@ func TestProtoServerRoundTrips(t *testing.T) { | |||||
| 93 | protocol.Detached{Reason: protocol.ReasonDetach}, | 93 | protocol.Detached{Reason: protocol.ReasonDetach}, | ||
| 94 | protocol.Pong{}, | 94 | protocol.Pong{}, | ||
| 95 | protocol.Sessions{}, | 95 | protocol.Sessions{}, | ||
| 96 | - | protocol.CommandResult{RequestID: 1, OK: true}, | 96 | + | protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}, |
| 97 | protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}, | 97 | protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}, | ||
| 98 | protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: work", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "delete failed"}}}, | 98 | protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: work", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "delete failed"}}}, | ||
| 99 | protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}, | 99 | protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}, | ||
M internal/adapters/sessionwire/proto_envelope.go +5 -1
| @@ -393,7 +393,11 @@ func encodeProtoServer(message protocol.ServerMessage) (*wire.ServerEnvelope, er | |||
| 393 | 393 | } | |
| 394 | 394 | return encodeProtoServer(*m) | |
| 395 | 395 | case protocol.CommandResult: | |
| 396 | - | return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: commandResultToWire(m)}}, nil | |
| 396 | + | converted, err := commandResultToWire(m) | |
| 397 | + | if err != nil { | |
| 398 | + | return nil, err | |
| 399 | + | } | |
| 400 | + | return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: converted}}, nil | |
| 397 | 401 | case protocol.KillResult: | |
| 398 | 402 | return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_KillResult{KillResult: killResultToWire(m)}}, nil | |
| 399 | 403 | case *protocol.KillResult: | |
| @@ -393,7 +393,11 @@ func encodeProtoServer(message protocol.ServerMessage) (*wire.ServerEnvelope, er | |||||
| 393 | } | 393 | } | ||
| 394 | return encodeProtoServer(*m) | 394 | return encodeProtoServer(*m) | ||
| 395 | case protocol.CommandResult: | 395 | case protocol.CommandResult: | ||
| 396 | - | return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: commandResultToWire(m)}}, nil | 396 | + | converted, err := commandResultToWire(m) |
| 397 | + | if err != nil { | |||
| 398 | + | return nil, err | |||
| 399 | + | } | |||
| 400 | + | return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: converted}}, nil | |||
| 397 | case protocol.KillResult: | 401 | case protocol.KillResult: | ||
| 398 | return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_KillResult{KillResult: killResultToWire(m)}}, nil | 402 | return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_KillResult{KillResult: killResultToWire(m)}}, nil | ||
| 399 | case *protocol.KillResult: | 403 | case *protocol.KillResult: | ||
M internal/adapters/sessionwire/server_test.go +1 -1
| @@ -61,7 +61,7 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||
| 61 | 61 | detached := protocol.Detached{Reason: protocol.ReasonDetach} | |
| 62 | 62 | pong := protocol.Pong{} | |
| 63 | 63 | sessions := protocol.Sessions{} | |
| 64 | - | commandResult := protocol.CommandResult{RequestID: 1, OK: true} | |
| 64 | + | commandResult := protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded} | |
| 65 | 65 | killResult := protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded} | |
| 66 | 66 | killFailure := protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}} | |
| 67 | 67 | attachTarget := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach} | |
| @@ -61,7 +61,7 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||||
| 61 | detached := protocol.Detached{Reason: protocol.ReasonDetach} | 61 | detached := protocol.Detached{Reason: protocol.ReasonDetach} | ||
| 62 | pong := protocol.Pong{} | 62 | pong := protocol.Pong{} | ||
| 63 | sessions := protocol.Sessions{} | 63 | sessions := protocol.Sessions{} | ||
| 64 | - | commandResult := protocol.CommandResult{RequestID: 1, OK: true} | 64 | + | commandResult := protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded} |
| 65 | killResult := protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded} | 65 | killResult := protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded} | ||
| 66 | killFailure := protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}} | 66 | killFailure := protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}} | ||
| 67 | attachTarget := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach} | 67 | attachTarget := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach} | ||
M internal/app/broker_offline_client_test.go +3 -3
| @@ -378,12 +378,12 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) { | |||
| 378 | 378 | require.Equal(t, sizes[i], receiveOfflineOutputSize(t, connection, sizes[i])) | |
| 379 | 379 | } | |
| 380 | 380 | require.NoError(t, connections[0].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: 6, Attached: true, Slug: "next-tab"})) | |
| 381 | - | require.True(t, receiveOfflineCommandResult(t, connections[0], 6).OK) | |
| 381 | + | require.True(t, receiveOfflineCommandResult(t, connections[0], 6).Outcome == protocol.CommandSucceeded) | |
| 382 | 382 | require.Equal(t, sizes[0], receiveOfflineOutputSize(t, connections[0], sizes[0])) | |
| 383 | 383 | for _, connection := range connections { | |
| 384 | 384 | require.NoError(t, connection.SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Attached: true, Slug: "next-tab"})) | |
| 385 | 385 | result := receiveOfflineCommandResult(t, connection, 7) | |
| 386 | - | require.True(t, result.OK, result.Text) | |
| 386 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 387 | 387 | } | |
| 388 | 388 | ||
| 389 | 389 | require.NoError(t, connections[0].SendClient(protocol.Detach{})) | |
| @@ -392,7 +392,7 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) { | |||
| 392 | 392 | for i := 1; i < len(connections); i++ { | |
| 393 | 393 | requestID := uint64(20 + i) | |
| 394 | 394 | require.NoError(t, connections[i].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: requestID, Attached: true, Slug: "next-tab"})) | |
| 395 | - | require.True(t, receiveOfflineCommandResult(t, connections[i], requestID).OK) | |
| 395 | + | require.True(t, receiveOfflineCommandResult(t, connections[i], requestID).Outcome == protocol.CommandSucceeded) | |
| 396 | 396 | } | |
| 397 | 397 | for i := 1; i < len(connections); i++ { | |
| 398 | 398 | require.NoError(t, connections[i].Close()) | |
| @@ -378,12 +378,12 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) { | |||||
| 378 | require.Equal(t, sizes[i], receiveOfflineOutputSize(t, connection, sizes[i])) | 378 | require.Equal(t, sizes[i], receiveOfflineOutputSize(t, connection, sizes[i])) | ||
| 379 | } | 379 | } | ||
| 380 | require.NoError(t, connections[0].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: 6, Attached: true, Slug: "next-tab"})) | 380 | require.NoError(t, connections[0].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: 6, Attached: true, Slug: "next-tab"})) | ||
| 381 | - | require.True(t, receiveOfflineCommandResult(t, connections[0], 6).OK) | 381 | + | require.True(t, receiveOfflineCommandResult(t, connections[0], 6).Outcome == protocol.CommandSucceeded) |
| 382 | require.Equal(t, sizes[0], receiveOfflineOutputSize(t, connections[0], sizes[0])) | 382 | require.Equal(t, sizes[0], receiveOfflineOutputSize(t, connections[0], sizes[0])) | ||
| 383 | for _, connection := range connections { | 383 | for _, connection := range connections { | ||
| 384 | require.NoError(t, connection.SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Attached: true, Slug: "next-tab"})) | 384 | require.NoError(t, connection.SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Attached: true, Slug: "next-tab"})) | ||
| 385 | result := receiveOfflineCommandResult(t, connection, 7) | 385 | result := receiveOfflineCommandResult(t, connection, 7) | ||
| 386 | - | require.True(t, result.OK, result.Text) | 386 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 387 | } | 387 | } | ||
| 388 | 388 | ||||
| 389 | require.NoError(t, connections[0].SendClient(protocol.Detach{})) | 389 | require.NoError(t, connections[0].SendClient(protocol.Detach{})) | ||
| @@ -392,7 +392,7 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) { | |||||
| 392 | for i := 1; i < len(connections); i++ { | 392 | for i := 1; i < len(connections); i++ { | ||
| 393 | requestID := uint64(20 + i) | 393 | requestID := uint64(20 + i) | ||
| 394 | require.NoError(t, connections[i].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: requestID, Attached: true, Slug: "next-tab"})) | 394 | require.NoError(t, connections[i].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: requestID, Attached: true, Slug: "next-tab"})) | ||
| 395 | - | require.True(t, receiveOfflineCommandResult(t, connections[i], requestID).OK) | 395 | + | require.True(t, receiveOfflineCommandResult(t, connections[i], requestID).Outcome == protocol.CommandSucceeded) |
| 396 | } | 396 | } | ||
| 397 | for i := 1; i < len(connections); i++ { | 397 | for i := 1; i < len(connections); i++ { | ||
| 398 | require.NoError(t, connections[i].Close()) | 398 | require.NoError(t, connections[i].Close()) | ||
M internal/app/cmd.go +53 -3
| @@ -127,6 +127,30 @@ func cmdHelp(invocation cmdInvocation) string { | |||
| 127 | 127 | ||
| 128 | 128 | var errDaemonUnreachable = errors.New("daemon not running") | |
| 129 | 129 | ||
| 130 | + | // errCommandNotSent reports a command request that was never placed on the wire | |
| 131 | + | // because its caller was already canceled. The outcome is definite: the daemon | |
| 132 | + | // cannot have acted, so it must not be reported as indeterminate. | |
| 133 | + | var errCommandNotSent = errors.New("command request not sent") | |
| 134 | + | ||
| 135 | + | // errCommandOutcomeUnknown reports a command whose reply was lost, wrong-typed, | |
| 136 | + | // uncorrelated, malformed, or never observed before the deadline. The daemon may | |
| 137 | + | // already have committed the command, so the outcome is indeterminate, the | |
| 138 | + | // request is never replayed, and a late result cannot change the verdict. | |
| 139 | + | var errCommandOutcomeUnknown = errors.New("vev: command outcome unknown") | |
| 140 | + | ||
| 141 | + | // commandOutcomeUnknown classifies one observation detail as an indeterminate | |
| 142 | + | // command outcome while preserving that detail's exact message and unwrap chain. | |
| 143 | + | type commandOutcomeUnknown struct{ detail error } | |
| 144 | + | ||
| 145 | + | func (e *commandOutcomeUnknown) Error() string { | |
| 146 | + | return "command outcome unknown: " + e.detail.Error() | |
| 147 | + | } | |
| 148 | + | func (e *commandOutcomeUnknown) Unwrap() error { return e.detail } | |
| 149 | + | ||
| 150 | + | // Is reports the shared unknown-outcome sentinel so callers classify a lost | |
| 151 | + | // reply without matching on its observation text. | |
| 152 | + | func (*commandOutcomeUnknown) Is(target error) bool { return target == errCommandOutcomeUnknown } | |
| 153 | + | ||
| 130 | 154 | type cmdDeps struct { | |
| 131 | 155 | stdout io.Writer | |
| 132 | 156 | getenv func(string) string | |
| @@ -191,6 +215,13 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) | |||
| 191 | 215 | requestID, outcome := tracker.Publish(generation) | |
| 192 | 216 | request.RequestID = requestID | |
| 193 | 217 | connection := sessionwire.NewClientConnection(transport) | |
| 218 | + | // A caller already canceled before the send has a definite not-sent outcome: | |
| 219 | + | // the request is never placed on the wire, so nothing can commit and the | |
| 220 | + | // request is never replayed. | |
| 221 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 222 | + | tracker.Remove(requestID, generation) | |
| 223 | + | return fmt.Errorf("%w: %w", errCommandNotSent, ctxErr) | |
| 224 | + | } | |
| 194 | 225 | if err := connection.SendClient(request); err != nil { | |
| 195 | 226 | tracker.Remove(requestID, generation) | |
| 196 | 227 | return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)} | |
| @@ -206,7 +237,14 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) | |||
| 206 | 237 | tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply %T", reply)) | |
| 207 | 238 | return | |
| 208 | 239 | } | |
| 209 | - | result.RequestID = requestID | |
| 240 | + | if result.RequestID != requestID { | |
| 241 | + | tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply request ID %d", result.RequestID)) | |
| 242 | + | return | |
| 243 | + | } | |
| 244 | + | if !result.Valid() { | |
| 245 | + | tracker.Fail(requestID, generation, errors.New("malformed command outcome")) | |
| 246 | + | return | |
| 247 | + | } | |
| 210 | 248 | tracker.Complete(generation, result) | |
| 211 | 249 | }() | |
| 212 | 250 | commandClock := deps.clock | |
| @@ -215,9 +253,21 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) | |||
| 215 | 253 | } | |
| 216 | 254 | result, err := tracker.Wait(ctx, commandClock, requestID, generation, outcome) | |
| 217 | 255 | if err != nil { | |
| 218 | - | return &exitCoded{code: 3, err: err} | |
| 256 | + | // A reply that never arrives (loss, EOF, cancellation, or deadline) leaves | |
| 257 | + | // the outcome indeterminate: the request was sent, so it is never replayed. | |
| 258 | + | return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: err}} | |
| 219 | 259 | } | |
| 220 | - | if !result.OK { | |
| 260 | + | if result.Outcome == protocol.CommandOutcomeUnknown { | |
| 261 | + | text := result.Text | |
| 262 | + | if text == "" { | |
| 263 | + | text = "daemon did not report a final outcome" | |
| 264 | + | } | |
| 265 | + | return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: errors.New(text)}} | |
| 266 | + | } | |
| 267 | + | if result.Outcome != protocol.CommandSucceeded { | |
| 268 | + | if result.Text == "" { | |
| 269 | + | result.Text = "command failed" | |
| 270 | + | } | |
| 221 | 271 | if result.Code == protocol.ErrInvalidCommandArgs { | |
| 222 | 272 | return &exitCoded{code: 2, err: errors.New(result.Text)} | |
| 223 | 273 | } | |
| @@ -127,6 +127,30 @@ func cmdHelp(invocation cmdInvocation) string { | |||||
| 127 | 127 | ||||
| 128 | var errDaemonUnreachable = errors.New("daemon not running") | 128 | var errDaemonUnreachable = errors.New("daemon not running") | ||
| 129 | 129 | ||||
| 130 | + | // errCommandNotSent reports a command request that was never placed on the wire | |||
| 131 | + | // because its caller was already canceled. The outcome is definite: the daemon | |||
| 132 | + | // cannot have acted, so it must not be reported as indeterminate. | |||
| 133 | + | var errCommandNotSent = errors.New("command request not sent") | |||
| 134 | + | ||||
| 135 | + | // errCommandOutcomeUnknown reports a command whose reply was lost, wrong-typed, | |||
| 136 | + | // uncorrelated, malformed, or never observed before the deadline. The daemon may | |||
| 137 | + | // already have committed the command, so the outcome is indeterminate, the | |||
| 138 | + | // request is never replayed, and a late result cannot change the verdict. | |||
| 139 | + | var errCommandOutcomeUnknown = errors.New("vev: command outcome unknown") | |||
| 140 | + | ||||
| 141 | + | // commandOutcomeUnknown classifies one observation detail as an indeterminate | |||
| 142 | + | // command outcome while preserving that detail's exact message and unwrap chain. | |||
| 143 | + | type commandOutcomeUnknown struct{ detail error } | |||
| 144 | + | ||||
| 145 | + | func (e *commandOutcomeUnknown) Error() string { | |||
| 146 | + | return "command outcome unknown: " + e.detail.Error() | |||
| 147 | + | } | |||
| 148 | + | func (e *commandOutcomeUnknown) Unwrap() error { return e.detail } | |||
| 149 | + | ||||
| 150 | + | // Is reports the shared unknown-outcome sentinel so callers classify a lost | |||
| 151 | + | // reply without matching on its observation text. | |||
| 152 | + | func (*commandOutcomeUnknown) Is(target error) bool { return target == errCommandOutcomeUnknown } | |||
| 153 | + | ||||
| 130 | type cmdDeps struct { | 154 | type cmdDeps struct { | ||
| 131 | stdout io.Writer | 155 | stdout io.Writer | ||
| 132 | getenv func(string) string | 156 | getenv func(string) string | ||
| @@ -191,6 +215,13 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) | |||||
| 191 | requestID, outcome := tracker.Publish(generation) | 215 | requestID, outcome := tracker.Publish(generation) | ||
| 192 | request.RequestID = requestID | 216 | request.RequestID = requestID | ||
| 193 | connection := sessionwire.NewClientConnection(transport) | 217 | connection := sessionwire.NewClientConnection(transport) | ||
| 218 | + | // A caller already canceled before the send has a definite not-sent outcome: | |||
| 219 | + | // the request is never placed on the wire, so nothing can commit and the | |||
| 220 | + | // request is never replayed. | |||
| 221 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 222 | + | tracker.Remove(requestID, generation) | |||
| 223 | + | return fmt.Errorf("%w: %w", errCommandNotSent, ctxErr) | |||
| 224 | + | } | |||
| 194 | if err := connection.SendClient(request); err != nil { | 225 | if err := connection.SendClient(request); err != nil { | ||
| 195 | tracker.Remove(requestID, generation) | 226 | tracker.Remove(requestID, generation) | ||
| 196 | return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)} | 227 | return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)} | ||
| @@ -206,7 +237,14 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) | |||||
| 206 | tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply %T", reply)) | 237 | tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply %T", reply)) | ||
| 207 | return | 238 | return | ||
| 208 | } | 239 | } | ||
| 209 | - | result.RequestID = requestID | 240 | + | if result.RequestID != requestID { |
| 241 | + | tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply request ID %d", result.RequestID)) | |||
| 242 | + | return | |||
| 243 | + | } | |||
| 244 | + | if !result.Valid() { | |||
| 245 | + | tracker.Fail(requestID, generation, errors.New("malformed command outcome")) | |||
| 246 | + | return | |||
| 247 | + | } | |||
| 210 | tracker.Complete(generation, result) | 248 | tracker.Complete(generation, result) | ||
| 211 | }() | 249 | }() | ||
| 212 | commandClock := deps.clock | 250 | commandClock := deps.clock | ||
| @@ -215,9 +253,21 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) | |||||
| 215 | } | 253 | } | ||
| 216 | result, err := tracker.Wait(ctx, commandClock, requestID, generation, outcome) | 254 | result, err := tracker.Wait(ctx, commandClock, requestID, generation, outcome) | ||
| 217 | if err != nil { | 255 | if err != nil { | ||
| 218 | - | return &exitCoded{code: 3, err: err} | 256 | + | // A reply that never arrives (loss, EOF, cancellation, or deadline) leaves |
| 257 | + | // the outcome indeterminate: the request was sent, so it is never replayed. | |||
| 258 | + | return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: err}} | |||
| 219 | } | 259 | } | ||
| 220 | - | if !result.OK { | 260 | + | if result.Outcome == protocol.CommandOutcomeUnknown { |
| 261 | + | text := result.Text | |||
| 262 | + | if text == "" { | |||
| 263 | + | text = "daemon did not report a final outcome" | |||
| 264 | + | } | |||
| 265 | + | return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: errors.New(text)}} | |||
| 266 | + | } | |||
| 267 | + | if result.Outcome != protocol.CommandSucceeded { | |||
| 268 | + | if result.Text == "" { | |||
| 269 | + | result.Text = "command failed" | |||
| 270 | + | } | |||
| 221 | if result.Code == protocol.ErrInvalidCommandArgs { | 271 | if result.Code == protocol.ErrInvalidCommandArgs { | ||
| 222 | return &exitCoded{code: 2, err: errors.New(result.Text)} | 272 | return &exitCoded{code: 2, err: errors.New(result.Text)} | ||
| 223 | } | 273 | } | ||
A internal/app/cmd_outcome_test.go +271 -0
| @@ -0,0 +1,271 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | // CommandResult caller contract (explicit command-result slice). runCmd sends | |
| 4 | + | // exactly one CommandRequest with a unique nonzero RequestID and consumes | |
| 5 | + | // exactly one correlated CommandResult: a lost, wrong-typed, uncorrelated, or | |
| 6 | + | // malformed reply, or a deadline/cancellation after the send, is a typed | |
| 7 | + | // outcome-unknown that is never replayed and whose send count stays one. A | |
| 8 | + | // caller canceled before the send is a definite not-sent outcome, and a | |
| 9 | + | // definite daemon failure keeps its bounded error and exit code. Every case is | |
| 10 | + | // bounded and uses fake clocks or gates, never sleeps. | |
| 11 | + | ||
| 12 | + | import ( | |
| 13 | + | "context" | |
| 14 | + | "io" | |
| 15 | + | "strings" | |
| 16 | + | "testing" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/stretchr/testify/mock" | |
| 20 | + | "github.com/stretchr/testify/require" | |
| 21 | + | "google.golang.org/protobuf/proto" | |
| 22 | + | ||
| 23 | + | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 24 | + | "github.com/bnema/vev/internal/ports" | |
| 25 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 26 | + | "github.com/bnema/vev/internal/protocol" | |
| 27 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 28 | + | "github.com/bnema/vev/internal/usecase/daemon" | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // countCommandRequests reports how many CommandRequests the transport placed on | |
| 32 | + | // the wire: exactly one is the no-replay invariant. | |
| 33 | + | func countCommandRequests(t *testing.T, transport *cmdTestTransport) int { | |
| 34 | + | t.Helper() | |
| 35 | + | count := 0 | |
| 36 | + | for _, frame := range transport.sent { | |
| 37 | + | message, err := sessionwire.DecodeClientEnvelope(frame.Payload) | |
| 38 | + | if err != nil { | |
| 39 | + | continue | |
| 40 | + | } | |
| 41 | + | if _, ok := message.(protocol.CommandRequest); ok { | |
| 42 | + | count++ | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | return count | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // runCmdReply drives one runCmdWithDeps invocation against a scripted transport. | |
| 49 | + | func runCmdReply(ctx context.Context, transport *cmdTestTransport, clock ports.Clock) error { | |
| 50 | + | return runCmdWithDeps(ctx, cmdInvocation{slug: "split-right"}, cmdDeps{ | |
| 51 | + | stdout: io.Discard, | |
| 52 | + | getenv: func(string) string { return "" }, | |
| 53 | + | dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, | |
| 54 | + | clock: clock, | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // rawServerEnvelope wraps already-serialized server envelope bytes without | |
| 59 | + | // re-encoding them. Fixtures that model hostile or corrupt frames use it so the | |
| 60 | + | // now-correct strict semantic encoder can never reject the fixture first. | |
| 61 | + | func rawServerEnvelope(raw []byte) wire.Envelope { return wire.Envelope{Payload: raw} } | |
| 62 | + | ||
| 63 | + | // malformedCommandOutcomeEnvelope marshals a server CommandResult whose | |
| 64 | + | // outcome/code combination the semantic contract forbids (a succeeded outcome | |
| 65 | + | // with a nonzero code). proto.Marshal deliberately bypasses | |
| 66 | + | // sessionwire.EncodeServerMessage, which refuses the semantic value outright. | |
| 67 | + | func malformedCommandOutcomeEnvelope(t *testing.T) wire.Envelope { | |
| 68 | + | t.Helper() | |
| 69 | + | raw, err := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: &wire.CommandResult{ | |
| 70 | + | RequestId: 1, | |
| 71 | + | Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, | |
| 72 | + | Code: uint32(protocol.ErrInternal), | |
| 73 | + | }}}) | |
| 74 | + | require.NoError(t, err) | |
| 75 | + | // The semantic encoder must refuse the same value, which proves this fixture | |
| 76 | + | // really is a hand-built frame rather than a re-encoded semantic message. | |
| 77 | + | _, encodeErr := sessionwire.EncodeServerMessage(protocol.CommandResult{ | |
| 78 | + | RequestID: 1, Outcome: protocol.CommandSucceeded, Code: protocol.ErrInternal, | |
| 79 | + | }) | |
| 80 | + | require.Error(t, encodeErr, "a forbidden outcome must not be encodable") | |
| 81 | + | return rawServerEnvelope(raw) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // TestRunCmdLostUncorrelatedOrWrongTypedReplyIsOutcomeUnknown proves a reply | |
| 85 | + | // that is lost, wrong-typed, uncorrelated, or malformed leaves a typed | |
| 86 | + | // outcome-unknown: the request was sent, so it is never resent, and the | |
| 87 | + | // one-shot connection is retired exactly once. | |
| 88 | + | func TestRunCmdLostUncorrelatedOrWrongTypedReplyIsOutcomeUnknown(t *testing.T) { | |
| 89 | + | tests := []struct { | |
| 90 | + | name string | |
| 91 | + | transport *cmdTestTransport | |
| 92 | + | wantContain string | |
| 93 | + | }{ | |
| 94 | + | { | |
| 95 | + | name: "reply lost with EOF", | |
| 96 | + | transport: &cmdTestTransport{recvErr: io.EOF}, | |
| 97 | + | wantContain: "EOF", | |
| 98 | + | }, | |
| 99 | + | { | |
| 100 | + | name: "reply lost with a closed pipe", | |
| 101 | + | transport: &cmdTestTransport{recvErr: io.ErrClosedPipe}, | |
| 102 | + | wantContain: "closed pipe", | |
| 103 | + | }, | |
| 104 | + | { | |
| 105 | + | name: "wrong reply type", | |
| 106 | + | transport: &cmdTestTransport{recv: mustServerEnvelope(protocol.Pong{})}, | |
| 107 | + | wantContain: "unexpected command reply protocol.Pong", | |
| 108 | + | }, | |
| 109 | + | { | |
| 110 | + | name: "wrong reply request ID", | |
| 111 | + | transport: &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{ | |
| 112 | + | RequestID: 99, Outcome: protocol.CommandSucceeded, | |
| 113 | + | })}, | |
| 114 | + | wantContain: "unexpected command reply request ID 99", | |
| 115 | + | }, | |
| 116 | + | { | |
| 117 | + | name: "malformed outcome bytes are refused", | |
| 118 | + | transport: &cmdTestTransport{recv: malformedCommandOutcomeEnvelope(t)}, | |
| 119 | + | wantContain: "wire value out of semantic range", | |
| 120 | + | }, | |
| 121 | + | { | |
| 122 | + | name: "truncated envelope bytes are refused", | |
| 123 | + | transport: &cmdTestTransport{recv: rawServerEnvelope( | |
| 124 | + | mustServerEnvelope(protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}).Payload[:3])}, | |
| 125 | + | wantContain: "wire: envelope is truncated", | |
| 126 | + | }, | |
| 127 | + | { | |
| 128 | + | name: "definite daemon unknown outcome", | |
| 129 | + | transport: &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{ | |
| 130 | + | Outcome: protocol.CommandOutcomeUnknown, Text: "daemon is shutting down", | |
| 131 | + | })}, | |
| 132 | + | wantContain: "daemon is shutting down", | |
| 133 | + | }, | |
| 134 | + | } | |
| 135 | + | for _, tt := range tests { | |
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |
| 137 | + | err := runCmdReply(context.Background(), tt.transport, nil) | |
| 138 | + | ||
| 139 | + | require.ErrorIs(t, err, errCommandOutcomeUnknown, "a lost or uncorrelated reply is outcome-unknown") | |
| 140 | + | require.NotErrorIs(t, err, errCommandNotSent, "the request was sent, so the outcome is not a definite not-sent") | |
| 141 | + | require.ErrorContains(t, err, tt.wantContain) | |
| 142 | + | require.Equal(t, 3, ExitCode(err)) | |
| 143 | + | require.Equal(t, 1, countCommandRequests(t, tt.transport), "an indeterminate command must never be resent") | |
| 144 | + | require.Equal(t, 1, tt.transport.closeCalls, "the one-shot connection is retired exactly once") | |
| 145 | + | }) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // TestRunCmdCancelAfterSendIsOutcomeUnknownWithoutResend proves a caller | |
| 150 | + | // canceled while awaiting the reply reports outcome-unknown, sends exactly one | |
| 151 | + | // request, and retires the connection. | |
| 152 | + | func TestRunCmdCancelAfterSendIsOutcomeUnknownWithoutResend(t *testing.T) { | |
| 153 | + | replyStarted := make(chan struct{}) | |
| 154 | + | recvGate := make(chan struct{}) | |
| 155 | + | transport := &cmdTestTransport{replyStarted: replyStarted, recvGate: recvGate} | |
| 156 | + | ||
| 157 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 158 | + | defer cancel() | |
| 159 | + | done := make(chan error, 1) | |
| 160 | + | go func() { done <- runCmdReply(ctx, transport, nil) }() | |
| 161 | + | ||
| 162 | + | // Wait until the reply read is actually parked, then cancel. The gate makes | |
| 163 | + | // entry deterministic without sleeping. | |
| 164 | + | <-replyStarted | |
| 165 | + | cancel() | |
| 166 | + | err := <-done | |
| 167 | + | close(recvGate) | |
| 168 | + | ||
| 169 | + | require.ErrorIs(t, err, errCommandOutcomeUnknown) | |
| 170 | + | require.Equal(t, 3, ExitCode(err)) | |
| 171 | + | require.Equal(t, 1, countCommandRequests(t, transport), "a canceled command must never be resent") | |
| 172 | + | require.Equal(t, 1, transport.closeCalls, "cancellation after the send must retire the connection") | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // TestRunCmdTimeoutAfterSendIsOutcomeUnknownWithoutResend proves a request | |
| 176 | + | // whose result deadline expires while awaiting the reply reports | |
| 177 | + | // outcome-unknown, sends exactly one request, and retires the connection. The | |
| 178 | + | // fake clock keeps the deadline deterministic. | |
| 179 | + | func TestRunCmdTimeoutAfterSendIsOutcomeUnknownWithoutResend(t *testing.T) { | |
| 180 | + | timerCh := make(chan time.Time, 1) | |
| 181 | + | timer := portsmocks.NewMockTimer(t) | |
| 182 | + | timer.EXPECT().C().Return(timerCh).Once() | |
| 183 | + | timer.EXPECT().Stop().Return(true).Once() | |
| 184 | + | delayCh := make(chan time.Duration, 1) | |
| 185 | + | clock := portsmocks.NewMockClock(t) | |
| 186 | + | clock.EXPECT().NewTimer(mock.Anything).Run(func(delay time.Duration) { | |
| 187 | + | delayCh <- delay | |
| 188 | + | }).Return(timer).Once() | |
| 189 | + | ||
| 190 | + | replyStarted := make(chan struct{}) | |
| 191 | + | recvGate := make(chan struct{}) | |
| 192 | + | transport := &cmdTestTransport{replyStarted: replyStarted, recvGate: recvGate} | |
| 193 | + | done := make(chan error, 1) | |
| 194 | + | go func() { done <- runCmdReply(context.Background(), transport, clock) }() | |
| 195 | + | ||
| 196 | + | <-replyStarted | |
| 197 | + | require.Equal(t, daemon.CommandRequestTimeout, <-delayCh) | |
| 198 | + | timerCh <- time.Time{} | |
| 199 | + | err := <-done | |
| 200 | + | close(recvGate) | |
| 201 | + | ||
| 202 | + | require.ErrorIs(t, err, errCommandOutcomeUnknown) | |
| 203 | + | require.ErrorContains(t, err, daemon.ErrCommandRequestTimeout.Error()) | |
| 204 | + | require.Equal(t, 3, ExitCode(err)) | |
| 205 | + | require.Equal(t, 1, countCommandRequests(t, transport), "a timed-out command must never be resent") | |
| 206 | + | require.Equal(t, 1, transport.closeCalls, "a result timeout must retire the connection") | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // TestRunCmdCancelBeforeSendIsDefiniteNotSent proves a caller already canceled | |
| 210 | + | // before the request is sent reports a definite not-sent outcome, puts no | |
| 211 | + | // CommandRequest on the wire, and is not misclassified as unknown. | |
| 212 | + | func TestRunCmdCancelBeforeSendIsDefiniteNotSent(t *testing.T) { | |
| 213 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} | |
| 214 | + | ||
| 215 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 216 | + | cancel() | |
| 217 | + | err := runCmdReply(ctx, transport, nil) | |
| 218 | + | ||
| 219 | + | require.ErrorIs(t, err, errCommandNotSent) | |
| 220 | + | require.NotErrorIs(t, err, errCommandOutcomeUnknown, "a not-sent command has a definite outcome") | |
| 221 | + | require.Equal(t, 0, countCommandRequests(t, transport), "a canceled-before-send command must not reach the wire") | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // TestRunCmdSuccessAndFailurePreserveCorrelationAndBoundedError proves a | |
| 225 | + | // definite daemon outcome is reported with its exact text and exit code, and | |
| 226 | + | // still sends exactly one request. | |
| 227 | + | func TestRunCmdSuccessAndFailurePreserveCorrelationAndBoundedError(t *testing.T) { | |
| 228 | + | tests := []struct { | |
| 229 | + | name string | |
| 230 | + | result protocol.CommandResult | |
| 231 | + | wantCode int | |
| 232 | + | }{ | |
| 233 | + | { | |
| 234 | + | name: "success", | |
| 235 | + | result: protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"}, | |
| 236 | + | wantCode: 0, | |
| 237 | + | }, | |
| 238 | + | { | |
| 239 | + | name: "invalid arguments are a usage error", | |
| 240 | + | result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: split-right"}, | |
| 241 | + | wantCode: 2, | |
| 242 | + | }, | |
| 243 | + | { | |
| 244 | + | name: "runtime failure is an exit one failure", | |
| 245 | + | result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"}, | |
| 246 | + | wantCode: 1, | |
| 247 | + | }, | |
| 248 | + | } | |
| 249 | + | for _, tt := range tests { | |
| 250 | + | t.Run(tt.name, func(t *testing.T) { | |
| 251 | + | out := new(strings.Builder) | |
| 252 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(tt.result)} | |
| 253 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | |
| 254 | + | stdout: out, | |
| 255 | + | getenv: func(string) string { return "" }, | |
| 256 | + | dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, | |
| 257 | + | }) | |
| 258 | + | ||
| 259 | + | require.Equal(t, tt.wantCode, ExitCode(err)) | |
| 260 | + | require.Equal(t, 1, countCommandRequests(t, transport), "a definite outcome sends exactly one request") | |
| 261 | + | require.NotErrorIs(t, err, errCommandOutcomeUnknown, "a definite outcome is never reported as unknown") | |
| 262 | + | if tt.result.Outcome == protocol.CommandSucceeded { | |
| 263 | + | require.NoError(t, err) | |
| 264 | + | require.Equal(t, tt.result.Output+"\n", out.String()) | |
| 265 | + | } else { | |
| 266 | + | require.ErrorContains(t, err, tt.result.Text) | |
| 267 | + | require.NotErrorIs(t, err, errCommandNotSent) | |
| 268 | + | } | |
| 269 | + | }) | |
| 270 | + | } | |
| 271 | + | } | |
| @@ -0,0 +1,271 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | // CommandResult caller contract (explicit command-result slice). runCmd sends | |||
| 4 | + | // exactly one CommandRequest with a unique nonzero RequestID and consumes | |||
| 5 | + | // exactly one correlated CommandResult: a lost, wrong-typed, uncorrelated, or | |||
| 6 | + | // malformed reply, or a deadline/cancellation after the send, is a typed | |||
| 7 | + | // outcome-unknown that is never replayed and whose send count stays one. A | |||
| 8 | + | // caller canceled before the send is a definite not-sent outcome, and a | |||
| 9 | + | // definite daemon failure keeps its bounded error and exit code. Every case is | |||
| 10 | + | // bounded and uses fake clocks or gates, never sleeps. | |||
| 11 | + | ||||
| 12 | + | import ( | |||
| 13 | + | "context" | |||
| 14 | + | "io" | |||
| 15 | + | "strings" | |||
| 16 | + | "testing" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "github.com/stretchr/testify/mock" | |||
| 20 | + | "github.com/stretchr/testify/require" | |||
| 21 | + | "google.golang.org/protobuf/proto" | |||
| 22 | + | ||||
| 23 | + | "github.com/bnema/vev/internal/adapters/sessionwire" | |||
| 24 | + | "github.com/bnema/vev/internal/ports" | |||
| 25 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 26 | + | "github.com/bnema/vev/internal/protocol" | |||
| 27 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 28 | + | "github.com/bnema/vev/internal/usecase/daemon" | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | // countCommandRequests reports how many CommandRequests the transport placed on | |||
| 32 | + | // the wire: exactly one is the no-replay invariant. | |||
| 33 | + | func countCommandRequests(t *testing.T, transport *cmdTestTransport) int { | |||
| 34 | + | t.Helper() | |||
| 35 | + | count := 0 | |||
| 36 | + | for _, frame := range transport.sent { | |||
| 37 | + | message, err := sessionwire.DecodeClientEnvelope(frame.Payload) | |||
| 38 | + | if err != nil { | |||
| 39 | + | continue | |||
| 40 | + | } | |||
| 41 | + | if _, ok := message.(protocol.CommandRequest); ok { | |||
| 42 | + | count++ | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | return count | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // runCmdReply drives one runCmdWithDeps invocation against a scripted transport. | |||
| 49 | + | func runCmdReply(ctx context.Context, transport *cmdTestTransport, clock ports.Clock) error { | |||
| 50 | + | return runCmdWithDeps(ctx, cmdInvocation{slug: "split-right"}, cmdDeps{ | |||
| 51 | + | stdout: io.Discard, | |||
| 52 | + | getenv: func(string) string { return "" }, | |||
| 53 | + | dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, | |||
| 54 | + | clock: clock, | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // rawServerEnvelope wraps already-serialized server envelope bytes without | |||
| 59 | + | // re-encoding them. Fixtures that model hostile or corrupt frames use it so the | |||
| 60 | + | // now-correct strict semantic encoder can never reject the fixture first. | |||
| 61 | + | func rawServerEnvelope(raw []byte) wire.Envelope { return wire.Envelope{Payload: raw} } | |||
| 62 | + | ||||
| 63 | + | // malformedCommandOutcomeEnvelope marshals a server CommandResult whose | |||
| 64 | + | // outcome/code combination the semantic contract forbids (a succeeded outcome | |||
| 65 | + | // with a nonzero code). proto.Marshal deliberately bypasses | |||
| 66 | + | // sessionwire.EncodeServerMessage, which refuses the semantic value outright. | |||
| 67 | + | func malformedCommandOutcomeEnvelope(t *testing.T) wire.Envelope { | |||
| 68 | + | t.Helper() | |||
| 69 | + | raw, err := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: &wire.CommandResult{ | |||
| 70 | + | RequestId: 1, | |||
| 71 | + | Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED, | |||
| 72 | + | Code: uint32(protocol.ErrInternal), | |||
| 73 | + | }}}) | |||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | // The semantic encoder must refuse the same value, which proves this fixture | |||
| 76 | + | // really is a hand-built frame rather than a re-encoded semantic message. | |||
| 77 | + | _, encodeErr := sessionwire.EncodeServerMessage(protocol.CommandResult{ | |||
| 78 | + | RequestID: 1, Outcome: protocol.CommandSucceeded, Code: protocol.ErrInternal, | |||
| 79 | + | }) | |||
| 80 | + | require.Error(t, encodeErr, "a forbidden outcome must not be encodable") | |||
| 81 | + | return rawServerEnvelope(raw) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // TestRunCmdLostUncorrelatedOrWrongTypedReplyIsOutcomeUnknown proves a reply | |||
| 85 | + | // that is lost, wrong-typed, uncorrelated, or malformed leaves a typed | |||
| 86 | + | // outcome-unknown: the request was sent, so it is never resent, and the | |||
| 87 | + | // one-shot connection is retired exactly once. | |||
| 88 | + | func TestRunCmdLostUncorrelatedOrWrongTypedReplyIsOutcomeUnknown(t *testing.T) { | |||
| 89 | + | tests := []struct { | |||
| 90 | + | name string | |||
| 91 | + | transport *cmdTestTransport | |||
| 92 | + | wantContain string | |||
| 93 | + | }{ | |||
| 94 | + | { | |||
| 95 | + | name: "reply lost with EOF", | |||
| 96 | + | transport: &cmdTestTransport{recvErr: io.EOF}, | |||
| 97 | + | wantContain: "EOF", | |||
| 98 | + | }, | |||
| 99 | + | { | |||
| 100 | + | name: "reply lost with a closed pipe", | |||
| 101 | + | transport: &cmdTestTransport{recvErr: io.ErrClosedPipe}, | |||
| 102 | + | wantContain: "closed pipe", | |||
| 103 | + | }, | |||
| 104 | + | { | |||
| 105 | + | name: "wrong reply type", | |||
| 106 | + | transport: &cmdTestTransport{recv: mustServerEnvelope(protocol.Pong{})}, | |||
| 107 | + | wantContain: "unexpected command reply protocol.Pong", | |||
| 108 | + | }, | |||
| 109 | + | { | |||
| 110 | + | name: "wrong reply request ID", | |||
| 111 | + | transport: &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{ | |||
| 112 | + | RequestID: 99, Outcome: protocol.CommandSucceeded, | |||
| 113 | + | })}, | |||
| 114 | + | wantContain: "unexpected command reply request ID 99", | |||
| 115 | + | }, | |||
| 116 | + | { | |||
| 117 | + | name: "malformed outcome bytes are refused", | |||
| 118 | + | transport: &cmdTestTransport{recv: malformedCommandOutcomeEnvelope(t)}, | |||
| 119 | + | wantContain: "wire value out of semantic range", | |||
| 120 | + | }, | |||
| 121 | + | { | |||
| 122 | + | name: "truncated envelope bytes are refused", | |||
| 123 | + | transport: &cmdTestTransport{recv: rawServerEnvelope( | |||
| 124 | + | mustServerEnvelope(protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}).Payload[:3])}, | |||
| 125 | + | wantContain: "wire: envelope is truncated", | |||
| 126 | + | }, | |||
| 127 | + | { | |||
| 128 | + | name: "definite daemon unknown outcome", | |||
| 129 | + | transport: &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{ | |||
| 130 | + | Outcome: protocol.CommandOutcomeUnknown, Text: "daemon is shutting down", | |||
| 131 | + | })}, | |||
| 132 | + | wantContain: "daemon is shutting down", | |||
| 133 | + | }, | |||
| 134 | + | } | |||
| 135 | + | for _, tt := range tests { | |||
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 137 | + | err := runCmdReply(context.Background(), tt.transport, nil) | |||
| 138 | + | ||||
| 139 | + | require.ErrorIs(t, err, errCommandOutcomeUnknown, "a lost or uncorrelated reply is outcome-unknown") | |||
| 140 | + | require.NotErrorIs(t, err, errCommandNotSent, "the request was sent, so the outcome is not a definite not-sent") | |||
| 141 | + | require.ErrorContains(t, err, tt.wantContain) | |||
| 142 | + | require.Equal(t, 3, ExitCode(err)) | |||
| 143 | + | require.Equal(t, 1, countCommandRequests(t, tt.transport), "an indeterminate command must never be resent") | |||
| 144 | + | require.Equal(t, 1, tt.transport.closeCalls, "the one-shot connection is retired exactly once") | |||
| 145 | + | }) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // TestRunCmdCancelAfterSendIsOutcomeUnknownWithoutResend proves a caller | |||
| 150 | + | // canceled while awaiting the reply reports outcome-unknown, sends exactly one | |||
| 151 | + | // request, and retires the connection. | |||
| 152 | + | func TestRunCmdCancelAfterSendIsOutcomeUnknownWithoutResend(t *testing.T) { | |||
| 153 | + | replyStarted := make(chan struct{}) | |||
| 154 | + | recvGate := make(chan struct{}) | |||
| 155 | + | transport := &cmdTestTransport{replyStarted: replyStarted, recvGate: recvGate} | |||
| 156 | + | ||||
| 157 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 158 | + | defer cancel() | |||
| 159 | + | done := make(chan error, 1) | |||
| 160 | + | go func() { done <- runCmdReply(ctx, transport, nil) }() | |||
| 161 | + | ||||
| 162 | + | // Wait until the reply read is actually parked, then cancel. The gate makes | |||
| 163 | + | // entry deterministic without sleeping. | |||
| 164 | + | <-replyStarted | |||
| 165 | + | cancel() | |||
| 166 | + | err := <-done | |||
| 167 | + | close(recvGate) | |||
| 168 | + | ||||
| 169 | + | require.ErrorIs(t, err, errCommandOutcomeUnknown) | |||
| 170 | + | require.Equal(t, 3, ExitCode(err)) | |||
| 171 | + | require.Equal(t, 1, countCommandRequests(t, transport), "a canceled command must never be resent") | |||
| 172 | + | require.Equal(t, 1, transport.closeCalls, "cancellation after the send must retire the connection") | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // TestRunCmdTimeoutAfterSendIsOutcomeUnknownWithoutResend proves a request | |||
| 176 | + | // whose result deadline expires while awaiting the reply reports | |||
| 177 | + | // outcome-unknown, sends exactly one request, and retires the connection. The | |||
| 178 | + | // fake clock keeps the deadline deterministic. | |||
| 179 | + | func TestRunCmdTimeoutAfterSendIsOutcomeUnknownWithoutResend(t *testing.T) { | |||
| 180 | + | timerCh := make(chan time.Time, 1) | |||
| 181 | + | timer := portsmocks.NewMockTimer(t) | |||
| 182 | + | timer.EXPECT().C().Return(timerCh).Once() | |||
| 183 | + | timer.EXPECT().Stop().Return(true).Once() | |||
| 184 | + | delayCh := make(chan time.Duration, 1) | |||
| 185 | + | clock := portsmocks.NewMockClock(t) | |||
| 186 | + | clock.EXPECT().NewTimer(mock.Anything).Run(func(delay time.Duration) { | |||
| 187 | + | delayCh <- delay | |||
| 188 | + | }).Return(timer).Once() | |||
| 189 | + | ||||
| 190 | + | replyStarted := make(chan struct{}) | |||
| 191 | + | recvGate := make(chan struct{}) | |||
| 192 | + | transport := &cmdTestTransport{replyStarted: replyStarted, recvGate: recvGate} | |||
| 193 | + | done := make(chan error, 1) | |||
| 194 | + | go func() { done <- runCmdReply(context.Background(), transport, clock) }() | |||
| 195 | + | ||||
| 196 | + | <-replyStarted | |||
| 197 | + | require.Equal(t, daemon.CommandRequestTimeout, <-delayCh) | |||
| 198 | + | timerCh <- time.Time{} | |||
| 199 | + | err := <-done | |||
| 200 | + | close(recvGate) | |||
| 201 | + | ||||
| 202 | + | require.ErrorIs(t, err, errCommandOutcomeUnknown) | |||
| 203 | + | require.ErrorContains(t, err, daemon.ErrCommandRequestTimeout.Error()) | |||
| 204 | + | require.Equal(t, 3, ExitCode(err)) | |||
| 205 | + | require.Equal(t, 1, countCommandRequests(t, transport), "a timed-out command must never be resent") | |||
| 206 | + | require.Equal(t, 1, transport.closeCalls, "a result timeout must retire the connection") | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // TestRunCmdCancelBeforeSendIsDefiniteNotSent proves a caller already canceled | |||
| 210 | + | // before the request is sent reports a definite not-sent outcome, puts no | |||
| 211 | + | // CommandRequest on the wire, and is not misclassified as unknown. | |||
| 212 | + | func TestRunCmdCancelBeforeSendIsDefiniteNotSent(t *testing.T) { | |||
| 213 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} | |||
| 214 | + | ||||
| 215 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 216 | + | cancel() | |||
| 217 | + | err := runCmdReply(ctx, transport, nil) | |||
| 218 | + | ||||
| 219 | + | require.ErrorIs(t, err, errCommandNotSent) | |||
| 220 | + | require.NotErrorIs(t, err, errCommandOutcomeUnknown, "a not-sent command has a definite outcome") | |||
| 221 | + | require.Equal(t, 0, countCommandRequests(t, transport), "a canceled-before-send command must not reach the wire") | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // TestRunCmdSuccessAndFailurePreserveCorrelationAndBoundedError proves a | |||
| 225 | + | // definite daemon outcome is reported with its exact text and exit code, and | |||
| 226 | + | // still sends exactly one request. | |||
| 227 | + | func TestRunCmdSuccessAndFailurePreserveCorrelationAndBoundedError(t *testing.T) { | |||
| 228 | + | tests := []struct { | |||
| 229 | + | name string | |||
| 230 | + | result protocol.CommandResult | |||
| 231 | + | wantCode int | |||
| 232 | + | }{ | |||
| 233 | + | { | |||
| 234 | + | name: "success", | |||
| 235 | + | result: protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"}, | |||
| 236 | + | wantCode: 0, | |||
| 237 | + | }, | |||
| 238 | + | { | |||
| 239 | + | name: "invalid arguments are a usage error", | |||
| 240 | + | result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: split-right"}, | |||
| 241 | + | wantCode: 2, | |||
| 242 | + | }, | |||
| 243 | + | { | |||
| 244 | + | name: "runtime failure is an exit one failure", | |||
| 245 | + | result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"}, | |||
| 246 | + | wantCode: 1, | |||
| 247 | + | }, | |||
| 248 | + | } | |||
| 249 | + | for _, tt := range tests { | |||
| 250 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 251 | + | out := new(strings.Builder) | |||
| 252 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(tt.result)} | |||
| 253 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | |||
| 254 | + | stdout: out, | |||
| 255 | + | getenv: func(string) string { return "" }, | |||
| 256 | + | dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, | |||
| 257 | + | }) | |||
| 258 | + | ||||
| 259 | + | require.Equal(t, tt.wantCode, ExitCode(err)) | |||
| 260 | + | require.Equal(t, 1, countCommandRequests(t, transport), "a definite outcome sends exactly one request") | |||
| 261 | + | require.NotErrorIs(t, err, errCommandOutcomeUnknown, "a definite outcome is never reported as unknown") | |||
| 262 | + | if tt.result.Outcome == protocol.CommandSucceeded { | |||
| 263 | + | require.NoError(t, err) | |||
| 264 | + | require.Equal(t, tt.result.Output+"\n", out.String()) | |||
| 265 | + | } else { | |||
| 266 | + | require.ErrorContains(t, err, tt.result.Text) | |||
| 267 | + | require.NotErrorIs(t, err, errCommandNotSent) | |||
| 268 | + | } | |||
| 269 | + | }) | |||
| 270 | + | } | |||
| 271 | + | } | |||
M internal/app/cmd_test.go +50 -24
| @@ -190,7 +190,7 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) { | |||
| 190 | 190 | t.Run(tt.slug, func(t *testing.T) { | |
| 191 | 191 | parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...)) | |
| 192 | 192 | require.NoError(t, err) | |
| 193 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})} | |
| 193 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} | |
| 194 | 194 | require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{ | |
| 195 | 195 | stdout: io.Discard, | |
| 196 | 196 | getenv: func(string) string { return "" }, | |
| @@ -208,7 +208,7 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) { | |||
| 208 | 208 | } | |
| 209 | 209 | ||
| 210 | 210 | func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) { | |
| 211 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})} | |
| 211 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} | |
| 212 | 212 | ensureCalls := 0 | |
| 213 | 213 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{ | |
| 214 | 214 | stdout: io.Discard, | |
| @@ -235,7 +235,7 @@ func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) { | |||
| 235 | 235 | } { | |
| 236 | 236 | t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) { | |
| 237 | 237 | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{ | |
| 238 | - | Code: protocol.ErrInvalidCommandArgs, Text: "invalid command arguments", | |
| 238 | + | Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "invalid command arguments", | |
| 239 | 239 | })} | |
| 240 | 240 | err := runCmdWithDeps(context.Background(), invocation, cmdDeps{ | |
| 241 | 241 | stdout: io.Discard, | |
| @@ -257,7 +257,7 @@ func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) { | |||
| 257 | 257 | t.Run(slug, func(t *testing.T) { | |
| 258 | 258 | invocation, err := parseArgs([]string{"cmd", "--self", slug}) | |
| 259 | 259 | require.NoError(t, err) | |
| 260 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})} | |
| 260 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} | |
| 261 | 261 | require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{ | |
| 262 | 262 | stdout: io.Discard, | |
| 263 | 263 | getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" }, | |
| @@ -277,7 +277,7 @@ func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) { | |||
| 277 | 277 | } | |
| 278 | 278 | ||
| 279 | 279 | func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | |
| 280 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true, Output: "done"})} | |
| 280 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"})} | |
| 281 | 281 | out := new(strings.Builder) | |
| 282 | 282 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{ | |
| 283 | 283 | stdout: out, | |
| @@ -304,7 +304,7 @@ func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | |||
| 304 | 304 | } | |
| 305 | 305 | ||
| 306 | 306 | func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) { | |
| 307 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})} | |
| 307 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} | |
| 308 | 308 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | |
| 309 | 309 | stdout: io.Discard, | |
| 310 | 310 | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | |
| @@ -324,7 +324,7 @@ func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) { | |||
| 324 | 324 | } | |
| 325 | 325 | ||
| 326 | 326 | func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) { | |
| 327 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})} | |
| 327 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} | |
| 328 | 328 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{ | |
| 329 | 329 | stdout: io.Discard, | |
| 330 | 330 | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | |
| @@ -407,18 +407,23 @@ func TestRunCmdTimesOutPendingRequest(t *testing.T) { | |||
| 407 | 407 | } | |
| 408 | 408 | } | |
| 409 | 409 | ||
| 410 | - | func TestRunCmdZeroRequestIDReplyReturnsDaemonError(t *testing.T) { | |
| 411 | - | const want = "daemon error" | |
| 412 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{ | |
| 413 | - | Code: protocol.ErrInternal, Text: want, | |
| 410 | + | func TestRunCmdZeroRequestIDReplyIsRejected(t *testing.T) { | |
| 411 | + | const want = "unexpected command reply request ID 0" | |
| 412 | + | transport := &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{ | |
| 413 | + | Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: want, | |
| 414 | 414 | })} | |
| 415 | 415 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | |
| 416 | 416 | stdout: io.Discard, | |
| 417 | 417 | getenv: func(string) string { return "" }, | |
| 418 | 418 | dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, | |
| 419 | 419 | }) | |
| 420 | - | if err == nil || err.Error() != want { | |
| 421 | - | t.Fatalf("error = %v, want daemon error %q", err, want) | |
| 420 | + | // A correctly typed but uncorrelated reply is an indeterminate outcome, not a | |
| 421 | + | // definite daemon failure: the request was sent, so it is never replayed. | |
| 422 | + | if err == nil || !errors.Is(err, errCommandOutcomeUnknown) { | |
| 423 | + | t.Fatalf("error = %v, want an outcome-unknown rejection of reply request ID 0", err) | |
| 424 | + | } | |
| 425 | + | if !strings.Contains(err.Error(), want) { | |
| 426 | + | t.Fatalf("error = %v, want message containing %q", err, want) | |
| 422 | 427 | } | |
| 423 | 428 | if errors.Is(err, daemon.ErrCommandRequestTimeout) { | |
| 424 | 429 | t.Fatalf("error = %v, want daemon error instead of timeout", err) | |
| @@ -452,22 +457,22 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) { | |||
| 452 | 457 | }{ | |
| 453 | 458 | { | |
| 454 | 459 | name: "invalid command arguments are usage errors", | |
| 455 | - | result: protocol.CommandResult{Code: protocol.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"}, | |
| 460 | + | result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"}, | |
| 456 | 461 | wantCode: 2, | |
| 457 | 462 | }, | |
| 458 | 463 | { | |
| 459 | 464 | name: "missing runtime target is a command failure", | |
| 460 | - | result: protocol.CommandResult{Code: protocol.ErrNoSuchTarget, Text: "no live sessions"}, | |
| 465 | + | result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"}, | |
| 461 | 466 | wantCode: 1, | |
| 462 | 467 | }, | |
| 463 | 468 | { | |
| 464 | 469 | name: "resize not in split remains an exit one failure", | |
| 465 | - | result: protocol.CommandResult{Code: protocol.ErrNoSuchTarget, Text: "pane is not in a split"}, | |
| 470 | + | result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane is not in a split"}, | |
| 466 | 471 | wantCode: 1, | |
| 467 | 472 | }, | |
| 468 | 473 | { | |
| 469 | 474 | name: "resize minimum remains an exit one failure", | |
| 470 | - | result: protocol.CommandResult{Code: protocol.ErrNoSuchTarget, Text: "pane cannot be resized further"}, | |
| 475 | + | result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane cannot be resized further"}, | |
| 471 | 476 | wantCode: 1, | |
| 472 | 477 | }, | |
| 473 | 478 | } | |
| @@ -487,13 +492,15 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) { | |||
| 487 | 492 | } | |
| 488 | 493 | ||
| 489 | 494 | type cmdTestTransport struct { | |
| 490 | - | sent []wire.Envelope | |
| 491 | - | recv wire.Envelope | |
| 492 | - | recvErr error | |
| 493 | - | recvCalls int | |
| 494 | - | closeCalls int | |
| 495 | - | recvStarted chan struct{} | |
| 496 | - | recvGate <-chan struct{} | |
| 495 | + | sent []wire.Envelope | |
| 496 | + | recv wire.Envelope | |
| 497 | + | recvErr error | |
| 498 | + | recvCalls int | |
| 499 | + | closeCalls int | |
| 500 | + | recvStarted chan struct{} | |
| 501 | + | replyStarted chan struct{} | |
| 502 | + | recvGate <-chan struct{} | |
| 503 | + | preserveReplyID bool | |
| 497 | 504 | } | |
| 498 | 505 | ||
| 499 | 506 | func (t *cmdTestTransport) Send(frame wire.Envelope) error { | |
| @@ -515,9 +522,28 @@ func (t *cmdTestTransport) Recv() (wire.Envelope, error) { | |||
| 515 | 522 | if t.recvCalls == 1 { | |
| 516 | 523 | return mustPreambleResponse(), nil | |
| 517 | 524 | } | |
| 525 | + | if t.replyStarted != nil { | |
| 526 | + | select { | |
| 527 | + | case <-t.replyStarted: | |
| 528 | + | default: | |
| 529 | + | close(t.replyStarted) | |
| 530 | + | } | |
| 531 | + | } | |
| 518 | 532 | if t.recvGate != nil { | |
| 519 | 533 | <-t.recvGate | |
| 520 | 534 | } | |
| 535 | + | if t.recvErr == nil && !t.preserveReplyID { | |
| 536 | + | message, err := sessionwire.DecodeServerEnvelope(t.recv.Payload) | |
| 537 | + | if err == nil { | |
| 538 | + | if result, ok := message.(protocol.CommandResult); ok && result.RequestID == 0 { | |
| 539 | + | requestMessage, decodeErr := sessionwire.DecodeClientEnvelope(t.sent[1].Payload) | |
| 540 | + | if decodeErr == nil { | |
| 541 | + | result.RequestID = requestMessage.(protocol.CommandRequest).RequestID | |
| 542 | + | t.recv = mustServerEnvelope(result) | |
| 543 | + | } | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | } | |
| 521 | 547 | return t.recv, t.recvErr | |
| 522 | 548 | } | |
| 523 | 549 | ||
| @@ -190,7 +190,7 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) { | |||||
| 190 | t.Run(tt.slug, func(t *testing.T) { | 190 | t.Run(tt.slug, func(t *testing.T) { | ||
| 191 | parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...)) | 191 | parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...)) | ||
| 192 | require.NoError(t, err) | 192 | require.NoError(t, err) | ||
| 193 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})} | 193 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} |
| 194 | require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{ | 194 | require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{ | ||
| 195 | stdout: io.Discard, | 195 | stdout: io.Discard, | ||
| 196 | getenv: func(string) string { return "" }, | 196 | getenv: func(string) string { return "" }, | ||
| @@ -208,7 +208,7 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) { | |||||
| 208 | } | 208 | } | ||
| 209 | 209 | ||||
| 210 | func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) { | 210 | func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) { | ||
| 211 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})} | 211 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} |
| 212 | ensureCalls := 0 | 212 | ensureCalls := 0 | ||
| 213 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{ | 213 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{ | ||
| 214 | stdout: io.Discard, | 214 | stdout: io.Discard, | ||
| @@ -235,7 +235,7 @@ func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) { | |||||
| 235 | } { | 235 | } { | ||
| 236 | t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) { | 236 | t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) { | ||
| 237 | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{ | 237 | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{ | ||
| 238 | - | Code: protocol.ErrInvalidCommandArgs, Text: "invalid command arguments", | 238 | + | Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "invalid command arguments", |
| 239 | })} | 239 | })} | ||
| 240 | err := runCmdWithDeps(context.Background(), invocation, cmdDeps{ | 240 | err := runCmdWithDeps(context.Background(), invocation, cmdDeps{ | ||
| 241 | stdout: io.Discard, | 241 | stdout: io.Discard, | ||
| @@ -257,7 +257,7 @@ func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) { | |||||
| 257 | t.Run(slug, func(t *testing.T) { | 257 | t.Run(slug, func(t *testing.T) { | ||
| 258 | invocation, err := parseArgs([]string{"cmd", "--self", slug}) | 258 | invocation, err := parseArgs([]string{"cmd", "--self", slug}) | ||
| 259 | require.NoError(t, err) | 259 | require.NoError(t, err) | ||
| 260 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})} | 260 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} |
| 261 | require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{ | 261 | require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{ | ||
| 262 | stdout: io.Discard, | 262 | stdout: io.Discard, | ||
| 263 | getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" }, | 263 | getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" }, | ||
| @@ -277,7 +277,7 @@ func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) { | |||||
| 277 | } | 277 | } | ||
| 278 | 278 | ||||
| 279 | func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | 279 | func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | ||
| 280 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true, Output: "done"})} | 280 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"})} |
| 281 | out := new(strings.Builder) | 281 | out := new(strings.Builder) | ||
| 282 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{ | 282 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{ | ||
| 283 | stdout: out, | 283 | stdout: out, | ||
| @@ -304,7 +304,7 @@ func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | |||||
| 304 | } | 304 | } | ||
| 305 | 305 | ||||
| 306 | func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) { | 306 | func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) { | ||
| 307 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})} | 307 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} |
| 308 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | 308 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | ||
| 309 | stdout: io.Discard, | 309 | stdout: io.Discard, | ||
| 310 | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | 310 | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | ||
| @@ -324,7 +324,7 @@ func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) { | |||||
| 324 | } | 324 | } | ||
| 325 | 325 | ||||
| 326 | func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) { | 326 | func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) { | ||
| 327 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{OK: true})} | 327 | + | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} |
| 328 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{ | 328 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{ | ||
| 329 | stdout: io.Discard, | 329 | stdout: io.Discard, | ||
| 330 | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | 330 | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | ||
| @@ -407,18 +407,23 @@ func TestRunCmdTimesOutPendingRequest(t *testing.T) { | |||||
| 407 | } | 407 | } | ||
| 408 | } | 408 | } | ||
| 409 | 409 | ||||
| 410 | - | func TestRunCmdZeroRequestIDReplyReturnsDaemonError(t *testing.T) { | 410 | + | func TestRunCmdZeroRequestIDReplyIsRejected(t *testing.T) { |
| 411 | - | const want = "daemon error" | 411 | + | const want = "unexpected command reply request ID 0" |
| 412 | - | transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{ | 412 | + | transport := &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{ |
| 413 | - | Code: protocol.ErrInternal, Text: want, | 413 | + | Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: want, |
| 414 | })} | 414 | })} | ||
| 415 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | 415 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | ||
| 416 | stdout: io.Discard, | 416 | stdout: io.Discard, | ||
| 417 | getenv: func(string) string { return "" }, | 417 | getenv: func(string) string { return "" }, | ||
| 418 | dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, | 418 | dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, | ||
| 419 | }) | 419 | }) | ||
| 420 | - | if err == nil || err.Error() != want { | 420 | + | // A correctly typed but uncorrelated reply is an indeterminate outcome, not a |
| 421 | - | t.Fatalf("error = %v, want daemon error %q", err, want) | 421 | + | // definite daemon failure: the request was sent, so it is never replayed. |
| 422 | + | if err == nil || !errors.Is(err, errCommandOutcomeUnknown) { | |||
| 423 | + | t.Fatalf("error = %v, want an outcome-unknown rejection of reply request ID 0", err) | |||
| 424 | + | } | |||
| 425 | + | if !strings.Contains(err.Error(), want) { | |||
| 426 | + | t.Fatalf("error = %v, want message containing %q", err, want) | |||
| 422 | } | 427 | } | ||
| 423 | if errors.Is(err, daemon.ErrCommandRequestTimeout) { | 428 | if errors.Is(err, daemon.ErrCommandRequestTimeout) { | ||
| 424 | t.Fatalf("error = %v, want daemon error instead of timeout", err) | 429 | t.Fatalf("error = %v, want daemon error instead of timeout", err) | ||
| @@ -452,22 +457,22 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) { | |||||
| 452 | }{ | 457 | }{ | ||
| 453 | { | 458 | { | ||
| 454 | name: "invalid command arguments are usage errors", | 459 | name: "invalid command arguments are usage errors", | ||
| 455 | - | result: protocol.CommandResult{Code: protocol.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"}, | 460 | + | result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"}, |
| 456 | wantCode: 2, | 461 | wantCode: 2, | ||
| 457 | }, | 462 | }, | ||
| 458 | { | 463 | { | ||
| 459 | name: "missing runtime target is a command failure", | 464 | name: "missing runtime target is a command failure", | ||
| 460 | - | result: protocol.CommandResult{Code: protocol.ErrNoSuchTarget, Text: "no live sessions"}, | 465 | + | result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"}, |
| 461 | wantCode: 1, | 466 | wantCode: 1, | ||
| 462 | }, | 467 | }, | ||
| 463 | { | 468 | { | ||
| 464 | name: "resize not in split remains an exit one failure", | 469 | name: "resize not in split remains an exit one failure", | ||
| 465 | - | result: protocol.CommandResult{Code: protocol.ErrNoSuchTarget, Text: "pane is not in a split"}, | 470 | + | result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane is not in a split"}, |
| 466 | wantCode: 1, | 471 | wantCode: 1, | ||
| 467 | }, | 472 | }, | ||
| 468 | { | 473 | { | ||
| 469 | name: "resize minimum remains an exit one failure", | 474 | name: "resize minimum remains an exit one failure", | ||
| 470 | - | result: protocol.CommandResult{Code: protocol.ErrNoSuchTarget, Text: "pane cannot be resized further"}, | 475 | + | result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane cannot be resized further"}, |
| 471 | wantCode: 1, | 476 | wantCode: 1, | ||
| 472 | }, | 477 | }, | ||
| 473 | } | 478 | } | ||
| @@ -487,13 +492,15 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) { | |||||
| 487 | } | 492 | } | ||
| 488 | 493 | ||||
| 489 | type cmdTestTransport struct { | 494 | type cmdTestTransport struct { | ||
| 490 | - | sent []wire.Envelope | 495 | + | sent []wire.Envelope |
| 491 | - | recv wire.Envelope | 496 | + | recv wire.Envelope |
| 492 | - | recvErr error | 497 | + | recvErr error |
| 493 | - | recvCalls int | 498 | + | recvCalls int |
| 494 | - | closeCalls int | 499 | + | closeCalls int |
| 495 | - | recvStarted chan struct{} | 500 | + | recvStarted chan struct{} |
| 496 | - | recvGate <-chan struct{} | 501 | + | replyStarted chan struct{} |
| 502 | + | recvGate <-chan struct{} | |||
| 503 | + | preserveReplyID bool | |||
| 497 | } | 504 | } | ||
| 498 | 505 | ||||
| 499 | func (t *cmdTestTransport) Send(frame wire.Envelope) error { | 506 | func (t *cmdTestTransport) Send(frame wire.Envelope) error { | ||
| @@ -515,9 +522,28 @@ func (t *cmdTestTransport) Recv() (wire.Envelope, error) { | |||||
| 515 | if t.recvCalls == 1 { | 522 | if t.recvCalls == 1 { | ||
| 516 | return mustPreambleResponse(), nil | 523 | return mustPreambleResponse(), nil | ||
| 517 | } | 524 | } | ||
| 525 | + | if t.replyStarted != nil { | |||
| 526 | + | select { | |||
| 527 | + | case <-t.replyStarted: | |||
| 528 | + | default: | |||
| 529 | + | close(t.replyStarted) | |||
| 530 | + | } | |||
| 531 | + | } | |||
| 518 | if t.recvGate != nil { | 532 | if t.recvGate != nil { | ||
| 519 | <-t.recvGate | 533 | <-t.recvGate | ||
| 520 | } | 534 | } | ||
| 535 | + | if t.recvErr == nil && !t.preserveReplyID { | |||
| 536 | + | message, err := sessionwire.DecodeServerEnvelope(t.recv.Payload) | |||
| 537 | + | if err == nil { | |||
| 538 | + | if result, ok := message.(protocol.CommandResult); ok && result.RequestID == 0 { | |||
| 539 | + | requestMessage, decodeErr := sessionwire.DecodeClientEnvelope(t.sent[1].Payload) | |||
| 540 | + | if decodeErr == nil { | |||
| 541 | + | result.RequestID = requestMessage.(protocol.CommandRequest).RequestID | |||
| 542 | + | t.recv = mustServerEnvelope(result) | |||
| 543 | + | } | |||
| 544 | + | } | |||
| 545 | + | } | |||
| 546 | + | } | |||
| 521 | return t.recv, t.recvErr | 547 | return t.recv, t.recvErr | ||
| 522 | } | 548 | } | ||
| 523 | 549 | ||||
M internal/app/daemon_integration_test.go +1 -1
| @@ -339,7 +339,7 @@ func TestIntegration_MalformedCommandPreservesVersionAndRequestID(t *testing.T) | |||
| 339 | 339 | { | |
| 340 | 340 | name: "incompatible version", | |
| 341 | 341 | payload: versionMismatch, | |
| 342 | - | want: &protocol.CommandResult{RequestID: 42, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"}, | |
| 342 | + | want: &protocol.CommandResult{Outcome: protocol.CommandFailed, RequestID: 42, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"}, | |
| 343 | 343 | }, | |
| 344 | 344 | { | |
| 345 | 345 | // A single zero byte carries no recoverable envelope tag: | |
| @@ -339,7 +339,7 @@ func TestIntegration_MalformedCommandPreservesVersionAndRequestID(t *testing.T) | |||||
| 339 | { | 339 | { | ||
| 340 | name: "incompatible version", | 340 | name: "incompatible version", | ||
| 341 | payload: versionMismatch, | 341 | payload: versionMismatch, | ||
| 342 | - | want: &protocol.CommandResult{RequestID: 42, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"}, | 342 | + | want: &protocol.CommandResult{Outcome: protocol.CommandFailed, RequestID: 42, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"}, |
| 343 | }, | 343 | }, | ||
| 344 | { | 344 | { | ||
| 345 | // A single zero byte carries no recoverable envelope tag: | 345 | // A single zero byte carries no recoverable envelope tag: | ||
M internal/app/final_acceptance_test.go +2 -2
| @@ -141,8 +141,8 @@ func TestAcceptanceAttachedCommandUsesItsConnectionOnly(t *testing.T) { | |||
| 141 | 141 | ||
| 142 | 142 | firstResult := awaitAcceptanceCommandResult(t, first, firstPump, 101, "new-tab") | |
| 143 | 143 | secondResult := awaitAcceptanceCommandResult(t, second, secondPump, 202, "new-tab") | |
| 144 | - | require.True(t, firstResult.OK, firstResult.Text) | |
| 145 | - | require.True(t, secondResult.OK, secondResult.Text) | |
| 144 | + | require.True(t, firstResult.Outcome == protocol.CommandSucceeded, firstResult.Text) | |
| 145 | + | require.True(t, secondResult.Outcome == protocol.CommandSucceeded, secondResult.Text) | |
| 146 | 146 | require.Empty(t, firstResult.Output) | |
| 147 | 147 | require.Empty(t, secondResult.Output) | |
| 148 | 148 | require.NotEqual(t, firstResult.RequestID, secondResult.RequestID) | |
| @@ -141,8 +141,8 @@ func TestAcceptanceAttachedCommandUsesItsConnectionOnly(t *testing.T) { | |||||
| 141 | 141 | ||||
| 142 | firstResult := awaitAcceptanceCommandResult(t, first, firstPump, 101, "new-tab") | 142 | firstResult := awaitAcceptanceCommandResult(t, first, firstPump, 101, "new-tab") | ||
| 143 | secondResult := awaitAcceptanceCommandResult(t, second, secondPump, 202, "new-tab") | 143 | secondResult := awaitAcceptanceCommandResult(t, second, secondPump, 202, "new-tab") | ||
| 144 | - | require.True(t, firstResult.OK, firstResult.Text) | 144 | + | require.True(t, firstResult.Outcome == protocol.CommandSucceeded, firstResult.Text) |
| 145 | - | require.True(t, secondResult.OK, secondResult.Text) | 145 | + | require.True(t, secondResult.Outcome == protocol.CommandSucceeded, secondResult.Text) |
| 146 | require.Empty(t, firstResult.Output) | 146 | require.Empty(t, firstResult.Output) | ||
| 147 | require.Empty(t, secondResult.Output) | 147 | require.Empty(t, secondResult.Output) | ||
| 148 | require.NotEqual(t, firstResult.RequestID, secondResult.RequestID) | 148 | require.NotEqual(t, firstResult.RequestID, secondResult.RequestID) | ||
M internal/protocol/command.go +25 -2
| @@ -15,11 +15,34 @@ type CommandRequest struct { | |||
| 15 | 15 | JSON bool | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | - | // CommandResult reports a control command's outcome. | |
| 18 | + | // CommandOutcome is the closed terminal state of a dispatched command. | |
| 19 | + | type CommandOutcome uint8 | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | CommandSucceeded CommandOutcome = iota + 1 | |
| 23 | + | CommandFailed | |
| 24 | + | CommandOutcomeUnknown | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // CommandResult reports a control command's explicit terminal outcome. | |
| 19 | 28 | type CommandResult struct { | |
| 20 | 29 | RequestID uint64 | |
| 21 | - | OK bool | |
| 30 | + | Outcome CommandOutcome | |
| 22 | 31 | Code uint16 | |
| 23 | 32 | Text string | |
| 24 | 33 | Output string | |
| 25 | 34 | } | |
| 35 | + | ||
| 36 | + | // Valid reports whether the result obeys the closed outcome contract. | |
| 37 | + | func (r CommandResult) Valid() bool { | |
| 38 | + | switch r.Outcome { | |
| 39 | + | case CommandSucceeded: | |
| 40 | + | return r.Code == 0 && r.Text == "" | |
| 41 | + | case CommandFailed: | |
| 42 | + | return r.Code != 0 && r.Output == "" | |
| 43 | + | case CommandOutcomeUnknown: | |
| 44 | + | return r.Code == 0 && r.Output == "" | |
| 45 | + | default: | |
| 46 | + | return false | |
| 47 | + | } | |
| 48 | + | } | |
| @@ -15,11 +15,34 @@ type CommandRequest struct { | |||||
| 15 | JSON bool | 15 | JSON bool | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | - | // CommandResult reports a control command's outcome. | 18 | + | // CommandOutcome is the closed terminal state of a dispatched command. |
| 19 | + | type CommandOutcome uint8 | |||
| 20 | + | ||||
| 21 | + | const ( | |||
| 22 | + | CommandSucceeded CommandOutcome = iota + 1 | |||
| 23 | + | CommandFailed | |||
| 24 | + | CommandOutcomeUnknown | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // CommandResult reports a control command's explicit terminal outcome. | |||
| 19 | type CommandResult struct { | 28 | type CommandResult struct { | ||
| 20 | RequestID uint64 | 29 | RequestID uint64 | ||
| 21 | - | OK bool | 30 | + | Outcome CommandOutcome |
| 22 | Code uint16 | 31 | Code uint16 | ||
| 23 | Text string | 32 | Text string | ||
| 24 | Output string | 33 | Output string | ||
| 25 | } | 34 | } | ||
| 35 | + | ||||
| 36 | + | // Valid reports whether the result obeys the closed outcome contract. | |||
| 37 | + | func (r CommandResult) Valid() bool { | |||
| 38 | + | switch r.Outcome { | |||
| 39 | + | case CommandSucceeded: | |||
| 40 | + | return r.Code == 0 && r.Text == "" | |||
| 41 | + | case CommandFailed: | |||
| 42 | + | return r.Code != 0 && r.Output == "" | |||
| 43 | + | case CommandOutcomeUnknown: | |||
| 44 | + | return r.Code == 0 && r.Output == "" | |||
| 45 | + | default: | |||
| 46 | + | return false | |||
| 47 | + | } | |||
| 48 | + | } | |||
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 = 57 | |
| 19 | + | const Version uint16 = 58 | |
| 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 = 57 | 19 | + | const Version uint16 = 58 |
| 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(57), Version) | |
| 13 | + | require.Equal(t, uint16(58), 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(57), Version) | 13 | + | require.Equal(t, uint16(58), 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/command.pb.go +83 -21 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 != 57 { | |
| 19 | - | t.Fatalf("protocol.Version = %d, want 57", protocol.Version) | |
| 18 | + | if protocol.Version != 58 { | |
| 19 | + | t.Fatalf("protocol.Version = %d, want 58", 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 != 57 { | 18 | + | if protocol.Version != 58 { |
| 19 | - | t.Fatalf("protocol.Version = %d, want 57", protocol.Version) | 19 | + | t.Fatalf("protocol.Version = %d, want 58", 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/schema/command.proto +12 -2
| @@ -20,11 +20,21 @@ message CommandRequest { | |||
| 20 | 20 | bool json = 10; | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | - | // CommandResult reports a control command's outcome. | |
| 23 | + | // CommandOutcome is the closed terminal state of a command request. | |
| 24 | + | enum CommandOutcome { | |
| 25 | + | COMMAND_OUTCOME_UNSPECIFIED = 0; | |
| 26 | + | COMMAND_OUTCOME_SUCCEEDED = 1; | |
| 27 | + | COMMAND_OUTCOME_FAILED = 2; | |
| 28 | + | COMMAND_OUTCOME_UNKNOWN = 3; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // CommandResult reports a control command's explicit terminal outcome. | |
| 24 | 32 | message CommandResult { | |
| 25 | 33 | uint64 request_id = 1; | |
| 26 | - | bool ok = 2; | |
| 34 | + | reserved 2; | |
| 35 | + | reserved "ok"; | |
| 27 | 36 | uint32 code = 3; | |
| 28 | 37 | string text = 4; | |
| 29 | 38 | string output = 5; | |
| 39 | + | CommandOutcome outcome = 6; | |
| 30 | 40 | } | |
| @@ -20,11 +20,21 @@ message CommandRequest { | |||||
| 20 | bool json = 10; | 20 | bool json = 10; | ||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | - | // CommandResult reports a control command's outcome. | 23 | + | // CommandOutcome is the closed terminal state of a command request. |
| 24 | + | enum CommandOutcome { | |||
| 25 | + | COMMAND_OUTCOME_UNSPECIFIED = 0; | |||
| 26 | + | COMMAND_OUTCOME_SUCCEEDED = 1; | |||
| 27 | + | COMMAND_OUTCOME_FAILED = 2; | |||
| 28 | + | COMMAND_OUTCOME_UNKNOWN = 3; | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // CommandResult reports a control command's explicit terminal outcome. | |||
| 24 | message CommandResult { | 32 | message CommandResult { | ||
| 25 | uint64 request_id = 1; | 33 | uint64 request_id = 1; | ||
| 26 | - | bool ok = 2; | 34 | + | reserved 2; |
| 35 | + | reserved "ok"; | |||
| 27 | uint32 code = 3; | 36 | uint32 code = 3; | ||
| 28 | string text = 4; | 37 | string text = 4; | ||
| 29 | string output = 5; | 38 | string output = 5; | ||
| 39 | + | CommandOutcome outcome = 6; | |||
| 30 | } | 40 | } | ||
A internal/protocol/wire/schema_policy_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // TestCommandResultV58CleanBreakPolicy pins both halves of the intentional | |
| 10 | + | // v58 clean break: Buf permits field deletion only under strict versioning, | |
| 11 | + | // while the deleted field number and name remain unavailable for reuse. | |
| 12 | + | func TestCommandResultV58CleanBreakPolicy(t *testing.T) { | |
| 13 | + | buf, err := os.ReadFile("../../../buf.yaml") | |
| 14 | + | if err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | policy := string(buf) | |
| 18 | + | for _, want := range []string{ | |
| 19 | + | "ignore_only:", | |
| 20 | + | "FIELD_NO_DELETE:", | |
| 21 | + | "- internal/protocol/wire/schema/command.proto", | |
| 22 | + | } { | |
| 23 | + | if !strings.Contains(policy, want) { | |
| 24 | + | t.Fatalf("buf breaking policy must scope the v58 field deletion to command.proto: missing %q", want) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | if strings.Contains(policy, "- FIELD_NO_DELETE") { | |
| 28 | + | t.Fatal("buf breaking policy must not permit field deletion globally") | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | schema, err := os.ReadFile("schema/command.proto") | |
| 32 | + | if err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | text := string(schema) | |
| 36 | + | for _, reservation := range []string{"reserved 2;", `reserved "ok";`} { | |
| 37 | + | if !strings.Contains(text, reservation) { | |
| 38 | + | t.Fatalf("CommandResult must retain %s", reservation) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package wire | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // TestCommandResultV58CleanBreakPolicy pins both halves of the intentional | |||
| 10 | + | // v58 clean break: Buf permits field deletion only under strict versioning, | |||
| 11 | + | // while the deleted field number and name remain unavailable for reuse. | |||
| 12 | + | func TestCommandResultV58CleanBreakPolicy(t *testing.T) { | |||
| 13 | + | buf, err := os.ReadFile("../../../buf.yaml") | |||
| 14 | + | if err != nil { | |||
| 15 | + | t.Fatal(err) | |||
| 16 | + | } | |||
| 17 | + | policy := string(buf) | |||
| 18 | + | for _, want := range []string{ | |||
| 19 | + | "ignore_only:", | |||
| 20 | + | "FIELD_NO_DELETE:", | |||
| 21 | + | "- internal/protocol/wire/schema/command.proto", | |||
| 22 | + | } { | |||
| 23 | + | if !strings.Contains(policy, want) { | |||
| 24 | + | t.Fatalf("buf breaking policy must scope the v58 field deletion to command.proto: missing %q", want) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | if strings.Contains(policy, "- FIELD_NO_DELETE") { | |||
| 28 | + | t.Fatal("buf breaking policy must not permit field deletion globally") | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | schema, err := os.ReadFile("schema/command.proto") | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | text := string(schema) | |||
| 36 | + | for _, reservation := range []string{"reserved 2;", `reserved "ok";`} { | |||
| 37 | + | if !strings.Contains(text, reservation) { | |||
| 38 | + | t.Fatalf("CommandResult must retain %s", reservation) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | } | |||
M internal/usecase/daemon/attachment_effect_gate_test.go +1 -1
| @@ -742,7 +742,7 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||
| 742 | 742 | break | |
| 743 | 743 | } | |
| 744 | 744 | } | |
| 745 | - | require.True(t, result.OK, result.Text) | |
| 745 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 746 | 746 | var action protocol.RouteNavigationAction | |
| 747 | 747 | for _, frame := range frames { | |
| 748 | 748 | message, err := sessionwire.DecodeServerEnvelope(frame.Payload) | |
| @@ -742,7 +742,7 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||||
| 742 | break | 742 | break | ||
| 743 | } | 743 | } | ||
| 744 | } | 744 | } | ||
| 745 | - | require.True(t, result.OK, result.Text) | 745 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 746 | var action protocol.RouteNavigationAction | 746 | var action protocol.RouteNavigationAction | ||
| 747 | for _, frame := range frames { | 747 | for _, frame := range frames { | ||
| 748 | message, err := sessionwire.DecodeServerEnvelope(frame.Payload) | 748 | message, err := sessionwire.DecodeServerEnvelope(frame.Payload) | ||
M internal/usecase/daemon/attachment_multiplex_semantics_test.go +1 -1
| @@ -131,7 +131,7 @@ func TestMultiplexAttachmentLocalStateAndFrames(t *testing.T) { | |||
| 131 | 131 | result, ok := decodeServerMessage(t, awaitFrame(t, firstFrames, "CommandResult")).(protocol.CommandResult) | |
| 132 | 132 | require.True(t, ok) | |
| 133 | 133 | require.Equal(t, uint64(77), result.RequestID) | |
| 134 | - | require.True(t, result.OK, result.Text) | |
| 134 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 135 | 135 | select { | |
| 136 | 136 | case frame := <-peerFrames: | |
| 137 | 137 | if command, ok := decodeServerMessage(t, frame).(protocol.CommandResult); ok && command.RequestID == 77 { | |
| @@ -131,7 +131,7 @@ func TestMultiplexAttachmentLocalStateAndFrames(t *testing.T) { | |||||
| 131 | result, ok := decodeServerMessage(t, awaitFrame(t, firstFrames, "CommandResult")).(protocol.CommandResult) | 131 | result, ok := decodeServerMessage(t, awaitFrame(t, firstFrames, "CommandResult")).(protocol.CommandResult) | ||
| 132 | require.True(t, ok) | 132 | require.True(t, ok) | ||
| 133 | require.Equal(t, uint64(77), result.RequestID) | 133 | require.Equal(t, uint64(77), result.RequestID) | ||
| 134 | - | require.True(t, result.OK, result.Text) | 134 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 135 | select { | 135 | select { | ||
| 136 | case frame := <-peerFrames: | 136 | case frame := <-peerFrames: | ||
| 137 | if command, ok := decodeServerMessage(t, frame).(protocol.CommandResult); ok && command.RequestID == 77 { | 137 | if command, ok := decodeServerMessage(t, frame).(protocol.CommandResult); ok && command.RequestID == 77 { | ||
M internal/usecase/daemon/client_frame_routing.go +5 -2
| @@ -315,7 +315,10 @@ func (d *Daemon) commandResultEffect(effect *attachmentEffect) (*attachmentEffec | |||
| 315 | 315 | } | |
| 316 | 316 | ||
| 317 | 317 | func (d *Daemon) executeAttachedCommand(effect *attachmentEffect, request protocol.CommandRequest) protocol.CommandResult { | |
| 318 | - | result := protocol.CommandResult{RequestID: request.RequestID} | |
| 318 | + | // Every validation arm below answers with a definite failure; the | |
| 319 | + | // success arm replaces the outcome explicitly. A zero (unspecified) | |
| 320 | + | // outcome would be refused by the wire decoder on the client side. | |
| 321 | + | result := protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandFailed} | |
| 319 | 322 | if request.Version != protocol.Version { | |
| 320 | 323 | result.Code = protocol.ErrInvalidCommandArgs | |
| 321 | 324 | result.Text = "unsupported command protocol version" | |
| @@ -369,7 +372,7 @@ func (d *Daemon) executeAttachedCommand(effect *attachmentEffect, request protoc | |||
| 369 | 372 | return cmd.Run(paletteExec{d: d, sess: sess, ac: effect.ac, effect: effect}, request.Args) | |
| 370 | 373 | }) | |
| 371 | 374 | if err == nil { | |
| 372 | - | result.OK = true | |
| 375 | + | result.Outcome = protocol.CommandSucceeded | |
| 373 | 376 | return result | |
| 374 | 377 | } | |
| 375 | 378 | if errors.Is(err, command.ErrInvalidArguments) { | |
| @@ -315,7 +315,10 @@ func (d *Daemon) commandResultEffect(effect *attachmentEffect) (*attachmentEffec | |||||
| 315 | } | 315 | } | ||
| 316 | 316 | ||||
| 317 | func (d *Daemon) executeAttachedCommand(effect *attachmentEffect, request protocol.CommandRequest) protocol.CommandResult { | 317 | func (d *Daemon) executeAttachedCommand(effect *attachmentEffect, request protocol.CommandRequest) protocol.CommandResult { | ||
| 318 | - | result := protocol.CommandResult{RequestID: request.RequestID} | 318 | + | // Every validation arm below answers with a definite failure; the |
| 319 | + | // success arm replaces the outcome explicitly. A zero (unspecified) | |||
| 320 | + | // outcome would be refused by the wire decoder on the client side. | |||
| 321 | + | result := protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandFailed} | |||
| 319 | if request.Version != protocol.Version { | 322 | if request.Version != protocol.Version { | ||
| 320 | result.Code = protocol.ErrInvalidCommandArgs | 323 | result.Code = protocol.ErrInvalidCommandArgs | ||
| 321 | result.Text = "unsupported command protocol version" | 324 | result.Text = "unsupported command protocol version" | ||
| @@ -369,7 +372,7 @@ func (d *Daemon) executeAttachedCommand(effect *attachmentEffect, request protoc | |||||
| 369 | return cmd.Run(paletteExec{d: d, sess: sess, ac: effect.ac, effect: effect}, request.Args) | 372 | return cmd.Run(paletteExec{d: d, sess: sess, ac: effect.ac, effect: effect}, request.Args) | ||
| 370 | }) | 373 | }) | ||
| 371 | if err == nil { | 374 | if err == nil { | ||
| 372 | - | result.OK = true | 375 | + | result.Outcome = protocol.CommandSucceeded |
| 373 | return result | 376 | return result | ||
| 374 | } | 377 | } | ||
| 375 | if errors.Is(err, command.ErrInvalidArguments) { | 378 | if errors.Is(err, command.ErrInvalidArguments) { | ||
A internal/usecase/daemon/command_result_outcome_test.go +375 -0
| @@ -0,0 +1,375 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | // Explicit CommandResult daemon contract for the one-shot control path: a | |
| 4 | + | // dispatched command has exactly one closed terminal outcome; a request whose | |
| 5 | + | // deadline expires while its command is still running answers with exactly one | |
| 6 | + | // correlated CommandOutcomeUnknown and never a second result for the late | |
| 7 | + | // completion; and every definite refusal or success preserves the wire request | |
| 8 | + | // correlation and a bounded error. All cases are deterministic and bounded | |
| 9 | + | // (fake clock / PTY gates / awaitFrame timeouts), never sleeping. | |
| 10 | + | ||
| 11 | + | import ( | |
| 12 | + | "context" | |
| 13 | + | "io" | |
| 14 | + | "sync" | |
| 15 | + | "testing" | |
| 16 | + | "time" | |
| 17 | + | ||
| 18 | + | "github.com/stretchr/testify/require" | |
| 19 | + | ||
| 20 | + | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 21 | + | "github.com/bnema/vev/internal/domain" | |
| 22 | + | "github.com/bnema/vev/internal/ports" | |
| 23 | + | "github.com/bnema/vev/internal/protocol" | |
| 24 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // gatedControlFactory blocks its first pane Open until release, so a test holds | |
| 28 | + | // a dispatched command inside its PTY spawn while the request deadline expires. | |
| 29 | + | // Later Opens (a background floating prewarm) return immediately. | |
| 30 | + | type gatedControlFactory struct { | |
| 31 | + | entered chan struct{} | |
| 32 | + | release chan struct{} | |
| 33 | + | // gateOnce signals first-Open entry exactly once; releaseOnce closes the | |
| 34 | + | // release channel exactly once. They are separate because Open blocks inside | |
| 35 | + | // its own Once body waiting for the release that releaseOnce performs. | |
| 36 | + | gateOnce sync.Once | |
| 37 | + | releaseOnce sync.Once | |
| 38 | + | ||
| 39 | + | mu sync.Mutex | |
| 40 | + | ptys []*gatedControlPTY | |
| 41 | + | first *gatedControlPTY | |
| 42 | + | firstOpened bool | |
| 43 | + | shut bool | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func newGatedControlFactory() *gatedControlFactory { | |
| 47 | + | f := &gatedControlFactory{entered: make(chan struct{}), release: make(chan struct{})} | |
| 48 | + | // Pre-allocate the gated pane so a test can await its reader even while the | |
| 49 | + | // first Open is still parked inside the gate. | |
| 50 | + | f.first = &gatedControlPTY{readStarted: make(chan struct{}), stop: make(chan struct{})} | |
| 51 | + | return f | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (f *gatedControlFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) { | |
| 55 | + | f.mu.Lock() | |
| 56 | + | first := f.first | |
| 57 | + | gate := !f.firstOpened | |
| 58 | + | if gate { | |
| 59 | + | f.firstOpened = true | |
| 60 | + | } | |
| 61 | + | f.mu.Unlock() | |
| 62 | + | if gate { | |
| 63 | + | f.gateOnce.Do(func() { close(f.entered) }) | |
| 64 | + | <-f.release | |
| 65 | + | return first, nil | |
| 66 | + | } | |
| 67 | + | p := &gatedControlPTY{readStarted: make(chan struct{}), stop: make(chan struct{})} | |
| 68 | + | f.mu.Lock() | |
| 69 | + | f.ptys = append(f.ptys, p) | |
| 70 | + | shut := f.shut | |
| 71 | + | f.mu.Unlock() | |
| 72 | + | if shut { | |
| 73 | + | _ = p.Close() | |
| 74 | + | } | |
| 75 | + | return p, nil | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (f *gatedControlFactory) releaseOpen() { | |
| 79 | + | f.releaseOnce.Do(func() { close(f.release) }) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (f *gatedControlFactory) firstReadStarted() <-chan struct{} { | |
| 83 | + | f.mu.Lock() | |
| 84 | + | defer f.mu.Unlock() | |
| 85 | + | return f.first.readStarted | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (f *gatedControlFactory) close() { | |
| 89 | + | f.releaseOpen() | |
| 90 | + | f.mu.Lock() | |
| 91 | + | defer f.mu.Unlock() | |
| 92 | + | f.shut = true | |
| 93 | + | _ = f.first.Close() | |
| 94 | + | for _, p := range f.ptys { | |
| 95 | + | _ = p.Close() | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // gatedControlPTY signals when its reader starts and parks until closed, so a | |
| 100 | + | // test observes a committed tab's launched reader without sleeping. | |
| 101 | + | type gatedControlPTY struct { | |
| 102 | + | readStarted chan struct{} | |
| 103 | + | stop chan struct{} | |
| 104 | + | startOnce sync.Once | |
| 105 | + | closeOnce sync.Once | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (p *gatedControlPTY) Read([]byte) (int, error) { | |
| 109 | + | p.startOnce.Do(func() { close(p.readStarted) }) | |
| 110 | + | <-p.stop | |
| 111 | + | return 0, io.EOF | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (*gatedControlPTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 115 | + | func (*gatedControlPTY) Resize(domain.Geometry) error { return nil } | |
| 116 | + | func (*gatedControlPTY) Pid() int { return 0 } | |
| 117 | + | func (*gatedControlPTY) ForegroundPgid() (int, error) { return 0, nil } | |
| 118 | + | ||
| 119 | + | func (p *gatedControlPTY) Close() error { | |
| 120 | + | p.closeOnce.Do(func() { close(p.stop) }) | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func controlSessionTabCount(t *testing.T, d *Daemon, name string) int { | |
| 125 | + | t.Helper() | |
| 126 | + | d.mu.Lock() | |
| 127 | + | sess := d.findByNameLocked(name) | |
| 128 | + | d.mu.Unlock() | |
| 129 | + | require.NotNil(t, sess, "session %q must exist", name) | |
| 130 | + | sess.mu.Lock() | |
| 131 | + | defer sess.mu.Unlock() | |
| 132 | + | return len(sess.tabs) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // requireNoFurtherCommandResults proves the handler produced no second result. | |
| 136 | + | func requireNoFurtherCommandResults(t *testing.T, sends chan wire.Envelope) { | |
| 137 | + | t.Helper() | |
| 138 | + | for { | |
| 139 | + | select { | |
| 140 | + | case frame := <-sends: | |
| 141 | + | if envelopeMessageName(t, frame.Payload) != "CommandResult" { | |
| 142 | + | continue | |
| 143 | + | } | |
| 144 | + | message, err := sessionwire.DecodeServerEnvelope(frame.Payload) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | t.Fatalf("command answered a second result: %+v", message) | |
| 147 | + | default: | |
| 148 | + | return | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // commandResultCaptureTransport records every server envelope and whether the | |
| 154 | + | // one-shot control connection was retired, so a test can assert both without a | |
| 155 | + | // mock expectation race. | |
| 156 | + | type commandResultCaptureTransport struct { | |
| 157 | + | mu sync.Mutex | |
| 158 | + | sends []wire.Envelope | |
| 159 | + | closed bool | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func (c *commandResultCaptureTransport) Send(envelope wire.Envelope) error { | |
| 163 | + | c.mu.Lock() | |
| 164 | + | defer c.mu.Unlock() | |
| 165 | + | c.sends = append(c.sends, envelope) | |
| 166 | + | return nil | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (*commandResultCaptureTransport) Recv() (wire.Envelope, error) { | |
| 170 | + | return wire.Envelope{}, io.EOF | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (c *commandResultCaptureTransport) Close() error { | |
| 174 | + | c.mu.Lock() | |
| 175 | + | defer c.mu.Unlock() | |
| 176 | + | c.closed = true | |
| 177 | + | return nil | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (c *commandResultCaptureTransport) commandResults(t *testing.T) []protocol.CommandResult { | |
| 181 | + | t.Helper() | |
| 182 | + | c.mu.Lock() | |
| 183 | + | frames := append([]wire.Envelope(nil), c.sends...) | |
| 184 | + | c.mu.Unlock() | |
| 185 | + | results := make([]protocol.CommandResult, 0, len(frames)) | |
| 186 | + | for _, frame := range frames { | |
| 187 | + | message, err := sessionwire.DecodeServerEnvelope(frame.Payload) | |
| 188 | + | require.NoError(t, err) | |
| 189 | + | if result, ok := message.(protocol.CommandResult); ok { | |
| 190 | + | results = append(results, result) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | return results | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (c *commandResultCaptureTransport) isClosed() bool { | |
| 197 | + | c.mu.Lock() | |
| 198 | + | defer c.mu.Unlock() | |
| 199 | + | return c.closed | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // TestHandleCommandTimeoutSendsOneCorrelatedUnknownAndDropsLateCompletion is | |
| 203 | + | // the core timeout contract: the command is dispatched and still running when | |
| 204 | + | // the deadline expires, so the client receives exactly one CommandOutcomeUnknown | |
| 205 | + | // correlated to its RequestID. The command then commits late; that late | |
| 206 | + | // completion produces no second result and cannot change the observed outcome. | |
| 207 | + | func TestHandleCommandTimeoutSendsOneCorrelatedUnknownAndDropsLateCompletion(t *testing.T) { | |
| 208 | + | factory := newGatedControlFactory() | |
| 209 | + | clock := &signalClock{timers: make(chan *signalTimer, 16)} | |
| 210 | + | d := newTestDaemon(t, factory, clock) | |
| 211 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 212 | + | t.Cleanup(func() { | |
| 213 | + | factory.close() | |
| 214 | + | d.sessWg.Wait() | |
| 215 | + | }) | |
| 216 | + | ||
| 217 | + | frame := commandFrame(t, protocol.CommandRequest{RequestID: 17, Slug: "new-tab", TargetSession: "work"}) | |
| 218 | + | tr, sends, releaseConn := newConn(t, frame) | |
| 219 | + | defer releaseConn() | |
| 220 | + | done := make(chan error, 1) | |
| 221 | + | go func() { done <- d.handleCommandFrame(tr, frame) }() | |
| 222 | + | ||
| 223 | + | // The command is admitted and parked inside its pane spawn. | |
| 224 | + | awaitTestCompletion(t, factory.entered, "command was not dispatched") | |
| 225 | + | timer := <-clock.timers | |
| 226 | + | require.Equal(t, CommandRequestTimeout, timer.duration) | |
| 227 | + | timer.ch <- time.Time{} | |
| 228 | + | ||
| 229 | + | result := awaitCommandResult(t, sends) | |
| 230 | + | require.Equal(t, uint64(17), result.RequestID, "the unknown outcome must correlate to its request") | |
| 231 | + | require.Equal(t, protocol.CommandOutcomeUnknown, result.Outcome) | |
| 232 | + | require.True(t, result.Valid(), "%+v must obey the outcome contract", result) | |
| 233 | + | require.Zero(t, result.Code, "an unknown outcome carries no definite code") | |
| 234 | + | require.Empty(t, result.Output, "an unknown outcome carries no output") | |
| 235 | + | require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text) | |
| 236 | + | require.NoError(t, <-done, "the one-shot handler must answer and return") | |
| 237 | + | ||
| 238 | + | // Release the parked command: it now commits after the client already saw | |
| 239 | + | // the unknown outcome. | |
| 240 | + | factory.releaseOpen() | |
| 241 | + | awaitTestCompletion(t, factory.firstReadStarted(), "late completion never committed its tab") | |
| 242 | + | require.Equal(t, 2, controlSessionTabCount(t, d, "work"), "the timed-out command still committed late") | |
| 243 | + | ||
| 244 | + | requireNoFurtherCommandResults(t, sends) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // TestHandleCommandTimeoutRetiresConnectionAfterOneResult proves the timeout | |
| 248 | + | // path attempts exactly one correlated unknown result and then retires the | |
| 249 | + | // one-shot control connection, so a late completion has no channel to answer on. | |
| 250 | + | func TestHandleCommandTimeoutRetiresConnectionAfterOneResult(t *testing.T) { | |
| 251 | + | factory := newGatedControlFactory() | |
| 252 | + | clock := &signalClock{timers: make(chan *signalTimer, 16)} | |
| 253 | + | d := newTestDaemon(t, factory, clock) | |
| 254 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 255 | + | t.Cleanup(func() { | |
| 256 | + | factory.close() | |
| 257 | + | d.sessWg.Wait() | |
| 258 | + | }) | |
| 259 | + | ||
| 260 | + | capture := &commandResultCaptureTransport{} | |
| 261 | + | frame := commandFrame(t, protocol.CommandRequest{RequestID: 23, Slug: "new-tab", TargetSession: "work"}) | |
| 262 | + | done := make(chan error, 1) | |
| 263 | + | go func() { done <- d.handleCommandFrame(&rawServerConnection{raw: capture}, frame) }() | |
| 264 | + | ||
| 265 | + | awaitTestCompletion(t, factory.entered, "command was not dispatched") | |
| 266 | + | timer := <-clock.timers | |
| 267 | + | require.Equal(t, CommandRequestTimeout, timer.duration) | |
| 268 | + | timer.ch <- time.Time{} | |
| 269 | + | require.NoError(t, <-done) | |
| 270 | + | ||
| 271 | + | results := capture.commandResults(t) | |
| 272 | + | require.Len(t, results, 1, "a timed-out command answers exactly one result") | |
| 273 | + | require.Equal(t, uint64(23), results[0].RequestID) | |
| 274 | + | require.Equal(t, protocol.CommandOutcomeUnknown, results[0].Outcome) | |
| 275 | + | require.True(t, capture.isClosed(), "a one-shot control connection is retired after its result") | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // TestHandleCommandDefiniteOutcomesPreserveCorrelationAndOutcome proves each | |
| 279 | + | // definite refusal and each success answers once with the closed outcome, the | |
| 280 | + | // wire request correlation, and (for failures) a bounded error and no output. | |
| 281 | + | func TestHandleCommandDefiniteOutcomesPreserveCorrelationAndOutcome(t *testing.T) { | |
| 282 | + | tests := []struct { | |
| 283 | + | name string | |
| 284 | + | request protocol.CommandRequest | |
| 285 | + | wantOutcome protocol.CommandOutcome | |
| 286 | + | wantCode uint16 | |
| 287 | + | wantOutput bool | |
| 288 | + | }{ | |
| 289 | + | { | |
| 290 | + | name: "success carries a bounded output", | |
| 291 | + | request: protocol.CommandRequest{Slug: "list-sessions"}, | |
| 292 | + | wantOutcome: protocol.CommandSucceeded, | |
| 293 | + | }, | |
| 294 | + | { | |
| 295 | + | name: "unknown slug is a definite failure", | |
| 296 | + | request: protocol.CommandRequest{Slug: "no-such"}, | |
| 297 | + | wantOutcome: protocol.CommandFailed, | |
| 298 | + | wantCode: protocol.ErrUnknownCommand, | |
| 299 | + | }, | |
| 300 | + | { | |
| 301 | + | name: "non-scriptable command is a definite failure", | |
| 302 | + | request: protocol.CommandRequest{Slug: "session-picker"}, | |
| 303 | + | wantOutcome: protocol.CommandFailed, | |
| 304 | + | wantCode: protocol.ErrNotScriptable, | |
| 305 | + | }, | |
| 306 | + | { | |
| 307 | + | name: "version mismatch is a definite failure", | |
| 308 | + | request: protocol.CommandRequest{Version: protocol.Version + 1, Slug: "list-sessions"}, | |
| 309 | + | wantOutcome: protocol.CommandFailed, | |
| 310 | + | wantCode: protocol.ErrVersionMismatch, | |
| 311 | + | }, | |
| 312 | + | { | |
| 313 | + | name: "attached relay refusal is a definite failure", | |
| 314 | + | request: protocol.CommandRequest{Attached: true, Slug: "list-sessions"}, | |
| 315 | + | wantOutcome: protocol.CommandFailed, | |
| 316 | + | wantCode: protocol.ErrNotScriptable, | |
| 317 | + | }, | |
| 318 | + | } | |
| 319 | + | for i, tt := range tests { | |
| 320 | + | t.Run(tt.name, func(t *testing.T) { | |
| 321 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 322 | + | request := tt.request | |
| 323 | + | request.RequestID = uint64(31 + i) | |
| 324 | + | ||
| 325 | + | result := sendCommand(t, d, request) | |
| 326 | + | ||
| 327 | + | require.Equal(t, request.RequestID, result.RequestID, "the outcome must correlate to its request") | |
| 328 | + | require.Equal(t, tt.wantOutcome, result.Outcome) | |
| 329 | + | require.True(t, result.Valid(), "%+v must obey the outcome contract", result) | |
| 330 | + | if tt.wantOutcome == protocol.CommandFailed { | |
| 331 | + | require.Equal(t, tt.wantCode, result.Code) | |
| 332 | + | require.NotEmpty(t, result.Text, "a definite failure must carry bounded error text") | |
| 333 | + | require.LessOrEqual(t, len(result.Text), wire.ControlEnvelopeLimit) | |
| 334 | + | require.Empty(t, result.Output, "a failure must not carry success output") | |
| 335 | + | } else { | |
| 336 | + | require.Zero(t, result.Code) | |
| 337 | + | require.Empty(t, result.Text) | |
| 338 | + | require.NotEmpty(t, result.Output) | |
| 339 | + | } | |
| 340 | + | }) | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // TestAttachedCommandRefusalSendsDefiniteFailedOutcome proves an attached | |
| 345 | + | // command refusal answers with a decodable, definite failure correlated to the | |
| 346 | + | // frame's RequestID rather than an unspecified outcome the wire boundary would | |
| 347 | + | // refuse. | |
| 348 | + | func TestAttachedCommandRefusalSendsDefiniteFailedOutcome(t *testing.T) { | |
| 349 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 350 | + | transport := &closeTrackingTransport{} | |
| 351 | + | ac.replaceTransport(transport) | |
| 352 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 353 | + | token := sess.captureAttachmentCapability(ac, transport) | |
| 354 | + | token.lease = rc.attachmentLease(ac) | |
| 355 | + | ac.installTestAttachmentCapability(token) | |
| 356 | + | ||
| 357 | + | require.False(t, d.handleAttachmentClientFrame(token, mustClientEnvelope(protocol.CommandRequest{ | |
| 358 | + | Version: protocol.Version, RequestID: 41, Slug: "no-such", Attached: true, | |
| 359 | + | }))) | |
| 360 | + | ||
| 361 | + | var result protocol.CommandResult | |
| 362 | + | found := false | |
| 363 | + | for _, frame := range transport.Sends() { | |
| 364 | + | message, err := sessionwire.DecodeServerEnvelope(frame.Payload) | |
| 365 | + | require.NoError(t, err) | |
| 366 | + | if decoded, ok := message.(protocol.CommandResult); ok { | |
| 367 | + | result, found = decoded, true | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | require.True(t, found, "an attached refusal must answer with a CommandResult") | |
| 371 | + | require.Equal(t, uint64(41), result.RequestID) | |
| 372 | + | require.Equal(t, protocol.CommandFailed, result.Outcome) | |
| 373 | + | require.Equal(t, protocol.ErrUnknownCommand, result.Code) | |
| 374 | + | require.True(t, result.Valid(), "%+v must obey the outcome contract", result) | |
| 375 | + | } | |
| @@ -0,0 +1,375 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | // Explicit CommandResult daemon contract for the one-shot control path: a | |||
| 4 | + | // dispatched command has exactly one closed terminal outcome; a request whose | |||
| 5 | + | // deadline expires while its command is still running answers with exactly one | |||
| 6 | + | // correlated CommandOutcomeUnknown and never a second result for the late | |||
| 7 | + | // completion; and every definite refusal or success preserves the wire request | |||
| 8 | + | // correlation and a bounded error. All cases are deterministic and bounded | |||
| 9 | + | // (fake clock / PTY gates / awaitFrame timeouts), never sleeping. | |||
| 10 | + | ||||
| 11 | + | import ( | |||
| 12 | + | "context" | |||
| 13 | + | "io" | |||
| 14 | + | "sync" | |||
| 15 | + | "testing" | |||
| 16 | + | "time" | |||
| 17 | + | ||||
| 18 | + | "github.com/stretchr/testify/require" | |||
| 19 | + | ||||
| 20 | + | "github.com/bnema/vev/internal/adapters/sessionwire" | |||
| 21 | + | "github.com/bnema/vev/internal/domain" | |||
| 22 | + | "github.com/bnema/vev/internal/ports" | |||
| 23 | + | "github.com/bnema/vev/internal/protocol" | |||
| 24 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // gatedControlFactory blocks its first pane Open until release, so a test holds | |||
| 28 | + | // a dispatched command inside its PTY spawn while the request deadline expires. | |||
| 29 | + | // Later Opens (a background floating prewarm) return immediately. | |||
| 30 | + | type gatedControlFactory struct { | |||
| 31 | + | entered chan struct{} | |||
| 32 | + | release chan struct{} | |||
| 33 | + | // gateOnce signals first-Open entry exactly once; releaseOnce closes the | |||
| 34 | + | // release channel exactly once. They are separate because Open blocks inside | |||
| 35 | + | // its own Once body waiting for the release that releaseOnce performs. | |||
| 36 | + | gateOnce sync.Once | |||
| 37 | + | releaseOnce sync.Once | |||
| 38 | + | ||||
| 39 | + | mu sync.Mutex | |||
| 40 | + | ptys []*gatedControlPTY | |||
| 41 | + | first *gatedControlPTY | |||
| 42 | + | firstOpened bool | |||
| 43 | + | shut bool | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func newGatedControlFactory() *gatedControlFactory { | |||
| 47 | + | f := &gatedControlFactory{entered: make(chan struct{}), release: make(chan struct{})} | |||
| 48 | + | // Pre-allocate the gated pane so a test can await its reader even while the | |||
| 49 | + | // first Open is still parked inside the gate. | |||
| 50 | + | f.first = &gatedControlPTY{readStarted: make(chan struct{}), stop: make(chan struct{})} | |||
| 51 | + | return f | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (f *gatedControlFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) { | |||
| 55 | + | f.mu.Lock() | |||
| 56 | + | first := f.first | |||
| 57 | + | gate := !f.firstOpened | |||
| 58 | + | if gate { | |||
| 59 | + | f.firstOpened = true | |||
| 60 | + | } | |||
| 61 | + | f.mu.Unlock() | |||
| 62 | + | if gate { | |||
| 63 | + | f.gateOnce.Do(func() { close(f.entered) }) | |||
| 64 | + | <-f.release | |||
| 65 | + | return first, nil | |||
| 66 | + | } | |||
| 67 | + | p := &gatedControlPTY{readStarted: make(chan struct{}), stop: make(chan struct{})} | |||
| 68 | + | f.mu.Lock() | |||
| 69 | + | f.ptys = append(f.ptys, p) | |||
| 70 | + | shut := f.shut | |||
| 71 | + | f.mu.Unlock() | |||
| 72 | + | if shut { | |||
| 73 | + | _ = p.Close() | |||
| 74 | + | } | |||
| 75 | + | return p, nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (f *gatedControlFactory) releaseOpen() { | |||
| 79 | + | f.releaseOnce.Do(func() { close(f.release) }) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (f *gatedControlFactory) firstReadStarted() <-chan struct{} { | |||
| 83 | + | f.mu.Lock() | |||
| 84 | + | defer f.mu.Unlock() | |||
| 85 | + | return f.first.readStarted | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (f *gatedControlFactory) close() { | |||
| 89 | + | f.releaseOpen() | |||
| 90 | + | f.mu.Lock() | |||
| 91 | + | defer f.mu.Unlock() | |||
| 92 | + | f.shut = true | |||
| 93 | + | _ = f.first.Close() | |||
| 94 | + | for _, p := range f.ptys { | |||
| 95 | + | _ = p.Close() | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // gatedControlPTY signals when its reader starts and parks until closed, so a | |||
| 100 | + | // test observes a committed tab's launched reader without sleeping. | |||
| 101 | + | type gatedControlPTY struct { | |||
| 102 | + | readStarted chan struct{} | |||
| 103 | + | stop chan struct{} | |||
| 104 | + | startOnce sync.Once | |||
| 105 | + | closeOnce sync.Once | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (p *gatedControlPTY) Read([]byte) (int, error) { | |||
| 109 | + | p.startOnce.Do(func() { close(p.readStarted) }) | |||
| 110 | + | <-p.stop | |||
| 111 | + | return 0, io.EOF | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (*gatedControlPTY) Write(b []byte) (int, error) { return len(b), nil } | |||
| 115 | + | func (*gatedControlPTY) Resize(domain.Geometry) error { return nil } | |||
| 116 | + | func (*gatedControlPTY) Pid() int { return 0 } | |||
| 117 | + | func (*gatedControlPTY) ForegroundPgid() (int, error) { return 0, nil } | |||
| 118 | + | ||||
| 119 | + | func (p *gatedControlPTY) Close() error { | |||
| 120 | + | p.closeOnce.Do(func() { close(p.stop) }) | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func controlSessionTabCount(t *testing.T, d *Daemon, name string) int { | |||
| 125 | + | t.Helper() | |||
| 126 | + | d.mu.Lock() | |||
| 127 | + | sess := d.findByNameLocked(name) | |||
| 128 | + | d.mu.Unlock() | |||
| 129 | + | require.NotNil(t, sess, "session %q must exist", name) | |||
| 130 | + | sess.mu.Lock() | |||
| 131 | + | defer sess.mu.Unlock() | |||
| 132 | + | return len(sess.tabs) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // requireNoFurtherCommandResults proves the handler produced no second result. | |||
| 136 | + | func requireNoFurtherCommandResults(t *testing.T, sends chan wire.Envelope) { | |||
| 137 | + | t.Helper() | |||
| 138 | + | for { | |||
| 139 | + | select { | |||
| 140 | + | case frame := <-sends: | |||
| 141 | + | if envelopeMessageName(t, frame.Payload) != "CommandResult" { | |||
| 142 | + | continue | |||
| 143 | + | } | |||
| 144 | + | message, err := sessionwire.DecodeServerEnvelope(frame.Payload) | |||
| 145 | + | require.NoError(t, err) | |||
| 146 | + | t.Fatalf("command answered a second result: %+v", message) | |||
| 147 | + | default: | |||
| 148 | + | return | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // commandResultCaptureTransport records every server envelope and whether the | |||
| 154 | + | // one-shot control connection was retired, so a test can assert both without a | |||
| 155 | + | // mock expectation race. | |||
| 156 | + | type commandResultCaptureTransport struct { | |||
| 157 | + | mu sync.Mutex | |||
| 158 | + | sends []wire.Envelope | |||
| 159 | + | closed bool | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func (c *commandResultCaptureTransport) Send(envelope wire.Envelope) error { | |||
| 163 | + | c.mu.Lock() | |||
| 164 | + | defer c.mu.Unlock() | |||
| 165 | + | c.sends = append(c.sends, envelope) | |||
| 166 | + | return nil | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (*commandResultCaptureTransport) Recv() (wire.Envelope, error) { | |||
| 170 | + | return wire.Envelope{}, io.EOF | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func (c *commandResultCaptureTransport) Close() error { | |||
| 174 | + | c.mu.Lock() | |||
| 175 | + | defer c.mu.Unlock() | |||
| 176 | + | c.closed = true | |||
| 177 | + | return nil | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func (c *commandResultCaptureTransport) commandResults(t *testing.T) []protocol.CommandResult { | |||
| 181 | + | t.Helper() | |||
| 182 | + | c.mu.Lock() | |||
| 183 | + | frames := append([]wire.Envelope(nil), c.sends...) | |||
| 184 | + | c.mu.Unlock() | |||
| 185 | + | results := make([]protocol.CommandResult, 0, len(frames)) | |||
| 186 | + | for _, frame := range frames { | |||
| 187 | + | message, err := sessionwire.DecodeServerEnvelope(frame.Payload) | |||
| 188 | + | require.NoError(t, err) | |||
| 189 | + | if result, ok := message.(protocol.CommandResult); ok { | |||
| 190 | + | results = append(results, result) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | return results | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func (c *commandResultCaptureTransport) isClosed() bool { | |||
| 197 | + | c.mu.Lock() | |||
| 198 | + | defer c.mu.Unlock() | |||
| 199 | + | return c.closed | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // TestHandleCommandTimeoutSendsOneCorrelatedUnknownAndDropsLateCompletion is | |||
| 203 | + | // the core timeout contract: the command is dispatched and still running when | |||
| 204 | + | // the deadline expires, so the client receives exactly one CommandOutcomeUnknown | |||
| 205 | + | // correlated to its RequestID. The command then commits late; that late | |||
| 206 | + | // completion produces no second result and cannot change the observed outcome. | |||
| 207 | + | func TestHandleCommandTimeoutSendsOneCorrelatedUnknownAndDropsLateCompletion(t *testing.T) { | |||
| 208 | + | factory := newGatedControlFactory() | |||
| 209 | + | clock := &signalClock{timers: make(chan *signalTimer, 16)} | |||
| 210 | + | d := newTestDaemon(t, factory, clock) | |||
| 211 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 212 | + | t.Cleanup(func() { | |||
| 213 | + | factory.close() | |||
| 214 | + | d.sessWg.Wait() | |||
| 215 | + | }) | |||
| 216 | + | ||||
| 217 | + | frame := commandFrame(t, protocol.CommandRequest{RequestID: 17, Slug: "new-tab", TargetSession: "work"}) | |||
| 218 | + | tr, sends, releaseConn := newConn(t, frame) | |||
| 219 | + | defer releaseConn() | |||
| 220 | + | done := make(chan error, 1) | |||
| 221 | + | go func() { done <- d.handleCommandFrame(tr, frame) }() | |||
| 222 | + | ||||
| 223 | + | // The command is admitted and parked inside its pane spawn. | |||
| 224 | + | awaitTestCompletion(t, factory.entered, "command was not dispatched") | |||
| 225 | + | timer := <-clock.timers | |||
| 226 | + | require.Equal(t, CommandRequestTimeout, timer.duration) | |||
| 227 | + | timer.ch <- time.Time{} | |||
| 228 | + | ||||
| 229 | + | result := awaitCommandResult(t, sends) | |||
| 230 | + | require.Equal(t, uint64(17), result.RequestID, "the unknown outcome must correlate to its request") | |||
| 231 | + | require.Equal(t, protocol.CommandOutcomeUnknown, result.Outcome) | |||
| 232 | + | require.True(t, result.Valid(), "%+v must obey the outcome contract", result) | |||
| 233 | + | require.Zero(t, result.Code, "an unknown outcome carries no definite code") | |||
| 234 | + | require.Empty(t, result.Output, "an unknown outcome carries no output") | |||
| 235 | + | require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text) | |||
| 236 | + | require.NoError(t, <-done, "the one-shot handler must answer and return") | |||
| 237 | + | ||||
| 238 | + | // Release the parked command: it now commits after the client already saw | |||
| 239 | + | // the unknown outcome. | |||
| 240 | + | factory.releaseOpen() | |||
| 241 | + | awaitTestCompletion(t, factory.firstReadStarted(), "late completion never committed its tab") | |||
| 242 | + | require.Equal(t, 2, controlSessionTabCount(t, d, "work"), "the timed-out command still committed late") | |||
| 243 | + | ||||
| 244 | + | requireNoFurtherCommandResults(t, sends) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // TestHandleCommandTimeoutRetiresConnectionAfterOneResult proves the timeout | |||
| 248 | + | // path attempts exactly one correlated unknown result and then retires the | |||
| 249 | + | // one-shot control connection, so a late completion has no channel to answer on. | |||
| 250 | + | func TestHandleCommandTimeoutRetiresConnectionAfterOneResult(t *testing.T) { | |||
| 251 | + | factory := newGatedControlFactory() | |||
| 252 | + | clock := &signalClock{timers: make(chan *signalTimer, 16)} | |||
| 253 | + | d := newTestDaemon(t, factory, clock) | |||
| 254 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 255 | + | t.Cleanup(func() { | |||
| 256 | + | factory.close() | |||
| 257 | + | d.sessWg.Wait() | |||
| 258 | + | }) | |||
| 259 | + | ||||
| 260 | + | capture := &commandResultCaptureTransport{} | |||
| 261 | + | frame := commandFrame(t, protocol.CommandRequest{RequestID: 23, Slug: "new-tab", TargetSession: "work"}) | |||
| 262 | + | done := make(chan error, 1) | |||
| 263 | + | go func() { done <- d.handleCommandFrame(&rawServerConnection{raw: capture}, frame) }() | |||
| 264 | + | ||||
| 265 | + | awaitTestCompletion(t, factory.entered, "command was not dispatched") | |||
| 266 | + | timer := <-clock.timers | |||
| 267 | + | require.Equal(t, CommandRequestTimeout, timer.duration) | |||
| 268 | + | timer.ch <- time.Time{} | |||
| 269 | + | require.NoError(t, <-done) | |||
| 270 | + | ||||
| 271 | + | results := capture.commandResults(t) | |||
| 272 | + | require.Len(t, results, 1, "a timed-out command answers exactly one result") | |||
| 273 | + | require.Equal(t, uint64(23), results[0].RequestID) | |||
| 274 | + | require.Equal(t, protocol.CommandOutcomeUnknown, results[0].Outcome) | |||
| 275 | + | require.True(t, capture.isClosed(), "a one-shot control connection is retired after its result") | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // TestHandleCommandDefiniteOutcomesPreserveCorrelationAndOutcome proves each | |||
| 279 | + | // definite refusal and each success answers once with the closed outcome, the | |||
| 280 | + | // wire request correlation, and (for failures) a bounded error and no output. | |||
| 281 | + | func TestHandleCommandDefiniteOutcomesPreserveCorrelationAndOutcome(t *testing.T) { | |||
| 282 | + | tests := []struct { | |||
| 283 | + | name string | |||
| 284 | + | request protocol.CommandRequest | |||
| 285 | + | wantOutcome protocol.CommandOutcome | |||
| 286 | + | wantCode uint16 | |||
| 287 | + | wantOutput bool | |||
| 288 | + | }{ | |||
| 289 | + | { | |||
| 290 | + | name: "success carries a bounded output", | |||
| 291 | + | request: protocol.CommandRequest{Slug: "list-sessions"}, | |||
| 292 | + | wantOutcome: protocol.CommandSucceeded, | |||
| 293 | + | }, | |||
| 294 | + | { | |||
| 295 | + | name: "unknown slug is a definite failure", | |||
| 296 | + | request: protocol.CommandRequest{Slug: "no-such"}, | |||
| 297 | + | wantOutcome: protocol.CommandFailed, | |||
| 298 | + | wantCode: protocol.ErrUnknownCommand, | |||
| 299 | + | }, | |||
| 300 | + | { | |||
| 301 | + | name: "non-scriptable command is a definite failure", | |||
| 302 | + | request: protocol.CommandRequest{Slug: "session-picker"}, | |||
| 303 | + | wantOutcome: protocol.CommandFailed, | |||
| 304 | + | wantCode: protocol.ErrNotScriptable, | |||
| 305 | + | }, | |||
| 306 | + | { | |||
| 307 | + | name: "version mismatch is a definite failure", | |||
| 308 | + | request: protocol.CommandRequest{Version: protocol.Version + 1, Slug: "list-sessions"}, | |||
| 309 | + | wantOutcome: protocol.CommandFailed, | |||
| 310 | + | wantCode: protocol.ErrVersionMismatch, | |||
| 311 | + | }, | |||
| 312 | + | { | |||
| 313 | + | name: "attached relay refusal is a definite failure", | |||
| 314 | + | request: protocol.CommandRequest{Attached: true, Slug: "list-sessions"}, | |||
| 315 | + | wantOutcome: protocol.CommandFailed, | |||
| 316 | + | wantCode: protocol.ErrNotScriptable, | |||
| 317 | + | }, | |||
| 318 | + | } | |||
| 319 | + | for i, tt := range tests { | |||
| 320 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 321 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 322 | + | request := tt.request | |||
| 323 | + | request.RequestID = uint64(31 + i) | |||
| 324 | + | ||||
| 325 | + | result := sendCommand(t, d, request) | |||
| 326 | + | ||||
| 327 | + | require.Equal(t, request.RequestID, result.RequestID, "the outcome must correlate to its request") | |||
| 328 | + | require.Equal(t, tt.wantOutcome, result.Outcome) | |||
| 329 | + | require.True(t, result.Valid(), "%+v must obey the outcome contract", result) | |||
| 330 | + | if tt.wantOutcome == protocol.CommandFailed { | |||
| 331 | + | require.Equal(t, tt.wantCode, result.Code) | |||
| 332 | + | require.NotEmpty(t, result.Text, "a definite failure must carry bounded error text") | |||
| 333 | + | require.LessOrEqual(t, len(result.Text), wire.ControlEnvelopeLimit) | |||
| 334 | + | require.Empty(t, result.Output, "a failure must not carry success output") | |||
| 335 | + | } else { | |||
| 336 | + | require.Zero(t, result.Code) | |||
| 337 | + | require.Empty(t, result.Text) | |||
| 338 | + | require.NotEmpty(t, result.Output) | |||
| 339 | + | } | |||
| 340 | + | }) | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | // TestAttachedCommandRefusalSendsDefiniteFailedOutcome proves an attached | |||
| 345 | + | // command refusal answers with a decodable, definite failure correlated to the | |||
| 346 | + | // frame's RequestID rather than an unspecified outcome the wire boundary would | |||
| 347 | + | // refuse. | |||
| 348 | + | func TestAttachedCommandRefusalSendsDefiniteFailedOutcome(t *testing.T) { | |||
| 349 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 350 | + | transport := &closeTrackingTransport{} | |||
| 351 | + | ac.replaceTransport(transport) | |||
| 352 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 353 | + | token := sess.captureAttachmentCapability(ac, transport) | |||
| 354 | + | token.lease = rc.attachmentLease(ac) | |||
| 355 | + | ac.installTestAttachmentCapability(token) | |||
| 356 | + | ||||
| 357 | + | require.False(t, d.handleAttachmentClientFrame(token, mustClientEnvelope(protocol.CommandRequest{ | |||
| 358 | + | Version: protocol.Version, RequestID: 41, Slug: "no-such", Attached: true, | |||
| 359 | + | }))) | |||
| 360 | + | ||||
| 361 | + | var result protocol.CommandResult | |||
| 362 | + | found := false | |||
| 363 | + | for _, frame := range transport.Sends() { | |||
| 364 | + | message, err := sessionwire.DecodeServerEnvelope(frame.Payload) | |||
| 365 | + | require.NoError(t, err) | |||
| 366 | + | if decoded, ok := message.(protocol.CommandResult); ok { | |||
| 367 | + | result, found = decoded, true | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | require.True(t, found, "an attached refusal must answer with a CommandResult") | |||
| 371 | + | require.Equal(t, uint64(41), result.RequestID) | |||
| 372 | + | require.Equal(t, protocol.CommandFailed, result.Outcome) | |||
| 373 | + | require.Equal(t, protocol.ErrUnknownCommand, result.Code) | |||
| 374 | + | require.True(t, result.Valid(), "%+v must obey the outcome contract", result) | |||
| 375 | + | } | |||
M internal/usecase/daemon/command_tracker_test.go +3 -3
| @@ -61,15 +61,15 @@ func TestCommandRequestTrackerTimeoutAndGenerationIsolation(t *testing.T) { | |||
| 61 | 61 | // old connection must not complete the new request. | |
| 62 | 62 | secondOutcome, ok := tracker.Track(requestID, 8) | |
| 63 | 63 | require.True(t, ok) | |
| 64 | - | tracker.Complete(4, protocol.CommandResult{RequestID: requestID, OK: true}) | |
| 64 | + | tracker.Complete(4, protocol.CommandResult{RequestID: requestID, Outcome: protocol.CommandSucceeded}) | |
| 65 | 65 | select { | |
| 66 | 66 | case <-secondOutcome: | |
| 67 | 67 | t.Fatal("late result from the old generation completed a newer request") | |
| 68 | 68 | default: | |
| 69 | 69 | } | |
| 70 | - | tracker.Complete(8, protocol.CommandResult{RequestID: requestID, OK: true}) | |
| 70 | + | tracker.Complete(8, protocol.CommandResult{RequestID: requestID, Outcome: protocol.CommandSucceeded}) | |
| 71 | 71 | result := <-secondOutcome | |
| 72 | - | require.True(t, result.Result.OK) | |
| 72 | + | require.True(t, result.Result.Outcome == protocol.CommandSucceeded) | |
| 73 | 73 | ||
| 74 | 74 | t.Run("wait outcomes", func(t *testing.T) { | |
| 75 | 75 | failure := errors.New("transport failed") | |
| @@ -61,15 +61,15 @@ func TestCommandRequestTrackerTimeoutAndGenerationIsolation(t *testing.T) { | |||||
| 61 | // old connection must not complete the new request. | 61 | // old connection must not complete the new request. | ||
| 62 | secondOutcome, ok := tracker.Track(requestID, 8) | 62 | secondOutcome, ok := tracker.Track(requestID, 8) | ||
| 63 | require.True(t, ok) | 63 | require.True(t, ok) | ||
| 64 | - | tracker.Complete(4, protocol.CommandResult{RequestID: requestID, OK: true}) | 64 | + | tracker.Complete(4, protocol.CommandResult{RequestID: requestID, Outcome: protocol.CommandSucceeded}) |
| 65 | select { | 65 | select { | ||
| 66 | case <-secondOutcome: | 66 | case <-secondOutcome: | ||
| 67 | t.Fatal("late result from the old generation completed a newer request") | 67 | t.Fatal("late result from the old generation completed a newer request") | ||
| 68 | default: | 68 | default: | ||
| 69 | } | 69 | } | ||
| 70 | - | tracker.Complete(8, protocol.CommandResult{RequestID: requestID, OK: true}) | 70 | + | tracker.Complete(8, protocol.CommandResult{RequestID: requestID, Outcome: protocol.CommandSucceeded}) |
| 71 | result := <-secondOutcome | 71 | result := <-secondOutcome | ||
| 72 | - | require.True(t, result.Result.OK) | 72 | + | require.True(t, result.Result.Outcome == protocol.CommandSucceeded) |
| 73 | 73 | ||||
| 74 | t.Run("wait outcomes", func(t *testing.T) { | 74 | t.Run("wait outcomes", func(t *testing.T) { | ||
| 75 | failure := errors.New("transport failed") | 75 | failure := errors.New("transport failed") | ||
M internal/usecase/daemon/control.go +5 -4
| @@ -23,11 +23,12 @@ func (d *Daemon) handleCommand(tr ports.ServerConnection, request protocol.Comma | |||
| 23 | 23 | defer func() { _ = tr.Close() }() | |
| 24 | 24 | ||
| 25 | 25 | if request.Version != protocol.Version { | |
| 26 | - | return d.sendCommandResult(tr, protocol.CommandResult{RequestID: request.RequestID, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"}) | |
| 26 | + | return d.sendCommandResult(tr, protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandFailed, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"}) | |
| 27 | 27 | } | |
| 28 | 28 | if request.Attached { | |
| 29 | 29 | return d.sendCommandResult(tr, protocol.CommandResult{ | |
| 30 | 30 | RequestID: request.RequestID, | |
| 31 | + | Outcome: protocol.CommandFailed, | |
| 31 | 32 | Code: protocol.ErrNotScriptable, | |
| 32 | 33 | Text: "attached command relay is not enabled", | |
| 33 | 34 | }) | |
| @@ -55,7 +56,7 @@ func (d *Daemon) handleCommand(tr ports.ServerConnection, request protocol.Comma | |||
| 55 | 56 | if waitErr != nil { | |
| 56 | 57 | return d.sendCommandResult(tr, protocol.CommandResult{ | |
| 57 | 58 | RequestID: request.RequestID, | |
| 58 | - | Code: protocol.ErrInternal, | |
| 59 | + | Outcome: protocol.CommandOutcomeUnknown, | |
| 59 | 60 | Text: waitErr.Error(), | |
| 60 | 61 | }) | |
| 61 | 62 | } | |
| @@ -152,7 +153,7 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request protocol.CommandRe | |||
| 152 | 153 | func (d *Daemon) runControl(cmd command.Command, exec controlExec, request protocol.CommandRequest) protocol.CommandResult { | |
| 153 | 154 | result, err := cmd.Control(exec, request.Args, command.ControlOptions{JSON: request.JSON}) | |
| 154 | 155 | if err == nil { | |
| 155 | - | return protocol.CommandResult{OK: true, Output: result.Output} | |
| 156 | + | return protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: result.Output} | |
| 156 | 157 | } | |
| 157 | 158 | switch { | |
| 158 | 159 | case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired), errors.Is(err, domain.ErrInvalidSessionName): | |
| @@ -181,7 +182,7 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request proto | |||
| 181 | 182 | } | |
| 182 | 183 | ||
| 183 | 184 | func commandFailure(code uint16, text string) protocol.CommandResult { | |
| 184 | - | return protocol.CommandResult{Code: code, Text: text} | |
| 185 | + | return protocol.CommandResult{Outcome: protocol.CommandFailed, Code: code, Text: text} | |
| 185 | 186 | } | |
| 186 | 187 | ||
| 187 | 188 | // resolveTargetSession applies explicit-name, stable-ID, then unique-session | |
| @@ -23,11 +23,12 @@ func (d *Daemon) handleCommand(tr ports.ServerConnection, request protocol.Comma | |||||
| 23 | defer func() { _ = tr.Close() }() | 23 | defer func() { _ = tr.Close() }() | ||
| 24 | 24 | ||||
| 25 | if request.Version != protocol.Version { | 25 | if request.Version != protocol.Version { | ||
| 26 | - | return d.sendCommandResult(tr, protocol.CommandResult{RequestID: request.RequestID, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"}) | 26 | + | return d.sendCommandResult(tr, protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandFailed, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"}) |
| 27 | } | 27 | } | ||
| 28 | if request.Attached { | 28 | if request.Attached { | ||
| 29 | return d.sendCommandResult(tr, protocol.CommandResult{ | 29 | return d.sendCommandResult(tr, protocol.CommandResult{ | ||
| 30 | RequestID: request.RequestID, | 30 | RequestID: request.RequestID, | ||
| 31 | + | Outcome: protocol.CommandFailed, | |||
| 31 | Code: protocol.ErrNotScriptable, | 32 | Code: protocol.ErrNotScriptable, | ||
| 32 | Text: "attached command relay is not enabled", | 33 | Text: "attached command relay is not enabled", | ||
| 33 | }) | 34 | }) | ||
| @@ -55,7 +56,7 @@ func (d *Daemon) handleCommand(tr ports.ServerConnection, request protocol.Comma | |||||
| 55 | if waitErr != nil { | 56 | if waitErr != nil { | ||
| 56 | return d.sendCommandResult(tr, protocol.CommandResult{ | 57 | return d.sendCommandResult(tr, protocol.CommandResult{ | ||
| 57 | RequestID: request.RequestID, | 58 | RequestID: request.RequestID, | ||
| 58 | - | Code: protocol.ErrInternal, | 59 | + | Outcome: protocol.CommandOutcomeUnknown, |
| 59 | Text: waitErr.Error(), | 60 | Text: waitErr.Error(), | ||
| 60 | }) | 61 | }) | ||
| 61 | } | 62 | } | ||
| @@ -152,7 +153,7 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request protocol.CommandRe | |||||
| 152 | func (d *Daemon) runControl(cmd command.Command, exec controlExec, request protocol.CommandRequest) protocol.CommandResult { | 153 | func (d *Daemon) runControl(cmd command.Command, exec controlExec, request protocol.CommandRequest) protocol.CommandResult { | ||
| 153 | result, err := cmd.Control(exec, request.Args, command.ControlOptions{JSON: request.JSON}) | 154 | result, err := cmd.Control(exec, request.Args, command.ControlOptions{JSON: request.JSON}) | ||
| 154 | if err == nil { | 155 | if err == nil { | ||
| 155 | - | return protocol.CommandResult{OK: true, Output: result.Output} | 156 | + | return protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: result.Output} |
| 156 | } | 157 | } | ||
| 157 | switch { | 158 | switch { | ||
| 158 | case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired), errors.Is(err, domain.ErrInvalidSessionName): | 159 | case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired), errors.Is(err, domain.ErrInvalidSessionName): | ||
| @@ -181,7 +182,7 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request proto | |||||
| 181 | } | 182 | } | ||
| 182 | 183 | ||||
| 183 | func commandFailure(code uint16, text string) protocol.CommandResult { | 184 | func commandFailure(code uint16, text string) protocol.CommandResult { | ||
| 184 | - | return protocol.CommandResult{Code: code, Text: text} | 185 | + | return protocol.CommandResult{Outcome: protocol.CommandFailed, Code: code, Text: text} |
| 185 | } | 186 | } | ||
| 186 | 187 | ||||
| 187 | // resolveTargetSession applies explicit-name, stable-ID, then unique-session | 188 | // resolveTargetSession applies explicit-name, stable-ID, then unique-session | ||
M internal/usecase/daemon/control_test.go +53 -53
| @@ -80,7 +80,7 @@ func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) { | |||
| 80 | 80 | TargetPane: targetStableID, | |
| 81 | 81 | }) | |
| 82 | 82 | ||
| 83 | - | require.True(t, result.OK, result.Text) | |
| 83 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 84 | 84 | require.Empty(t, result.Output) | |
| 85 | 85 | require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab") | |
| 86 | 86 | h.tab.mu.Lock() | |
| @@ -111,7 +111,7 @@ func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) { | |||
| 111 | 111 | TargetPane: h.panes["pane-1"].stableID, | |
| 112 | 112 | }) | |
| 113 | 113 | ||
| 114 | - | require.True(t, result.OK, result.Text) | |
| 114 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 115 | 115 | require.Empty(t, result.Output) | |
| 116 | 116 | require.Equal(t, before, h.snapshot()) | |
| 117 | 117 | require.Empty(t, h.daemon.notices.history()) | |
| @@ -148,7 +148,7 @@ func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) { | |||
| 148 | 148 | timer.ch <- time.Time{} | |
| 149 | 149 | result := awaitCommandResult(t, sends) | |
| 150 | 150 | require.Equal(t, uint64(17), result.RequestID) | |
| 151 | - | require.Equal(t, protocol.ErrInternal, result.Code) | |
| 151 | + | require.Equal(t, uint16(0), result.Code) | |
| 152 | 152 | require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text) | |
| 153 | 153 | require.NoError(t, <-done) | |
| 154 | 154 | close(factory.release) | |
| @@ -203,7 +203,7 @@ func TestHandleConnRoutesCommand(t *testing.T) { | |||
| 203 | 203 | d.handleConn(tr) | |
| 204 | 204 | ||
| 205 | 205 | result := awaitCommandResult(t, sends) | |
| 206 | - | require.True(t, result.OK, result.Text) | |
| 206 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 207 | 207 | } | |
| 208 | 208 | ||
| 209 | 209 | func TestHandleCommandDispatchAndTargetErrors(t *testing.T) { | |
| @@ -248,7 +248,7 @@ func TestHandleCommandDispatchAndTargetErrors(t *testing.T) { | |||
| 248 | 248 | tt.arrange(d) | |
| 249 | 249 | } | |
| 250 | 250 | result := sendCommand(t, d, tt.request) | |
| 251 | - | require.False(t, result.OK) | |
| 251 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) | |
| 252 | 252 | require.Equal(t, tt.code, result.Code) | |
| 253 | 253 | }) | |
| 254 | 254 | } | |
| @@ -263,7 +263,7 @@ func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) { | |||
| 263 | 263 | TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work", | |
| 264 | 264 | }) | |
| 265 | 265 | ||
| 266 | - | require.True(t, result.OK, result.Text) | |
| 266 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 267 | 267 | require.Equal(t, uint64(1), sess.mruAt.Load()) | |
| 268 | 268 | } | |
| 269 | 269 | ||
| @@ -283,7 +283,7 @@ func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) { | |||
| 283 | 283 | TargetTab: "t_second", TargetPane: "p_second", | |
| 284 | 284 | }) | |
| 285 | 285 | ||
| 286 | - | require.True(t, result.OK, result.Text) | |
| 286 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 287 | 287 | first.mu.Lock() | |
| 288 | 288 | require.Equal(t, "targeted", first.name) | |
| 289 | 289 | first.mu.Unlock() | |
| @@ -315,7 +315,7 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) | |||
| 315 | 315 | TargetTab: "t_origin", TargetPane: "p_origin", | |
| 316 | 316 | }) | |
| 317 | 317 | ||
| 318 | - | require.True(t, result.OK, result.Text) | |
| 318 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 319 | 319 | sess.mu.Lock() | |
| 320 | 320 | activeIndex := testAttachmentTabIndexLocked(sess) | |
| 321 | 321 | sess.mu.Unlock() | |
| @@ -351,7 +351,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||
| 351 | 351 | Slug: "rename-tab", Args: []string{"invoking"}, Self: true, | |
| 352 | 352 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 353 | 353 | }) | |
| 354 | - | require.True(t, rename.OK, rename.Text) | |
| 354 | + | require.True(t, rename.Outcome == protocol.CommandSucceeded, rename.Text) | |
| 355 | 355 | active.mu.Lock() | |
| 356 | 356 | require.Empty(t, active.name) | |
| 357 | 357 | active.mu.Unlock() | |
| @@ -363,7 +363,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||
| 363 | 363 | Slug: "split-right", Self: true, | |
| 364 | 364 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 365 | 365 | }) | |
| 366 | - | require.True(t, split.OK, split.Text) | |
| 366 | + | require.True(t, split.Outcome == protocol.CommandSucceeded, split.Text) | |
| 367 | 367 | active.mu.Lock() | |
| 368 | 368 | require.Len(t, active.panes, 1) | |
| 369 | 369 | active.mu.Unlock() | |
| @@ -377,7 +377,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||
| 377 | 377 | Slug: "grow-pane-width", Self: true, | |
| 378 | 378 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 379 | 379 | }) | |
| 380 | - | require.True(t, grow.OK, grow.Text) | |
| 380 | + | require.True(t, grow.Outcome == protocol.CommandSucceeded, grow.Text) | |
| 381 | 381 | invoking.mu.Lock() | |
| 382 | 382 | require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted resize must not refocus the tab") | |
| 383 | 383 | require.Equal(t, beforeGeneration+1, invoking.layoutGeneration) | |
| @@ -391,7 +391,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||
| 391 | 391 | Slug: "equalize-panes", Self: true, | |
| 392 | 392 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 393 | 393 | }) | |
| 394 | - | require.True(t, equalize.OK, equalize.Text) | |
| 394 | + | require.True(t, equalize.Outcome == protocol.CommandSucceeded, equalize.Text) | |
| 395 | 395 | invoking.mu.Lock() | |
| 396 | 396 | require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted equalize must not refocus the tab") | |
| 397 | 397 | for _, child := range invoking.tree.Root.Children { | |
| @@ -416,8 +416,8 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) { | |||
| 416 | 416 | d := newTestDaemon(t, factory, stubClock{}) | |
| 417 | 417 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 418 | 418 | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 419 | - | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 420 | - | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK) | |
| 419 | + | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded) | |
| 420 | + | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-down", TargetSession: "work"}).Outcome == protocol.CommandSucceeded) | |
| 421 | 421 | tb := testAttachmentTab(sess) | |
| 422 | 422 | tb.mu.Lock() | |
| 423 | 423 | generation := tb.layoutGeneration | |
| @@ -429,7 +429,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) { | |||
| 429 | 429 | request := target.request() | |
| 430 | 430 | request.Slug = slug | |
| 431 | 431 | result := sendCommand(t, d, request) | |
| 432 | - | require.True(t, result.OK, result.Text) | |
| 432 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 433 | 433 | require.Empty(t, result.Output) | |
| 434 | 434 | tb.mu.Lock() | |
| 435 | 435 | require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary") | |
| @@ -451,7 +451,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) { | |||
| 451 | 451 | d := newTestDaemon(t, factory, stubClock{}) | |
| 452 | 452 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 453 | 453 | sess := addControlSession(d, "work", "t_active", "p_active") | |
| 454 | - | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 454 | + | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded) | |
| 455 | 455 | active := sess.tabs[0] | |
| 456 | 456 | locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 457 | 457 | locator.ctx, locator.cancel = context.WithCancel(d.serveCtx) | |
| @@ -466,7 +466,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) { | |||
| 466 | 466 | result := sendCommand(t, d, protocol.CommandRequest{ | |
| 467 | 467 | Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator", | |
| 468 | 468 | }) | |
| 469 | - | require.True(t, result.OK, result.Text) | |
| 469 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 470 | 470 | active.mu.Lock() | |
| 471 | 471 | require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action") | |
| 472 | 472 | active.mu.Unlock() | |
| @@ -483,7 +483,7 @@ func TestResizeControlTooSmallUsesStableFailure(t *testing.T) { | |||
| 483 | 483 | d := newTestDaemon(t, factory, stubClock{}) | |
| 484 | 484 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 485 | 485 | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 486 | - | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 486 | + | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded) | |
| 487 | 487 | tb := testAttachmentTab(sess) | |
| 488 | 488 | tb.mu.Lock() | |
| 489 | 489 | tb.size.Cols = 41 | |
| @@ -491,7 +491,7 @@ func TestResizeControlTooSmallUsesStableFailure(t *testing.T) { | |||
| 491 | 491 | tb.mu.Unlock() | |
| 492 | 492 | ||
| 493 | 493 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"}) | |
| 494 | - | require.False(t, result.OK) | |
| 494 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) | |
| 495 | 495 | require.Equal(t, protocol.ErrNoSuchTarget, result.Code) | |
| 496 | 496 | require.Equal(t, "pane cannot be resized further", result.Text) | |
| 497 | 497 | } | |
| @@ -510,7 +510,7 @@ func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) { | |||
| 510 | 510 | Slug: "list-panes", Self: true, JSON: true, | |
| 511 | 511 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 512 | 512 | }) | |
| 513 | - | require.True(t, result.OK, result.Text) | |
| 513 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 514 | 514 | require.Contains(t, result.Output, "p_invoking") | |
| 515 | 515 | require.NotContains(t, result.Output, "p_active") | |
| 516 | 516 | } | |
| @@ -523,7 +523,7 @@ func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) | |||
| 523 | 523 | Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work", | |
| 524 | 524 | }) | |
| 525 | 525 | ||
| 526 | - | require.False(t, result.OK) | |
| 526 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) | |
| 527 | 527 | require.Equal(t, protocol.ErrInvalidCommandArgs, result.Code) | |
| 528 | 528 | } | |
| 529 | 529 | ||
| @@ -540,7 +540,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) { | |||
| 540 | 540 | target: daemonActionTarget{session: sess, tab: stale}, | |
| 541 | 541 | }, protocol.CommandRequest{Slug: "close-tab"}) | |
| 542 | 542 | ||
| 543 | - | require.False(t, result.OK) | |
| 543 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) | |
| 544 | 544 | require.Equal(t, protocol.ErrInternal, result.Code) | |
| 545 | 545 | require.Len(t, sess.tabs, 1, "a stale close must retain the live tab") | |
| 546 | 546 | }) | |
| @@ -558,7 +558,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) { | |||
| 558 | 558 | ||
| 559 | 559 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-pane", TargetSession: "work"}) | |
| 560 | 560 | ||
| 561 | - | require.False(t, result.OK) | |
| 561 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) | |
| 562 | 562 | require.Equal(t, protocol.ErrInternal, result.Code) | |
| 563 | 563 | d.mu.Lock() | |
| 564 | 564 | require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session") | |
| @@ -580,7 +580,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) { | |||
| 580 | 580 | ||
| 581 | 581 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"}) | |
| 582 | 582 | ||
| 583 | - | require.True(t, result.OK, result.Text) | |
| 583 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 584 | 584 | sess.mu.Lock() | |
| 585 | 585 | require.Len(t, sess.tabs, 1) | |
| 586 | 586 | require.Equal(t, "t_second", sess.tabs[0].stableID) | |
| @@ -623,7 +623,7 @@ func TestHandleCommandHeadlessMutations(t *testing.T) { | |||
| 623 | 623 | ||
| 624 | 624 | result := sendCommand(t, d, protocol.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"}) | |
| 625 | 625 | ||
| 626 | - | require.True(t, result.OK, result.Text) | |
| 626 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 627 | 627 | tt.verify(t, d, sess) | |
| 628 | 628 | }) | |
| 629 | 629 | } | |
| @@ -642,7 +642,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T | |||
| 642 | 642 | ||
| 643 | 643 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | |
| 644 | 644 | ||
| 645 | - | require.True(t, result.OK, result.Text) | |
| 645 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 646 | 646 | d.mu.Lock() | |
| 647 | 647 | created := d.findByNameLocked("scripted") | |
| 648 | 648 | d.mu.Unlock() | |
| @@ -659,7 +659,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T | |||
| 659 | 659 | require.Empty(t, source.snapshotAttachments()) | |
| 660 | 660 | ||
| 661 | 661 | taken := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | |
| 662 | - | require.False(t, taken.OK) | |
| 662 | + | require.False(t, taken.Outcome == protocol.CommandSucceeded) | |
| 663 | 663 | require.Equal(t, protocol.ErrNameTaken, taken.Code) | |
| 664 | 664 | } | |
| 665 | 665 | ||
| @@ -676,7 +676,7 @@ func TestHandleCommandNewSessionPreservesSourceGeometry(t *testing.T) { | |||
| 676 | 676 | ||
| 677 | 677 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | |
| 678 | 678 | ||
| 679 | - | require.True(t, result.OK, result.Text) | |
| 679 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 680 | 680 | d.mu.Lock() | |
| 681 | 681 | created := d.findByNameLocked("scripted") | |
| 682 | 682 | d.mu.Unlock() | |
| @@ -698,11 +698,11 @@ func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) { | |||
| 698 | 698 | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 699 | 699 | ||
| 700 | 700 | bad := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"}) | |
| 701 | - | require.False(t, bad.OK) | |
| 701 | + | require.False(t, bad.Outcome == protocol.CommandSucceeded) | |
| 702 | 702 | require.Equal(t, protocol.ErrInvalidCommandArgs, bad.Code) | |
| 703 | 703 | ||
| 704 | 704 | good := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"}) | |
| 705 | - | require.True(t, good.OK, good.Text) | |
| 705 | + | require.True(t, good.Outcome == protocol.CommandSucceeded, good.Text) | |
| 706 | 706 | d.notices.mu.Lock() | |
| 707 | 707 | require.Len(t, d.notices.pending, 1) | |
| 708 | 708 | require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code) | |
| @@ -716,16 +716,16 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) { | |||
| 716 | 716 | sess.tabs[0].name = "shell" | |
| 717 | 717 | ||
| 718 | 718 | sessions := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"}) | |
| 719 | - | require.True(t, sessions.OK, sessions.Text) | |
| 719 | + | require.True(t, sessions.Outcome == protocol.CommandSucceeded, sessions.Text) | |
| 720 | 720 | require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE") | |
| 721 | 721 | require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue") | |
| 722 | 722 | ||
| 723 | 723 | tabs := sendCommand(t, d, protocol.CommandRequest{Slug: "list-tabs", TargetSession: "work"}) | |
| 724 | - | require.True(t, tabs.OK, tabs.Text) | |
| 724 | + | require.True(t, tabs.Outcome == protocol.CommandSucceeded, tabs.Text) | |
| 725 | 725 | require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue") | |
| 726 | 726 | ||
| 727 | 727 | panes := sendCommand(t, d, protocol.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true}) | |
| 728 | - | require.True(t, panes.OK, panes.Text) | |
| 728 | + | require.True(t, panes.Outcome == protocol.CommandSucceeded, panes.Text) | |
| 729 | 729 | var decoded []map[string]any | |
| 730 | 730 | require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded)) | |
| 731 | 731 | require.Len(t, decoded, 1) | |
| @@ -741,7 +741,7 @@ func TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) { | |||
| 741 | 741 | d.mu.Unlock() | |
| 742 | 742 | ||
| 743 | 743 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true}) | |
| 744 | - | require.True(t, result.OK, result.Text) | |
| 744 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 745 | 745 | ||
| 746 | 746 | // Observation is read-only: the stopped record stays stopped, is not | |
| 747 | 747 | // resumed into a live session, and is not purged. | |
| @@ -783,18 +783,18 @@ func TestRemoteCatalogJSONOutput(t *testing.T) { | |||
| 783 | 783 | d.mu.Unlock() | |
| 784 | 784 | ||
| 785 | 785 | listBefore := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"}) | |
| 786 | - | require.True(t, listBefore.OK, listBefore.Text) | |
| 786 | + | require.True(t, listBefore.Outcome == protocol.CommandSucceeded, listBefore.Text) | |
| 787 | 787 | ||
| 788 | 788 | missingJSON := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog"}) | |
| 789 | - | require.False(t, missingJSON.OK) | |
| 789 | + | require.False(t, missingJSON.Outcome == protocol.CommandSucceeded) | |
| 790 | 790 | require.Equal(t, protocol.ErrInvalidCommandArgs, missingJSON.Code) | |
| 791 | 791 | ||
| 792 | 792 | withArgs := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true}) | |
| 793 | - | require.False(t, withArgs.OK) | |
| 793 | + | require.False(t, withArgs.Outcome == protocol.CommandSucceeded) | |
| 794 | 794 | require.Equal(t, protocol.ErrInvalidCommandArgs, withArgs.Code) | |
| 795 | 795 | ||
| 796 | 796 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true}) | |
| 797 | - | require.True(t, result.OK, result.Text) | |
| 797 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 798 | 798 | require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated") | |
| 799 | 799 | ||
| 800 | 800 | var catalog catalogue.RemoteCatalog | |
| @@ -818,7 +818,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) { | |||
| 818 | 818 | require.Equal(t, catalogue.RemoteCatalogSessionDown, catalog.Sessions[1].State) | |
| 819 | 819 | ||
| 820 | 820 | listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"}) | |
| 821 | - | require.True(t, listAfter.OK, listAfter.Text) | |
| 821 | + | require.True(t, listAfter.Outcome == protocol.CommandSucceeded, listAfter.Text) | |
| 822 | 822 | require.Equal(t, listBefore.Output, listAfter.Output) | |
| 823 | 823 | require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t") | |
| 824 | 824 | require.NotContains(t, listAfter.Output, "old") | |
| @@ -840,7 +840,7 @@ func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) { | |||
| 840 | 840 | } | |
| 841 | 841 | ||
| 842 | 842 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true}) | |
| 843 | - | require.True(t, result.OK, result.Text) | |
| 843 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 844 | 844 | var catalog catalogue.RemoteCatalog | |
| 845 | 845 | require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog)) | |
| 846 | 846 | require.Equal(t, []catalogue.RemoteCatalogSession{{ | |
| @@ -872,7 +872,7 @@ func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) { | |||
| 872 | 872 | sess.mu.Unlock() | |
| 873 | 873 | ||
| 874 | 874 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true}) | |
| 875 | - | require.False(t, result.OK) | |
| 875 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) | |
| 876 | 876 | require.Contains(t, result.Text, "too many tabs") | |
| 877 | 877 | } | |
| 878 | 878 | ||
| @@ -927,8 +927,8 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |||
| 927 | 927 | close(factory.release) | |
| 928 | 928 | <-firstDone | |
| 929 | 929 | <-secondDone | |
| 930 | - | require.True(t, awaitCommandResult(t, firstSends).OK) | |
| 931 | - | require.True(t, awaitCommandResult(t, secondSends).OK) | |
| 930 | + | require.True(t, awaitCommandResult(t, firstSends).Outcome == protocol.CommandSucceeded) | |
| 931 | + | require.True(t, awaitCommandResult(t, secondSends).Outcome == protocol.CommandSucceeded) | |
| 932 | 932 | ||
| 933 | 933 | sess.mu.Lock() | |
| 934 | 934 | require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab") | |
| @@ -975,7 +975,7 @@ func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing. | |||
| 975 | 975 | result := sendCommand(t, d, protocol.CommandRequest{ | |
| 976 | 976 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work", | |
| 977 | 977 | }) | |
| 978 | - | require.True(t, result.OK, result.Text) | |
| 978 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 979 | 979 | ||
| 980 | 980 | source.mu.Lock() | |
| 981 | 981 | require.Len(t, source.tabs, 1) | |
| @@ -1008,7 +1008,7 @@ func TestHandleCommandMovePanePurgeAdmissionMapsToRetryableError(t *testing.T) { | |||
| 1008 | 1008 | result := sendCommand(t, d, protocol.CommandRequest{ | |
| 1009 | 1009 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work", | |
| 1010 | 1010 | }) | |
| 1011 | - | require.False(t, result.OK) | |
| 1011 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) | |
| 1012 | 1012 | require.Equal(t, protocol.ErrInternal, result.Code) | |
| 1013 | 1013 | require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text) | |
| 1014 | 1014 | d.endPurgeAdmission() | |
| @@ -1030,7 +1030,7 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) { | |||
| 1030 | 1030 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | |
| 1031 | 1031 | TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive", | |
| 1032 | 1032 | }) | |
| 1033 | - | require.True(t, result.OK, result.Text) | |
| 1033 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 1034 | 1034 | ||
| 1035 | 1035 | active := source.tabs[0] | |
| 1036 | 1036 | active.mu.Lock() | |
| @@ -1068,7 +1068,7 @@ func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) { | |||
| 1068 | 1068 | Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false, | |
| 1069 | 1069 | TargetTab: "t_second", TargetPane: "p_second", | |
| 1070 | 1070 | }) | |
| 1071 | - | require.True(t, result.OK, result.Text) | |
| 1071 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 1072 | 1072 | ||
| 1073 | 1073 | source.mu.Lock() | |
| 1074 | 1074 | require.Len(t, source.tabs, 1) | |
| @@ -1096,7 +1096,7 @@ func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) { | |||
| 1096 | 1096 | Slug: "move-tab", Args: []string{"dest"}, Self: true, | |
| 1097 | 1097 | TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second", | |
| 1098 | 1098 | }) | |
| 1099 | - | require.True(t, result.OK, result.Text) | |
| 1099 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 1100 | 1100 | ||
| 1101 | 1101 | source.mu.Lock() | |
| 1102 | 1102 | require.Len(t, source.tabs, 1) | |
| @@ -1215,7 +1215,7 @@ func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) { | |||
| 1215 | 1215 | d := newTestDaemon(t, nil, stubClock{}) | |
| 1216 | 1216 | request := tt.arrange(d) | |
| 1217 | 1217 | result := sendCommand(t, d, request) | |
| 1218 | - | require.False(t, result.OK) | |
| 1218 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) | |
| 1219 | 1219 | require.Equal(t, tt.code, result.Code) | |
| 1220 | 1220 | if tt.text != "" { | |
| 1221 | 1221 | require.Equal(t, tt.text, result.Text) | |
| @@ -1234,7 +1234,7 @@ func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *t | |||
| 1234 | 1234 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | |
| 1235 | 1235 | TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work", | |
| 1236 | 1236 | }) | |
| 1237 | - | require.True(t, result.OK, result.Text) | |
| 1237 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 1238 | 1238 | ||
| 1239 | 1239 | source.mu.Lock() | |
| 1240 | 1240 | require.Len(t, source.tabs, 0) | |
| @@ -1266,7 +1266,7 @@ func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testi | |||
| 1266 | 1266 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | |
| 1267 | 1267 | TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive", | |
| 1268 | 1268 | }) | |
| 1269 | - | require.True(t, result.OK, result.Text) | |
| 1269 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 1270 | 1270 | ||
| 1271 | 1271 | source.mu.Lock() | |
| 1272 | 1272 | require.Len(t, source.tabs, 1) | |
| @@ -1332,7 +1332,7 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) { | |||
| 1332 | 1332 | select { | |
| 1333 | 1333 | case <-done: | |
| 1334 | 1334 | result := awaitCommandResult(t, sends) | |
| 1335 | - | require.True(t, result.OK, result.Text) | |
| 1335 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) | |
| 1336 | 1336 | case <-time.After(5 * time.Second): | |
| 1337 | 1337 | t.Fatal("opposite-direction move commands deadlocked") | |
| 1338 | 1338 | } | |
| @@ -1514,7 +1514,7 @@ func TestCloseTabParticipantsChangedReportsRetryable(t *testing.T) { | |||
| 1514 | 1514 | t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil }) | |
| 1515 | 1515 | ||
| 1516 | 1516 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"}) | |
| 1517 | - | require.False(t, result.OK) | |
| 1517 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) | |
| 1518 | 1518 | require.Equal(t, protocol.ErrInternal, result.Code) | |
| 1519 | 1519 | require.Equal(t, sessionKillRetryMessage, result.Text) | |
| 1520 | 1520 | ||
| @@ -80,7 +80,7 @@ func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) { | |||||
| 80 | TargetPane: targetStableID, | 80 | TargetPane: targetStableID, | ||
| 81 | }) | 81 | }) | ||
| 82 | 82 | ||||
| 83 | - | require.True(t, result.OK, result.Text) | 83 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 84 | require.Empty(t, result.Output) | 84 | require.Empty(t, result.Output) | ||
| 85 | require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab") | 85 | require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab") | ||
| 86 | h.tab.mu.Lock() | 86 | h.tab.mu.Lock() | ||
| @@ -111,7 +111,7 @@ func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) { | |||||
| 111 | TargetPane: h.panes["pane-1"].stableID, | 111 | TargetPane: h.panes["pane-1"].stableID, | ||
| 112 | }) | 112 | }) | ||
| 113 | 113 | ||||
| 114 | - | require.True(t, result.OK, result.Text) | 114 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 115 | require.Empty(t, result.Output) | 115 | require.Empty(t, result.Output) | ||
| 116 | require.Equal(t, before, h.snapshot()) | 116 | require.Equal(t, before, h.snapshot()) | ||
| 117 | require.Empty(t, h.daemon.notices.history()) | 117 | require.Empty(t, h.daemon.notices.history()) | ||
| @@ -148,7 +148,7 @@ func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) { | |||||
| 148 | timer.ch <- time.Time{} | 148 | timer.ch <- time.Time{} | ||
| 149 | result := awaitCommandResult(t, sends) | 149 | result := awaitCommandResult(t, sends) | ||
| 150 | require.Equal(t, uint64(17), result.RequestID) | 150 | require.Equal(t, uint64(17), result.RequestID) | ||
| 151 | - | require.Equal(t, protocol.ErrInternal, result.Code) | 151 | + | require.Equal(t, uint16(0), result.Code) |
| 152 | require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text) | 152 | require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text) | ||
| 153 | require.NoError(t, <-done) | 153 | require.NoError(t, <-done) | ||
| 154 | close(factory.release) | 154 | close(factory.release) | ||
| @@ -203,7 +203,7 @@ func TestHandleConnRoutesCommand(t *testing.T) { | |||||
| 203 | d.handleConn(tr) | 203 | d.handleConn(tr) | ||
| 204 | 204 | ||||
| 205 | result := awaitCommandResult(t, sends) | 205 | result := awaitCommandResult(t, sends) | ||
| 206 | - | require.True(t, result.OK, result.Text) | 206 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 207 | } | 207 | } | ||
| 208 | 208 | ||||
| 209 | func TestHandleCommandDispatchAndTargetErrors(t *testing.T) { | 209 | func TestHandleCommandDispatchAndTargetErrors(t *testing.T) { | ||
| @@ -248,7 +248,7 @@ func TestHandleCommandDispatchAndTargetErrors(t *testing.T) { | |||||
| 248 | tt.arrange(d) | 248 | tt.arrange(d) | ||
| 249 | } | 249 | } | ||
| 250 | result := sendCommand(t, d, tt.request) | 250 | result := sendCommand(t, d, tt.request) | ||
| 251 | - | require.False(t, result.OK) | 251 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) |
| 252 | require.Equal(t, tt.code, result.Code) | 252 | require.Equal(t, tt.code, result.Code) | ||
| 253 | }) | 253 | }) | ||
| 254 | } | 254 | } | ||
| @@ -263,7 +263,7 @@ func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) { | |||||
| 263 | TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work", | 263 | TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work", | ||
| 264 | }) | 264 | }) | ||
| 265 | 265 | ||||
| 266 | - | require.True(t, result.OK, result.Text) | 266 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 267 | require.Equal(t, uint64(1), sess.mruAt.Load()) | 267 | require.Equal(t, uint64(1), sess.mruAt.Load()) | ||
| 268 | } | 268 | } | ||
| 269 | 269 | ||||
| @@ -283,7 +283,7 @@ func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) { | |||||
| 283 | TargetTab: "t_second", TargetPane: "p_second", | 283 | TargetTab: "t_second", TargetPane: "p_second", | ||
| 284 | }) | 284 | }) | ||
| 285 | 285 | ||||
| 286 | - | require.True(t, result.OK, result.Text) | 286 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 287 | first.mu.Lock() | 287 | first.mu.Lock() | ||
| 288 | require.Equal(t, "targeted", first.name) | 288 | require.Equal(t, "targeted", first.name) | ||
| 289 | first.mu.Unlock() | 289 | first.mu.Unlock() | ||
| @@ -315,7 +315,7 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) | |||||
| 315 | TargetTab: "t_origin", TargetPane: "p_origin", | 315 | TargetTab: "t_origin", TargetPane: "p_origin", | ||
| 316 | }) | 316 | }) | ||
| 317 | 317 | ||||
| 318 | - | require.True(t, result.OK, result.Text) | 318 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 319 | sess.mu.Lock() | 319 | sess.mu.Lock() | ||
| 320 | activeIndex := testAttachmentTabIndexLocked(sess) | 320 | activeIndex := testAttachmentTabIndexLocked(sess) | ||
| 321 | sess.mu.Unlock() | 321 | sess.mu.Unlock() | ||
| @@ -351,7 +351,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||||
| 351 | Slug: "rename-tab", Args: []string{"invoking"}, Self: true, | 351 | Slug: "rename-tab", Args: []string{"invoking"}, Self: true, | ||
| 352 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | 352 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | ||
| 353 | }) | 353 | }) | ||
| 354 | - | require.True(t, rename.OK, rename.Text) | 354 | + | require.True(t, rename.Outcome == protocol.CommandSucceeded, rename.Text) |
| 355 | active.mu.Lock() | 355 | active.mu.Lock() | ||
| 356 | require.Empty(t, active.name) | 356 | require.Empty(t, active.name) | ||
| 357 | active.mu.Unlock() | 357 | active.mu.Unlock() | ||
| @@ -363,7 +363,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||||
| 363 | Slug: "split-right", Self: true, | 363 | Slug: "split-right", Self: true, | ||
| 364 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | 364 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | ||
| 365 | }) | 365 | }) | ||
| 366 | - | require.True(t, split.OK, split.Text) | 366 | + | require.True(t, split.Outcome == protocol.CommandSucceeded, split.Text) |
| 367 | active.mu.Lock() | 367 | active.mu.Lock() | ||
| 368 | require.Len(t, active.panes, 1) | 368 | require.Len(t, active.panes, 1) | ||
| 369 | active.mu.Unlock() | 369 | active.mu.Unlock() | ||
| @@ -377,7 +377,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||||
| 377 | Slug: "grow-pane-width", Self: true, | 377 | Slug: "grow-pane-width", Self: true, | ||
| 378 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | 378 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | ||
| 379 | }) | 379 | }) | ||
| 380 | - | require.True(t, grow.OK, grow.Text) | 380 | + | require.True(t, grow.Outcome == protocol.CommandSucceeded, grow.Text) |
| 381 | invoking.mu.Lock() | 381 | invoking.mu.Lock() | ||
| 382 | require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted resize must not refocus the tab") | 382 | require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted resize must not refocus the tab") | ||
| 383 | require.Equal(t, beforeGeneration+1, invoking.layoutGeneration) | 383 | require.Equal(t, beforeGeneration+1, invoking.layoutGeneration) | ||
| @@ -391,7 +391,7 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||||
| 391 | Slug: "equalize-panes", Self: true, | 391 | Slug: "equalize-panes", Self: true, | ||
| 392 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | 392 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | ||
| 393 | }) | 393 | }) | ||
| 394 | - | require.True(t, equalize.OK, equalize.Text) | 394 | + | require.True(t, equalize.Outcome == protocol.CommandSucceeded, equalize.Text) |
| 395 | invoking.mu.Lock() | 395 | invoking.mu.Lock() | ||
| 396 | require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted equalize must not refocus the tab") | 396 | require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted equalize must not refocus the tab") | ||
| 397 | for _, child := range invoking.tree.Root.Children { | 397 | for _, child := range invoking.tree.Root.Children { | ||
| @@ -416,8 +416,8 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) { | |||||
| 416 | d := newTestDaemon(t, factory, stubClock{}) | 416 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 417 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | 417 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | ||
| 418 | sess := addControlSession(d, "work", "t_work", "p_work") | 418 | sess := addControlSession(d, "work", "t_work", "p_work") | ||
| 419 | - | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | 419 | + | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded) |
| 420 | - | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK) | 420 | + | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-down", TargetSession: "work"}).Outcome == protocol.CommandSucceeded) |
| 421 | tb := testAttachmentTab(sess) | 421 | tb := testAttachmentTab(sess) | ||
| 422 | tb.mu.Lock() | 422 | tb.mu.Lock() | ||
| 423 | generation := tb.layoutGeneration | 423 | generation := tb.layoutGeneration | ||
| @@ -429,7 +429,7 @@ func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) { | |||||
| 429 | request := target.request() | 429 | request := target.request() | ||
| 430 | request.Slug = slug | 430 | request.Slug = slug | ||
| 431 | result := sendCommand(t, d, request) | 431 | result := sendCommand(t, d, request) | ||
| 432 | - | require.True(t, result.OK, result.Text) | 432 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 433 | require.Empty(t, result.Output) | 433 | require.Empty(t, result.Output) | ||
| 434 | tb.mu.Lock() | 434 | tb.mu.Lock() | ||
| 435 | require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary") | 435 | require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary") | ||
| @@ -451,7 +451,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) { | |||||
| 451 | d := newTestDaemon(t, factory, stubClock{}) | 451 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 452 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | 452 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | ||
| 453 | sess := addControlSession(d, "work", "t_active", "p_active") | 453 | sess := addControlSession(d, "work", "t_active", "p_active") | ||
| 454 | - | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | 454 | + | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded) |
| 455 | active := sess.tabs[0] | 455 | active := sess.tabs[0] | ||
| 456 | locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | 456 | locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | ||
| 457 | locator.ctx, locator.cancel = context.WithCancel(d.serveCtx) | 457 | locator.ctx, locator.cancel = context.WithCancel(d.serveCtx) | ||
| @@ -466,7 +466,7 @@ func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) { | |||||
| 466 | result := sendCommand(t, d, protocol.CommandRequest{ | 466 | result := sendCommand(t, d, protocol.CommandRequest{ | ||
| 467 | Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator", | 467 | Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator", | ||
| 468 | }) | 468 | }) | ||
| 469 | - | require.True(t, result.OK, result.Text) | 469 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 470 | active.mu.Lock() | 470 | active.mu.Lock() | ||
| 471 | require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action") | 471 | require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action") | ||
| 472 | active.mu.Unlock() | 472 | active.mu.Unlock() | ||
| @@ -483,7 +483,7 @@ func TestResizeControlTooSmallUsesStableFailure(t *testing.T) { | |||||
| 483 | d := newTestDaemon(t, factory, stubClock{}) | 483 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 484 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | 484 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | ||
| 485 | sess := addControlSession(d, "work", "t_work", "p_work") | 485 | sess := addControlSession(d, "work", "t_work", "p_work") | ||
| 486 | - | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | 486 | + | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded) |
| 487 | tb := testAttachmentTab(sess) | 487 | tb := testAttachmentTab(sess) | ||
| 488 | tb.mu.Lock() | 488 | tb.mu.Lock() | ||
| 489 | tb.size.Cols = 41 | 489 | tb.size.Cols = 41 | ||
| @@ -491,7 +491,7 @@ func TestResizeControlTooSmallUsesStableFailure(t *testing.T) { | |||||
| 491 | tb.mu.Unlock() | 491 | tb.mu.Unlock() | ||
| 492 | 492 | ||||
| 493 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"}) | 493 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"}) | ||
| 494 | - | require.False(t, result.OK) | 494 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) |
| 495 | require.Equal(t, protocol.ErrNoSuchTarget, result.Code) | 495 | require.Equal(t, protocol.ErrNoSuchTarget, result.Code) | ||
| 496 | require.Equal(t, "pane cannot be resized further", result.Text) | 496 | require.Equal(t, "pane cannot be resized further", result.Text) | ||
| 497 | } | 497 | } | ||
| @@ -510,7 +510,7 @@ func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) { | |||||
| 510 | Slug: "list-panes", Self: true, JSON: true, | 510 | Slug: "list-panes", Self: true, JSON: true, | ||
| 511 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | 511 | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | ||
| 512 | }) | 512 | }) | ||
| 513 | - | require.True(t, result.OK, result.Text) | 513 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 514 | require.Contains(t, result.Output, "p_invoking") | 514 | require.Contains(t, result.Output, "p_invoking") | ||
| 515 | require.NotContains(t, result.Output, "p_active") | 515 | require.NotContains(t, result.Output, "p_active") | ||
| 516 | } | 516 | } | ||
| @@ -523,7 +523,7 @@ func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) | |||||
| 523 | Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work", | 523 | Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work", | ||
| 524 | }) | 524 | }) | ||
| 525 | 525 | ||||
| 526 | - | require.False(t, result.OK) | 526 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) |
| 527 | require.Equal(t, protocol.ErrInvalidCommandArgs, result.Code) | 527 | require.Equal(t, protocol.ErrInvalidCommandArgs, result.Code) | ||
| 528 | } | 528 | } | ||
| 529 | 529 | ||||
| @@ -540,7 +540,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) { | |||||
| 540 | target: daemonActionTarget{session: sess, tab: stale}, | 540 | target: daemonActionTarget{session: sess, tab: stale}, | ||
| 541 | }, protocol.CommandRequest{Slug: "close-tab"}) | 541 | }, protocol.CommandRequest{Slug: "close-tab"}) | ||
| 542 | 542 | ||||
| 543 | - | require.False(t, result.OK) | 543 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) |
| 544 | require.Equal(t, protocol.ErrInternal, result.Code) | 544 | require.Equal(t, protocol.ErrInternal, result.Code) | ||
| 545 | require.Len(t, sess.tabs, 1, "a stale close must retain the live tab") | 545 | require.Len(t, sess.tabs, 1, "a stale close must retain the live tab") | ||
| 546 | }) | 546 | }) | ||
| @@ -558,7 +558,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) { | |||||
| 558 | 558 | ||||
| 559 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-pane", TargetSession: "work"}) | 559 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-pane", TargetSession: "work"}) | ||
| 560 | 560 | ||||
| 561 | - | require.False(t, result.OK) | 561 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) |
| 562 | require.Equal(t, protocol.ErrInternal, result.Code) | 562 | require.Equal(t, protocol.ErrInternal, result.Code) | ||
| 563 | d.mu.Lock() | 563 | d.mu.Lock() | ||
| 564 | require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session") | 564 | require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session") | ||
| @@ -580,7 +580,7 @@ func TestCloseCommandsReportMutationOutcome(t *testing.T) { | |||||
| 580 | 580 | ||||
| 581 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"}) | 581 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"}) | ||
| 582 | 582 | ||||
| 583 | - | require.True(t, result.OK, result.Text) | 583 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 584 | sess.mu.Lock() | 584 | sess.mu.Lock() | ||
| 585 | require.Len(t, sess.tabs, 1) | 585 | require.Len(t, sess.tabs, 1) | ||
| 586 | require.Equal(t, "t_second", sess.tabs[0].stableID) | 586 | require.Equal(t, "t_second", sess.tabs[0].stableID) | ||
| @@ -623,7 +623,7 @@ func TestHandleCommandHeadlessMutations(t *testing.T) { | |||||
| 623 | 623 | ||||
| 624 | result := sendCommand(t, d, protocol.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"}) | 624 | result := sendCommand(t, d, protocol.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"}) | ||
| 625 | 625 | ||||
| 626 | - | require.True(t, result.OK, result.Text) | 626 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 627 | tt.verify(t, d, sess) | 627 | tt.verify(t, d, sess) | ||
| 628 | }) | 628 | }) | ||
| 629 | } | 629 | } | ||
| @@ -642,7 +642,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T | |||||
| 642 | 642 | ||||
| 643 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | 643 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | ||
| 644 | 644 | ||||
| 645 | - | require.True(t, result.OK, result.Text) | 645 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 646 | d.mu.Lock() | 646 | d.mu.Lock() | ||
| 647 | created := d.findByNameLocked("scripted") | 647 | created := d.findByNameLocked("scripted") | ||
| 648 | d.mu.Unlock() | 648 | d.mu.Unlock() | ||
| @@ -659,7 +659,7 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T | |||||
| 659 | require.Empty(t, source.snapshotAttachments()) | 659 | require.Empty(t, source.snapshotAttachments()) | ||
| 660 | 660 | ||||
| 661 | taken := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | 661 | taken := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | ||
| 662 | - | require.False(t, taken.OK) | 662 | + | require.False(t, taken.Outcome == protocol.CommandSucceeded) |
| 663 | require.Equal(t, protocol.ErrNameTaken, taken.Code) | 663 | require.Equal(t, protocol.ErrNameTaken, taken.Code) | ||
| 664 | } | 664 | } | ||
| 665 | 665 | ||||
| @@ -676,7 +676,7 @@ func TestHandleCommandNewSessionPreservesSourceGeometry(t *testing.T) { | |||||
| 676 | 676 | ||||
| 677 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | 677 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | ||
| 678 | 678 | ||||
| 679 | - | require.True(t, result.OK, result.Text) | 679 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 680 | d.mu.Lock() | 680 | d.mu.Lock() | ||
| 681 | created := d.findByNameLocked("scripted") | 681 | created := d.findByNameLocked("scripted") | ||
| 682 | d.mu.Unlock() | 682 | d.mu.Unlock() | ||
| @@ -698,11 +698,11 @@ func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) { | |||||
| 698 | sess := addControlSession(d, "work", "t_work", "p_work") | 698 | sess := addControlSession(d, "work", "t_work", "p_work") | ||
| 699 | 699 | ||||
| 700 | bad := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"}) | 700 | bad := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"}) | ||
| 701 | - | require.False(t, bad.OK) | 701 | + | require.False(t, bad.Outcome == protocol.CommandSucceeded) |
| 702 | require.Equal(t, protocol.ErrInvalidCommandArgs, bad.Code) | 702 | require.Equal(t, protocol.ErrInvalidCommandArgs, bad.Code) | ||
| 703 | 703 | ||||
| 704 | good := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"}) | 704 | good := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"}) | ||
| 705 | - | require.True(t, good.OK, good.Text) | 705 | + | require.True(t, good.Outcome == protocol.CommandSucceeded, good.Text) |
| 706 | d.notices.mu.Lock() | 706 | d.notices.mu.Lock() | ||
| 707 | require.Len(t, d.notices.pending, 1) | 707 | require.Len(t, d.notices.pending, 1) | ||
| 708 | require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code) | 708 | require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code) | ||
| @@ -716,16 +716,16 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) { | |||||
| 716 | sess.tabs[0].name = "shell" | 716 | sess.tabs[0].name = "shell" | ||
| 717 | 717 | ||||
| 718 | sessions := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"}) | 718 | sessions := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"}) | ||
| 719 | - | require.True(t, sessions.OK, sessions.Text) | 719 | + | require.True(t, sessions.Outcome == protocol.CommandSucceeded, sessions.Text) |
| 720 | require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE") | 720 | require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE") | ||
| 721 | require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue") | 721 | require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue") | ||
| 722 | 722 | ||||
| 723 | tabs := sendCommand(t, d, protocol.CommandRequest{Slug: "list-tabs", TargetSession: "work"}) | 723 | tabs := sendCommand(t, d, protocol.CommandRequest{Slug: "list-tabs", TargetSession: "work"}) | ||
| 724 | - | require.True(t, tabs.OK, tabs.Text) | 724 | + | require.True(t, tabs.Outcome == protocol.CommandSucceeded, tabs.Text) |
| 725 | require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue") | 725 | require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue") | ||
| 726 | 726 | ||||
| 727 | panes := sendCommand(t, d, protocol.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true}) | 727 | panes := sendCommand(t, d, protocol.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true}) | ||
| 728 | - | require.True(t, panes.OK, panes.Text) | 728 | + | require.True(t, panes.Outcome == protocol.CommandSucceeded, panes.Text) |
| 729 | var decoded []map[string]any | 729 | var decoded []map[string]any | ||
| 730 | require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded)) | 730 | require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded)) | ||
| 731 | require.Len(t, decoded, 1) | 731 | require.Len(t, decoded, 1) | ||
| @@ -741,7 +741,7 @@ func TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) { | |||||
| 741 | d.mu.Unlock() | 741 | d.mu.Unlock() | ||
| 742 | 742 | ||||
| 743 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true}) | 743 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true}) | ||
| 744 | - | require.True(t, result.OK, result.Text) | 744 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 745 | 745 | ||||
| 746 | // Observation is read-only: the stopped record stays stopped, is not | 746 | // Observation is read-only: the stopped record stays stopped, is not | ||
| 747 | // resumed into a live session, and is not purged. | 747 | // resumed into a live session, and is not purged. | ||
| @@ -783,18 +783,18 @@ func TestRemoteCatalogJSONOutput(t *testing.T) { | |||||
| 783 | d.mu.Unlock() | 783 | d.mu.Unlock() | ||
| 784 | 784 | ||||
| 785 | listBefore := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"}) | 785 | listBefore := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"}) | ||
| 786 | - | require.True(t, listBefore.OK, listBefore.Text) | 786 | + | require.True(t, listBefore.Outcome == protocol.CommandSucceeded, listBefore.Text) |
| 787 | 787 | ||||
| 788 | missingJSON := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog"}) | 788 | missingJSON := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog"}) | ||
| 789 | - | require.False(t, missingJSON.OK) | 789 | + | require.False(t, missingJSON.Outcome == protocol.CommandSucceeded) |
| 790 | require.Equal(t, protocol.ErrInvalidCommandArgs, missingJSON.Code) | 790 | require.Equal(t, protocol.ErrInvalidCommandArgs, missingJSON.Code) | ||
| 791 | 791 | ||||
| 792 | withArgs := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true}) | 792 | withArgs := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true}) | ||
| 793 | - | require.False(t, withArgs.OK) | 793 | + | require.False(t, withArgs.Outcome == protocol.CommandSucceeded) |
| 794 | require.Equal(t, protocol.ErrInvalidCommandArgs, withArgs.Code) | 794 | require.Equal(t, protocol.ErrInvalidCommandArgs, withArgs.Code) | ||
| 795 | 795 | ||||
| 796 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true}) | 796 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true}) | ||
| 797 | - | require.True(t, result.OK, result.Text) | 797 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 798 | require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated") | 798 | require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated") | ||
| 799 | 799 | ||||
| 800 | var catalog catalogue.RemoteCatalog | 800 | var catalog catalogue.RemoteCatalog | ||
| @@ -818,7 +818,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) { | |||||
| 818 | require.Equal(t, catalogue.RemoteCatalogSessionDown, catalog.Sessions[1].State) | 818 | require.Equal(t, catalogue.RemoteCatalogSessionDown, catalog.Sessions[1].State) | ||
| 819 | 819 | ||||
| 820 | listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"}) | 820 | listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"}) | ||
| 821 | - | require.True(t, listAfter.OK, listAfter.Text) | 821 | + | require.True(t, listAfter.Outcome == protocol.CommandSucceeded, listAfter.Text) |
| 822 | require.Equal(t, listBefore.Output, listAfter.Output) | 822 | require.Equal(t, listBefore.Output, listAfter.Output) | ||
| 823 | require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t") | 823 | require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t") | ||
| 824 | require.NotContains(t, listAfter.Output, "old") | 824 | require.NotContains(t, listAfter.Output, "old") | ||
| @@ -840,7 +840,7 @@ func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) { | |||||
| 840 | } | 840 | } | ||
| 841 | 841 | ||||
| 842 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true}) | 842 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true}) | ||
| 843 | - | require.True(t, result.OK, result.Text) | 843 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 844 | var catalog catalogue.RemoteCatalog | 844 | var catalog catalogue.RemoteCatalog | ||
| 845 | require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog)) | 845 | require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog)) | ||
| 846 | require.Equal(t, []catalogue.RemoteCatalogSession{{ | 846 | require.Equal(t, []catalogue.RemoteCatalogSession{{ | ||
| @@ -872,7 +872,7 @@ func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) { | |||||
| 872 | sess.mu.Unlock() | 872 | sess.mu.Unlock() | ||
| 873 | 873 | ||||
| 874 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true}) | 874 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true}) | ||
| 875 | - | require.False(t, result.OK) | 875 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) |
| 876 | require.Contains(t, result.Text, "too many tabs") | 876 | require.Contains(t, result.Text, "too many tabs") | ||
| 877 | } | 877 | } | ||
| 878 | 878 | ||||
| @@ -927,8 +927,8 @@ func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |||||
| 927 | close(factory.release) | 927 | close(factory.release) | ||
| 928 | <-firstDone | 928 | <-firstDone | ||
| 929 | <-secondDone | 929 | <-secondDone | ||
| 930 | - | require.True(t, awaitCommandResult(t, firstSends).OK) | 930 | + | require.True(t, awaitCommandResult(t, firstSends).Outcome == protocol.CommandSucceeded) |
| 931 | - | require.True(t, awaitCommandResult(t, secondSends).OK) | 931 | + | require.True(t, awaitCommandResult(t, secondSends).Outcome == protocol.CommandSucceeded) |
| 932 | 932 | ||||
| 933 | sess.mu.Lock() | 933 | sess.mu.Lock() | ||
| 934 | require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab") | 934 | require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab") | ||
| @@ -975,7 +975,7 @@ func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing. | |||||
| 975 | result := sendCommand(t, d, protocol.CommandRequest{ | 975 | result := sendCommand(t, d, protocol.CommandRequest{ | ||
| 976 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work", | 976 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work", | ||
| 977 | }) | 977 | }) | ||
| 978 | - | require.True(t, result.OK, result.Text) | 978 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 979 | 979 | ||||
| 980 | source.mu.Lock() | 980 | source.mu.Lock() | ||
| 981 | require.Len(t, source.tabs, 1) | 981 | require.Len(t, source.tabs, 1) | ||
| @@ -1008,7 +1008,7 @@ func TestHandleCommandMovePanePurgeAdmissionMapsToRetryableError(t *testing.T) { | |||||
| 1008 | result := sendCommand(t, d, protocol.CommandRequest{ | 1008 | result := sendCommand(t, d, protocol.CommandRequest{ | ||
| 1009 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work", | 1009 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work", | ||
| 1010 | }) | 1010 | }) | ||
| 1011 | - | require.False(t, result.OK) | 1011 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) |
| 1012 | require.Equal(t, protocol.ErrInternal, result.Code) | 1012 | require.Equal(t, protocol.ErrInternal, result.Code) | ||
| 1013 | require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text) | 1013 | require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text) | ||
| 1014 | d.endPurgeAdmission() | 1014 | d.endPurgeAdmission() | ||
| @@ -1030,7 +1030,7 @@ func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) { | |||||
| 1030 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | 1030 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | ||
| 1031 | TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive", | 1031 | TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive", | ||
| 1032 | }) | 1032 | }) | ||
| 1033 | - | require.True(t, result.OK, result.Text) | 1033 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 1034 | 1034 | ||||
| 1035 | active := source.tabs[0] | 1035 | active := source.tabs[0] | ||
| 1036 | active.mu.Lock() | 1036 | active.mu.Lock() | ||
| @@ -1068,7 +1068,7 @@ func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) { | |||||
| 1068 | Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false, | 1068 | Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false, | ||
| 1069 | TargetTab: "t_second", TargetPane: "p_second", | 1069 | TargetTab: "t_second", TargetPane: "p_second", | ||
| 1070 | }) | 1070 | }) | ||
| 1071 | - | require.True(t, result.OK, result.Text) | 1071 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 1072 | 1072 | ||||
| 1073 | source.mu.Lock() | 1073 | source.mu.Lock() | ||
| 1074 | require.Len(t, source.tabs, 1) | 1074 | require.Len(t, source.tabs, 1) | ||
| @@ -1096,7 +1096,7 @@ func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) { | |||||
| 1096 | Slug: "move-tab", Args: []string{"dest"}, Self: true, | 1096 | Slug: "move-tab", Args: []string{"dest"}, Self: true, | ||
| 1097 | TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second", | 1097 | TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second", | ||
| 1098 | }) | 1098 | }) | ||
| 1099 | - | require.True(t, result.OK, result.Text) | 1099 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 1100 | 1100 | ||||
| 1101 | source.mu.Lock() | 1101 | source.mu.Lock() | ||
| 1102 | require.Len(t, source.tabs, 1) | 1102 | require.Len(t, source.tabs, 1) | ||
| @@ -1215,7 +1215,7 @@ func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) { | |||||
| 1215 | d := newTestDaemon(t, nil, stubClock{}) | 1215 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 1216 | request := tt.arrange(d) | 1216 | request := tt.arrange(d) | ||
| 1217 | result := sendCommand(t, d, request) | 1217 | result := sendCommand(t, d, request) | ||
| 1218 | - | require.False(t, result.OK) | 1218 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) |
| 1219 | require.Equal(t, tt.code, result.Code) | 1219 | require.Equal(t, tt.code, result.Code) | ||
| 1220 | if tt.text != "" { | 1220 | if tt.text != "" { | ||
| 1221 | require.Equal(t, tt.text, result.Text) | 1221 | require.Equal(t, tt.text, result.Text) | ||
| @@ -1234,7 +1234,7 @@ func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *t | |||||
| 1234 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | 1234 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | ||
| 1235 | TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work", | 1235 | TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work", | ||
| 1236 | }) | 1236 | }) | ||
| 1237 | - | require.True(t, result.OK, result.Text) | 1237 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 1238 | 1238 | ||||
| 1239 | source.mu.Lock() | 1239 | source.mu.Lock() | ||
| 1240 | require.Len(t, source.tabs, 0) | 1240 | require.Len(t, source.tabs, 0) | ||
| @@ -1266,7 +1266,7 @@ func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testi | |||||
| 1266 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | 1266 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | ||
| 1267 | TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive", | 1267 | TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive", | ||
| 1268 | }) | 1268 | }) | ||
| 1269 | - | require.True(t, result.OK, result.Text) | 1269 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 1270 | 1270 | ||||
| 1271 | source.mu.Lock() | 1271 | source.mu.Lock() | ||
| 1272 | require.Len(t, source.tabs, 1) | 1272 | require.Len(t, source.tabs, 1) | ||
| @@ -1332,7 +1332,7 @@ func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) { | |||||
| 1332 | select { | 1332 | select { | ||
| 1333 | case <-done: | 1333 | case <-done: | ||
| 1334 | result := awaitCommandResult(t, sends) | 1334 | result := awaitCommandResult(t, sends) | ||
| 1335 | - | require.True(t, result.OK, result.Text) | 1335 | + | require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text) |
| 1336 | case <-time.After(5 * time.Second): | 1336 | case <-time.After(5 * time.Second): | ||
| 1337 | t.Fatal("opposite-direction move commands deadlocked") | 1337 | t.Fatal("opposite-direction move commands deadlocked") | ||
| 1338 | } | 1338 | } | ||
| @@ -1514,7 +1514,7 @@ func TestCloseTabParticipantsChangedReportsRetryable(t *testing.T) { | |||||
| 1514 | t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil }) | 1514 | t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil }) | ||
| 1515 | 1515 | ||||
| 1516 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"}) | 1516 | result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"}) | ||
| 1517 | - | require.False(t, result.OK) | 1517 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) |
| 1518 | require.Equal(t, protocol.ErrInternal, result.Code) | 1518 | require.Equal(t, protocol.ErrInternal, result.Code) | ||
| 1519 | require.Equal(t, sessionKillRetryMessage, result.Text) | 1519 | require.Equal(t, sessionKillRetryMessage, result.Text) | ||
| 1520 | 1520 | ||||
M internal/usecase/daemon/daemon.go +1 -1
| @@ -1263,7 +1263,7 @@ func (d *Daemon) handleInitialDecodeFailure(ctx context.Context, tr ports.Server | |||
| 1263 | 1263 | if failure.Version != 0 && failure.Version != protocol.Version { | |
| 1264 | 1264 | code, text = protocol.ErrVersionMismatch, "protocol version mismatch" | |
| 1265 | 1265 | } | |
| 1266 | - | send(protocol.CommandResult{RequestID: failure.RequestID, Code: code, Text: text}) | |
| 1266 | + | send(protocol.CommandResult{RequestID: failure.RequestID, Outcome: protocol.CommandFailed, Code: code, Text: text}) | |
| 1267 | 1267 | case protocol.DecodeMessageKill: | |
| 1268 | 1268 | send(serverError(protocol.ErrInternal, "malformed kill request")) | |
| 1269 | 1269 | case protocol.DecodeMessageRemotePreview: | |
| @@ -1263,7 +1263,7 @@ func (d *Daemon) handleInitialDecodeFailure(ctx context.Context, tr ports.Server | |||||
| 1263 | if failure.Version != 0 && failure.Version != protocol.Version { | 1263 | if failure.Version != 0 && failure.Version != protocol.Version { | ||
| 1264 | code, text = protocol.ErrVersionMismatch, "protocol version mismatch" | 1264 | code, text = protocol.ErrVersionMismatch, "protocol version mismatch" | ||
| 1265 | } | 1265 | } | ||
| 1266 | - | send(protocol.CommandResult{RequestID: failure.RequestID, Code: code, Text: text}) | 1266 | + | send(protocol.CommandResult{RequestID: failure.RequestID, Outcome: protocol.CommandFailed, Code: code, Text: text}) |
| 1267 | case protocol.DecodeMessageKill: | 1267 | case protocol.DecodeMessageKill: | ||
| 1268 | send(serverError(protocol.ErrInternal, "malformed kill request")) | 1268 | send(serverError(protocol.ErrInternal, "malformed kill request")) | ||
| 1269 | case protocol.DecodeMessageRemotePreview: | 1269 | case protocol.DecodeMessageRemotePreview: | ||
M internal/usecase/daemon/move_rejection_test.go +1 -1
| @@ -106,7 +106,7 @@ func TestMoveRejectionPresentationParity(t *testing.T) { | |||
| 106 | 106 | require.Equal(t, tt.paletteText, userErr.Msg) | |
| 107 | 107 | ||
| 108 | 108 | result := moveCommandFailure(tt.err) | |
| 109 | - | require.False(t, result.OK) | |
| 109 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) | |
| 110 | 110 | require.Equal(t, descriptor.CommandCode, result.Code) | |
| 111 | 111 | require.Equal(t, descriptor.CommandText, result.Text) | |
| 112 | 112 | require.Equal(t, protocol.ErrNoSuchTarget, result.Code) | |
| @@ -106,7 +106,7 @@ func TestMoveRejectionPresentationParity(t *testing.T) { | |||||
| 106 | require.Equal(t, tt.paletteText, userErr.Msg) | 106 | require.Equal(t, tt.paletteText, userErr.Msg) | ||
| 107 | 107 | ||||
| 108 | result := moveCommandFailure(tt.err) | 108 | result := moveCommandFailure(tt.err) | ||
| 109 | - | require.False(t, result.OK) | 109 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) |
| 110 | require.Equal(t, descriptor.CommandCode, result.Code) | 110 | require.Equal(t, descriptor.CommandCode, result.Code) | ||
| 111 | require.Equal(t, descriptor.CommandText, result.Text) | 111 | require.Equal(t, descriptor.CommandText, result.Text) | ||
| 112 | require.Equal(t, protocol.ErrNoSuchTarget, result.Code) | 112 | require.Equal(t, protocol.ErrNoSuchTarget, result.Code) | ||
M internal/usecase/daemon/purge_test.go +3 -3
| @@ -235,7 +235,7 @@ func TestPurgeAdmissionGatesAndReopens(t *testing.T) { | |||
| 235 | 235 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | |
| 236 | 236 | TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work", | |
| 237 | 237 | }) | |
| 238 | - | require.False(t, rejected.OK, "a move during the purge must be rejected") | |
| 238 | + | require.False(t, rejected.Outcome == protocol.CommandSucceeded, "a move during the purge must be rejected") | |
| 239 | 239 | ||
| 240 | 240 | d.endPurgeAdmission() | |
| 241 | 241 | require.NoError(t, awaitTestValue(t, created, "route did not resume after purge admission reopened")) | |
| @@ -245,7 +245,7 @@ func TestPurgeAdmissionGatesAndReopens(t *testing.T) { | |||
| 245 | 245 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | |
| 246 | 246 | TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work", | |
| 247 | 247 | }) | |
| 248 | - | require.True(t, accepted.OK, accepted.Text) | |
| 248 | + | require.True(t, accepted.Outcome == protocol.CommandSucceeded, accepted.Text) | |
| 249 | 249 | } | |
| 250 | 250 | ||
| 251 | 251 | // TestPurgeAllLinearizesConcurrentCreate races KillAll against a route create. | |
| @@ -664,7 +664,7 @@ func TestCommandPathPurgeAdmissionMapsToRetryableError(t *testing.T) { | |||
| 664 | 664 | Slug: "new-session", Args: []string{"fresh"}, Self: true, | |
| 665 | 665 | TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work", | |
| 666 | 666 | }) | |
| 667 | - | require.False(t, result.OK) | |
| 667 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) | |
| 668 | 668 | require.Equal(t, protocol.ErrInternal, result.Code) | |
| 669 | 669 | require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text) | |
| 670 | 670 | d.endPurgeAdmission() | |
| @@ -235,7 +235,7 @@ func TestPurgeAdmissionGatesAndReopens(t *testing.T) { | |||||
| 235 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | 235 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | ||
| 236 | TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work", | 236 | TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work", | ||
| 237 | }) | 237 | }) | ||
| 238 | - | require.False(t, rejected.OK, "a move during the purge must be rejected") | 238 | + | require.False(t, rejected.Outcome == protocol.CommandSucceeded, "a move during the purge must be rejected") |
| 239 | 239 | ||||
| 240 | d.endPurgeAdmission() | 240 | d.endPurgeAdmission() | ||
| 241 | require.NoError(t, awaitTestValue(t, created, "route did not resume after purge admission reopened")) | 241 | require.NoError(t, awaitTestValue(t, created, "route did not resume after purge admission reopened")) | ||
| @@ -245,7 +245,7 @@ func TestPurgeAdmissionGatesAndReopens(t *testing.T) { | |||||
| 245 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | 245 | Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true, | ||
| 246 | TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work", | 246 | TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work", | ||
| 247 | }) | 247 | }) | ||
| 248 | - | require.True(t, accepted.OK, accepted.Text) | 248 | + | require.True(t, accepted.Outcome == protocol.CommandSucceeded, accepted.Text) |
| 249 | } | 249 | } | ||
| 250 | 250 | ||||
| 251 | // TestPurgeAllLinearizesConcurrentCreate races KillAll against a route create. | 251 | // TestPurgeAllLinearizesConcurrentCreate races KillAll against a route create. | ||
| @@ -664,7 +664,7 @@ func TestCommandPathPurgeAdmissionMapsToRetryableError(t *testing.T) { | |||||
| 664 | Slug: "new-session", Args: []string{"fresh"}, Self: true, | 664 | Slug: "new-session", Args: []string{"fresh"}, Self: true, | ||
| 665 | TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work", | 665 | TargetSession: "work", TargetTab: "t_work", TargetPane: "p_work", | ||
| 666 | }) | 666 | }) | ||
| 667 | - | require.False(t, result.OK) | 667 | + | require.False(t, result.Outcome == protocol.CommandSucceeded) |
| 668 | require.Equal(t, protocol.ErrInternal, result.Code) | 668 | require.Equal(t, protocol.ErrInternal, result.Code) | ||
| 669 | require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text) | 669 | require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text) | ||
| 670 | d.endPurgeAdmission() | 670 | d.endPurgeAdmission() | ||
M internal/usecase/daemon/resize_mode_test.go +3 -3
| @@ -57,11 +57,11 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) { | |||
| 57 | 57 | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 58 | 58 | ||
| 59 | 59 | tooEarly := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"}) | |
| 60 | - | require.False(t, tooEarly.OK) | |
| 60 | + | require.False(t, tooEarly.Outcome == protocol.CommandSucceeded) | |
| 61 | 61 | require.Equal(t, protocol.ErrNoSuchTarget, tooEarly.Code) | |
| 62 | 62 | require.Equal(t, "pane is not in a split", tooEarly.Text) | |
| 63 | 63 | ||
| 64 | - | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 64 | + | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded) | |
| 65 | 65 | tb := testAttachmentTab(sess) | |
| 66 | 66 | tb.mu.Lock() | |
| 67 | 67 | beforeFocus := tb.tree.Focus | |
| @@ -88,7 +88,7 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) { | |||
| 88 | 88 | tb.mu.Unlock() | |
| 89 | 89 | ||
| 90 | 90 | equalized := sendCommand(t, d, protocol.CommandRequest{Slug: "equalize-panes", TargetSession: "work"}) | |
| 91 | - | require.True(t, equalized.OK, equalized.Text) | |
| 91 | + | require.True(t, equalized.Outcome == protocol.CommandSucceeded, equalized.Text) | |
| 92 | 92 | require.Empty(t, equalized.Output) | |
| 93 | 93 | } | |
| 94 | 94 | ||
| @@ -57,11 +57,11 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) { | |||||
| 57 | sess := addControlSession(d, "work", "t_work", "p_work") | 57 | sess := addControlSession(d, "work", "t_work", "p_work") | ||
| 58 | 58 | ||||
| 59 | tooEarly := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"}) | 59 | tooEarly := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"}) | ||
| 60 | - | require.False(t, tooEarly.OK) | 60 | + | require.False(t, tooEarly.Outcome == protocol.CommandSucceeded) |
| 61 | require.Equal(t, protocol.ErrNoSuchTarget, tooEarly.Code) | 61 | require.Equal(t, protocol.ErrNoSuchTarget, tooEarly.Code) | ||
| 62 | require.Equal(t, "pane is not in a split", tooEarly.Text) | 62 | require.Equal(t, "pane is not in a split", tooEarly.Text) | ||
| 63 | 63 | ||||
| 64 | - | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | 64 | + | require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded) |
| 65 | tb := testAttachmentTab(sess) | 65 | tb := testAttachmentTab(sess) | ||
| 66 | tb.mu.Lock() | 66 | tb.mu.Lock() | ||
| 67 | beforeFocus := tb.tree.Focus | 67 | beforeFocus := tb.tree.Focus | ||
| @@ -88,7 +88,7 @@ func TestResizeControlHeadlessAndErrors(t *testing.T) { | |||||
| 88 | tb.mu.Unlock() | 88 | tb.mu.Unlock() | ||
| 89 | 89 | ||||
| 90 | equalized := sendCommand(t, d, protocol.CommandRequest{Slug: "equalize-panes", TargetSession: "work"}) | 90 | equalized := sendCommand(t, d, protocol.CommandRequest{Slug: "equalize-panes", TargetSession: "work"}) | ||
| 91 | - | require.True(t, equalized.OK, equalized.Text) | 91 | + | require.True(t, equalized.Outcome == protocol.CommandSucceeded, equalized.Text) |
| 92 | require.Empty(t, equalized.Output) | 92 | require.Empty(t, equalized.Output) | ||
| 93 | } | 93 | } | ||
| 94 | 94 | ||||
M internal/usecase/daemon/typed_connection_test.go +3 -3
| @@ -335,7 +335,7 @@ func (d *Daemon) handleCommandFrame(connection ports.ServerConnection, envelope | |||
| 335 | 335 | code = protocol.ErrVersionMismatch | |
| 336 | 336 | text = "protocol version mismatch" | |
| 337 | 337 | } | |
| 338 | - | return d.sendCommandResult(connection, protocol.CommandResult{RequestID: failure.RequestID, Code: code, Text: text}) | |
| 338 | + | return d.sendCommandResult(connection, protocol.CommandResult{RequestID: failure.RequestID, Outcome: protocol.CommandFailed, Code: code, Text: text}) | |
| 339 | 339 | case protocol.DecodeMessageHello: | |
| 340 | 340 | if failure.Version != 0 && failure.Version != protocol.Version { | |
| 341 | 341 | code = protocol.ErrVersionMismatch | |
| @@ -345,11 +345,11 @@ func (d *Daemon) handleCommandFrame(connection ports.ServerConnection, envelope | |||
| 345 | 345 | } | |
| 346 | 346 | } | |
| 347 | 347 | } | |
| 348 | - | return d.sendCommandResult(connection, protocol.CommandResult{Code: code, Text: text}) | |
| 348 | + | return d.sendCommandResult(connection, protocol.CommandResult{Outcome: protocol.CommandFailed, Code: code, Text: text}) | |
| 349 | 349 | } | |
| 350 | 350 | command, ok := request.(protocol.CommandRequest) | |
| 351 | 351 | if !ok { | |
| 352 | - | return d.sendCommandResult(connection, protocol.CommandResult{Code: protocol.ErrInternal, Text: "expected command"}) | |
| 352 | + | return d.sendCommandResult(connection, protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: "expected command"}) | |
| 353 | 353 | } | |
| 354 | 354 | return d.handleCommand(connection, command) | |
| 355 | 355 | } | |
| @@ -335,7 +335,7 @@ func (d *Daemon) handleCommandFrame(connection ports.ServerConnection, envelope | |||||
| 335 | code = protocol.ErrVersionMismatch | 335 | code = protocol.ErrVersionMismatch | ||
| 336 | text = "protocol version mismatch" | 336 | text = "protocol version mismatch" | ||
| 337 | } | 337 | } | ||
| 338 | - | return d.sendCommandResult(connection, protocol.CommandResult{RequestID: failure.RequestID, Code: code, Text: text}) | 338 | + | return d.sendCommandResult(connection, protocol.CommandResult{RequestID: failure.RequestID, Outcome: protocol.CommandFailed, Code: code, Text: text}) |
| 339 | case protocol.DecodeMessageHello: | 339 | case protocol.DecodeMessageHello: | ||
| 340 | if failure.Version != 0 && failure.Version != protocol.Version { | 340 | if failure.Version != 0 && failure.Version != protocol.Version { | ||
| 341 | code = protocol.ErrVersionMismatch | 341 | code = protocol.ErrVersionMismatch | ||
| @@ -345,11 +345,11 @@ func (d *Daemon) handleCommandFrame(connection ports.ServerConnection, envelope | |||||
| 345 | } | 345 | } | ||
| 346 | } | 346 | } | ||
| 347 | } | 347 | } | ||
| 348 | - | return d.sendCommandResult(connection, protocol.CommandResult{Code: code, Text: text}) | 348 | + | return d.sendCommandResult(connection, protocol.CommandResult{Outcome: protocol.CommandFailed, Code: code, Text: text}) |
| 349 | } | 349 | } | ||
| 350 | command, ok := request.(protocol.CommandRequest) | 350 | command, ok := request.(protocol.CommandRequest) | ||
| 351 | if !ok { | 351 | if !ok { | ||
| 352 | - | return d.sendCommandResult(connection, protocol.CommandResult{Code: protocol.ErrInternal, Text: "expected command"}) | 352 | + | return d.sendCommandResult(connection, protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: "expected command"}) |
| 353 | } | 353 | } | ||
| 354 | return d.handleCommand(connection, command) | 354 | return d.handleCommand(connection, command) | ||
| 355 | } | 355 | } | ||