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 statusMutex(SshSession.h:167) std::atomic<bool>用在别处:statusChange、stopThread、SshPortForward的shouldStop/running/m_pfConnected、napiInterface的isCallbackValid/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)用了谓词版本,正确处理虚假唤醒- 析构:置
stop→notify_all()→ 逐个join(),优雅退出 enqueue用shared_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:1461 和 1493,两个 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 libssh2Mutex(SshSession.h:130)保护所有 libssh2 调用。用递归锁通常说明有嵌套加锁路径,面试可能被追问”为什么需要 recursive、能不能改成普通 mutex”- 一共 5 把锁:
libssh2Mutex(递归)、bufferMutex、callBackDataMutex、statusMutex、pendingMessagesMutex。锁顺序是个必然的追问点 ShellConfigGuard(SshSession.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.cpp 里 status 的写入,772、834、1236 行都持有 statusMutex,但 639 行 status = SessionStatus::CONNECTED; 没有加锁。
同时 SshSession.h:202 的 isConnected() 是 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) |
六、待办
- 改网站
website/src/content/projects/crossshellnext.mdx:atomic<SessionStatus>的说法要改(见 2.1),另有 PuTTY「无法移植」的表述也站不住 - 简历
resume_cpp_v4.md项目一「并发控制」条:目前写「使用 atomic 管理会话状态」,与代码不符,需改成 mutex - 面试手册第 12、15、17、19 题可以用本文的实际代码替换掉推测性描述