You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Go实现SSH服务调用close()后PTY未关闭问题排查

问题背景

我在实现SSH服务端功能时,收到shell请求会创建一对PTY-TTY pair,核心初始代码如下:

import (
    "github.com/creack/pty"
    ...
)

func attachPty(channel ssh.Channel, shell *exec.Cmd) {
    mypty, err := pty.Start(shell)
    go func() {
        io.Copy(channel, mypty) // (1) ; 替换为直接调用read()系统调用会出现相同问题
    }()
    go func() {
        io.Copy(mypty, channel) // (2) - 通道退出时该操作返回EOF,此时执行mypty关闭
        if err := syscall.Close(int(mypty.Fd())); err != nil {
            fmt.Printf("error closing fd") // 无错误打印,/proc/fd下显示fd已关闭
        }
    }()
}

预期行为:SSH通道关闭时执行PTY关闭操作,向关联的shell进程发送SIGHUP信号。

异常现象

保留代码中(1)处从mypty拷贝数据到channel的逻辑时,会出现两个异常:

  • (1)处的io.Copy调用永不返回,说明对mypty执行的read系统调用始终阻塞,未收到EOF,疑似PTY主设备未被真正关闭
  • 关联的shell进程无法收到SIGHUP信号
    如果注释掉(1)处的拷贝逻辑,功能可正常运行。
已排查线索
  • PTY的read操作会分派到tty层处理
  • SIGHUP触发流程为PTY主设备关闭后,内核调用tty_vhangup向从设备侧前台进程组发送信号
  • 内核pty.c中pty_close实现会调用tty_vhangup(tty->link)触发信号发送
  • Go侧特殊现象:
    • 直接调用syscall.Close关闭fd,是因为常规os.File.Close()执行后,对应fd仍出现在/proc/<pid>/fd目录下,看似未真正关闭
    • 将(1)处io.Copy替换为直接调用read系统调用,会复现完全相同的问题
根因分析
  1. PTY设备触发SIGHUP的核心前提是主设备侧所有打开的文件引用计数全部归零,此时内核才会执行pty_close逻辑,调用tty_vhangup向从设备绑定的shell进程发送SIGHUP,同时唤醒所有阻塞在主设备read操作上的上下文返回EOF。
  2. 阻塞在read系统调用上的goroutine会持有PTY主设备对应内核file结构体的额外引用:你调用syscall.Close(int(mypty.Fd()))仅关闭了当前获取到的fd编号,但阻塞的read上下文持有的引用未被释放,内核判定主设备仍处于打开状态,不会触发hangup流程,因此read永久阻塞、shell收不到SIGHUP。
  3. 注释掉(1)处读逻辑后,主设备侧无阻塞read持有额外引用,调用close后引用计数直接归零,可正常触发hangup流程,因此功能恢复正常。
  4. 此前os.File.Close()无法关闭fd的问题,根源是调用mypty.Fd()方法时,Go runtime会将该文件从非阻塞IO池移除、切换为阻塞模式,同时runtime内部会持有fd的额外引用,导致仅调用Close无法完全释放所有引用,fd残留在/proc目录下。
修复方案
  • 禁止随意调用mypty.Fd()方法,避免破坏Go runtime对文件描述符的引用管理逻辑,不要直接使用syscall.Close关闭PTY设备,统一使用mypty.Close()方法完成关闭。
  • 当SSH通道断开、(2)处io.Copy返回EOF时,直接调用mypty.Close(),此时Go runtime会释放所有持有的PTY主设备引用,引用计数归零后内核自动触发hangup流程,阻塞在(1)处read的io.Copy会立刻返回,shell进程也会收到预期的SIGHUP信号。
  • 修正后的核心代码如下:
func attachPty(channel ssh.Channel, shell *exec.Cmd) {
    mypty, err := pty.Start(shell)
    if err != nil {
        // 自行处理启动错误
        return
    }
    // 函数退出时兜底关闭PTY
    defer mypty.Close()

    go func() {
        _, _ = io.Copy(channel, mypty)
        // PTY侧读结束后关闭SSH通道写端,通知对端连接已断开
        _ = channel.CloseWrite()
    }()

    go func() {
        _, _ = io.Copy(mypty, channel)
        // SSH通道断开时主动关闭PTY主设备,触发SIGHUP发送
        _ = mypty.Close()
    }()

    // 等待shell进程退出,回收僵尸进程
    _ = shell.Wait()
}

内容的提问来源于stack exchange,提问作者Noam Solovechick

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 22:01:09