PERSONAL LAB / interview/项目分析
Interview 项目分析

CrossShellNext 源码分析

分析日期:2026-09-06。源码来自 gitcode.com/OpenHarmonyPCDeveloper/CrossShellNext(浅克隆 depth=50)。 本文只记录代码里实际存在的内容,行号对应克隆时的版本。

一、代码规模与结构

自写 C++ 约 1.4 万行(不含 entry/libs/include 下的 openssl / libssh2 头文件)。

entry/src/main/cpp/
├── protocol/
│   ├── Session.h                     抽象基类 + 待投递消息缓存
│   ├── SessionFactory/               按协议类型创建
│   ├── SessionManager/               单例,map<int, shared_ptr<Session>>
│   ├── ssh/
│   │   ├── SshSession.cpp    2170    含内联 ThreadPool 实现
│   │   ├── SftpSession.cpp   2048    含 keepAliveThread
│   │   ├── SshPortForward.cpp 1827   本地/远程/动态三种转发
│   │   └── LogManager.cpp     688
│   ├── telnetSession.cpp     1151    自建 socket + 非阻塞 connect
│   └── serialSession.cpp      634
├── napiSftp.cpp              2349
├── napiInterface.cpp         2007
├── napiPortForward.cpp        555
├── napiWrapper.hpp / .cpp
├── key_manager.cpp                   OpenSSL 密钥生成,RAII 包装
└── command/ jsonAnalyse/

二、简历声明核查

简历写的核查结果
unique_ptr + 自定义 deleter 封装 session / channel / knownhosts✅ 属实,SshSession.h:123-125
回调「先缓存后投递」✅ 属实,Session.h setDataCallbacks()
线程池执行阻塞 I/O✅ 属实,自研 ThreadPool,8 线程
心跳保活✅ 属实,libssh2_keepalive_config/_send + 专用线程
socket、TCP/IP✅ 属实,且简历写保守了,见下
端口转发(本地/远程/动态)✅ 属实,SOCKS5 握手是自己解析的
SFTP 浏览与传输使用独立会话✅ 属实,是两条独立 SSH 连接
atomic 管理会话状态不属实,需改,见 2.1

2.1 必须修正:atomic<SessionStatus> 不存在

网站 crossshellnext.mdx 写「用 atomic<SessionStatus> + mutex 实现连接/断开互斥」。实际代码:

  • Session.h 里是裸成员 SessionStatus status;
  • 保护它的是 std::mutex statusMutexSshSession.h:167
  • std::atomic<bool> 用在别处:statusChangestopThreadSshPortForwardshouldStop/running/m_pfConnectednapiInterfaceisCallbackValid/tsfnCreated/g_directoryCallbackRegistered

正确说法:会话状态用 mutex 保护,atomic<bool> 用于线程停止标志和回调有效性标志。

面试若被问「你怎么用 atomic 管状态的」,按实际情况答。说错了对方一看源码就穿帮。

三、逐模块细节

3.1 三类 libssh2 句柄:函数指针式 deleter

// SshSession.h:123-125
std::unique_ptr<LIBSSH2_SESSION,    decltype(&libssh2_session_free)>   session;
std::unique_ptr<LIBSSH2_CHANNEL,    decltype(&libssh2_channel_free)>   channelShell;
std::unique_ptr<LIBSSH2_KNOWNHOSTS, decltype(&libssh2_knownhost_free)> knownHosts;

// SshSession.cpp:16-18 构造函数初始化列表
: session(nullptr, &libssh2_session_free),
  channelShell(nullptr, &libssh2_channel_free),
  knownHosts(nullptr, &libssh2_knownhost_free),

用的是函数指针当 deleter。代价:每个 unique_ptr 是 16 字节(8 资源指针 + 8 函数指针),且必须在构造时显式传入 deleter——上面三行初始化列表就是这个代价。

对比 key_manager.cpp:19-24,那里用的是无状态仿函数

struct BIOFree      { void operator()(BIO* bio)        { if (bio) BIO_free_all(bio); } };
struct RSAFree      { void operator()(RSA* rsa)        { if (rsa) RSA_free(rsa); } };
struct EVPKeyFree   { void operator()(EVP_PKEY* pkey)  { if (pkey) EVP_PKEY_free(pkey); } };
struct BIGNUMFree   { void operator()(BIGNUM* bn)      { if (bn) BN_free(bn); } };
struct EVPKeyCtxFree{ void operator()(EVP_PKEY_CTX* c) { if (c) EVP_PKEY_CTX_free(c); } };

using BIOPtr = std::unique_ptr<BIO, BIOFree>;   // sizeof == 8,且可默认构造

