fix(client): fall back after resume expiry (#237)
- commit
- b788892be7ee068d33e8120296559e760062b367
- author
- bnema · · 2026-08-29 08:29 UTC
- committer
- GitHub · · 2026-08-29 08:29 UTC
- parent
- ff4fca7
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
3 files · +64 -2
M docs/remote-resilience.md +4 -2
| @@ -127,8 +127,10 @@ picker selection changes. | |||
| 127 | 127 | canonical encoding. The protocol validates the compression kind, declared | |
| 128 | 128 | decoded length, stream integrity, and trailing bytes before terminal output | |
| 129 | 129 | is applied. | |
| 130 | - | - The attachment reconnects with its resume token after a path change. The | |
| 131 | - | remote session and its PTYs remain in place while the attachment is offline. | |
| 130 | + | - The attachment reconnects with its resume token after a path change. If the | |
| 131 | + | attachment resume window expires but its exact session lifecycle still | |
| 132 | + | exists, the client opens a fresh exact attachment instead. The remote session | |
| 133 | + | and its PTYs remain in place while the attachment is offline. | |
| 132 | 134 | ||
| 133 | 135 | The raw UDP AEAD key is held only in memory during bootstrap and transport | |
| 134 | 136 | setup. It is not written to durable state. Key buffers are cleared on the | |
| @@ -127,8 +127,10 @@ picker selection changes. | |||||
| 127 | canonical encoding. The protocol validates the compression kind, declared | 127 | canonical encoding. The protocol validates the compression kind, declared | ||
| 128 | decoded length, stream integrity, and trailing bytes before terminal output | 128 | decoded length, stream integrity, and trailing bytes before terminal output | ||
| 129 | is applied. | 129 | is applied. | ||
| 130 | - | - The attachment reconnects with its resume token after a path change. The | 130 | + | - The attachment reconnects with its resume token after a path change. If the |
| 131 | - | remote session and its PTYs remain in place while the attachment is offline. | 131 | + | attachment resume window expires but its exact session lifecycle still |
| 132 | + | exists, the client opens a fresh exact attachment instead. The remote session | |||
| 133 | + | and its PTYs remain in place while the attachment is offline. | |||
| 132 | 134 | ||||
| 133 | The raw UDP AEAD key is held only in memory during bootstrap and transport | 135 | The raw UDP AEAD key is held only in memory during bootstrap and transport | ||
| 134 | setup. It is not written to durable state. Key buffers are cleared on the | 136 | setup. It is not written to durable state. Key buffers are cleared on the | ||
M internal/usecase/client/client.go +8 -0
| @@ -927,6 +927,14 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 927 | 927 | enterHomePicker() | |
| 928 | 928 | continue | |
| 929 | 929 | } | |
| 930 | + | if attemptRequest.Intent == protocol.IntentResume && !result.welcomed && | |
| 931 | + | attemptRequest.ExactTarget != nil && resumeNeedsExactAttach(result.err) { | |
| 932 | + | attemptRequest.Intent = protocol.IntentAttach | |
| 933 | + | attemptRequest.RemoteTarget = nil | |
| 934 | + | resumeToken = 0 | |
| 935 | + | backoff = defaultReconnectBackoff.initial | |
| 936 | + | continue | |
| 937 | + | } | |
| 930 | 938 | if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil { | |
| 931 | 939 | if clearErr := reconnect.clear(); clearErr != nil { | |
| 932 | 940 | return errors.Join(result.err, clearErr) | |
| @@ -927,6 +927,14 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 927 | enterHomePicker() | 927 | enterHomePicker() | ||
| 928 | continue | 928 | continue | ||
| 929 | } | 929 | } | ||
| 930 | + | if attemptRequest.Intent == protocol.IntentResume && !result.welcomed && | |||
| 931 | + | attemptRequest.ExactTarget != nil && resumeNeedsExactAttach(result.err) { | |||
| 932 | + | attemptRequest.Intent = protocol.IntentAttach | |||
| 933 | + | attemptRequest.RemoteTarget = nil | |||
| 934 | + | resumeToken = 0 | |||
| 935 | + | backoff = defaultReconnectBackoff.initial | |||
| 936 | + | continue | |||
| 937 | + | } | |||
| 930 | if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil { | 938 | if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil { | ||
| 931 | if clearErr := reconnect.clear(); clearErr != nil { | 939 | if clearErr := reconnect.clear(); clearErr != nil { | ||
| 932 | return errors.Join(result.err, clearErr) | 940 | return errors.Join(result.err, clearErr) | ||
M internal/usecase/client/client_test.go +52 -0
| @@ -1854,6 +1854,58 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||
| 1854 | 1854 | require.Contains(t, term.out.String(), "\x1b[2K") | |
| 1855 | 1855 | } | |
| 1856 | 1856 | ||
| 1857 | + | func TestExpiredResumeFallsBackToFreshExactAttach(t *testing.T) { | |
| 1858 | + | term := newRunTerminal() | |
| 1859 | + | defer term.in.unblock() | |
| 1860 | + | target := protocol.ExactSessionTarget{ | |
| 1861 | + | LifecycleID: domain.SessionLifecycleID{7, 8, 9}, | |
| 1862 | + | SessionName: "work", | |
| 1863 | + | } | |
| 1864 | + | remoteTarget := domain.RemoteSessionTarget{ | |
| 1865 | + | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: target.LifecycleID, | |
| 1866 | + | SessionName: target.SessionName, LiveTabID: "stale-tab", | |
| 1867 | + | } | |
| 1868 | + | welcome := func(token uint64) wire.Frame { | |
| 1869 | + | return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{ | |
| 1870 | + | SessionID: "work", SessionName: "work", ResumeToken: token, Capabilities: protocol.CapabilityResume, | |
| 1871 | + | CommittedIdentity: &protocol.CommittedRouteIdentity{Target: target}, | |
| 1872 | + | })) | |
| 1873 | + | } | |
| 1874 | + | tr1 := &recordingTransport{recvs: []recvItem{{f: welcome(11)}, {err: io.EOF}}} | |
| 1875 | + | tr2 := &recordingTransport{recvs: []recvItem{{f: frameOf(wire.MsgError, wire.MarshalErrorMsg(protocol.ErrorMsg{ | |
| 1876 | + | Code: protocol.ErrNoSuchSession, Text: "resume token is no longer valid", | |
| 1877 | + | }))}}} | |
| 1878 | + | tr3 := &recordingTransport{recvs: []recvItem{ | |
| 1879 | + | {f: welcome(22)}, | |
| 1880 | + | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 1881 | + | }} | |
| 1882 | + | dialer := &sequenceDialer{trs: []wire.Transport{tr1, tr2, tr3}} | |
| 1883 | + | clock := &reconnectTestClock{} | |
| 1884 | + | result := make(chan error, 1) | |
| 1885 | + | go func() { | |
| 1886 | + | result <- runTestClient(context.Background(), testDependencies(dialer, term, clock, nil, nil), client.AttachRequest{ | |
| 1887 | + | Intent: protocol.IntentAttach, SessionName: "work", Remote: true, | |
| 1888 | + | Origin: protocol.RouteOriginDiscovery, OriginKey: "remote", RemoteTarget: &remoteTarget, | |
| 1889 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 1890 | + | }) | |
| 1891 | + | }() | |
| 1892 | + | ||
| 1893 | + | clock.fireReconnect(t) | |
| 1894 | + | require.NoError(t, <-result) | |
| 1895 | + | require.Equal(t, int32(3), dialer.calls.Load()) | |
| 1896 | + | ||
| 1897 | + | initialHello := helloFromSend(t, tr1) | |
| 1898 | + | resumeHello := helloFromSend(t, tr2) | |
| 1899 | + | fallbackHello := helloFromSend(t, tr3) | |
| 1900 | + | require.Equal(t, protocol.IntentResume, resumeHello.Intent) | |
| 1901 | + | require.Equal(t, uint64(11), resumeHello.ResumeToken) | |
| 1902 | + | require.Equal(t, protocol.IntentAttach, fallbackHello.Intent) | |
| 1903 | + | require.Zero(t, fallbackHello.ResumeToken) | |
| 1904 | + | require.Equal(t, &target, fallbackHello.ExactTarget) | |
| 1905 | + | require.Nil(t, fallbackHello.RemoteTarget) | |
| 1906 | + | require.Equal(t, initialHello.ClientID, fallbackHello.ClientID) | |
| 1907 | + | } | |
| 1908 | + | ||
| 1857 | 1909 | func TestReconnectRestoresLastPublishedRouteTab(t *testing.T) { | |
| 1858 | 1910 | term := newRunTerminal() | |
| 1859 | 1911 | defer term.in.unblock() | |
| @@ -1854,6 +1854,58 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||||
| 1854 | require.Contains(t, term.out.String(), "\x1b[2K") | 1854 | require.Contains(t, term.out.String(), "\x1b[2K") | ||
| 1855 | } | 1855 | } | ||
| 1856 | 1856 | ||||
| 1857 | + | func TestExpiredResumeFallsBackToFreshExactAttach(t *testing.T) { | |||
| 1858 | + | term := newRunTerminal() | |||
| 1859 | + | defer term.in.unblock() | |||
| 1860 | + | target := protocol.ExactSessionTarget{ | |||
| 1861 | + | LifecycleID: domain.SessionLifecycleID{7, 8, 9}, | |||
| 1862 | + | SessionName: "work", | |||
| 1863 | + | } | |||
| 1864 | + | remoteTarget := domain.RemoteSessionTarget{ | |||
| 1865 | + | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: target.LifecycleID, | |||
| 1866 | + | SessionName: target.SessionName, LiveTabID: "stale-tab", | |||
| 1867 | + | } | |||
| 1868 | + | welcome := func(token uint64) wire.Frame { | |||
| 1869 | + | return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{ | |||
| 1870 | + | SessionID: "work", SessionName: "work", ResumeToken: token, Capabilities: protocol.CapabilityResume, | |||
| 1871 | + | CommittedIdentity: &protocol.CommittedRouteIdentity{Target: target}, | |||
| 1872 | + | })) | |||
| 1873 | + | } | |||
| 1874 | + | tr1 := &recordingTransport{recvs: []recvItem{{f: welcome(11)}, {err: io.EOF}}} | |||
| 1875 | + | tr2 := &recordingTransport{recvs: []recvItem{{f: frameOf(wire.MsgError, wire.MarshalErrorMsg(protocol.ErrorMsg{ | |||
| 1876 | + | Code: protocol.ErrNoSuchSession, Text: "resume token is no longer valid", | |||
| 1877 | + | }))}}} | |||
| 1878 | + | tr3 := &recordingTransport{recvs: []recvItem{ | |||
| 1879 | + | {f: welcome(22)}, | |||
| 1880 | + | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |||
| 1881 | + | }} | |||
| 1882 | + | dialer := &sequenceDialer{trs: []wire.Transport{tr1, tr2, tr3}} | |||
| 1883 | + | clock := &reconnectTestClock{} | |||
| 1884 | + | result := make(chan error, 1) | |||
| 1885 | + | go func() { | |||
| 1886 | + | result <- runTestClient(context.Background(), testDependencies(dialer, term, clock, nil, nil), client.AttachRequest{ | |||
| 1887 | + | Intent: protocol.IntentAttach, SessionName: "work", Remote: true, | |||
| 1888 | + | Origin: protocol.RouteOriginDiscovery, OriginKey: "remote", RemoteTarget: &remoteTarget, | |||
| 1889 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |||
| 1890 | + | }) | |||
| 1891 | + | }() | |||
| 1892 | + | ||||
| 1893 | + | clock.fireReconnect(t) | |||
| 1894 | + | require.NoError(t, <-result) | |||
| 1895 | + | require.Equal(t, int32(3), dialer.calls.Load()) | |||
| 1896 | + | ||||
| 1897 | + | initialHello := helloFromSend(t, tr1) | |||
| 1898 | + | resumeHello := helloFromSend(t, tr2) | |||
| 1899 | + | fallbackHello := helloFromSend(t, tr3) | |||
| 1900 | + | require.Equal(t, protocol.IntentResume, resumeHello.Intent) | |||
| 1901 | + | require.Equal(t, uint64(11), resumeHello.ResumeToken) | |||
| 1902 | + | require.Equal(t, protocol.IntentAttach, fallbackHello.Intent) | |||
| 1903 | + | require.Zero(t, fallbackHello.ResumeToken) | |||
| 1904 | + | require.Equal(t, &target, fallbackHello.ExactTarget) | |||
| 1905 | + | require.Nil(t, fallbackHello.RemoteTarget) | |||
| 1906 | + | require.Equal(t, initialHello.ClientID, fallbackHello.ClientID) | |||
| 1907 | + | } | |||
| 1908 | + | ||||
| 1857 | func TestReconnectRestoresLastPublishedRouteTab(t *testing.T) { | 1909 | func TestReconnectRestoresLastPublishedRouteTab(t *testing.T) { | ||
| 1858 | term := newRunTerminal() | 1910 | term := newRunTerminal() | ||
| 1859 | defer term.in.unblock() | 1911 | defer term.in.unblock() | ||