@cc-remote/iroh 1.0.0-rc.4 → 1.0.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ # iroh-js: 下游 "exit 144 / SIGTERM at teardown" 排查结论
2
+
3
+ ## 修正(2026-06-28,下游 macOS 复验后)
4
+
5
+ 下游用 `async_hooks` 在全量/子集 vitest 上复验,结论需要分两层,**本文档下半部分
6
+ 原先把二者合并、过度归因了**,在此更正:
7
+
8
+ - **绑定层确有的问题(本文档主体,结论仍成立)**:fire-and-forget / 永不 resolve 的
9
+ napi `async` 调用(已确认的实例:`endpoint.online()`)会留下一个 pending 的
10
+ `napi_resolve_deferred`,ref 住 libuv loop;它对 `_getActiveHandles()` 不可见、
11
+ 且 **`close()` 取消不掉**。这是值得在 iroh-ffi 侧改进的真实缺陷。
12
+ - **但它不必然是某个下游测试套件 exit-144 的主因**:下游的 `noiroh` 子集(完全不加载
13
+ iroh、transport 被 mock、不调用任何 `online()`)**同样 144**,且该子集 worker 里
14
+ `napi_resolve_deferred = 0`。顶住 loop 的是**测试侧普通 Node 句柄泄漏**:大量未清
15
+ `Timeout`、某 worker 高达 **954 个 `FILEHANDLE`**、`DNSCHANNEL`、子进程的
16
+ `PROCESSWRAP/PIPEWRAP`、未关的 `TCPWRAP`。
17
+ - **正确结论**:该套件的 144 是"**多源句柄泄漏 → worker 退不掉 → tinypool 收尾
18
+ SIGTERM**"的聚合问题。`online()` 至多是 iroh/relay 那几个 worker 的额外一份泄漏。
19
+ 彻底修需要**测试侧清理**(afterEach 关 server/子进程/fd、unref 或清定时器)**+**
20
+ iroh-ffi 侧让 `online()` 不悬挂,两边都做;只改 `online()` 不解决。
21
+
22
+ 下面原始分析对"napi 悬挂 async 如何隐形顶住 loop"的机制描述与复现仍然有效,只是它的
23
+ **适用范围**应理解为"绑定层的一类隐患",而非"任意下游 144 的唯一根因"。
24
+
25
+ ### 已在本仓库修复的两个绑定层缺陷
26
+
27
+ 1. **`watch_*` abort(SIGABRT/134)**:同步 `#[napi]` 方法里用 `tokio::spawn`
28
+ 导致 "no reactor running" → abort。改用 `napi::bindgen_prelude::spawn`(直接
29
+ `Runtime::spawn` 到 napi 全局 runtime)。见 `src/watch.rs`。
30
+ 2. **`online()` 悬挂不受 `close()` 取消**:`endpoint.online()` 现在 race
31
+ `endpoint.closed()`(`EndpointClosed::run_until`),所以 `close()` 能让一个
32
+ pending 的 `online()` 立即返回、释放 loop。见 `src/endpoint.rs`。
33
+ 复现矩阵里 `ok-online-close` 现已 exit 0(改前为 HANG)。
34
+
35
+ 仍需注意:**fire-and-forget 且既不 await 又不 close 的 `online()`**(矩阵里
36
+ `hang-online`)依然会顶住进程 —— 没有任何信号可供取消,这本质上是调用方的
37
+ 责任;但现在只要在 teardown `close()` 即可干净释放。`acceptNext()` 同理,
38
+ close() 一直就能取消它。
39
+
40
+ ---
41
+
42
+ ## TL;DR(绑定层隐患的机制)
43
+
44
+ 下游进程在「测试跑完、准备退出」阶段退不掉,被外层(tinypool / vitest / CI 的
45
+ 超时)`SIGTERM` 强杀 → `128 + 15 = 144`。
46
+
47
+ 根因**不是**原生线程或 libuv 句柄顶住 event loop,而是:
48
+
49
+ > **某个 napi `async` 方法的 Promise 被「发起但从不 await / 永不 resolve」,
50
+ > 留下一个 pending 的 `napi_resolve_deferred` 异步资源,它 ref 住了 libuv
51
+ > event loop,使 Node 永远无法自行 drain 退出。**
52
+
53
+ 这条 ref 有两个特性,正好解释了之前排查的所有困惑:
54
+
55
+ 1. **对 `process._getActiveHandles()` / `_getActiveRequests()` 完全不可见**
56
+ (二者都返回 `[]`)——所以之前 dump 句柄查不到任何东西。
57
+ 2. 只有**真正加载了原生模块、且留了一个这样的悬挂调用的 worker** 才会触发。
58
+ 纯 JS 的未 resolve Promise **不会**顶住 libuv;必须是 napi async 调用。
59
+ 这解释了「必须有 iroh worker」以及「哪些测试文件落到同一个 worker」带来的
60
+ 规模/组合敏感性(不是真的和『32 个』这个数字有关,而是和『某个会留悬挂
61
+ async 调用的测试是否被调度进一个 iroh worker』有关)。
62
+
63
+ 它**可以**通过 `async_hooks` 看到 —— 表现为一个一直存活的
64
+ `napi_resolve_deferred` 资源。这就是在真实测试套件里定位元凶的方法。
65
+
66
+ ## 复现(在 Linux 上即可复现,与平台无关)
67
+
68
+ ```
69
+ node test/exit-hang-repro.mjs # 跑完整 matrix
70
+ node test/exit-hang-repro.mjs diagnose # 用 async_hooks 显示那条隐形 ref
71
+ ```
72
+
73
+ matrix 实测结果:
74
+
75
+ | 场景 | 操作 | 结果 |
76
+ |---|---|---|
77
+ | `clean-bind-close` | bind + close | ✅ exit 0 |
78
+ | `clean-bind-noclose` | bind,不 close | ✅ exit 0 |
79
+ | `hang-online` | `ep.online()` 不 await | ❌ HANG → SIGTERM |
80
+ | `hang-online-close` | `ep.online()` 后 `close()` | ❌ **仍 HANG**(close 取消不了 online) |
81
+ | `hang-accept` | `ep.acceptNext()` 不 await | ❌ HANG → SIGTERM |
82
+ | `ok-accept-close` | `ep.acceptNext()` 后 close | ✅ exit 0(close 让 acceptNext resolve) |
83
+
84
+ `diagnose` 输出:
85
+ ```
86
+ still-alive async resources pinning the loop: {"PROMISE":2,"napi_resolve_deferred":1,"Timeout":1}
87
+ ```
88
+
89
+ ## 关键细节
90
+
91
+ - **`endpoint.online()` 最危险**:它等待 home relay,且**不被 `close()` 取消**。
92
+ 在 relay-only / 离线 / 拿不到 relay 时它**永不 resolve**,fire-and-forget
93
+ 调用会永久顶住进程,即便事后 `close()` 也没用。
94
+ - **`endpoint.acceptNext()`** 也会顶住,但 `close()` 能让它 resolve(返回
95
+ `None`)从而释放 loop —— 所以只要每个 endpoint 都被 close,悬挂的 accept
96
+ 循环会自然收尾。
97
+ - 任何「发起但不结束」的 async 方法(`connection.closed()`、各种 `read*`/
98
+ `accept*`/`stopped()` 等长等待)同理。
99
+
100
+ ## 为什么单进程 / Linux 规模测试不崩
101
+
102
+ 排查中在 Linux 上跑了:单进程各种 bind/close 组合、32 进程并发、
103
+ fork+IPC+tinypool 式 SIGTERM terminate —— **全部干净退出**。因为这些路径里
104
+ 没有留下「永不 resolve 的悬挂 async 调用」。下游的 relay 相关测试里有(最可能
105
+ 是 `online()` 或一个没被 close 收尾的后台 `acceptNext()`/watch 循环),macOS
106
+ 上 home relay 行为又和沙箱里(无网络、relay 立即失败)不同,于是只在那边显形。
107
+
108
+ ## 建议的修法(按优先级)
109
+
110
+ ### A. 下游(能立即自验,优先)
111
+ 1. 用 `node test/exit-hang-repro.mjs diagnose` 同款 `async_hooks` 钩子(或
112
+ `why-is-node-running`)跑一遍全量测试,定位那个一直存活的
113
+ `napi_resolve_deferred`,找到对应的 iroh 调用。
114
+ 2. 永远不要 fire-and-forget iroh 的 async 方法。对 `online()` 这类:
115
+ `await Promise.race([ep.online(), timeout(ms)])`,或干脆不调用。
116
+ 3. 每个 `Endpoint` 在测试 teardown 里 `await ep.close()`;后台 `acceptNext()`
117
+ 循环要能在 close 后退出。
118
+
119
+ ### B. 绑定层(本仓,需在 macOS 下游复验)
120
+ - 给所有「可能无限等待」的 async 方法补文档,明确:必须 await/race,且
121
+ **`online()` 不受 `close()` 取消**。
122
+ - 可考虑让 `online()` 等方法响应 `close()`(随 endpoint 关闭而 resolve/reject),
123
+ 使悬挂调用在 shutdown 时能释放 loop。需评估 iroh-core 语义。
124
+ - 可考虑提供 `Endpoint.close()` 后把内部 endpoint 真正 drop,确保后台任务停。
125
+
126
+ ## 顺带发现的另一个独立 bug(不是 144 的元凶,是 SIGABRT)
127
+
128
+ 所有 `watch_*` 同步方法(`watchAddr` / `watchHomeRelay` /
129
+ `watchNetworkChange` / `Connection.watchPaths` / `watchPathEvents`)在函数体里
130
+ 调用 `n0_future::task::spawn`(= `tokio::spawn`),但同步 `#[napi]` 函数运行在
131
+ JS 主线程、**不在 tokio runtime 上下文**,于是 panic:
132
+
133
+ ```
134
+ thread '<unnamed>' panicked at iroh-js/src/watch.rs:50:16:
135
+ there is no reactor running, must be called from the context of a Tokio 1.x runtime
136
+ fatal runtime error: failed to initiate panic, error 5, aborting
137
+ ```
138
+
139
+ → 进程 **abort(SIGABRT / exit 134)**。任何调用这些 `watch*` 的代码都会直接崩。
140
+ 修法:在这些方法里用 napi 的 runtime handle 来 spawn(`napi::tokio::spawn` /
141
+ `tokio_runtime::spawn`),或把它们改成 `async fn` 以进入 runtime 上下文。
142
+ (下游目前没报 134,说明暂未用到 watch,但这是真实缺陷。)
package/index.js CHANGED
@@ -77,8 +77,8 @@ function requireNative() {
77
77
  try {
78
78
  const binding = require('@cc-remote/iroh-android-arm64')
79
79
  const bindingPackageVersion = require('@cc-remote/iroh-android-arm64/package.json').version
80
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
81
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
80
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
81
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
82
82
  }
83
83
  return binding
84
84
  } catch (e) {
@@ -93,8 +93,8 @@ function requireNative() {
93
93
  try {
94
94
  const binding = require('@cc-remote/iroh-android-arm-eabi')
95
95
  const bindingPackageVersion = require('@cc-remote/iroh-android-arm-eabi/package.json').version
96
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
97
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
96
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
97
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
98
98
  }
99
99
  return binding
100
100
  } catch (e) {
@@ -114,8 +114,8 @@ function requireNative() {
114
114
  try {
115
115
  const binding = require('@cc-remote/iroh-win32-x64-gnu')
116
116
  const bindingPackageVersion = require('@cc-remote/iroh-win32-x64-gnu/package.json').version
117
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
118
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
117
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
118
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
119
119
  }
120
120
  return binding
121
121
  } catch (e) {
@@ -130,8 +130,8 @@ function requireNative() {
130
130
  try {
131
131
  const binding = require('@cc-remote/iroh-win32-x64-msvc')
132
132
  const bindingPackageVersion = require('@cc-remote/iroh-win32-x64-msvc/package.json').version
133
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
134
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
133
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
134
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
135
135
  }
136
136
  return binding
137
137
  } catch (e) {
@@ -147,8 +147,8 @@ function requireNative() {
147
147
  try {
148
148
  const binding = require('@cc-remote/iroh-win32-ia32-msvc')
149
149
  const bindingPackageVersion = require('@cc-remote/iroh-win32-ia32-msvc/package.json').version
150
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
151
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
150
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
151
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
152
152
  }
153
153
  return binding
154
154
  } catch (e) {
@@ -163,8 +163,8 @@ function requireNative() {
163
163
  try {
164
164
  const binding = require('@cc-remote/iroh-win32-arm64-msvc')
165
165
  const bindingPackageVersion = require('@cc-remote/iroh-win32-arm64-msvc/package.json').version
166
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
167
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
166
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
167
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
168
168
  }
169
169
  return binding
170
170
  } catch (e) {
@@ -182,8 +182,8 @@ function requireNative() {
182
182
  try {
183
183
  const binding = require('@cc-remote/iroh-darwin-universal')
184
184
  const bindingPackageVersion = require('@cc-remote/iroh-darwin-universal/package.json').version
185
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
186
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
185
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
186
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
187
187
  }
188
188
  return binding
189
189
  } catch (e) {
@@ -198,8 +198,8 @@ function requireNative() {
198
198
  try {
199
199
  const binding = require('@cc-remote/iroh-darwin-x64')
200
200
  const bindingPackageVersion = require('@cc-remote/iroh-darwin-x64/package.json').version
201
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
202
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
201
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
202
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
203
203
  }
204
204
  return binding
205
205
  } catch (e) {
@@ -214,8 +214,8 @@ function requireNative() {
214
214
  try {
215
215
  const binding = require('@cc-remote/iroh-darwin-arm64')
216
216
  const bindingPackageVersion = require('@cc-remote/iroh-darwin-arm64/package.json').version
217
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
218
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
217
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
218
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
219
219
  }
220
220
  return binding
221
221
  } catch (e) {
@@ -234,8 +234,8 @@ function requireNative() {
234
234
  try {
235
235
  const binding = require('@cc-remote/iroh-freebsd-x64')
236
236
  const bindingPackageVersion = require('@cc-remote/iroh-freebsd-x64/package.json').version
237
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
238
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
237
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
238
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
239
239
  }
240
240
  return binding
241
241
  } catch (e) {
@@ -250,8 +250,8 @@ function requireNative() {
250
250
  try {
251
251
  const binding = require('@cc-remote/iroh-freebsd-arm64')
252
252
  const bindingPackageVersion = require('@cc-remote/iroh-freebsd-arm64/package.json').version
253
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
254
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
253
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
254
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
255
255
  }
256
256
  return binding
257
257
  } catch (e) {
@@ -271,8 +271,8 @@ function requireNative() {
271
271
  try {
272
272
  const binding = require('@cc-remote/iroh-linux-x64-musl')
273
273
  const bindingPackageVersion = require('@cc-remote/iroh-linux-x64-musl/package.json').version
274
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
275
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
274
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
275
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
276
276
  }
277
277
  return binding
278
278
  } catch (e) {
@@ -287,8 +287,8 @@ function requireNative() {
287
287
  try {
288
288
  const binding = require('@cc-remote/iroh-linux-x64-gnu')
289
289
  const bindingPackageVersion = require('@cc-remote/iroh-linux-x64-gnu/package.json').version
290
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
291
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
290
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
291
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
292
292
  }
293
293
  return binding
294
294
  } catch (e) {
@@ -305,8 +305,8 @@ function requireNative() {
305
305
  try {
306
306
  const binding = require('@cc-remote/iroh-linux-arm64-musl')
307
307
  const bindingPackageVersion = require('@cc-remote/iroh-linux-arm64-musl/package.json').version
308
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
309
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
308
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
309
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
310
310
  }
311
311
  return binding
312
312
  } catch (e) {
@@ -321,8 +321,8 @@ function requireNative() {
321
321
  try {
322
322
  const binding = require('@cc-remote/iroh-linux-arm64-gnu')
323
323
  const bindingPackageVersion = require('@cc-remote/iroh-linux-arm64-gnu/package.json').version
324
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
325
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
324
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
325
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
326
326
  }
327
327
  return binding
328
328
  } catch (e) {
@@ -339,8 +339,8 @@ function requireNative() {
339
339
  try {
340
340
  const binding = require('@cc-remote/iroh-linux-arm-musleabihf')
341
341
  const bindingPackageVersion = require('@cc-remote/iroh-linux-arm-musleabihf/package.json').version
342
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
343
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
342
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
343
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
344
344
  }
345
345
  return binding
346
346
  } catch (e) {
@@ -355,8 +355,8 @@ function requireNative() {
355
355
  try {
356
356
  const binding = require('@cc-remote/iroh-linux-arm-gnueabihf')
357
357
  const bindingPackageVersion = require('@cc-remote/iroh-linux-arm-gnueabihf/package.json').version
358
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
359
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
358
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
359
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
360
360
  }
361
361
  return binding
362
362
  } catch (e) {
@@ -373,8 +373,8 @@ function requireNative() {
373
373
  try {
374
374
  const binding = require('@cc-remote/iroh-linux-loong64-musl')
375
375
  const bindingPackageVersion = require('@cc-remote/iroh-linux-loong64-musl/package.json').version
376
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
377
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
376
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
377
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
378
378
  }
379
379
  return binding
380
380
  } catch (e) {
@@ -389,8 +389,8 @@ function requireNative() {
389
389
  try {
390
390
  const binding = require('@cc-remote/iroh-linux-loong64-gnu')
391
391
  const bindingPackageVersion = require('@cc-remote/iroh-linux-loong64-gnu/package.json').version
392
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
393
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
392
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
393
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
394
394
  }
395
395
  return binding
396
396
  } catch (e) {
@@ -407,8 +407,8 @@ function requireNative() {
407
407
  try {
408
408
  const binding = require('@cc-remote/iroh-linux-riscv64-musl')
409
409
  const bindingPackageVersion = require('@cc-remote/iroh-linux-riscv64-musl/package.json').version
410
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
411
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
410
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
411
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
412
412
  }
413
413
  return binding
414
414
  } catch (e) {
@@ -423,8 +423,8 @@ function requireNative() {
423
423
  try {
424
424
  const binding = require('@cc-remote/iroh-linux-riscv64-gnu')
425
425
  const bindingPackageVersion = require('@cc-remote/iroh-linux-riscv64-gnu/package.json').version
426
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
427
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
426
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
427
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
428
428
  }
429
429
  return binding
430
430
  } catch (e) {
@@ -440,8 +440,8 @@ function requireNative() {
440
440
  try {
441
441
  const binding = require('@cc-remote/iroh-linux-ppc64-gnu')
442
442
  const bindingPackageVersion = require('@cc-remote/iroh-linux-ppc64-gnu/package.json').version
443
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
444
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
443
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
444
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
445
445
  }
446
446
  return binding
447
447
  } catch (e) {
@@ -456,8 +456,8 @@ function requireNative() {
456
456
  try {
457
457
  const binding = require('@cc-remote/iroh-linux-s390x-gnu')
458
458
  const bindingPackageVersion = require('@cc-remote/iroh-linux-s390x-gnu/package.json').version
459
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
460
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
459
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
460
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
461
461
  }
462
462
  return binding
463
463
  } catch (e) {
@@ -476,8 +476,8 @@ function requireNative() {
476
476
  try {
477
477
  const binding = require('@cc-remote/iroh-openharmony-arm64')
478
478
  const bindingPackageVersion = require('@cc-remote/iroh-openharmony-arm64/package.json').version
479
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
480
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
479
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
480
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
481
481
  }
482
482
  return binding
483
483
  } catch (e) {
@@ -492,8 +492,8 @@ function requireNative() {
492
492
  try {
493
493
  const binding = require('@cc-remote/iroh-openharmony-x64')
494
494
  const bindingPackageVersion = require('@cc-remote/iroh-openharmony-x64/package.json').version
495
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
496
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
495
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
496
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
497
497
  }
498
498
  return binding
499
499
  } catch (e) {
@@ -508,8 +508,8 @@ function requireNative() {
508
508
  try {
509
509
  const binding = require('@cc-remote/iroh-openharmony-arm')
510
510
  const bindingPackageVersion = require('@cc-remote/iroh-openharmony-arm/package.json').version
511
- if (bindingPackageVersion !== '1.0.0-rc.4' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
512
- throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.4 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
511
+ if (bindingPackageVersion !== '1.0.0-rc.6' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
512
+ throw new Error(`Native binding package version mismatch, expected 1.0.0-rc.6 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
513
513
  }
514
514
  return binding
515
515
  } catch (e) {
package/package.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "network",
13
13
  "protocol"
14
14
  ],
15
- "version": "1.0.0-rc.4",
15
+ "version": "1.0.0-rc.6",
16
16
  "type": "commonjs",
17
17
  "main": "index.js",
18
18
  "types": "index.d.ts",
@@ -55,12 +55,12 @@
55
55
  "access": "public"
56
56
  },
57
57
  "optionalDependencies": {
58
- "@cc-remote/iroh-darwin-arm64": "1.0.0-rc.4",
59
- "@cc-remote/iroh-linux-arm64-gnu": "1.0.0-rc.4",
60
- "@cc-remote/iroh-linux-arm64-musl": "1.0.0-rc.4",
61
- "@cc-remote/iroh-linux-x64-gnu": "1.0.0-rc.4",
62
- "@cc-remote/iroh-linux-x64-musl": "1.0.0-rc.4",
63
- "@cc-remote/iroh-linux-arm-gnueabihf": "1.0.0-rc.4",
64
- "@cc-remote/iroh-linux-arm-musleabihf": "1.0.0-rc.4"
58
+ "@cc-remote/iroh-darwin-arm64": "1.0.0-rc.6",
59
+ "@cc-remote/iroh-linux-arm64-gnu": "1.0.0-rc.6",
60
+ "@cc-remote/iroh-linux-arm64-musl": "1.0.0-rc.6",
61
+ "@cc-remote/iroh-linux-x64-gnu": "1.0.0-rc.6",
62
+ "@cc-remote/iroh-linux-x64-musl": "1.0.0-rc.6",
63
+ "@cc-remote/iroh-linux-arm-gnueabihf": "1.0.0-rc.6",
64
+ "@cc-remote/iroh-linux-arm-musleabihf": "1.0.0-rc.6"
65
65
  }
66
66
  }
package/src/endpoint.rs CHANGED
@@ -422,10 +422,19 @@ impl Endpoint {
422
422
  .collect()
423
423
  }
424
424
 
425
- /// Resolves once the endpoint has a usable home relay.
425
+ /// Resolves once the endpoint has a usable home relay, or earlier if the
426
+ /// endpoint is closed.
427
+ ///
428
+ /// iroh's `online()` never returns until a home relay connects, and it does
429
+ /// **not** resolve when the endpoint is closed (it parks forever). A
430
+ /// fire-and-forget `online()` whose relay never connects would therefore
431
+ /// leave a napi async task pending indefinitely, which refs the Node event
432
+ /// loop (invisibly to `process._getActiveHandles()`) and stops the host
433
+ /// process from exiting. Racing it against endpoint closure means `close()`
434
+ /// releases a pending `online()` so the loop can drain.
426
435
  #[napi]
427
436
  pub async fn online(&self) {
428
- self.inner.online().await;
437
+ let _ = self.inner.closed().run_until(self.inner.online()).await;
429
438
  }
430
439
 
431
440
  /// Insert (or replace) a relay configuration at runtime.
package/src/lib.rs CHANGED
@@ -52,8 +52,12 @@ pub fn set_log_level(level: LogLevel) {
52
52
  let (filter, _) = reload::Layer::new(filter);
53
53
  let mut layer = fmt::Layer::default();
54
54
  layer.set_ansi(false);
55
- tracing_subscriber::registry()
55
+ // try_init (not init): set_global_default panics if a global subscriber is
56
+ // already set, so a second call in the same process would abort. The reload
57
+ // handle is discarded anyway, so subsequent calls can't change the level —
58
+ // make that explicit and panic-free by ignoring the "already set" error.
59
+ let _ = tracing_subscriber::registry()
56
60
  .with(filter)
57
61
  .with(layer)
58
- .init();
62
+ .try_init();
59
63
  }
package/src/watch.rs CHANGED
@@ -1,8 +1,18 @@
1
1
  use n0_future::{StreamExt, task::AbortOnDropHandle};
2
- use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
2
+ use napi::{
3
+ bindgen_prelude::spawn,
4
+ threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
5
+ };
3
6
  use napi_derive::napi;
4
7
  use tokio::sync::Mutex;
5
8
 
9
+ // NOTE: these watchers are spawned from *synchronous* `#[napi]` methods, which
10
+ // run on the JS main thread — outside any tokio runtime context. Using
11
+ // `tokio::spawn` / `n0_future::task::spawn` there panics with "there is no
12
+ // reactor running" and aborts the process. `napi::bindgen_prelude::spawn`
13
+ // schedules onto napi's global runtime via `Runtime::spawn` directly, so it
14
+ // works without an entered runtime context.
15
+
6
16
  use crate::{EndpointAddr, PathEvent, PathSnapshot};
7
17
 
8
18
  /// Handle to a running watcher task. Call `stop()` (or drop) to unregister.
@@ -32,7 +42,7 @@ pub(crate) fn spawn_watch_addr(
32
42
  endpoint: iroh::Endpoint,
33
43
  cb: ThreadsafeFunction<EndpointAddr>,
34
44
  ) -> WatchHandle {
35
- let task = n0_future::task::spawn(async move {
45
+ let task = spawn(async move {
36
46
  use iroh::Watcher;
37
47
  let mut stream = endpoint.watch_addr().stream();
38
48
  while let Some(addr) = stream.next().await {
@@ -47,7 +57,7 @@ pub(crate) fn spawn_home_relay_watch(
47
57
  endpoint: iroh::Endpoint,
48
58
  cb: ThreadsafeFunction<Vec<String>>,
49
59
  ) -> WatchHandle {
50
- let task = n0_future::task::spawn(async move {
60
+ let task = spawn(async move {
51
61
  use iroh::Watcher;
52
62
  let mut stream = endpoint.home_relay_status().stream();
53
63
  while let Some(statuses) = stream.next().await {
@@ -62,7 +72,7 @@ pub(crate) fn spawn_network_change_watch(
62
72
  endpoint: iroh::Endpoint,
63
73
  cb: ThreadsafeFunction<()>,
64
74
  ) -> WatchHandle {
65
- let task = n0_future::task::spawn(async move {
75
+ let task = spawn(async move {
66
76
  loop {
67
77
  endpoint.network_change().await;
68
78
  cb.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
@@ -75,7 +85,7 @@ pub(crate) fn spawn_paths_watch(
75
85
  conn: iroh::endpoint::Connection,
76
86
  cb: ThreadsafeFunction<Vec<PathSnapshot>>,
77
87
  ) -> WatchHandle {
78
- let task = n0_future::task::spawn(async move {
88
+ let task = spawn(async move {
79
89
  let mut stream = conn.paths_stream();
80
90
  while let Some(snapshot) = stream.next().await {
81
91
  let mapped: Vec<PathSnapshot> = snapshot
@@ -100,7 +110,7 @@ pub(crate) fn spawn_path_events_watch(
100
110
  conn: iroh::endpoint::Connection,
101
111
  cb: ThreadsafeFunction<PathEvent>,
102
112
  ) -> WatchHandle {
103
- let task = n0_future::task::spawn(async move {
113
+ let task = spawn(async move {
104
114
  let mut stream = conn.path_events();
105
115
  while let Some(event) = stream.next().await {
106
116
  let mapped: PathEvent = event.into();
@@ -0,0 +1,141 @@
1
+ // Reproduction for the downstream "exit 144 / SIGTERM at process teardown" bug.
2
+ //
3
+ // ROOT CAUSE (reproduced on Linux): a napi *async* method whose returned
4
+ // promise is never awaited / never resolves leaves a pending
5
+ // `napi_resolve_deferred` async resource that REFS the libuv event loop, so
6
+ // Node can never drain its loop and exit on its own. The host process then
7
+ // hangs at teardown and is killed by an external timeout (tinypool / vitest /
8
+ // CI) -> 128 + SIGTERM(15) = 144.
9
+ //
10
+ // Two things make this hard to find downstream, both confirmed here:
11
+ // 1. The ref is INVISIBLE to process._getActiveHandles()/_getActiveRequests()
12
+ // (they report []), which is why the usual handle dump found nothing.
13
+ // 2. It only needs a worker that actually loaded the native module AND left
14
+ // one such call dangling — pure-JS unresolved promises do NOT pin libuv.
15
+ //
16
+ // It IS visible via async_hooks as a live `napi_resolve_deferred` (see the
17
+ // `diagnose` scenario) — that is how to locate the exact offending call in a
18
+ // real test suite (or use the `why-is-node-running` package).
19
+ //
20
+ // Usage:
21
+ // node test/exit-hang-repro.mjs <scenario>
22
+ // Scenarios (parent measures whether the child exits on its own in N seconds):
23
+ // clean-bind-close bind + close -> exits 0 (control)
24
+ // clean-bind-noclose bind, no close -> exits 0 (control)
25
+ // hang-online ep.online() not awaited -> HANG (no close: nothing cancels it)
26
+ // ok-online-close ep.online() then ep.close() -> exits 0 (close() now cancels online())
27
+ // hang-accept ep.acceptNext() not awaited -> HANG
28
+ // ok-accept-close ep.acceptNext() then close() -> exits 0 (close cancels acceptNext())
29
+ // diagnose show the async_hooks resource that pins the loop
30
+ //
31
+ // With no scenario, runs the whole matrix as a parent harness.
32
+ import { spawn } from 'node:child_process'
33
+ import { fileURLToPath } from 'node:url'
34
+ import { dirname } from 'node:path'
35
+ import { createRequire } from 'node:module'
36
+
37
+ const __filename = fileURLToPath(import.meta.url)
38
+ const __dirname = dirname(__filename)
39
+ const require = createRequire(import.meta.url)
40
+
41
+ const scenario = process.argv[2]
42
+
43
+ async function buildMinimal(iroh) {
44
+ const b = iroh.Endpoint.builder()
45
+ iroh.presetMinimal(b)
46
+ b.alpns([Array.from(Buffer.from('exit-hang-repro/0'))])
47
+ return await b.bind()
48
+ }
49
+
50
+ async function child(scenario) {
51
+ const iroh = require('../index.js')
52
+ const ep = await buildMinimal(iroh)
53
+ switch (scenario) {
54
+ case 'clean-bind-close':
55
+ await ep.close()
56
+ break
57
+ case 'clean-bind-noclose':
58
+ break
59
+ case 'hang-online':
60
+ ep.online().then(() => {}, () => {}) // fire-and-forget; never resolves offline
61
+ break
62
+ case 'ok-online-close':
63
+ ep.online().then(() => {}, () => {})
64
+ await ep.close() // close() now cancels the pending online() (binding races closed())
65
+ break
66
+ case 'hang-accept':
67
+ ep.acceptNext().then(() => {}, () => {})
68
+ break
69
+ case 'ok-accept-close':
70
+ ep.acceptNext().then(() => {}, () => {})
71
+ await ep.close() // close() makes acceptNext() resolve(None) -> loop released
72
+ break
73
+ default:
74
+ throw new Error(`unknown scenario ${scenario}`)
75
+ }
76
+ // No process.exit(): the whole point is to observe natural event-loop drain.
77
+ }
78
+
79
+ async function diagnose() {
80
+ const async_hooks = await import('node:async_hooks')
81
+ const iroh = require('../index.js')
82
+ const alive = new Map()
83
+ const hook = async_hooks.createHook({
84
+ init: (id, type) => alive.set(id, type),
85
+ destroy: (id) => alive.delete(id),
86
+ promiseResolve: (id) => alive.delete(id),
87
+ })
88
+ hook.enable()
89
+ const ep = await buildMinimal(iroh)
90
+ ep.online().then(() => {}, () => {}) // the offender
91
+ setTimeout(() => {
92
+ hook.disable()
93
+ const counts = {}
94
+ for (const t of alive.values()) counts[t] = (counts[t] || 0) + 1
95
+ console.log('still-alive async resources pinning the loop:', JSON.stringify(counts))
96
+ console.log('--> `napi_resolve_deferred` is the invisible ref from the dangling async call.')
97
+ process.exit(0)
98
+ }, 300)
99
+ }
100
+
101
+ const ALL = [
102
+ 'clean-bind-close',
103
+ 'clean-bind-noclose',
104
+ 'hang-online',
105
+ 'ok-online-close',
106
+ 'hang-accept',
107
+ 'ok-accept-close',
108
+ ]
109
+ const DEADLINE_MS = 4000
110
+
111
+ function runChild(spec) {
112
+ return new Promise((resolve) => {
113
+ const start = Date.now()
114
+ const c = spawn(process.execPath, [__filename, spec], { stdio: ['ignore', 'ignore', 'ignore'] })
115
+ let timedOut = false
116
+ const t = setTimeout(() => {
117
+ timedOut = true
118
+ c.kill('SIGTERM')
119
+ }, DEADLINE_MS)
120
+ c.on('exit', (code, signal) => {
121
+ clearTimeout(t)
122
+ resolve({ spec, ms: Date.now() - start, code, signal, timedOut })
123
+ })
124
+ })
125
+ }
126
+
127
+ if (scenario === 'diagnose') {
128
+ await diagnose()
129
+ } else if (scenario) {
130
+ await child(scenario)
131
+ } else {
132
+ const results = []
133
+ for (const s of ALL) results.push(await runChild(s))
134
+ console.log('\n==================== exit-hang matrix ====================')
135
+ for (const r of results) {
136
+ const v = r.timedOut ? `HANG -> SIGTERM after ${r.ms}ms` : `clean exit code=${r.code} in ${r.ms}ms`
137
+ console.log(`${r.spec.padEnd(20)} ${v}`)
138
+ }
139
+ console.log('\nRun `node test/exit-hang-repro.mjs diagnose` to see the invisible pin.')
140
+ process.exitCode = results.some((r) => r.timedOut) ? 1 : 0
141
+ }