同一个项目里两种写法都有,这是很好的面试素材:能讲清 sizeof 差异、能否默认构造、类型名可读性三个取舍点,比背概念强。

释放顺序:声明顺序 session → channelShell → knownHosts,成员按声明逆序析构,所以实际是 knownHosts → channelShell → session。channel 先于 session 释放,是对的。但这依赖成员声明顺序,谁调整了就悄悄引入 use-after-free,编译器不报错。

3.2 「先缓存后投递」的真实实现

// Session.h
std::vector<std::string> pendingMessages;
std::mutex pendingMessagesMutex;

void setDataCallbacks(std::function<void(int, std::string, bool)> dataCb) {
    std::lock_guard<std::mutex> lock(pendingMessagesMutex);
    if (dataCb == nullptr) return;
    dataCallback = dataCb;
    for (const auto& msg : pendingMessages) {
        dataCallback(sessionId, msg, false);   // 补投
    }
    pendingMessages.clear();
}

回答追问用得上的事实:

  • 粒度:按会话,缓存是 Session 的成员
  • 上限没有pendingMessages 是无界 vector。被问到就如实说没设上限
  • 顺序:补投和 dataCallback 赋值在同一把锁内完成,所以不会出现”补投还没完就开始直投”
  • 释放:缓存是 Session 成员,会话析构时自动释放

简历没写的第二层机制SessionManager 里还有反方向的缓存——

std::vector<std::function<void(int, const std::string&, bool)>> pendingDataCallbacks;
std::vector<std::function<void(int, const std::string&)>> pendingDirChangeCallbacks;
void applyPendingCallbacks(std::shared_ptr<Session> session);
void applyDataCallbackToExistingSessions(...);

会话侧缓存的是数据(数据早于回调到达);管理器侧缓存的是回调(回调早于会话创建)。两个方向都覆盖了,这个设计比简历上那一句完整得多。

3.3 自研 ThreadPool(SshSession.h:58-118

教科书式实现,几个能讲的点:

  • condition.wait(lock, pred) 用了谓词版本,正确处理虚假唤醒
  • 析构:置 stopnotify_all() → 逐个 join(),优雅退出
  • enqueueshared_ptr<packaged_task> 包一层再塞进 std::function。原因:std::function 要求可拷贝,而 packaged_task 只能移动——这是个经典技巧,能讲出来是加分
  • 用了 std::result_of,它在 C++17 弃用、C++20 移除。说明代码基线是 C++17;被问到可以说”现在写会用 std::invoke_result_t
  • 线程数 8,构造时写死(SshSession.cpp:20

3.4 Telnet 的 socket 实现(简历写保守了)

telnetSession.cpp 里是完整的非阻塞 connect + 超时:

264:  connectSocket = socket(ptr->ai_family, ptr->ai_socktype, ptr->ai_protocol);  // 配合 getaddrinfo 遍历
 59:  fcntl(socket, F_GETFL, 0)  →  65: fcntl(..., flags | O_NONBLOCK)              // 设非阻塞
278:  ::connect(connectSocket, ptr->ai_addr, ...)
105:  select(socket + 1, NULL, &writefds, NULL, &tv)                                // 等可写
296:  getsockopt(connectSocket, SOL_SOCKET, SO_ERROR, &error, &len)                 // 判定成败
328:  fcntl(connectSocket, F_SETFL, flags & ~O_NONBLOCK)                            // 连上后改回阻塞

这正是非阻塞 connect 的标准做法:可写不等于成功,必须再读 SO_ERROR。面试手册里那道”超时、断线和自动重连”的题,你代码里已经做对了。

简历只写「socket、TCP/IP」是低估了,实际能讲 getaddrinfo 遍历、O_NONBLOCK 切换、select 超时、SO_ERROR 判定四层。

3.5 端口转发三种模式

487-519:  socket → bind → listen(10) → accept        本地转发的服务端流程
736:      libssh2_channel_forward_accept(...)         远程转发
973+:     SOCKS5 握手自己解析                          动态转发

SOCKS5 部分是自己写的:greeting 校验(VER=0x05、方法数)、回 {0x05, 0x00} 选无认证、再 select 等请求、校验 n >= 10 && request[0] == 0x05

有一条错误日志很能说明问题——

"Client may be using HTTP CONNECT instead of SOCKS5. Please ensure your
 browser/application is configured for SOCKS5 proxy, not HTTP proxy."

这说明你真的调过”浏览器配错代理类型”这个坑。这是个现成的面试故事:现象是握手第一个字节不对,排查发现客户端发的是 HTTP CONNECT,于是在错误路径上加了可诊断的提示。

3.6 SFTP 双通道

SshSession.cpp:14611493,两个 std::thread 并行建立两个独立的 SftpSession,每个自己 connect()

所以隔离级别是两条独立的 SSH 连接,不是同一 session 的两个 channel。面试被问”分的是什么”,答案明确。

线程内的写法值得一提:

auto sftpSession = std::make_unique<SftpSession>(config, true);
if (sftpSession->connect()) {
    tempInteractiveSession = std::move(sftpSession);   // 成功才移交所有权
    interactiveSuccess.store(true);
}
// 失败时 sftpSession 出作用域自动析构

先在局部 make_unique、成功才 move 到外层——失败路径靠 RAII 自动清理,不需要手写 cleanup。三层 catch(具体异常 / std::exception / ...),错误信息用 errorMutex 保护。

3.7 keepalive

SftpSession.cpp:481 libssh2_keepalive_config(session.get(), 1, config.keepAliveInterval),配合专用的 keepAliveThread()

  • 循环里先检查 sessionValid,无效就 break 退出
  • 发送前持 sessionMutex,再检查 session 非空
  • libssh2_keepalive_send 返回 LIBSSH2_ERROR_EAGAIN 时视为正常(发送缓冲区满),稍后再试
  • 析构里 stopThread.store(true) 停线程

这就是”心跳线程与关闭如何不打架”的实际答案:检查和发送在同一段锁内,且退出标志是 atomic。

3.8 其他值得注意的

  • std::recursive_mutex libssh2MutexSshSession.h:130)保护所有 libssh2 调用。用递归锁通常说明有嵌套加锁路径,面试可能被追问”为什么需要 recursive、能不能改成普通 mutex”
  • 一共 5 把锁:libssh2Mutex(递归)、bufferMutexcallBackDataMutexstatusMutexpendingMessagesMutex锁顺序是个必然的追问点
  • ShellConfigGuardSshSession.cpp:26-45):一个小巧的 RAII flag guard,构造置位、析构复位,拷贝和移动全 = delete

