Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 66 additions & 5 deletions cmd/workspace/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ import (
)

const (
DisableSSHKeepAlive time.Duration = 0 * time.Second
DisableSSHKeepAlive time.Duration = 0 * time.Second
sshKeepAliveProbeTimeout = 10 * time.Second
sshKeepAliveMaxFailures = 3
)

// SSHCmd holds the ssh cmd flags.
Expand Down Expand Up @@ -685,27 +687,86 @@ func (cmd *SSHCmd) startServices(
}
}

type sshKeepAliveClient interface {
SendRequest(string, bool, []byte) (bool, []byte, error)
Close() error
}

type sshKeepAliveOptions struct {
interval time.Duration
probeTimeout time.Duration
maxFailures int
}

func startSSHKeepAlive(
ctx context.Context,
client *ssh.Client,
client sshKeepAliveClient,
interval time.Duration,
) {
ticker := time.NewTicker(interval)
startSSHKeepAliveWithOptions(ctx, client, sshKeepAliveOptions{
interval: interval,
probeTimeout: sshKeepAliveProbeTimeout,
maxFailures: sshKeepAliveMaxFailures,
})
}

func startSSHKeepAliveWithOptions(
ctx context.Context,
client sshKeepAliveClient,
opts sshKeepAliveOptions,
) {
ticker := time.NewTicker(opts.interval)
defer ticker.Stop()
failures := 0

for {
select {
case <-ctx.Done():
return
case <-ticker.C:
ok, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
if err := checkKeepAliveResponse(ok, err); err != nil {
if err := sendBoundedKeepAlive(ctx, client, opts.probeTimeout); err != nil {
if ctx.Err() != nil {
return
}
log.Errorf("failed to send keepalive: %v", err)
failures++
if failures >= opts.maxFailures {
log.Errorf(
"SSH keepalive failed %d consecutive times; closing client",
failures,
)
_ = client.Close()
return
}
continue
}
failures = 0
}
}
}

func sendBoundedKeepAlive(
ctx context.Context,
client sshKeepAliveClient,
timeout time.Duration,
) error {
result := make(chan error, 1)
go func() {
ok, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
result <- checkKeepAliveResponse(ok, err)
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-result:
return err
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return fmt.Errorf("keepalive probe timed out after %s", timeout)
}
}

// A positive or negative SSH reply proves that the peer is alive. Only a
// transport error means that the keepalive request failed.
func checkKeepAliveResponse(_ bool, err error) error {
Expand Down
20 changes: 10 additions & 10 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ require (
github.com/devsy-org/agentapi v1.0.1
github.com/devsy-org/api v1.1.0
github.com/devsy-org/apiserver v1.5.3
github.com/devsy-org/ssh v1.2.5
github.com/devsy-org/ssh v1.2.9
github.com/distribution/reference v0.6.0
github.com/docker/cli v29.7.2+incompatible
github.com/docker/docker v28.5.2+incompatible
Expand Down Expand Up @@ -64,11 +64,11 @@ require (
go.uber.org/atomic v1.11.0
go.uber.org/goleak v1.3.0
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.55.0
golang.org/x/mod v0.38.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/crypto v0.57.0
golang.org/x/mod v0.41.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
google.golang.org/grpc v1.83.0
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
gopkg.in/yaml.v3 v3.0.1
Expand Down Expand Up @@ -506,12 +506,12 @@ require (
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
gocloud.dev v0.46.0 // indirect
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/net v0.57.1-0.20260729233039-99c3b0a8f463 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.48.0 // indirect
golang.org/x/tools v0.49.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
Expand Down
40 changes: 20 additions & 20 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,8 @@ github.com/devsy-org/api v1.1.0 h1:l7T9k7RVwatwN4lxeDTF3iN6EYmfGZgR3ZTJMDGha1M=
github.com/devsy-org/api v1.1.0/go.mod h1:mAZklKdnywJYiXDReBLte/H+3m69z6G7RHB3n1lI53Q=
github.com/devsy-org/apiserver v1.5.3 h1:tFKMgPxxfvojJ+C+wo0oqSmbE9PTJ2ur1E2yFj0eISI=
github.com/devsy-org/apiserver v1.5.3/go.mod h1:m7gpbrh++Hp8iEM5jaP7vjjODPbs9Bmh3l4+iPFg1jE=
github.com/devsy-org/ssh v1.2.5 h1:Z7gTanYs2ZslT1swTw4leoVVuDEmuNNhQi48W2kNqMU=
github.com/devsy-org/ssh v1.2.5/go.mod h1:6r5tZ+H9JFoMl6NrxXRgltg9HhEryTd69avY831Lio8=
github.com/devsy-org/ssh v1.2.9 h1:KHqX1xAplGFanm0FMSAojtiC9nV/UFxUeP/5jU5quak=
github.com/devsy-org/ssh v1.2.9/go.mod h1:Uff10+cSSDZk3bG07u5D9+eQ8GMGqsgE70WCUJWcHv4=
github.com/devsy-org/tailscale v1.102.2 h1:9SB6htvO+HmG8alal8WGCshHapfHR7dUFRSMYiFIzIM=
github.com/devsy-org/tailscale v1.102.2/go.mod h1:kQUA0lYb/bqCJJZzShx+gqLSxggEFgXB4VDVZDzxWc8=
github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb h1:7ENzkH+O3juL+yj2undESLTaAeRllHwCs/b8z6aWSfc=
Expand Down Expand Up @@ -1459,8 +1459,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
Expand All @@ -1478,8 +1478,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
Expand All @@ -1495,8 +1495,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.57.1-0.20260729233039-99c3b0a8f463 h1:56+vNxUDjzdwAJjglL/DMnp31nd8UeytlPRLbZfz55Q=
golang.org/x/net v0.57.1-0.20260729233039-99c3b0a8f463/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
Expand All @@ -1508,8 +1508,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
Expand Down Expand Up @@ -1537,17 +1537,17 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A=
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q=
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
Expand All @@ -1558,8 +1558,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
Expand All @@ -1577,8 +1577,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
Expand Down
17 changes: 10 additions & 7 deletions pkg/devcontainer/sshtunnel/sshtunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ func runSSHTunnel(ctx context.Context, p sshTunnelParams) (*config2.Result, erro
err = p.grpcBridge.RunPair(ctx,
func(ctx context.Context, stdin, stdout *os.File) error {
return runCommandInSSHTunnel(ctx, sshCommandParams{
sshClient: sshClient,
command: p.opts.Command,
session: sess,
command: p.opts.Command,
}, stdin, stdout)
},
func(ctx context.Context, stdout, stdin *os.File) error {
Expand Down Expand Up @@ -231,17 +231,16 @@ func setupSSHAgentForwarding(sshClient *ssh.Client, sess *ssh.Session) {
}

type sshCommandParams struct {
sshClient *ssh.Client
command string
session *ssh.Session
command string
}

func runCommandInSSHTunnel(ctx context.Context, p sshCommandParams, stdin, stdout *os.File) error {
streamer := newSSHTunnelJSONLogStreamer()
defer func() { _ = streamer.Close() }()

log.Debugf("running agent command in SSH tunnel: %q", p.command)
err := devssh.Run(ctx, devssh.RunOptions{
Client: p.sshClient,
err := devssh.RunSession(ctx, p.session, devssh.RunSessionOptions{
Command: p.command,
Stdin: stdin,
Stdout: stdout,
Expand All @@ -257,12 +256,16 @@ func runCommandInSSHTunnel(ctx context.Context, p sshCommandParams, stdin, stdou
return nil
}

const maxLogLines = 1
const (
maxLogLines = 25
maxLogBytes = 256 * 1024
)

func newSSHTunnelJSONLogStreamer() *log.JSONLogStreamer {
return log.NewJSONLogStreamer(log.StreamerOptions{
FallbackLevel: log.LevelDebug,
CaptureLines: maxLogLines,
CaptureBytes: maxLogBytes,
DetectLevelPrefixes: true,
TreatUnknownJSONAsDebug: true,
})
Expand Down
44 changes: 37 additions & 7 deletions pkg/log/streamer.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ type StreamerOptions struct {
FallbackLevel int
// CaptureLines retains this many raw lines for ErrorOutput.
CaptureLines int
// CaptureBytes bounds the total raw bytes retained for ErrorOutput,
// including newline separators. A zero value means no byte limit.
CaptureBytes int
// DetectLevelPrefixes preserves a level from timestamp-prefixed plain text.
DetectLevelPrefixes bool
// TreatUnknownJSONAsDebug preserves the historical tunnel behavior for
Expand All @@ -34,11 +37,13 @@ type JSONLogStreamer struct {
detectLevelPrefixes bool
treatUnknownJSONAsDebug bool
captureLines int
captureBytes int

mu sync.Mutex
lastLines []string
closeOnce sync.Once
closeErr error
mu sync.Mutex
lastLines []string
capturedBytes int
closeOnce sync.Once
closeErr error
}

// NewJSONLogStreamer returns a writer that decodes Devsy JSON log lines while
Expand All @@ -52,6 +57,7 @@ func NewJSONLogStreamer(options StreamerOptions) *JSONLogStreamer {
detectLevelPrefixes: options.DetectLevelPrefixes,
treatUnknownJSONAsDebug: options.TreatUnknownJSONAsDebug,
captureLines: options.CaptureLines,
captureBytes: options.CaptureBytes,
}
if options.CaptureLines > 0 {
streamer.lastLines = make([]string, 0, options.CaptureLines)
Expand Down Expand Up @@ -126,15 +132,39 @@ func (s *JSONLogStreamer) process(reader io.Reader) {
}

func (s *JSONLogStreamer) capture(line string) {
if s.captureLines <= 0 {
if s.captureLines <= 0 && s.captureBytes <= 0 {
return
}
if s.captureBytes > 0 && len(line) > s.captureBytes {
line = line[len(line)-s.captureBytes:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 UTF-8 diagnostics can corrupt

When retained remote output exceeds the byte limit, this raw byte slice can split a multi-byte UTF-8 character. ErrorOutput then carries invalid UTF-8 into the user-facing tunnel error, corrupting non-ASCII diagnostics precisely when truncation is needed. Truncation should preserve valid UTF-8 boundaries.

}
s.mu.Lock()
defer s.mu.Unlock()
if len(s.lastLines) >= s.captureLines {
s.lastLines = s.lastLines[1:]
for (s.captureLines > 0 && len(s.lastLines) >= s.captureLines) ||
(s.captureBytes > 0 && len(s.lastLines) > 0 &&
s.capturedBytes+1+len(line) > s.captureBytes) {
s.dropOldestLine()
}
if s.captureBytes > 0 && len(s.lastLines) > 0 &&
s.capturedBytes+1+len(line) > s.captureBytes {
line = line[len(line)-(s.captureBytes-s.capturedBytes-1):]
}
if len(s.lastLines) > 0 {
s.capturedBytes++
}
s.lastLines = append(s.lastLines, line)
s.capturedBytes += len(line)
}

func (s *JSONLogStreamer) dropOldestLine() {
if len(s.lastLines) == 0 {
return
}
s.capturedBytes -= len(s.lastLines[0])
if len(s.lastLines) > 1 {
s.capturedBytes--
}
s.lastLines = s.lastLines[1:]
}

type jsonLine struct {
Expand Down
Loading
Loading