From 069b93d5ae9dab8867c9282677cc31dee0e67c54 Mon Sep 17 00:00:00 2001 From: nix-tkobayashi Date: Mon, 29 Jun 2026 18:42:03 +0900 Subject: [PATCH] Disable DSUSP (Ctrl+Y) on macOS/BSD to prevent session termination On BSD-derived systems, including macOS, the terminal driver treats Ctrl+Y as the DSUSP (delayed suspend) special character even in cbreak mode. While a shell/SSM session is connected, pressing Ctrl+Y triggers a delayed suspend that makes the Stdin read fail with "read /dev/stdin: resource temporarily unavailable", terminating the session. Linux does not implement DSUSP, so it is unaffected. Disable the DSUSP control character (equivalent to the documented `stty dsusp undef` workaround) on darwin/freebsd/netbsd/openbsd via a new per-OS disableDelayedSuspend(), called from disableEchoAndInputBuffering. It is a no-op on linux, where stty rejects the `dsusp` operand. setState now splits its buffer with strings.Fields so multi-token operands like "dsusp undef" are passed to stty as separate arguments; existing callers pass single tokens and are unaffected. Fixes #29 Co-Authored-By: Claude Opus 4.8 (1M context) --- RELEASENOTES.md | 1 + .../session/shellsession/shellsession_bsd.go | 32 +++++++++++++++++++ .../shellsession/shellsession_linux.go | 24 ++++++++++++++ .../session/shellsession/shellsession_unix.go | 9 ++++-- 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/sessionmanagerplugin/session/shellsession/shellsession_bsd.go create mode 100644 src/sessionmanagerplugin/session/shellsession/shellsession_linux.go diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b304922ae..39ed5d875 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,7 @@ Latest ================ - Add SSH-style escape sequences to shell sessions (~. to terminate, ~? for help) - Fix port forwarding sessions silently dying after WebSocket reconnection +- Fix shell sessions terminating on macOS/BSD when Ctrl+Y is pressed by disabling the DSUSP terminal control character 1.2.814.0 ================ diff --git a/src/sessionmanagerplugin/session/shellsession/shellsession_bsd.go b/src/sessionmanagerplugin/session/shellsession/shellsession_bsd.go new file mode 100644 index 000000000..668da917a --- /dev/null +++ b/src/sessionmanagerplugin/session/shellsession/shellsession_bsd.go @@ -0,0 +1,32 @@ +// Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may not +// use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +// either express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +//go:build darwin || freebsd || netbsd || openbsd +// +build darwin freebsd netbsd openbsd + +// Package shellsession starts shell session. +package shellsession + +import "bytes" + +// disableDelayedSuspend disables the DSUSP terminal special character (Ctrl+Y by +// default) on BSD-derived systems, including macOS. +// +// In cbreak mode the terminal driver still acts on DSUSP, so pressing Ctrl+Y +// triggers a delayed suspend that causes the Stdin read to fail with +// "read /dev/stdin: resource temporarily unavailable" and the session to +// terminate. Linux does not implement DSUSP, so this is a no-op there. +// See https://github.com/aws/session-manager-plugin/issues/29. +func (s *ShellSession) disableDelayedSuspend() { + setState(bytes.NewBufferString("dsusp undef")) +} diff --git a/src/sessionmanagerplugin/session/shellsession/shellsession_linux.go b/src/sessionmanagerplugin/session/shellsession/shellsession_linux.go new file mode 100644 index 000000000..dd1bf6a8d --- /dev/null +++ b/src/sessionmanagerplugin/session/shellsession/shellsession_linux.go @@ -0,0 +1,24 @@ +// Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may not +// use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +// either express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +//go:build linux +// +build linux + +// Package shellsession starts shell session. +package shellsession + +// disableDelayedSuspend is a no-op on Linux, which does not implement the DSUSP +// (delayed suspend) terminal special character. The "dsusp" setting is rejected +// by stty on Linux, so it must only be applied on BSD-derived systems. See the +// BSD implementation and https://github.com/aws/session-manager-plugin/issues/29. +func (s *ShellSession) disableDelayedSuspend() {} diff --git a/src/sessionmanagerplugin/session/shellsession/shellsession_unix.go b/src/sessionmanagerplugin/session/shellsession/shellsession_unix.go index b050d8c7d..db3482d2a 100644 --- a/src/sessionmanagerplugin/session/shellsession/shellsession_unix.go +++ b/src/sessionmanagerplugin/session/shellsession/shellsession_unix.go @@ -23,6 +23,7 @@ import ( "errors" "os" "os/exec" + "strings" "time" "github.com/aws/session-manager-plugin/src/log" @@ -34,6 +35,7 @@ func (s *ShellSession) disableEchoAndInputBuffering() { getState(&s.originalSttyState) setState(bytes.NewBufferString("cbreak")) setState(bytes.NewBufferString("-echo")) + s.disableDelayedSuspend() } // getState gets current state of terminal @@ -44,9 +46,12 @@ func getState(state *bytes.Buffer) error { return cmd.Run() } -// setState sets the new settings to terminal +// setState sets the new settings to terminal. The buffer may contain several +// whitespace-separated stty operands (for example "dsusp undef"), which must be +// passed to stty as individual arguments rather than a single operand. func setState(state *bytes.Buffer) error { - cmd := exec.Command("stty", state.String()) + args := strings.Fields(state.String()) + cmd := exec.Command("stty", args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout return cmd.Run()