四、代码里的问题(建议面试主动讲)

主动指出自己代码的问题,比被问出来强得多。这两个都是真的:

4.1 runAsyncInThreadPool 每次多起一个线程

// SshSession.cpp:48-52
void SshSession::runAsyncInThreadPool(std::function<void()> task) {
    std::thread([future = threadPool->enqueue(std::move(task))]() mutable {
        future.get();
    }).detach();
}

任务确实进了线程池,但又额外起了一个 detached 线程只为了 future.get()。这抵消了线程池”复用线程”的意义——每次调用净多一个线程;而且 detach() 之后无法 join,析构时序不可控。

讲法:「当时是为了拿到任务里的异常,实现上退化成每次多起一个线程。如果重写,要么让任务自己捕获异常并回调,要么把 future 收集起来统一等待,不该在这里 detach。」

4.2 status 有一处无锁写

SshSession.cppstatus 的写入,772、834、1236 行都持有 statusMutex,但 639 行 status = SessionStatus::CONNECTED; 没有加锁

同时 SshSession.h:202isConnected()return status == SessionStatus::CONNECTED;,也是无锁读。

一处无锁写 + 无锁读 = data race。讲法:「状态大部分路径走 statusMutex,但连接成功那一处漏了锁,读取接口也没加。要么统一加锁,要么把 status 本身改成 atomic——后者更适合这种单一枚举值。」

顺带:如果改成 std::atomic<SessionStatus>,简历上那句话反而就成立了。但现在还没改,所以现在不能那么写。

五、可以直接用的面试故事

场景素材
讲一个真实的排障SOCKS5 握手失败,实为客户端配成 HTTP CONNECT(3.5)
讲 RAII 的实际收益两种 deleter 写法的取舍(3.1)+ SFTP 失败路径自动清理(3.6)
讲并发设计五把锁的职责划分、keepalive 线程与关闭的协调(3.7)
讲自己代码的不足runAsyncInThreadPool 的线程浪费(4.1)、status 漏锁(4.2)
讲底层网络非阻塞 connect 四步:O_NONBLOCK → connect → select 可写 → SO_ERROR(3.4)

六、待办

  1. 改网站 website/src/content/projects/crossshellnext.mdxatomic<SessionStatus> 的说法要改(见 2.1),另有 PuTTY「无法移植」的表述也站不住
  2. 简历 resume_cpp_v4.md 项目一「并发控制」条:目前写「使用 atomic 管理会话状态」,与代码不符,需改成 mutex
  3. 面试手册第 12、15、17、19 题可以用本文的实际代码替换掉推测性描述
⎇ main interview/项目分析 17 节 253 notes UTF-8