pico
created pr with
136.1
cmds
checkout latest patchset:
ssh pr.pico.sh print 136 | git am -3checkout any patchset in a patch request:
ssh pr.pico.sh print 136.[rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 136
Patchset
136.1
fix(pipe): cleanly terminate pipe sessions when peer disconnects
Eric Bower
2026-08-15T17:41:43ZPreviously, when one party in a bidirectional `pipe` disconnected, the broker treated the remaining client as an active publisher (because its direction is `InputOutput`), leaving the remaining client hanging in a half-closed state with frozen terminal input. - broker: unblock `Connect()` immediately when `client.Done` closes and trigger `client.Cleanup()` on read/write errors - client: close underlying `ReadWriter` in `Cleanup()` if it implements `io.Closer` - pipe/cli: implement `Close()` on `throttledMonitorRW`
Semantic diff summary
3 added,
14 modified,
0 signature changed,
0 removed
across 4 analyzed files
pkg/apps/pipe/ssh_test.go
-
chunklines 26-32modified -
method_declarationFindUserByPubkeymodified -
method_declarationfindUserLockedadded -
method_declarationFindUsermodified -
method_declarationFindUserByNamemodified -
method_declarationFindFeaturemodified -
method_declarationAddUsermodified -
method_declarationAddPubkeymodified -
function_declarationUpsertPipeMonitormodified -
function_declarationUpdatePipeMonitorLastPingmodified -
function_declarationcopyPipeMonitoradded -
method_declarationFindPipeMonitorByTopicmodified -
method_declarationFindPipeMonitorsByUsermodified -
function_declarationTestPipe_Bidirectionalmodified
+7
-0
pkg/apps/pipe/cli.go
#
| ... | ... | @@ -932,6 +932,13 @@ func (t *throttledMonitorRW) Write(p []byte) (int, error) { | |
| 932 | 932 | return n, err | |
| 933 | 933 | } | |
| 934 | 934 | ||
| 935 | + | func (t *throttledMonitorRW) Close() error { | |
| 936 | + | if closer, ok := t.rw.(io.Closer); ok { | |
| 937 | + | return closer.Close() | |
| 938 | + | } | |
| 939 | + | return nil | |
| 940 | + | } | |
| 941 | + | ||
| 935 | 942 | func (handler *CliHandler) sub(cmd *CliCmd, topic string, clientID string) error { | |
| 936 | 943 | subCmd := flagSet("sub", cmd.sesh) | |
| 937 | 944 | access := subCmd.String("a", "", "Comma separated list of pico usernames or ssh-key fingerprints to allow access to a topic") |
+95
-12
pkg/apps/pipe/ssh_test.go
#
| ... | ... | @@ -39,36 +40,51 @@ func NewTestDB(logger *slog.Logger) *TestDB { | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 41 | 42 | func (t *TestDB) FindUserByPubkey(key string) (*db.User, error) { | |
| 43 | + | t.mu.RLock() | |
| 44 | + | defer t.mu.RUnlock() | |
| 42 | 45 | for _, pk := range t.Pubkeys { | |
| 43 | 46 | if pk.Key == key { | |
| 44 | - | return t.FindUser(pk.UserID) | |
| 47 | + | return t.findUserLocked(pk.UserID) | |
| 45 | 48 | } | |
| 46 | 49 | } | |
| 47 | 50 | return nil, fmt.Errorf("user not found for pubkey") | |
| 48 | 51 | } | |
| 49 | 52 | ||
| 50 | - | func (t *TestDB) FindUser(userID string) (*db.User, error) { | |
| 53 | + | func (t *TestDB) findUserLocked(userID string) (*db.User, error) { | |
| 51 | 54 | for _, user := range t.Users { | |
| 52 | 55 | if user.ID == userID { | |
| 53 | - | return user, nil | |
| 56 | + | cp := *user | |
| 57 | + | return &cp, nil | |
| 54 | 58 | } | |
| 55 | 59 | } | |
| 56 | 60 | return nil, fmt.Errorf("user not found") | |
| 57 | 61 | } | |
| 58 | 62 | ||
| 63 | + | func (t *TestDB) FindUser(userID string) (*db.User, error) { | |
| 64 | + | t.mu.RLock() | |
| 65 | + | defer t.mu.RUnlock() | |
| 66 | + | return t.findUserLocked(userID) | |
| 67 | + | } | |
| 68 | + | ||
| 59 | 69 | func (t *TestDB) FindUserByName(name string) (*db.User, error) { | |
| 70 | + | t.mu.RLock() | |
| 71 | + | defer t.mu.RUnlock() | |
| 60 | 72 | for _, user := range t.Users { | |
| 61 | 73 | if user.Name == name { | |
| 62 | - | return user, nil | |
| 74 | + | cp := *user | |
| 75 | + | return &cp, nil | |
| 63 | 76 | } | |
| 64 | 77 | } | |
| 65 | 78 | return nil, fmt.Errorf("user not found") | |
| 66 | 79 | } | |
| 67 | 80 | ||
| 68 | 81 | func (t *TestDB) FindFeature(userID, name string) (*db.FeatureFlag, error) { | |
| 82 | + | t.mu.RLock() | |
| 83 | + | defer t.mu.RUnlock() | |
| 69 | 84 | for _, ff := range t.Features { | |
| 70 | 85 | if ff.UserID == userID && ff.Name == name { | |
| 71 | - | return ff, nil | |
| 86 | + | cp := *ff | |
| 87 | + | return &cp, nil | |
| 72 | 88 | } | |
| 73 | 89 | } | |
| 74 | 90 | return nil, fmt.Errorf("feature not found") |
| ... | ... | @@ -91,18 +107,31 @@ func (t *TestDB) Close() error { | |
| 91 | 107 | } | |
| 92 | 108 | ||
| 93 | 109 | func (t *TestDB) AddUser(user *db.User) { | |
| 94 | - | t.Users = append(t.Users, user) | |
| 110 | + | t.mu.Lock() | |
| 111 | + | defer t.mu.Unlock() | |
| 112 | + | cp := *user | |
| 113 | + | t.Users = append(t.Users, &cp) | |
| 95 | 114 | } | |
| 96 | 115 | ||
| 97 | 116 | func (t *TestDB) AddPubkey(pubkey *db.PublicKey) { | |
| 98 | - | t.Pubkeys = append(t.Pubkeys, pubkey) | |
| 117 | + | t.mu.Lock() | |
| 118 | + | defer t.mu.Unlock() | |
| 119 | + | cp := *pubkey | |
| 120 | + | t.Pubkeys = append(t.Pubkeys, &cp) | |
| 99 | 121 | } | |
| 100 | 122 | ||
| 101 | 123 | func (t *TestDB) UpsertPipeMonitor(userID, topic string, dur time.Duration, winEnd *time.Time) error { | |
| 124 | + | t.mu.Lock() | |
| 125 | + | defer t.mu.Unlock() | |
| 126 | + | var winEndCopy *time.Time | |
| 127 | + | if winEnd != nil { | |
| 128 | + | w := *winEnd | |
| 129 | + | winEndCopy = &w | |
| 130 | + | } | |
| 102 | 131 | for _, m := range t.PipeMonitors { | |
| 103 | 132 | if m.UserId == userID && m.Topic == topic { | |
| 104 | 133 | m.WindowDur = dur | |
| 105 | - | m.WindowEnd = winEnd | |
| 134 | + | m.WindowEnd = winEndCopy | |
| 106 | 135 | now := time.Now() | |
| 107 | 136 | m.UpdatedAt = &now | |
| 108 | 137 | return nil |
| ... | ... | @@ -122,9 +151,16 @@ func (t *TestDB) UpsertPipeMonitor(userID, topic string, dur time.Duration, winE | |
| 122 | 151 | } | |
| 123 | 152 | ||
| 124 | 153 | func (t *TestDB) UpdatePipeMonitorLastPing(userID, topic string, lastPing *time.Time) error { | |
| 154 | + | t.mu.Lock() | |
| 155 | + | defer t.mu.Unlock() | |
| 156 | + | var lastPingCopy *time.Time | |
| 157 | + | if lastPing != nil { | |
| 158 | + | p := *lastPing | |
| 159 | + | lastPingCopy = &p | |
| 160 | + | } | |
| 125 | 161 | for _, m := range t.PipeMonitors { | |
| 126 | 162 | if m.UserId == userID && m.Topic == topic { | |
| 127 | - | m.LastPing = lastPing | |
| 163 | + | m.LastPing = lastPingCopy | |
| 128 | 164 | now := time.Now() | |
| 129 | 165 | m.UpdatedAt = &now | |
| 130 | 166 | return nil |
| ... | ... | @@ -134,6 +170,8 @@ func (t *TestDB) UpdatePipeMonitorLastPing(userID, topic string, lastPing *time. | |
| 134 | 170 | } | |
| 135 | 171 | ||
| 136 | 172 | func (t *TestDB) RemovePipeMonitor(userID, topic string) error { | |
| 173 | + | t.mu.Lock() | |
| 174 | + | defer t.mu.Unlock() | |
| 137 | 175 | for i, m := range t.PipeMonitors { | |
| 138 | 176 | if m.UserId == userID && m.Topic == topic { | |
| 139 | 177 | t.PipeMonitors = append(t.PipeMonitors[:i], t.PipeMonitors[i+1:]...) |
| ... | ... | @@ -143,20 +181,48 @@ func (t *TestDB) RemovePipeMonitor(userID, topic string) error { | |
| 143 | 181 | return fmt.Errorf("monitor not found") | |
| 144 | 182 | } | |
| 145 | 183 | ||
| 184 | + | func copyPipeMonitor(m *db.PipeMonitor) *db.PipeMonitor { | |
| 185 | + | if m == nil { | |
| 186 | + | return nil | |
| 187 | + | } | |
| 188 | + | cp := *m | |
| 189 | + | if m.WindowEnd != nil { | |
| 190 | + | w := *m.WindowEnd | |
| 191 | + | cp.WindowEnd = &w | |
| 192 | + | } | |
| 193 | + | if m.LastPing != nil { | |
| 194 | + | p := *m.LastPing | |
| 195 | + | cp.LastPing = &p | |
| 196 | + | } | |
| 197 | + | if m.CreatedAt != nil { | |
| 198 | + | c := *m.CreatedAt | |
| 199 | + | cp.CreatedAt = &c | |
| 200 | + | } | |
| 201 | + | if m.UpdatedAt != nil { | |
| 202 | + | u := *m.UpdatedAt | |
| 203 | + | cp.UpdatedAt = &u | |
| 204 | + | } | |
| 205 | + | return &cp | |
| 206 | + | } | |
| 207 | + | ||
| 146 | 208 | func (t *TestDB) FindPipeMonitorByTopic(userID, topic string) (*db.PipeMonitor, error) { | |
| 209 | + | t.mu.RLock() | |
| 210 | + | defer t.mu.RUnlock() | |
| 147 | 211 | for _, m := range t.PipeMonitors { | |
| 148 | 212 | if m.UserId == userID && m.Topic == topic { | |
| 149 | - | return m, nil | |
| 213 | + | return copyPipeMonitor(m), nil | |
| 150 | 214 | } | |
| 151 | 215 | } | |
| 152 | 216 | return nil, fmt.Errorf("monitor not found") | |
| 153 | 217 | } | |
| 154 | 218 | ||
| 155 | 219 | func (t *TestDB) FindPipeMonitorsByUser(userID string) ([]*db.PipeMonitor, error) { | |
| 220 | + | t.mu.RLock() | |
| 221 | + | defer t.mu.RUnlock() | |
| 156 | 222 | var monitors []*db.PipeMonitor | |
| 157 | 223 | for _, m := range t.PipeMonitors { | |
| 158 | 224 | if m.UserId == userID { | |
| 159 | - | monitors = append(monitors, m) | |
| 225 | + | monitors = append(monitors, copyPipeMonitor(m)) | |
| 160 | 226 | } | |
| 161 | 227 | } | |
| 162 | 228 | return monitors, nil |
| ... | ... | @@ -643,6 +709,23 @@ func TestPipe_Bidirectional(t *testing.T) { | |
| 643 | 709 | if !strings.Contains(string(aliceReceived[:n]), "hello from bob") { | |
| 644 | 710 | t.Errorf("alice did not receive bob's message, got: %q", string(aliceReceived[:n])) | |
| 645 | 711 | } | |
| 712 | + | ||
| 713 | + | // When alice disconnects, bob's session should terminate cleanly without hanging | |
| 714 | + | _ = aliceStdin.Close() | |
| 715 | + | _ = aliceSession.Close() | |
| 716 | + | _ = bobStdin.Close() | |
| 717 | + | ||
| 718 | + | bobDone := make(chan error, 1) | |
| 719 | + | go func() { | |
| 720 | + | bobDone <- bobSession.Wait() | |
| 721 | + | }() | |
| 722 | + | ||
| 723 | + | select { | |
| 724 | + | case <-bobDone: | |
| 725 | + | // Bob's session terminated cleanly | |
| 726 | + | case <-time.After(3 * time.Second): | |
| 727 | + | t.Fatal("bob's pipe session hung after alice disconnected") | |
| 728 | + | } | |
| 646 | 729 | } | |
| 647 | 730 | ||
| 648 | 731 | func TestPipe_AutoGeneratedTopic(t *testing.T) { |
+20
-5
pkg/pubsub/broker.go
#
| ... | ... | @@ -108,14 +108,18 @@ func (b *BaseBroker) Connect(client *Client, channels []*Channel) (error, error) | |
| 108 | 108 | ||
| 109 | 109 | client.Cleanup() | |
| 110 | 110 | ||
| 111 | - | count := 0 | |
| 111 | + | inputCount := 0 | |
| 112 | + | pipeCount := 0 | |
| 112 | 113 | for _, cl := range dataChannel.GetClients() { | |
| 113 | - | if cl.Direction == ChannelDirectionInput || cl.Direction == ChannelDirectionInputOutput { | |
| 114 | - | count++ | |
| 114 | + | switch cl.Direction { | |
| 115 | + | case ChannelDirectionInput: | |
| 116 | + | inputCount++ | |
| 117 | + | case ChannelDirectionInputOutput: | |
| 118 | + | pipeCount++ | |
| 115 | 119 | } | |
| 116 | 120 | } | |
| 117 | 121 | ||
| 118 | - | if count == 0 { | |
| 122 | + | if inputCount == 0 && pipeCount <= 1 { | |
| 119 | 123 | for _, cl := range dataChannel.GetClients() { | |
| 120 | 124 | if !cl.KeepAlive { | |
| 121 | 125 | otherChannels := 0 |
| ... | ... | @@ -235,7 +241,16 @@ func (b *BaseBroker) Connect(client *Client, channels []*Channel) (error, error) | |
| 235 | 241 | }() | |
| 236 | 242 | } | |
| 237 | 243 | ||
| 238 | - | wg.Wait() | |
| 244 | + | done := make(chan struct{}) | |
| 245 | + | go func() { | |
| 246 | + | wg.Wait() | |
| 247 | + | close(done) | |
| 248 | + | }() | |
| 249 | + | ||
| 250 | + | select { | |
| 251 | + | case <-done: | |
| 252 | + | case <-client.Done: | |
| 253 | + | } | |
| 239 | 254 | ||
| 240 | 255 | return inputErr, outputErr | |
| 241 | 256 | } |