From 2054548cec55416ab73d4564bcec97e7a4f6d4df Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Mon, 24 Aug 2026 10:31:07 +0800 Subject: [PATCH 1/3] feat: add Fiber (FNN) support to the local devnet (#483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Fiber (FNN) support to the local devnet Add a local Fiber development environment to offckb: - Genesis: the devnet now carries the Fiber contracts auth, funding_lock and commitment_lock, copied from the new ckb/fiber submodule pinned to the FNN v0.9.0-rc7 commit (bc361aa). They are appended after the existing system cells so existing script type ids (accounts, sudt, xudt, ...) stay unchanged; the genesis tx hash changes, so cell dep out points are always read from a fresh `ckb list-hashes` at start. - FNN install: download/cache of the tested FNN release (0.9.0-rc7, portable tarballs), keeping the bundled config/testnet/config.yml as the devnet config template; --binary-path/--fnn-binary-path run a locally built FNN, using its sibling testnet config or the shipped fallback. - offckb node --fiber: start CKB, miner, RPC proxy and FNN nodes with one command (daemon mode included). Plain local devnet only: mainnet/testnet and forked devnets (any fork.json) are rejected before any daemon respawn. - offckb fiber start/stop/status/logs/clean: manage FNNs on an already-running devnet. Node N uses built-in account N+2, RPC port 21713+N and P2P port 8343+N (1-16 nodes). Each node writes only to its own fnn.log; per-node FNN config overrides live in fiber/nodes.yml; config.yml is regenerated every start from the FNN testnet config with unknown fields preserved. - Startup checks: genesis hash agreement between list-hashes, CKB RPC and every FNN node_info, node identity key vs fiber/sk, funding account vs the expected built-in account, and available balance; then node 1 connects to the other nodes (verified via list_peers). - Process management: a shared .offckb-devnet.lock, runtime.json for manager/node records, daemon PID files with identity checks, stop only ever signals recorded managers (never per-FNN kills), store LOCK verification before cleans; offckb clean removes fiber stores with --data and refuses while a daemon or live store lock is confirmed. - fiber status reports CKB and per-node state (starting/running/stopped/unknown/conflict) plus an OFFCKB-managed column, as a table or --json. Co-Authored-By: Claude Fable 5 * fix(fiber): address PR #483 review feedback - Sanitize builder home paths (/Users/quake, /home/quake) embedded in the committed funding_lock/commitment_lock binaries with equal-length replacements; make fiber reproduces the sanitized copies and fails with a clear message when the ckb/fiber submodule is missing - Keep already-spawned FNNs from being orphaned when a later spawn fails - Treat signal-terminated children as exited (exitCode is null there) - Capture store lock files before signaling the manager; it removes runtime.json during its own shutdown - isStoreLockHeld: only lsof exit 1 means 'no holder'; any other exit status or a timeout kill now reports 'unknown' instead of 'free' - Align the node --fiber --daemon readiness wait with the fiber daemon's 10-minute budget (first run may download FNN) - Mark ckb.udt_whitelist as a managed nodes.yml field so per-node overrides cannot silently break UDT payments - Replace deprecated wmic with PowerShell Get-CimInstance for Windows process command-line lookup - Give requireScript a full contextual error instead of raw missing: - Warn that shrinking nodes.yml discards the removed nodes' config overrides - Share helpers: fiberAccountIndex in the start summary, lockMatches between status and manager, nodeDaemonPaths across cmd/fiber modules, fiberNodeIds for node enumeration, SystemScriptName for script lookup - Delete the downloaded FNN tarball after install (also on failure) Co-Authored-By: Claude Fable 5 * fix(fiber): fail-closed daemon identity, startup signal window, lifecycle tests Address the PR #483 test-squad review gates: - J1 (Critical): verifyDaemonIdentity no longer trusts the pid file's scriptPath/scriptDir and no longer matches "node"/"index.js" substrings anywhere in the command line. Identity now comes from the live process: the executable must be this Node runtime (exact process.execPath match or an exact node/nodejs basename), its first argument must equal this installation's CLI entry after realpath normalization, and its start time must match the pid file's startedAt within 30s — the PID-reuse guard, finally reading the field that was always written but never checked. Inspection prefers /proc (exact argv, tick-precision start time) and falls back to ps lstart/args, or one CIM JSON call returning CommandLine + CreationDate on Windows. Every unverifiable step fails closed: stop refuses without signaling. - J2 (High): startFiberEnvironment installs SIGINT/SIGTERM handlers before the first FNN spawn; a signal inside the startup window runs the same cleanup as a post-ready stop (SIGTERM, SIGKILL after the grace period, runtime.json dropped) and exits 130/143. The handlers are removed once the environment is ready so supervision handlers take over. - J3 (High): lifecycle-layer tests cover the verifyDaemonIdentity true/false matrix (forged scriptPath, basename-only match, non-node executable with "node" substrings, start-time mismatch, legacy record), the isStoreLockHeld missing/held/free/unavailable/error states, assertFiberFullyStopped fail-closed paths, stopFiberNodes SIGTERM-to-SIGKILL escalation and signal-exited children (null exitCode), and the startup signal window. - J7: fiber.store_path joins the nodes.yml managed fields so a per-node override cannot relocate the store away from clean's RocksDB LOCK check. stopFiberNodes and isStoreLockHeld take optional grace-period/command parameters for deterministic tests; the ps/CIM probes now run with a 5s timeout. Co-Authored-By: Claude Fable 5 * fix(fiber): address remaining PR #483 review items beyond the merge gates - J6: `node stop` now refuses while a foreground fiber environment is live instead of orphaning its FNNs on a stopped chain; `--force` overrides with a warning - J8: isRuntimeStale fails closed — an unverifiable manager (EPERM) keeps its runtime record instead of being discarded, matching the environment lock's philosophy - S1: FNN downloads are verified against SHA-256 digests pinned in source (all five v0.9.0-rc7 packages, cross-checked against the GitHub release API and a local re-download); missing pins fail closed - S5: jest coverage ratchet for src/fiber/** and src/util/daemon.ts - S2: FiberContractsMissingError now carries the full migration guidance itself, so `node --fiber` on a pre-Fiber devnet prints the same rebuild instructions as `fiber start` - clean: re-verify fully-stopped after the confirmation prompt, closing the prompt-window race; fiberClean locks the env lock of the settings it was given instead of the default - docs: single-instance/fixed-ports caveat (F9/J4), pre-Fiber devnet migration note, node stop --force - tests: +24 — isRuntimeStale fail-closed, node-stop guard matrix, stopFiber stale/foreign-daemon paths, fiberClean refusal/deletion, checksum verify matrix, assertPlainDevnet, lockMatches list-hashes verification (merge-base 4f27ae9 vs PR head): all 19 pre-existing system cells keep identical index/data_hash/type_hash; fiber auth/funding_lock/commitment_lock are appended at 20-22; dep groups unchanged; genesis b15fa8a5.. -> 334344de.. * fix(fiber): address latest CodeRabbit review round on PR #483 - node stop: verify fiber daemon identity before refusing, honor --force - node foreground: share one shutdown latch between component-exit and signal handlers so cleanup cannot race itself - fiber daemon start: keep live unverifiable PID metadata instead of replacing it and stranding the real daemon - fiber stop: keep pid/runtime records when manager exit is unconfirmed; bound 'starting' records by the startup grace window so an interrupted launcher no longer deadlocks stop/clean - install: private per-run temp dir (mkdtemp) for download+extract, reject unsupported linux/darwin architectures instead of mapping to x86_64, parameterize and export buildFnnPackageName - nodes.yml: reject an empty stored node list - fiber status: verify daemon identity for the OFFCKB column, probe all FNNs concurrently - tests: isolate ps lstart answers in foreign-process mocks, drop the obsolete wmic mock branch, park a rejection handler in the startup-window test, cover the new behaviors * fix(fiber): don't let EPIPE truncate a graceful shutdown in progress With piped output (`offckb node --fiber 2>&1 | tee log`), Ctrl+C kills the pipeline reader together with the CLI; the first log line of the shutdown cleanup then hits EPIPE and installBrokenPipeHandlers exits the process with 0 in the middle of teardown — runtime.json is left behind and the 130 exit code is lost. Add a process-wide graceful-shutdown marker (util/shutdown.ts): the signal handlers and the component-exit teardown enter it before their first log line, and the broken-pipe handler swallows EPIPE while a shutdown is running so the cleanup completes and exits 130/143 itself. Normal broken-pipe behavior (quiet exit 0 for `| head` etc.) is unchanged. Also document the UDT channel gotchas found in the same test round: UDT channels need a manual accept_channel with funding_amount 0x0, both sides must hold the UDT first, and `udt issue` amounts are base units. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .changeset/fiber-devnet.md | 5 + .gitmodules | 3 + Makefile | 22 +- README.md | 46 ++ ckb/devnet/specs/dev.toml | 9 + ckb/devnet/specs/fiber/auth | Bin 0 -> 150904 bytes ckb/devnet/specs/fiber/commitment_lock | Bin 0 -> 111880 bytes ckb/devnet/specs/fiber/funding_lock | Bin 0 -> 74800 bytes ckb/devnet/specs/fiber/testnet-config.yml | 104 ++++ ckb/fiber | 1 + jest.config.js | 17 + package.json | 2 + pnpm-lock.yaml | 11 + src/cfg/setting.ts | 2 + src/cli.ts | 105 +++- src/cmd/clean.ts | 88 ++- src/cmd/config.ts | 19 + src/cmd/fiber.ts | 202 +++++++ src/cmd/node.ts | 501 ++++++++-------- src/fiber/accounts.ts | 113 ++++ src/fiber/ckb-env.ts | 69 +++ src/fiber/clean.ts | 114 ++++ src/fiber/config-gen.ts | 104 ++++ src/fiber/daemon.ts | 385 ++++++++++++ src/fiber/env-lock.ts | 115 ++++ src/fiber/install.ts | 258 ++++++++ src/fiber/manager.ts | 449 ++++++++++++++ src/fiber/nodes-yml.ts | 156 +++++ src/fiber/paths.ts | 138 +++++ src/fiber/rpc.ts | 47 ++ src/fiber/runtime.ts | 100 ++++ src/fiber/scripts.ts | 157 +++++ src/fiber/status.ts | 250 ++++++++ src/fiber/store-lock.ts | 72 +++ src/scripts/public.ts | 6 + src/scripts/type.ts | 3 + src/util/daemon.ts | 491 ++++++++++++++++ src/util/shutdown.ts | 47 ++ tests/broken-pipe.test.ts | 90 +++ tests/fiber-accounts.test.ts | 97 +++ tests/fiber-ckb-env.test.ts | 43 ++ tests/fiber-config-gen.test.ts | 152 +++++ tests/fiber-env-lock.test.ts | 60 ++ tests/fiber-install.test.ts | 139 +++++ tests/fiber-lifecycle.test.ts | 680 ++++++++++++++++++++++ tests/fiber-nodes-yml.test.ts | 130 +++++ tests/fiber-scripts.test.ts | 96 +++ tests/fiber-status.test.ts | 29 + tests/node-command.test.ts | 144 ++++- 49 files changed, 5558 insertions(+), 313 deletions(-) create mode 100644 .changeset/fiber-devnet.md create mode 100755 ckb/devnet/specs/fiber/auth create mode 100755 ckb/devnet/specs/fiber/commitment_lock create mode 100755 ckb/devnet/specs/fiber/funding_lock create mode 100644 ckb/devnet/specs/fiber/testnet-config.yml create mode 160000 ckb/fiber create mode 100644 src/cmd/fiber.ts create mode 100644 src/fiber/accounts.ts create mode 100644 src/fiber/ckb-env.ts create mode 100644 src/fiber/clean.ts create mode 100644 src/fiber/config-gen.ts create mode 100644 src/fiber/daemon.ts create mode 100644 src/fiber/env-lock.ts create mode 100644 src/fiber/install.ts create mode 100644 src/fiber/manager.ts create mode 100644 src/fiber/nodes-yml.ts create mode 100644 src/fiber/paths.ts create mode 100644 src/fiber/rpc.ts create mode 100644 src/fiber/runtime.ts create mode 100644 src/fiber/scripts.ts create mode 100644 src/fiber/status.ts create mode 100644 src/fiber/store-lock.ts create mode 100644 src/util/daemon.ts create mode 100644 src/util/shutdown.ts create mode 100644 tests/broken-pipe.test.ts create mode 100644 tests/fiber-accounts.test.ts create mode 100644 tests/fiber-ckb-env.test.ts create mode 100644 tests/fiber-config-gen.test.ts create mode 100644 tests/fiber-env-lock.test.ts create mode 100644 tests/fiber-install.test.ts create mode 100644 tests/fiber-lifecycle.test.ts create mode 100644 tests/fiber-nodes-yml.test.ts create mode 100644 tests/fiber-scripts.test.ts create mode 100644 tests/fiber-status.test.ts diff --git a/.changeset/fiber-devnet.md b/.changeset/fiber-devnet.md new file mode 100644 index 00000000..52234e6c --- /dev/null +++ b/.changeset/fiber-devnet.md @@ -0,0 +1,5 @@ +--- +'@offckb/cli': minor +--- + +Add Fiber (FNN) support to the local devnet. The devnet genesis now carries the Fiber contracts `auth`, `funding_lock` and `commitment_lock` (pinned to the FNN v0.9.0-rc7 source), and a new `offckb fiber` command family manages a local Fiber environment: `offckb node --fiber` starts CKB, miner, RPC proxy and FNN nodes in one go, while `offckb fiber start` adds FNN nodes to an already-running devnet (with `--daemon`/`offckb fiber stop` for background operation). Each node gets its own built-in CKB account (node N → account N+2), network identity, RPC port 21713+N, P2P port 8343+N and a `fnn.log`; `offckb fiber status [--json]` reports live node health, `offckb fiber logs --node ` reads node logs, and `offckb fiber clean [--data]` removes stores or the whole fiber environment. Startup verifies the devnet spec, CKB and every FNN agree on the same chain and checks node identity keys, funding accounts and balances. Plain local devnet only — mainnet/testnet and forked devnets are rejected. `offckb clean` now also removes fiber stores with `--data` and refuses to delete data while a CKB/fiber daemon or a live FNN store lock can be confirmed. New devnets only: devnets initialized by earlier offckb versions lack the Fiber contracts and must be rebuilt (`offckb clean`) to use Fiber, which changes the genesis hash. diff --git a/.gitmodules b/.gitmodules index 2734029f..c044b4c6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -22,3 +22,6 @@ [submodule "ckb/ckb-system-scripts"] path = ckb/ckb-system-scripts url = https://github.com/nervosnetwork/ckb-system-scripts.git +[submodule "ckb/fiber"] + path = ckb/fiber + url = https://github.com/nervosnetwork/fiber.git diff --git a/Makefile b/Makefile index 78f34efd..5ecd4198 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.PHONY: all omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock pw-lock secp256k1_multisig_v2 +.PHONY: all omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock pw-lock secp256k1_multisig_v2 fiber -all: omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock pw-lock secp256k1_multisig_v2 +all: omnilock anyone-can-pay xudt spore ckb-js-vm nostr-lock pw-lock secp256k1_multisig_v2 fiber omnilock: @echo "Building omnilock via submodule" @@ -52,3 +52,21 @@ secp256k1_multisig_v2: @echo "Building secp256k1_multisig_v2 via submodule" cd ckb/ckb-system-scripts/ && make all-via-docker cp ckb/ckb-system-scripts/specs/cells/secp256k1_blake160_multisig_all ckb/devnet/specs/secp256k1_blake160_multisig_all_v2 + +# Fiber contracts are copied (not rebuilt) from the pinned ckb/fiber submodule +# (FNN v0.9.0-rc7, fiber commit bc361aa) and committed under +# ckb/devnet/specs/fiber/ so published packages work offline; re-run this +# target after re-pinning the submodule. The upstream binaries embed the +# builder's home directory in panic metadata, so the copies are sanitized +# below with equal-length replacements (contract logic is untouched). +fiber: + @echo "Copying Fiber contracts via submodule" + @test -d ckb/fiber/tests/deploy/contracts || \ + (echo "ckb/fiber submodule is missing. Run: git submodule update --init ckb/fiber" && exit 1) + mkdir -p ckb/devnet/specs/fiber + cp ckb/fiber/tests/deploy/contracts/auth ckb/devnet/specs/fiber/auth + cp ckb/fiber/tests/deploy/contracts/funding-lock ckb/devnet/specs/fiber/funding_lock + cp ckb/fiber/tests/deploy/contracts/commitment-lock ckb/devnet/specs/fiber/commitment_lock + cp ckb/fiber/config/testnet/config.yml ckb/devnet/specs/fiber/testnet-config.yml + perl -pi -e 's{/home/quake/}{/home/fiber/}g' ckb/devnet/specs/fiber/funding_lock + perl -pi -e 's{/Users/quake/}{/Users/fiber/}g' ckb/devnet/specs/fiber/commitment_lock diff --git a/README.md b/README.md index 5d99c069..fc04471d 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,11 @@ Options: Commands: node [CKB-Version] Use the CKB to start devnet node stop Stop the running CKB devnet daemon + fiber start [FNN-Version] Start Fiber (FNN) nodes on the running devnet CKB + fiber stop Stop the daemon-managed fiber nodes + fiber status Show the status of the local CKB and all fiber nodes + fiber logs --node Show the log of a fiber node + fiber clean Clean the fiber environment create [options] [project-name] Create a new CKB Smart Contract project in JavaScript. deploy [options] Deploy contracts to different networks, only supports devnet and testnet debug [options] Quickly debug transaction with tx-hash @@ -146,6 +151,8 @@ Stop the daemon later with: offckb node stop ``` +If a fiber environment is running in a foreground terminal, `node stop` refuses rather than orphaning its FNNs on a stopped chain — stop them there first, or pass `offckb node stop --force` to stop CKB anyway (the FNNs keep running). + **View Logs** A foreground `offckb node` stays quiet by default: it prints lifecycle events, contract script debug output (`debug!` in your scripts), submitted transaction hashes, and RPC errors. The node, miner, and RPC proxy always write full logs to files under the devnet data folder, and `offckb logs` reads them in any run mode (foreground, daemon, or while `offckb status` is attached): @@ -444,6 +451,43 @@ On a forked devnet, `offckb system-scripts`, transfers, deploys and `offckb debu `offckb transfer` fails closed on a Mainnet fork: non-built-in keys require `--allow-external-key-on-mainnet-fork`, and inputs copied from Mainnet are rejected even with that override. (`--allow-mainnet-replay-risk` from 0.4.9 remains as a deprecated alias.) +### 8. Run a Fiber Devnet {#fiber-devnet} + +OffCKB can start and manage a local [Fiber](https://github.com/nervosnetwork/fiber) development environment on top of the devnet: the Fiber contracts (`auth`, `funding_lock`, `commitment_lock`) live in the local chain's genesis block, and each FNN node gets its own CKB account, network identity, ports, data and log file. + +```sh +# Start CKB, miner, RPC proxy and 2 FNN nodes in one command +offckb node --fiber + +# Or start only the FNN nodes on an already-running devnet +offckb node +offckb fiber start + +# Background mode +offckb node --fiber --daemon # one manager for CKB + FNNs, stopped by `offckb node stop` +offckb fiber start --daemon # separate fiber manager, stopped by `offckb fiber stop` + +# Inspect +offckb fiber status [--json] +offckb fiber logs --node 1 [-f] + +# Clean up +offckb fiber clean --data # delete only the FNN stores (channels/payments) +offckb fiber clean # delete the whole fiber environment +``` + +- Only the plain local devnet is supported: no mainnet/testnet, and no forked devnet (a `fork.json` present in the devnet directory rejects Fiber startup). +- A devnet created by an offckb version without Fiber support does not have the Fiber contracts in its genesis. `fiber start` / `node --fiber` on such a devnet refuse with migration guidance: rebuild with `offckb clean` (which deletes the local chain data) and start again; a plain `offckb node` keeps working on the old devnet unchanged. +- Node `N` uses built-in CKB account `N+2` (accounts 3-18 are reserved for Fiber), RPC port `21713+N` and P2P port `8343+N`. Up to 16 nodes: `offckb fiber start --nodes 4`. +- `offckb fiber start [FNN-Version]` downloads a tested FNN release (currently `0.9.0-rc7`). Downloaded tarballs are verified against SHA-256 digests pinned in offckb before installation. Use `--binary-path ` (or `--fnn-binary-path ` with `node --fiber`) to run a locally built FNN. +- Every FNN writes its stdout/stderr to `devnet/fiber/nodes//fnn.log`, never to your terminal. Per-node FNN config overrides live in `devnet/fiber/nodes.yml` (regenerated `config.yml` files do not keep hand edits). Fields owned by offckb — chain, scripts, listening/bootnode addresses, store path, CKB RPC/UDT wiring, services — are managed and cannot be overridden there. +- Startup verifies that the devnet spec, the running CKB and every FNN agree on the same chain (genesis hash), and checks each node's identity key, CKB account and available balance before reporting ready. +- One fiber environment per machine: the RPC/P2P ports are fixed per node id, so a second concurrent fiber environment fails its port check. Note the CKB side of the check is the chain's genesis hash, and every plain offckb devnet shares the same genesis — if you run several offckb environments on one machine (e.g. separate `XDG_DATA_HOME`), make sure `fiber start` attaches to the CKB you actually started for it; when in doubt, check `offckb fiber status` against the environment you mean to use. +- UDT channels: the FNN config whitelists the devnet sUDT and xUDT issued by built-in account 19, so issue test UDTs from that account (`offckb udt issue ... --privkey-file` with account 19's key) to the node accounts before opening UDT channels. A few things that differ from plain CKB channels: + - `offckb udt issue ` takes the amount in the token's base unit (no 10^8 conversion): `3000` issues 0.00003 sUDT at 8 decimals, so issue generously before funding a channel. + - Both sides of a UDT channel must hold the UDT. The accepting node needs its own UDT cells — otherwise `accept_channel` fails with "can not find enough UDT owner cells" — so issue the UDT to both node accounts first. + - UDT channels are not auto-accepted (CKB channels are). After `open_channel` the peer stays in `NegotiatingFunding` until you call `accept_channel` on the peer node, and its `funding_amount` must be `0x0` — a nonzero amount fails with "invalid funding tx". + ## Config Setting ### List All Settings @@ -501,6 +545,8 @@ LOG_LEVEL=debug offckb node - version: 1.0.0 - [x] Nostr-Lock https://github.com/cryptape/nostr-binding/tree/main/contracts/nostr-lock - version: 25dd59d +- [x] Fiber (auth / funding-lock / commitment-lock) https://github.com/nervosnetwork/fiber + - commit id: bc361aa (FNN v0.9.0-rc7) - [x] Type ID built-in ## Accounts diff --git a/ckb/devnet/specs/dev.toml b/ckb/devnet/specs/dev.toml index c3b8f84f..ee79174f 100644 --- a/ckb/devnet/specs/dev.toml +++ b/ckb/devnet/specs/dev.toml @@ -79,6 +79,15 @@ create_type_id = false [[genesis.system_cells]] file = { file = "secp256k1_blake160_multisig_all_v2" } create_type_id = false +[[genesis.system_cells]] +file = { file = "fiber/auth" } +create_type_id = false +[[genesis.system_cells]] +file = { file = "fiber/funding_lock" } +create_type_id = false +[[genesis.system_cells]] +file = { file = "fiber/commitment_lock" } +create_type_id = false [genesis.system_cells_lock] code_hash = "0x0000000000000000000000000000000000000000000000000000000000000000" diff --git a/ckb/devnet/specs/fiber/auth b/ckb/devnet/specs/fiber/auth new file mode 100755 index 0000000000000000000000000000000000000000..4c4b885b8a07cd5c8e73fe56ee84181d71497837 GIT binary patch literal 150904 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

7t@vMHfqmr8fKkO8jo~o^6-)DJ889NvB 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 YxE=}ak~r+15tmQ=ITHMmb|umO1vmEO0{{R3 literal 0 HcmV?d00001 diff --git a/ckb/devnet/specs/fiber/commitment_lock b/ckb/devnet/specs/fiber/commitment_lock new file mode 100755 index 0000000000000000000000000000000000000000..698ef3bcff7031b203fea9d3fddf06e78ebbf3a3 GIT binary patch literal 111880 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 literal 0 HcmV?d00001 diff --git a/ckb/devnet/specs/fiber/testnet-config.yml b/ckb/devnet/specs/fiber/testnet-config.yml new file mode 100644 index 00000000..d87c471d --- /dev/null +++ b/ckb/devnet/specs/fiber/testnet-config.yml @@ -0,0 +1,104 @@ +# This configuration file only contains the necessary configurations for the testnet deployment. +# All options' descriptions can be found via `fnn --help` and be overridden by command line arguments or environment variables. +fiber: + listening_addr: "/ip4/0.0.0.0/tcp/8228" + # Node name announced to the Fiber network. It is shown in RPC responses, + # the TUI header, and the network graph. + # announced_node_name: "my-fiber-node" + # Disable automatic peer reconnect backoff after disconnects. + # enable_peer_reconnect_backoff: true + bootnode_addrs: + - "/ip4/54.179.226.154/tcp/8228/p2p/Qmes1EBD4yNo9Ywkfe6eRw9tG1nVNGLDmMud1xJMsoYFKy" + - "/ip4/16.163.7.105/tcp/8228/p2p/QmdyQWjPtbK4NWWsvy8s69NGJaQULwgeQDT5ZpNDrTNaeV" + announce_listening_addr: true + announced_addrs: + # If you want to announce your fiber node public address to the network, you need to add the address here, please change the ip to your public ip accordingly. + # - "/ip4/YOUR-FIBER-NODE-PUBLIC-IP/tcp/8228" + chain: testnet + + ## SOCKS5 proxy settings + ## Uncomment to route all outbound P2P connections through a SOCKS5 proxy (e.g. Tor). + # proxy: + # proxy_url: "socks5://127.0.0.1:9050" + # ## Use random username/password for each proxy connection to improve Tor stream isolation. [default: true] + # proxy_random_auth: true + + ## Tor onion hidden service settings + ## Uncomment to make this node reachable via a .onion address. + ## Requires a running Tor daemon with ControlPort enabled. + # onion: + # listen_on_onion: false + # ## Tor SOCKS5 proxy url for routing .onion address connections. e.g. 127.0.0.1:9050 + # onion_server: "127.0.0.1:9050" + # ## The local address that the onion service forwards traffic to. + # ## If not set, it is derived from listening_addr. e.g. "127.0.0.1:8228" + # p2p_listen_address: "127.0.0.1:8228" + # ## Path to store the onion service private key. [default: $BASE_DIR/fiber/onion_private_key] + # onion_private_key_path: "" + # ## Tor controller address. [default: 127.0.0.1:9051] + # tor_controller: "127.0.0.1:9051" + # ## Tor controller hashed password (if HashedControlPassword is set in torrc). + # tor_password: "" + # ## The external port exposed by the onion service. [default: 8228] + # onion_external_port: 8228 + # ## Maximum time in seconds to wait for the onion service to register with Tor. [default: 5] + # onion_service_start_timeout: 5 + # lock script configurations related to fiber network + # https://github.com/nervosnetwork/fiber-scripts/blob/main/deployment/testnet/migrations/2025-02-28-111246.json + scripts: + - name: FundingLock + script: + code_hash: 0x6c67887fe201ee0c7853f1682c0b77c0e6214044c156c7558269390a8afa6d7c + hash_type: type + args: 0x + cell_deps: + - type_id: + code_hash: 0x00000000000000000000000000000000000000000000000000545950455f4944 + hash_type: type + args: 0x3cb7c0304fe53f75bb5727e2484d0beae4bd99d979813c6fc97c3cca569f10f6 + - cell_dep: + out_point: + tx_hash: 0x12c569a258dd9c5bd99f632bb8314b1263b90921ba31496467580d6b79dd14a7 # ckb_auth + index: 0x0 + dep_type: code + - name: CommitmentLock + script: + code_hash: 0x740dee83f87c6f309824d8fd3fbdd3c8380ee6fc9acc90b1a748438afcdf81d8 + hash_type: type + args: 0x + cell_deps: + - type_id: + code_hash: 0x00000000000000000000000000000000000000000000000000545950455f4944 + hash_type: type + args: 0xf7e458887495cf70dd30d1543cad47dc1dfe9d874177bf19291e4db478d5751b + - cell_dep: + out_point: + tx_hash: 0x12c569a258dd9c5bd99f632bb8314b1263b90921ba31496467580d6b79dd14a7 #ckb_auth + index: 0x0 + dep_type: code + +rpc: + # By default RPC only binds to localhost, thus it only allows accessing from the same machine. + # Allowing arbitrary machines to access the JSON-RPC port is dangerous and strongly discouraged. + # Please strictly limit the access to only trusted machines. + listening_addr: "127.0.0.1:8227" + +ckb: + rpc_url: "https://testnet.ckbapp.dev/" + udt_whitelist: + - name: RUSD + script: + code_hash: 0x1142755a044bf2ee358cba9f2da187ce928c91cd4dc8692ded0337efa677d21a + hash_type: type + args: 0x878fcc6f1f08d48e87bb1c3b3d5083f23f8a39c5d5c764f253b55b998526439b + cell_deps: + - type_id: + code_hash: 0x00000000000000000000000000000000000000000000000000545950455f4944 + hash_type: type + args: 0x97d30b723c0b2c66e9cb8d4d0df4ab5d7222cbb00d4a9a2055ce2e5d7f0d8b0f + auto_accept_amount: 1000000000 + +services: + - fiber + - rpc + - ckb diff --git a/ckb/fiber b/ckb/fiber new file mode 160000 index 00000000..bc361aaa --- /dev/null +++ b/ckb/fiber @@ -0,0 +1 @@ +Subproject commit bc361aaaa40d1394b83e6a1808869b0b06c48c13 diff --git a/jest.config.js b/jest.config.js index 07a73c1e..12c90b47 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,6 +16,23 @@ module.exports = { global: { statements: 10, }, + // Ratchet for the fiber lifecycle layer and the daemon identity module + // (the fail-closed safety properties must stay regression-protected). + // Thresholds sit below the current coverage on every CI platform — + // POSIX-only tests skip on Windows, so the margins absorb that variance; + // raise them as coverage grows, never lower them to make a run pass. + './src/fiber/': { + statements: 50, + branches: 32, + functions: 55, + lines: 50, + }, + './src/util/daemon.ts': { + statements: 65, + branches: 50, + functions: 75, + lines: 65, + }, }, moduleNameMapper: { '^@/(.*)$': '/src/$1', diff --git a/package.json b/package.json index 91c6adfb..ae0a1bf0 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@types/adm-zip": "^0.5.5", "@types/blessed": "0.1.27", "@types/jest": "^30.0.0", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.17.24", "@types/node-fetch": "^2.6.11", "@types/semver": "^7.5.7", @@ -85,6 +86,7 @@ "commander": "^12.0.0", "http-proxy": "^1.18.1", "https-proxy-agent": "^7.0.5", + "js-yaml": "4.3.1", "node-fetch": "2", "semver": "^7.6.0", "tar": "^7.5.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index feba3231..0d655227 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: https-proxy-agent: specifier: ^7.0.5 version: 7.0.6 + js-yaml: + specifier: 4.3.1 + version: 4.3.1 node-fetch: specifier: '2' version: 2.7.0 @@ -81,6 +84,9 @@ importers: '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': specifier: ^20.17.24 version: 20.17.24 @@ -864,6 +870,9 @@ packages: '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -4194,6 +4203,8 @@ snapshots: expect: 30.2.0 pretty-format: 30.2.0 + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/node-fetch@2.6.12': diff --git a/src/cfg/setting.ts b/src/cfg/setting.ts index f96de691..4990d88e 100644 --- a/src/cfg/setting.ts +++ b/src/cfg/setting.ts @@ -29,6 +29,7 @@ export interface Settings { bins: { rootFolder: string; defaultCKBVersion: string; + defaultFnnVersion: string; downloadPath: string; }; devnet: { @@ -67,6 +68,7 @@ export const defaultSettings: Settings = { bins: { rootFolder: path.resolve(dataPath, 'bins'), defaultCKBVersion: '0.208.0', + defaultFnnVersion: '0.9.0-rc7', downloadPath: path.resolve(cachePath, 'download'), }, devnet: { diff --git a/src/cli.ts b/src/cli.ts index c02ba167..6108cf6c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,10 +18,21 @@ import { debugSingleScript, debugTransaction, parseSingleScriptOption } from './ import { logsCommand, LogsOptions } from './cmd/logs'; import { printSystemScripts } from './cmd/system-scripts'; import { transferAll } from './cmd/transfer-all'; +import { + fiberCleanCommand, + fiberLogs, + fiberStart, + fiberStatusCommand, + fiberStopCommand, + FiberLogsOptions, + FiberStartOptions, +} from './cmd/fiber'; +import { FiberCleanOptions } from './fiber/clean'; import { genSystemScriptsJsonFile } from './scripts/gen'; import { CKBDebugger } from './tools/ckb-debugger'; import { resolveMainnetForkOverride } from './util/fork-safety'; import { logger } from './util/logger'; +import { installBrokenPipeHandlers } from './util/shutdown'; import { Network } from './type/base'; import { status } from './cmd/status'; @@ -81,10 +92,29 @@ const nodeCommand = program '--verbose', 'Print the full raw node/miner output (default shows lifecycle events, script output, tx hashes, and RPC errors)', ) + .option('--fiber', 'Also start Fiber (FNN) nodes on the devnet (plain local chain only)') + .option('--fnn-version ', 'Specify the FNN version to use with --fiber') + .option('--fnn-binary-path ', 'Specify a locally built FNN binary to use with --fiber') + .option('--fiber-nodes ', 'Number of FNN nodes to start with --fiber (1-16, default 2)', (value: string) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 16) { + throw new InvalidArgumentError('--fiber-nodes must be an integer between 1 and 16'); + } + return parsed; + }) .action( async ( version: string, - options: { network: Network; binaryPath?: string; daemon?: boolean; verbose?: boolean }, + options: { + network: Network; + binaryPath?: string; + daemon?: boolean; + verbose?: boolean; + fiber?: boolean; + fnnVersion?: string; + fiberNodes?: number; + fnnBinaryPath?: string; + }, ) => { return startNode({ version, @@ -92,6 +122,10 @@ const nodeCommand = program binaryPath: options.binaryPath, daemon: options.daemon, verbose: options.verbose, + fiber: options.fiber, + fnnVersion: options.fnnVersion, + fiberNodes: options.fiberNodes, + fnnBinaryPath: options.fnnBinaryPath, }); }, ); @@ -99,7 +133,63 @@ const nodeCommand = program nodeCommand .command('stop') .description('Stop the running CKB devnet daemon') - .action(async () => stopNode()); + .option('--force', 'Stop CKB even while a foreground fiber environment is running (its FNNs keep running)') + .action(async (options: { force?: boolean }) => stopNode(options)); + +const fiberCommand = program.command('fiber').description('Manage Fiber (FNN) nodes on the local devnet'); + +fiberCommand + .command('start [FNN-Version]') + .description('Start Fiber (FNN) nodes on the running devnet CKB') + .option('--nodes ', 'Total number of FNN nodes (1-16, default 2)', (value: string) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 16) { + throw new InvalidArgumentError('--nodes must be an integer between 1 and 16'); + } + return parsed; + }) + .option('-b, --binary-path ', 'Specify a locally built FNN binary path to use') + .option('--daemon', 'Run the fiber nodes in the background as a daemon') + .action(async (version: string | undefined, options: FiberStartOptions) => { + return fiberStart(version, options); + }); + +fiberCommand + .command('stop') + .description('Stop the daemon-managed fiber nodes') + .action(async () => fiberStopCommand()); + +fiberCommand + .command('status') + .description('Show the status of the local CKB and all fiber nodes') + .action(async () => fiberStatusCommand()); + +fiberCommand + .command('logs') + .description('Show the log of a fiber node') + .requiredOption('--node ', 'Which fiber node to read logs from', (value: string) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new InvalidArgumentError('--node must be a positive integer'); + } + return parsed; + }) + .option('-f, --follow', 'Stream new log lines as they are written (like tail -f)') + .option('--tail ', 'Show the last N lines before following', (value: string) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new InvalidArgumentError('--tail must be a non-negative integer'); + } + return parsed; + }) + .action((options: FiberLogsOptions) => fiberLogs(options)); + +fiberCommand + .command('clean') + .description('Clean the fiber environment (does not touch the devnet CKB config or chain data)') + .option('-d, --data', 'Only remove the FNN stores, keep node accounts, identity keys and configs') + .option('-y, --yes', 'Skip the confirmation prompt') + .action(async (options: FiberCleanOptions) => fiberCleanCommand(options)); program .command('logs') @@ -345,17 +435,6 @@ function normalizeGlobalJsonFlag(argv: string[]): string[] { return [argv[0], argv[1], '--json', ...argv.slice(2).filter((arg) => arg !== '--json')]; } -function installBrokenPipeHandlers() { - for (const stream of [process.stdout, process.stderr]) { - stream.on('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'EPIPE') { - process.exit(0); - } - throw error; - }); - } -} - function configureCommanderErrors(command: Command) { command.exitOverride(); command.configureOutput({ diff --git a/src/cmd/clean.ts b/src/cmd/clean.ts index a7c5738c..9a2c8186 100644 --- a/src/cmd/clean.ts +++ b/src/cmd/clean.ts @@ -2,41 +2,85 @@ import fs from 'fs'; import { isFolderExists } from '../util/fs'; import { readSettings } from '../cfg/setting'; import { logger } from '../util/logger'; +import { isProcessAlive, nodeDaemonPaths, readPidFile } from '../util/daemon'; +import { acquireEnvLock } from '../fiber/env-lock'; +import { assertFiberFullyStopped } from '../fiber/clean'; +import { fiberNodeIds, fiberNodePaths } from '../fiber/paths'; +import { removeRuntimeFileIfStale } from '../fiber/runtime'; export interface CleanOptions { data?: boolean; } +function assertCkbDaemonStopped() { + const pidFile = nodeDaemonPaths(readSettings()).pidFile; + const metadata = readPidFile(pidFile); + if (metadata && Number.isInteger(metadata.pid) && metadata.pid > 0 && isProcessAlive(metadata.pid)) { + throw new Error( + `The CKB devnet daemon is still running (PID ${metadata.pid}). Stop it first with: offckb node stop`, + ); + } +} + +function fiberStoreDirs(settings: ReturnType): string[] { + return fiberNodeIds(settings) + .map((id) => fiberNodePaths(id, settings).fiberStoreDir) + .filter((storeDir) => isFolderExists(storeDir)); +} + export function clean(options?: CleanOptions) { const settings = readSettings(); const allDevnetDataPath = settings.devnet.configPath; const dataOnly = options?.data || false; - if (dataOnly) { - // Only clean the chain data subdirectory - const chainDataPath = settings.devnet.dataPath; - if (isFolderExists(chainDataPath)) { - try { - fs.rmSync(chainDataPath, { recursive: true }); - logger.info(`Chain data cleaned. Devnet config files preserved.`); - } catch (error: unknown) { - throw new Error(`Failed to clean chain data. Did you stop the chain first? ${(error as Error).message}`); + // The environment lock lives next to the devnet directory, so it can be + // held while the whole devnet tree (including every fiber store) is + // deleted; other OffCKB processes stay out for the whole operation. + const lock = acquireEnvLock(dataOnly ? 'offckb clean --data' : 'offckb clean'); + try { + assertCkbDaemonStopped(); + // Any fiber data being removed requires every FNN stopped; refusing when + // that cannot be confirmed is cheaper than corrupting a live store. + assertFiberFullyStopped(settings); + + if (dataOnly) { + // Only clean the chain data subdirectory + const chainDataPath = settings.devnet.dataPath; + if (isFolderExists(chainDataPath)) { + try { + fs.rmSync(chainDataPath, { recursive: true }); + logger.info(`Chain data cleaned. Devnet config files preserved.`); + } catch (error: unknown) { + throw new Error(`Failed to clean chain data. Did you stop the chain first? ${(error as Error).message}`); + } + } else { + logger.info(`Nothing to clean. Chain data directory ${chainDataPath} not found.`); } - } else { - logger.info(`Nothing to clean. Chain data directory ${chainDataPath} not found.`); - } - } else { - // Clean everything - the original behavior - // this is the root folder of devnet, it contains config, data, debugFullTransactions, transactions, failed-transactions, contracts - if (isFolderExists(allDevnetDataPath)) { - try { - fs.rmSync(allDevnetDataPath, { recursive: true }); - logger.info(`Chain data cleaned.`); - } catch (error: unknown) { - throw new Error(`Failed to clean devnet data. Did you stop the chain first? ${(error as Error).message}`); + + // Fiber stores (channels, payments, runtime records) can no longer map + // onto the reset chain and are removed too; node configs, keys and + // passwords are kept. + removeRuntimeFileIfStale(settings); + for (const storeDir of fiberStoreDirs(settings)) { + fs.rmSync(storeDir, { recursive: true, force: true }); + logger.info(`Fiber store cleaned: ${storeDir}`); } } else { - logger.info(`Nothing to clean. Devnet data directory ${allDevnetDataPath} not found.`); + // Clean everything - the original behavior + // this is the root folder of devnet, it contains config, data, debugFullTransactions, transactions, failed-transactions, contracts + // and the whole fiber environment (configs, keys, stores, logs) + if (isFolderExists(allDevnetDataPath)) { + try { + fs.rmSync(allDevnetDataPath, { recursive: true }); + logger.info(`Chain data cleaned.`); + } catch (error: unknown) { + throw new Error(`Failed to clean devnet data. Did you stop the chain first? ${(error as Error).message}`); + } + } else { + logger.info(`Nothing to clean. Devnet data directory ${allDevnetDataPath} not found.`); + } } + } finally { + lock.release(); } } diff --git a/src/cmd/config.ts b/src/cmd/config.ts index ab1f5dca..f4276436 100644 --- a/src/cmd/config.ts +++ b/src/cmd/config.ts @@ -13,6 +13,7 @@ export enum ConfigAction { export enum ConfigItem { proxy = 'proxy', ckbVersion = 'ckb-version', + fnnVersion = 'fnn-version', } export async function Config(action: ConfigAction, item: ConfigItem, value?: string) { @@ -38,6 +39,12 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str return logger.info(`${version}`); } + case ConfigItem.fnnVersion: { + const settings = readSettings(); + const version = settings.bins.defaultFnnVersion; + return logger.info(`${version}`); + } + default: break; } @@ -73,6 +80,18 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str return writeSettings(settings); } + case ConfigItem.fnnVersion: { + if (!isValidVersion(value)) { + throw new Error( + `invalid version value, ${value}. Check available versions on https://github.com/nervosnetwork/fiber/tags`, + ); + } + const settings = readSettings(); + const version = extractVersion(value!); + settings.bins.defaultFnnVersion = version; + return writeSettings(settings); + } + default: break; } diff --git a/src/cmd/fiber.ts b/src/cmd/fiber.ts new file mode 100644 index 00000000..dddad786 --- /dev/null +++ b/src/cmd/fiber.ts @@ -0,0 +1,202 @@ +import { readSettings, Settings } from '../cfg/setting'; +import { logger } from '../util/logger'; +import { assertPlainDevnet, assertCkbEnvReadyForFiber } from '../fiber/ckb-env'; +import { acquireEnvLock } from '../fiber/env-lock'; +import { resolveFnnBinary } from '../fiber/install'; +import { resolveFiberChainScripts, FiberContractsMissingError } from '../fiber/scripts'; +import { startFiberEnvironment, stopFiberNodes, FiberEnvironment } from '../fiber/manager'; +import { startFiberDaemon, stopFiber } from '../fiber/daemon'; +import { fiberStatus } from '../fiber/status'; +import { fiberClean, FiberCleanOptions } from '../fiber/clean'; +import { fiberAccountIndex, fiberNodePaths, FIBER_DAEMON_PID_FILE, fiberDaemonPaths } from '../fiber/paths'; +import { readNodesYml } from '../fiber/nodes-yml'; +import { readLogTail, followLogFile } from '../devnet/log-file'; +import { cleanupPidFile } from '../util/daemon'; +import { enterGracefulShutdown } from '../util/shutdown'; +import * as fs from 'fs'; + +export interface FiberStartOptions { + nodes?: number; + binaryPath?: string; + daemon?: boolean; +} + +function fiberDaemonChildArgs(): string[] { + return process.argv.slice(2).filter((arg) => arg !== '--daemon'); +} + +// The error message carries the full migration guidance (rebuild via +// `offckb clean`); `node --fiber` surfaces the same text by rethrowing. +function logMissingContractsGuidance(error: FiberContractsMissingError) { + logger.error(error.message); +} + +export function printFiberSummary(env: FiberEnvironment) { + logger.success(`Fiber environment is ready (${env.nodes.length} node(s)).`); + for (const node of env.nodes) { + const info = env.nodeInfos.get(node.id); + const version = info ? `${info.version} (${(info.commit_hash || '').slice(0, 7) || 'unknown commit'})` : 'unknown'; + logger.info( + ` node ${node.id}: FNN ${version}, RPC ${node.rpcUrl}, account #${fiberAccountIndex(node.id)}, log: ${node.logFile}`, + ); + } +} + +/** + * Keep the current process managing the FNN children until one of them exits + * or a stop signal arrives. An unexpected child exit stops the rest of the + * group; a signal stops the children, drops runtime.json and exits. + */ +export async function superviseFiberNodes( + env: FiberEnvironment, + settings: Settings, + extraCleanup?: () => void, +): Promise { + let stopping = false; + const stopAll = async (reason: string, exitCode: number): Promise => { + if (stopping) { + // A second FNN exit while we are already stopping: nothing more to do. + return new Promise(() => {}); + } + stopping = true; + // Committed to tearing down: a broken stdout/stderr pipe must not abort + // the cleanup below (see util/shutdown.ts). + enterGracefulShutdown(); + if (reason) logger.error(reason); + await stopFiberNodes(env.nodes, settings); + if (process.env.OFFCKB_DAEMON_CHILD === '1') { + cleanupPidFile(fiberDaemonPaths(settings).pidFile); + } + extraCleanup?.(); + process.exit(exitCode); + }; + + for (const node of env.nodes) { + node.process.once('exit', (code, signal) => { + void stopAll( + `FNN node ${node.id} exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'none'}). ` + + `See its log: ${node.logFile}`, + typeof code === 'number' && code > 0 ? code : 1, + ); + }); + } + + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + void stopAll(`Received ${signal}, stopping fiber nodes...`, signal === 'SIGINT' ? 130 : 143); + }); + } + + // FNN children keep the event loop alive; this promise resolves only via stopAll. + return new Promise(() => {}); +} + +/** + * `offckb fiber start`: launch only the FNN side of the devnet on top of an + * already-running CKB environment. Never starts, stops or replaces CKB, the + * miner or the RPC proxy. + */ +export async function fiberStart(version: string | undefined, options: FiberStartOptions) { + const settings = readSettings(); + // Network/fork checks run before anything else — including before a + // --daemon respawn, so an unsupported environment fails in the foreground. + assertPlainDevnet(settings); + + if (options.daemon) { + return startFiberDaemon(fiberDaemonChildArgs(), settings); + } + + const lock = acquireEnvLock('offckb fiber start'); + try { + await assertCkbEnvReadyForFiber(settings); + + const fnn = await resolveFnnBinary({ version, binaryPath: options.binaryPath }, settings); + let chainScripts; + try { + chainScripts = resolveFiberChainScripts(); + } catch (error) { + if (error instanceof FiberContractsMissingError) { + logMissingContractsGuidance(error); + process.exitCode = 1; + return; + } + throw error; + } + + const env = await startFiberEnvironment({ + fnnPath: fnn.fnnPath, + testnetConfigPath: fnn.testnetConfigPath, + chainScripts, + nodeCount: options.nodes, + settings, + }); + printFiberSummary(env); + logger.result({ + command: 'fiber.start', + daemon: false, + nodes: env.nodes.map((node) => ({ + id: node.id, + pid: node.process.pid, + rpcUrl: node.rpcUrl, + logFile: node.logFile, + })), + }); + lock.release(); + return superviseFiberNodes(env, settings); + } catch (error) { + lock.release(); + throw error; + } +} + +export async function fiberStopCommand() { + const settings = readSettings(); + const lock = acquireEnvLock('offckb fiber stop'); + try { + await stopFiber(settings); + } finally { + lock.release(); + } +} + +export async function fiberStatusCommand() { + await fiberStatus(readSettings()); +} + +export interface FiberLogsOptions { + node: number; + follow?: boolean; + tail?: number; +} + +export function fiberLogs(options: FiberLogsOptions) { + const settings = readSettings(); + const nodeId = Number(options.node); + if (!Number.isInteger(nodeId) || nodeId <= 0) { + throw new Error('--node must be a positive integer (the node number, e.g. --node 1).'); + } + const entries = readNodesYml(settings); + if (entries == null || !entries.some((entry) => entry.id === nodeId)) { + throw new Error(`Fiber node ${nodeId} does not exist (no matching entry in fiber/nodes.yml).`); + } + const { logFile } = fiberNodePaths(nodeId, settings); + if (!fs.existsSync(logFile)) { + throw new Error(`Fiber node ${nodeId} has no log yet (${logFile} has not been created).`); + } + + const tail = options.tail ?? 100; + for (const line of readLogTail(logFile, tail)) { + logger.info(line); + } + if (options.follow) { + followLogFile(logFile, (line) => logger.info(line)); + } + logger.result({ command: 'fiber.logs', node: nodeId, logFile, follow: !!options.follow }); +} + +export async function fiberCleanCommand(options: FiberCleanOptions) { + await fiberClean(options, readSettings()); +} + +// Re-exported so `node --fiber` can share the same pieces without a cycle. +export { assertPlainDevnet, resolveFnnBinary, resolveFiberChainScripts, startFiberEnvironment, FIBER_DAEMON_PID_FILE }; diff --git a/src/cmd/node.ts b/src/cmd/node.ts index 3f743fd9..b7c43bbc 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -1,4 +1,4 @@ -import { execFile, execFileSync, spawn, ChildProcess } from 'child_process'; +import { execFileSync, spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { @@ -8,7 +8,7 @@ import { TERMINAL_RPC_MIN_CKB_VERSION, } from '../node/init-chain'; import { getVersionFromBinary, installCKBBinary } from '../node/install'; -import { getCKBBinaryPath, readSettings } from '../cfg/setting'; +import { getCKBBinaryPath, readSettings, Settings } from '../cfg/setting'; import { createRPCProxy } from '../tools/rpc-proxy'; import { markForkFirstRunComplete, readForkState } from '../devnet/fork'; import { callJsonRpc } from '../util/json-rpc'; @@ -17,6 +17,30 @@ import { logger } from '../util/logger'; import { checkNodeReadiness, waitForNodeReady } from '../devnet/readiness'; import { devnetTcpListenAddress, subscribeToNodeLogs, SubscriptionHandle } from '../devnet/log-subscription'; import { SCRIPT_LOG_TARGET } from '../devnet/log-file'; +import { + cleanupPidFile, + closeFileDescriptors, + isProcessAlive, + nodeDaemonPaths, + PidMetadata, + readPidFile, + reservePidFile, + resolveCliEntry, + terminateProcess, + verifyDaemonIdentity, + waitForProcessExit, + writePidFile, +} from '../util/daemon'; +import { assertPlainDevnet } from '../fiber/ckb-env'; +import { acquireEnvLock, EnvLockHandle } from '../fiber/env-lock'; +import { resolveFnnBinary, ResolvedFnn } from '../fiber/install'; +import { resolveFiberChainScripts } from '../fiber/scripts'; +import { FiberEnvironment, startFiberEnvironment, stopFiberNodes } from '../fiber/manager'; +import { printFiberSummary } from './fiber'; +import { readRuntime } from '../fiber/runtime'; +import { enterGracefulShutdown } from '../util/shutdown'; +import { fiberDaemonPaths } from '../fiber/paths'; +import { assertNodeStopDoesNotOrphanFiber, FIBER_DAEMON_READY_TIMEOUT_MS } from '../fiber/daemon'; export interface NodeProp { version?: string; @@ -24,18 +48,12 @@ export interface NodeProp { binaryPath?: string; daemon?: boolean; verbose?: boolean; + fiber?: boolean; + fnnVersion?: string; + fiberNodes?: number; + fnnBinaryPath?: string; } -interface PidMetadata { - pid: number; - scriptPath: string; - startedAt: string; - status?: 'starting' | 'running'; -} - -const DAEMON_LOG_DIR = 'logs'; -const DAEMON_LOG_FILE = 'daemon.log'; -const DAEMON_PID_FILE = 'daemon.pid'; const DAEMON_CHILD_ENV = 'OFFCKB_DAEMON_CHILD'; const NODE_READY_TIMEOUT_MS = 90_000; const FORK_NODE_READY_TIMEOUT_MS = 10 * 60_000; @@ -52,7 +70,17 @@ function cleanChildOutput(data: unknown): string { .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, ''); } -export function startNode({ version, network = Network.devnet, binaryPath, daemon, verbose }: NodeProp) { +export function startNode({ + version, + network = Network.devnet, + binaryPath, + daemon, + verbose, + fiber, + fnnVersion, + fiberNodes, + fnnBinaryPath, +}: NodeProp) { if (binaryPath && network !== Network.devnet) { logger.warn('Custom binaryPath is only supported for devnet. The provided binaryPath will be ignored.'); } @@ -60,9 +88,18 @@ export function startNode({ version, network = Network.devnet, binaryPath, daemo logger.warn('Daemon mode is only supported for devnet. The daemon flag will be ignored.'); } + if (fiber) { + if (network !== Network.devnet) { + throw new Error(`--fiber is only supported on the plain local devnet; --network ${network} cannot be used.`); + } + // A forked devnet is rejected before any daemon respawn, so an + // unsupported environment always fails in the foreground. + assertPlainDevnet(readSettings()); + } + switch (network) { case Network.devnet: - return nodeDevnet({ version, binaryPath, daemon, verbose }); + return nodeDevnet({ version, binaryPath, daemon, verbose, fiber, fnnVersion, fiberNodes, fnnBinaryPath }); case Network.testnet: return nodeTestnet(); case Network.mainnet: @@ -72,13 +109,42 @@ export function startNode({ version, network = Network.devnet, binaryPath, daemo } } -export async function nodeDevnet({ version, binaryPath, daemon, verbose }: NodeProp) { +export async function nodeDevnet(props: NodeProp) { + const { daemon, fiber } = props; if (daemon) { - return startDaemon(); + return startDaemon(!!fiber); } const settings = readSettings(); + // --fiber shares the devnet environment with the fiber commands, so it + // takes the same environment lock before mutating anything, and refuses to + // adopt an already-running CKB (use `offckb fiber start` for that). + let envLock: EnvLockHandle | null = null; + if (fiber) { + const occupied = await checkNodeReadiness(settings.devnet.rpcUrl, 1000); + if (occupied.ready) { + throw new Error( + `A CKB node is already answering at ${settings.devnet.rpcUrl}. OffCKB does not take over a node it did not start; ` + + 'add FNN nodes to it with: offckb fiber start', + ); + } + envLock = acquireEnvLock('offckb node --fiber'); + } + try { + return await runNodeDevnet(props, envLock, settings); + } catch (error) { + envLock?.release(); + throw error; + } +} + +async function runNodeDevnet( + { version, binaryPath, verbose, fiber, fnnVersion, fiberNodes, fnnBinaryPath }: NodeProp, + envLock: EnvLockHandle | null, + settings: Settings, +) { const ckbVersion = version || settings.bins.defaultCKBVersion; + let ckbBinPath = ''; // The version the chain config will be validated against. A managed binary // knows its version by construction; a custom --binary-path is probed, and @@ -155,6 +221,16 @@ export async function nodeDevnet({ version, binaryPath, daemon, verbose }: NodeP ckbExited = true; }); + // With --fiber, FNN selection/download starts as soon as CKB begins to + // start, so it overlaps with the devnet readiness wait below. + let fnnPrep: Promise | null = null; + if (fiber) { + fnnPrep = resolveFnnBinary({ version: fnnVersion, binaryPath: fnnBinaryPath }, settings); + fnnPrep.catch(() => { + // surfaced when awaited after the CKB environment is ready + }); + } + const timeoutMs = forkState ? FORK_NODE_READY_TIMEOUT_MS : NODE_READY_TIMEOUT_MS; const readiness = await waitForNodeReady(settings.devnet.rpcUrl, timeoutMs, () => !ckbExited); if (!readiness.ready) { @@ -226,30 +302,120 @@ export async function nodeDevnet({ version, binaryPath, daemon, verbose }: NodeP if (!verbose) { logger.info('Follow the full node log with: offckb logs -f'); } + + // The CKB environment is up. With --fiber, wait for the FNN binary + // preparation (started above, concurrent with CKB startup) and run the + // shared Fiber startup flow. Any failure stops everything started here. + let fiberEnv: FiberEnvironment | null = null; + if (fiber && fnnPrep) { + const stopStartedProcesses = () => { + logSubscription?.close(); + if (!ckbProcess.killed) ckbProcess.kill('SIGTERM'); + if (!minerProcess.killed) minerProcess.kill('SIGTERM'); + proxy.stop(); + envLock?.release(); + }; + try { + const fnn = await fnnPrep; + fiberEnv = await startFiberEnvironment({ + fnnPath: fnn.fnnPath, + testnetConfigPath: fnn.testnetConfigPath, + chainScripts: resolveFiberChainScripts(), + nodeCount: fiberNodes, + settings, + }); + } catch (error) { + stopStartedProcesses(); + throw error; + } + printFiberSummary(fiberEnv); + // The environment is built; further mutations by other OffCKB processes + // (stop/clean) check the manager records instead of the lock. + envLock?.release(); + envLock = null; + } + logger.result({ command: 'node', network: Network.devnet, daemon: false, rpcUrl: settings.devnet.rpcUrl, proxyUrl: `http://127.0.0.1:${settings.devnet.rpcProxyPort}`, + ...(fiberEnv + ? { fiber: fiberEnv.nodes.map((node) => ({ id: node.id, pid: node.process.pid, rpcUrl: node.rpcUrl })) } + : {}), }); - // Treat CKB, miner and proxy as one service. A dead CKB must not leave a - // healthy-looking proxy and a miner that retries forever. - let serviceStopping = false; - const stopService = (component: 'CKB node' | 'CKB miner', code: number | null, signal: NodeJS.Signals | null) => { - if (serviceStopping) return; - serviceStopping = true; - logSubscription?.close(); - if (component !== 'CKB node' && !ckbProcess.killed) ckbProcess.kill('SIGTERM'); - if (component !== 'CKB miner' && !minerProcess.killed) minerProcess.kill('SIGTERM'); - proxy.stop(); - if (process.env[DAEMON_CHILD_ENV] === '1') cleanupPidFile(resolveDaemonPaths().pidFile); - logger.error(`${component} exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'none'}).`); - process.exitCode = typeof code === 'number' && code > 0 ? code : 1; + // Treat CKB, miner, proxy and the FNNs as one service. A dead component + // must not leave the rest looking healthy. + // + // Component-exit and Ctrl+C/SIGTERM shutdowns share ONE cleanup promise: + // whoever fires second awaits the in-progress cleanup instead of running a + // competing stopFiberNodes on the same handles and exiting the process in + // the middle of runtime/lock teardown. Only a component exit reports the + // failure and sets the exit code; the signal path reports its own code. + type ShutdownTrigger = + | { component: string; code: number | null; signal: NodeJS.Signals | null } + | { signal: 'SIGINT' | 'SIGTERM' }; + let shutdownPromise: Promise | null = null; + const runShutdownOnce = (trigger: ShutdownTrigger): Promise => { + if (shutdownPromise) return shutdownPromise; + // Committed to tearing down: a broken stdout/stderr pipe must not abort + // the cleanup below (see util/shutdown.ts). + enterGracefulShutdown(); + shutdownPromise = (async () => { + const failedComponent = 'component' in trigger ? trigger.component : null; + logSubscription?.close(); + if (failedComponent !== 'CKB node' && !ckbProcess.killed) ckbProcess.kill('SIGTERM'); + if (failedComponent !== 'CKB miner' && !minerProcess.killed) minerProcess.kill('SIGTERM'); + proxy.stop(); + if (fiberEnv) { + await stopFiberNodes(fiberEnv.nodes, settings); + } + if (process.env[DAEMON_CHILD_ENV] === '1') cleanupPidFile(resolveDaemonPaths().pidFile); + envLock?.release(); + if ('component' in trigger) { + logger.error( + `${trigger.component} exited unexpectedly (code=${trigger.code ?? 'null'}, signal=${trigger.signal ?? 'none'}).`, + ); + process.exitCode = typeof trigger.code === 'number' && trigger.code > 0 ? trigger.code : 1; + } + })(); + return shutdownPromise; + }; + const stopService = (component: string, code: number | null, signal: NodeJS.Signals | null) => { + void runShutdownOnce({ component, code, signal }); }; ckbProcess.once('exit', (code, signal) => stopService('CKB node', code, signal)); minerProcess.once('exit', (code, signal) => stopService('CKB miner', code, signal)); + if (fiberEnv) { + for (const node of fiberEnv.nodes) { + node.process.once('exit', (code, signal) => stopService(`FNN node ${node.id}`, code, signal)); + } + installFiberSignalHandlers(runShutdownOnce); + } +} + +// With --fiber the process group contains FNNs whose runtime.json should not +// outlive a clean shutdown. Stop the whole group on Ctrl+C/SIGTERM instead of +// letting each process fend for itself. The cleanup itself is shared with the +// component-exit path via runShutdownOnce; this only adds the exit code. +function installFiberSignalHandlers(runShutdownOnce: (trigger: { signal: 'SIGINT' | 'SIGTERM' }) => Promise) { + let handling = false; + const handler = (signal: 'SIGINT' | 'SIGTERM') => { + if (handling) return; + handling = true; + // Set before the first log line: with piped output the reader may die + // with this same signal, and an EPIPE must not abort the shutdown. + enterGracefulShutdown(); + void (async () => { + logger.info(`Received ${signal}, stopping the devnet and fiber nodes...`); + await runShutdownOnce({ signal }); + process.exit(signal === 'SIGINT' ? 130 : 143); + })(); + }; + process.once('SIGINT', () => handler('SIGINT')); + process.once('SIGTERM', () => handler('SIGTERM')); } // CKB < 0.205.0 rejects the Terminal RPC module during config deserialization @@ -281,11 +447,7 @@ function waitForChildSpawn(child: ChildProcess, label: string): Promise { } function resolveDaemonPaths() { - const settings = readSettings(); - const logDir = path.join(settings.devnet.dataPath, DAEMON_LOG_DIR); - const logFile = path.join(logDir, DAEMON_LOG_FILE); - const pidFile = path.join(logDir, DAEMON_PID_FILE); - return { logDir, logFile, pidFile }; + return nodeDaemonPaths(readSettings()); } // Best-effort check that the spawned process is the one listening on the RPC @@ -384,220 +546,6 @@ async function clearForkFirstRunWhenNodeUp( } } -function readPidFile(pidFile: string): PidMetadata | null { - let raw: string; - try { - raw = fs.readFileSync(pidFile, 'utf8').trim(); - } catch (error) { - // Treat a missing or unreadable PID file as "no daemon". - return null; - } - - if (!raw) { - return null; - } - - // Backward compatibility: plain integer PID written by older versions. - const plainPid = Number(raw); - if (Number.isInteger(plainPid) && plainPid > 0) { - return { pid: plainPid, scriptPath: resolveCliEntry() ?? '', startedAt: new Date(0).toISOString() }; - } - - try { - const parsed = JSON.parse(raw) as Partial; - const pid = Number(parsed.pid); - if (Number.isInteger(pid) && pid > 0 && typeof parsed.scriptPath === 'string') { - return { - pid, - scriptPath: parsed.scriptPath, - startedAt: parsed.startedAt ?? new Date(0).toISOString(), - status: parsed.status, - }; - } - } catch { - // fall through to sentinel below - } - - // Content exists but is neither a valid plain PID nor valid metadata. - // Return a sentinel so stopNode can report an invalid PID and clean up. - return { pid: NaN, scriptPath: '', startedAt: new Date(0).toISOString() }; -} - -function writePidFile(pidFile: string, metadata: PidMetadata) { - fs.writeFileSync(pidFile, JSON.stringify(metadata, null, 2)); -} - -function reservePidFile(pidFile: string, scriptPath: string): void { - let fd: number; - try { - fd = fs.openSync(pidFile, 'wx'); - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'EEXIST') { - throw new Error('A CKB devnet daemon startup is already in progress. Try again after it completes.'); - } - throw new Error(`Failed to reserve daemon PID file ${pidFile}: ${err.message}`); - } - - let writeError: Error | undefined; - try { - const reservation: PidMetadata = { - pid: process.pid, - scriptPath, - startedAt: new Date().toISOString(), - status: 'starting', - }; - fs.writeFileSync(fd, JSON.stringify(reservation, null, 2)); - } catch (error) { - writeError = error as Error; - } finally { - fs.closeSync(fd); - } - if (writeError) { - cleanupPidFile(pidFile); - throw new Error(`Failed to initialize daemon PID reservation ${pidFile}: ${writeError.message}`); - } -} - -function resolveCliEntry(): string | null { - // In priority order. process.argv[1] is the most reliable for a Node CLI. - // OFFCKB_CLI_PATH is an escape hatch for packaged/npx/weird environments. - // require.main?.filename is a final fallback when argv is unavailable. - const candidates = [process.env.OFFCKB_CLI_PATH, process.argv[1], require.main?.filename].filter( - (c): c is string => typeof c === 'string' && c.length > 0, - ); - - for (const candidate of candidates) { - try { - const resolved = path.resolve(candidate); - const stats = fs.statSync(resolved); - if (stats.isFile()) { - return resolved; - } - } catch { - // Candidate is missing or not a file; try the next one. - } - } - - return null; -} - -function isProcessAlive(pid: number): boolean { - if (!Number.isInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'ESRCH') return false; - if (err.code === 'EPERM') throw new Error(`Permission denied when checking daemon process ${pid}.`); - throw error; - } -} - -function cleanupPidFile(pidFile: string) { - try { - fs.unlinkSync(pidFile); - } catch (error) { - logger.warn(`Failed to remove PID file ${pidFile}:`, error); - } -} - -function waitForProcessExit(pid: number, timeoutMs: number): Promise { - const start = Date.now(); - return new Promise((resolve, reject) => { - const check = () => { - try { - if (!isProcessAlive(pid)) { - resolve(true); - return; - } - } catch (error) { - reject(error); - return; - } - if (Date.now() - start >= timeoutMs) { - resolve(false); - return; - } - setTimeout(check, 100); - }; - check(); - }); -} - -function getProcessCommandLine(pid: number): Promise { - return new Promise((resolve) => { - // Argument arrays, never an interpolated shell string: even though pid is - // validated as a positive integer on every path here, execFile keeps that - // true after any future refactor. - const [cmd, args]: [string, string[]] = - process.platform === 'win32' - ? ['wmic', ['process', 'where', `ProcessId=${pid}`, 'get', 'CommandLine', '/format:list']] - : ['ps', ['-p', String(pid), '-o', 'args=']]; - execFile(cmd, args, (error, stdout) => { - if (error) { - resolve(null); - return; - } - if (process.platform === 'win32') { - const match = stdout.match(/CommandLine=(.+)/); - resolve(match ? match[1].trim() : null); - } else { - resolve(stdout.trim()); - } - }); - }); -} - -async function verifyDaemonIdentity(pid: number, metadata: PidMetadata): Promise { - const cmdline = await getProcessCommandLine(pid); - if (!cmdline) { - return false; - } - - // The daemon child re-runs the same CLI entry point, so its command line - // should reference the same script and should be a Node process. - const scriptName = path.basename(metadata.scriptPath); - const scriptDir = path.dirname(metadata.scriptPath); - const looksLikeNode = cmdline.includes('node') || cmdline.includes('nodejs'); - const looksLikeOurScript = - cmdline.includes(metadata.scriptPath) || (scriptName !== '' && cmdline.includes(scriptName)); - const looksLikeOffckb = cmdline.includes('offckb') || scriptDir.includes('offckb'); - - return looksLikeNode && (looksLikeOurScript || looksLikeOffckb); -} - -function terminateProcess(pid: number, signal: 'SIGTERM' | 'SIGKILL'): Promise { - return new Promise((resolve, reject) => { - if (process.platform === 'win32') { - // Windows has no POSIX signals and process.kill(pid) only terminates the - // single process. Use taskkill to terminate the whole tree. - // /T kills the process and all child processes. - // /F forces termination when SIGKILL is requested. - const args = signal === 'SIGKILL' ? ['/T', '/F', '/PID', String(pid)] : ['/T', '/PID', String(pid)]; - const taskkill = spawn('taskkill', args, { stdio: 'ignore' }); - taskkill.on('error', reject); - taskkill.on('exit', () => { - // taskkill may return non-zero if the process is already gone, which - // is acceptable for our purposes. - resolve(); - }); - return; - } - - // On POSIX, detached: true makes the child a session/process group leader. - // A negative pid sends the signal to the entire process group, ensuring - // the CKB node, miner and RPC proxy all receive it. - try { - process.kill(-pid, signal); - resolve(); - } catch (error) { - reject(error); - } - }); -} - async function failDaemonStartup(error: Error, pid: number, pidFile: string): Promise { let exited = false; try { @@ -628,7 +576,7 @@ async function failDaemonStartup(error: Error, pid: number, pidFile: string): Pr throw error; } -async function startDaemon() { +async function startDaemon(waitForFiber = false) { const { logDir, logFile, pidFile } = resolveDaemonPaths(); try { @@ -745,6 +693,11 @@ async function startDaemon() { `CKB devnet daemon failed to become ready. See ${logFile}. ${readiness.error ?? 'Daemon process exited.'}`, ); } + if (waitForFiber) { + // node --fiber --daemon: the child records a running fiber environment + // in runtime.json only after every Fiber startup check has passed. + await waitForFiberRuntimeRunning(child.pid!, settings, logFile); + } writePidFile(pidFile, { ...metadata, status: 'running' }); } catch (error) { return failDaemonStartup(error as Error, child.pid, pidFile); @@ -766,18 +719,22 @@ async function startDaemon() { }); } -function closeFileDescriptors(...fds: (number | undefined)[]) { - for (const fd of fds) { - if (fd === undefined) continue; - try { - fs.closeSync(fd); - } catch { - // ignore +async function waitForFiberRuntimeRunning(managerPid: number, settings: Settings, logFile: string) { + const start = Date.now(); + while (Date.now() - start < FIBER_DAEMON_READY_TIMEOUT_MS) { + if (!isProcessAlive(managerPid)) { + throw new Error(`The daemon exited before the fiber environment became ready. See ${logFile}.`); + } + const runtime = readRuntime(settings); + if (runtime && runtime.managerPid === managerPid && runtime.status === 'running') { + return; } + await new Promise((resolve) => setTimeout(resolve, 500)); } + throw new Error(`Timed out waiting for the fiber environment to become ready. See ${logFile}.`); } -export async function stopNode() { +export async function stopNode(options: { force?: boolean } = {}) { const { pidFile } = resolveDaemonPaths(); const metadata = readPidFile(pidFile); @@ -787,6 +744,32 @@ export async function stopNode() { return; } + // FNNs managed by a separate fiber daemon must be stopped by that daemon's + // owner command; node stop never reaches across another manager. The guard + // fires only for a CONFIRMED fiber daemon — an unverifiable (recycled or + // foreign) PID must not deadlock node stop, and the runtime-based check + // below (assertNodeStopDoesNotOrphanFiber) remains the fail-closed net for + // any live fiber manager, verified or not. + const settings = readSettings(); + const fiberDaemon = readPidFile(fiberDaemonPaths(settings).pidFile); + if ( + fiberDaemon && + Number.isInteger(fiberDaemon.pid) && + fiberDaemon.pid > 0 && + isProcessAlive(fiberDaemon.pid) && + (await verifyDaemonIdentity(fiberDaemon.pid, fiberDaemon)) + ) { + if (!options.force) { + throw new Error( + `Fiber nodes are managed by a separate fiber daemon (PID ${fiberDaemon.pid}). ` + + 'Stop them first with: offckb fiber stop, or override with: offckb node stop --force', + ); + } + logger.warn( + `Fiber nodes managed by the fiber daemon (PID ${fiberDaemon.pid}) will keep running on a stopped chain (--force).`, + ); + } + const pid = metadata.pid; if (!Number.isInteger(pid) || pid <= 0) { cleanupPidFile(pidFile); @@ -812,6 +795,10 @@ export async function stopNode() { ); } + // A fiber environment managed by another live process (a foreground + // terminal) would be orphaned on the stopped chain — refuse unless forced. + assertNodeStopDoesNotOrphanFiber({ ckbDaemonPid: pid, force: options.force }, settings); + logger.info(`Stopping CKB devnet daemon (PID ${pid})...`); try { await terminateProcess(pid, 'SIGTERM'); diff --git a/src/fiber/accounts.ts b/src/fiber/accounts.ts new file mode 100644 index 00000000..a185364c --- /dev/null +++ b/src/fiber/accounts.ts @@ -0,0 +1,113 @@ +import * as fs from 'fs'; +import crypto from 'crypto'; +import { ccc } from '@ckb-ccc/core'; +import accountConfig from '../../account/account.json'; +import { fiberAccountIndex, fiberNodePaths, UDT_ISSUER_ACCOUNT_INDEX } from './paths'; +import { readSettings, Settings } from '../cfg/setting'; + +export interface BuiltinAccount { + privkey: string; + pubkey: string; + lockScript: { + codeHash: string; + hashType: string; + args: string; + }; + address: string; + args: string; +} + +const accounts = accountConfig as unknown as BuiltinAccount[]; + +export function getBuiltinAccount(index: number): BuiltinAccount { + const account = accounts[index]; + if (!account) { + throw new Error(`Built-in account #${index} does not exist (account.json has ${accounts.length} accounts).`); + } + return account; +} + +// Node N uses CKB account N+2; accounts 3-18 are reserved for Fiber nodes. +export function fiberNodeAccount(nodeId: number): BuiltinAccount { + return getBuiltinAccount(fiberAccountIndex(nodeId)); +} + +// Account 19 deploys contracts and issues the sUDT/xUDT used for testing. +export function udtIssuerAccount(): BuiltinAccount { + return getBuiltinAccount(UDT_ISSUER_ACCOUNT_INDEX); +} + +export function udtIssuerLockHash(): string { + const issuer = udtIssuerAccount(); + return ccc.Script.from(issuer.lockScript as ccc.ScriptLike).hash(); +} + +// Derive the compressed secp256k1 public key of a raw 32-byte secret, the +// format FNN reports as node_info.pubkey (hex, no 0x prefix). +export function fiberPublicKeyFromSecret(secret: Buffer): string { + const signer = new ccc.SignerCkbPrivateKey({} as never, `0x${secret.toString('hex')}` as `0x${string}`); + return signer.publicKey.slice(2).toLowerCase(); +} + +export function readFiberNodeSecretKey(nodeId: number, settings: Settings = readSettings()): Buffer | null { + const skFile = fiberNodePaths(nodeId, settings).fiberSkFile; + try { + const data = fs.readFileSync(skFile); + return data.length >= 32 ? data.subarray(0, 32) : null; + } catch { + return null; + } +} + +function writePrivateFile(file: string, content: string) { + fs.writeFileSync(file, content, { mode: 0o600 }); + try { + fs.chmodSync(file, 0o600); + } catch { + // Windows has no POSIX modes; the file inherits directory ACLs. + } +} + +/** + * Create the node directory layout and its CKB key material. The CKB secret + * key is written as plain hex (FNN encrypts it in place on first start, + * using the per-node password passed via FIBER_SECRET_KEY_PASSWORD). The + * Fiber network identity key (fiber/sk) is generated by FNN itself on first + * start and must never be overwritten here. + * + * Existing nodes keep their key and password; a node directory with missing + * key material is an error, never silently re-created (the on-chain account + * and any channels are tied to the original keys). + */ +export function ensureNodeKeyMaterial(nodeId: number, settings: Settings = readSettings()): { created: boolean } { + const paths = fiberNodePaths(nodeId, settings); + const keyExists = fs.existsSync(paths.ckbKeyFile); + const passwordExists = fs.existsSync(paths.passwordFile); + if (keyExists && passwordExists) { + return { created: false }; + } + if (keyExists !== passwordExists) { + const missing = keyExists ? paths.passwordFile : paths.ckbKeyFile; + throw new Error( + `Fiber node ${nodeId} has incomplete key material: ${missing} is missing. ` + + 'The CKB key and its password must both come from the same provisioning; ' + + 'restore the file or remove the node directory and start again.', + ); + } + + fs.mkdirSync(paths.ckbDir, { recursive: true }); + fs.mkdirSync(paths.fiberDir, { recursive: true }); + const account = fiberNodeAccount(nodeId); + writePrivateFile(paths.ckbKeyFile, account.privkey.replace(/^0x/, '')); + writePrivateFile(paths.passwordFile, crypto.randomBytes(24).toString('base64')); + return { created: true }; +} + +export function readNodePassword(nodeId: number, settings: Settings = readSettings()): string { + const passwordFile = fiberNodePaths(nodeId, settings).passwordFile; + try { + return fs.readFileSync(passwordFile, 'utf8').trim(); + } catch (error) { + throw new Error(`Failed to read the password of fiber node ${nodeId}: ${(error as Error).message}`); + } +} diff --git a/src/fiber/ckb-env.ts b/src/fiber/ckb-env.ts new file mode 100644 index 00000000..4d3f85ae --- /dev/null +++ b/src/fiber/ckb-env.ts @@ -0,0 +1,69 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { checkNodeReadiness } from '../devnet/readiness'; +import { readForkState, FORK_STATE_FILE } from '../devnet/fork'; +import { callJsonRpc } from '../util/json-rpc'; +import { readSettings, Settings } from '../cfg/setting'; + +// Fiber commands only work on a plain local devnet. A forked devnet keeps its +// source chain's data in the same directory and records the fork in +// fork.json, so the file's mere presence — valid or not — rejects Fiber. +export function assertPlainDevnet(settings: Settings = readSettings()) { + const forkFile = path.join(settings.devnet.configPath, FORK_STATE_FILE); + if (!fs.existsSync(forkFile)) return; + const forkState = readForkState(settings.devnet.configPath); + if (forkState) { + throw new Error( + `Fiber is not supported on a forked devnet (fork of ${forkState.source}, recorded in ${forkFile}). ` + + 'Run `offckb clean` and start a plain local chain to use Fiber.', + ); + } + throw new Error( + `${forkFile} exists but cannot be read or parsed; cannot verify this is a plain local chain. ` + + 'Refusing to start Fiber. Remove the file only if you are sure this devnet is not a fork.', + ); +} + +function parseHexNumber(value: unknown): bigint | null { + if (typeof value !== 'string' || !/^0x[0-9a-f]+$/i.test(value)) return null; + return BigInt(value); +} + +/** + * `fiber start` requires a healthy local CKB environment it must not create + * or replace itself: RPC answering, indexer answering, and the chain still + * producing blocks. + */ +export async function assertCkbEnvReadyForFiber(settings: Settings = readSettings()) { + const readiness = await checkNodeReadiness(settings.devnet.rpcUrl, 2000); + if (!readiness.ready) { + throw new Error( + `The local CKB node is not answering at ${settings.devnet.rpcUrl}: ${readiness.error ?? 'unavailable'}. ` + + 'Start it first with `offckb node` (or use `offckb node --fiber` to start everything at once).', + ); + } + if (readiness.indexerTip == null) { + throw new Error( + `The CKB indexer is not ready at ${settings.devnet.rpcUrl}. Fiber needs the indexer; ` + + 'wait for the node to finish starting and try again.', + ); + } + + const firstTip = readiness.nodeTip ?? BigInt(0); + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + try { + const tip = parseHexNumber(await callJsonRpc(settings.devnet.rpcUrl, 'get_tip_block_number', [], 2000)); + if (tip != null && tip > firstTip) { + return; + } + } catch { + // keep waiting until the deadline + } + } + throw new Error( + `The CKB devnet at ${settings.devnet.rpcUrl} is not producing blocks. ` + + 'Fiber requires a mining devnet; check the node and miner (e.g. `offckb logs`).', + ); +} diff --git a/src/fiber/clean.ts b/src/fiber/clean.ts new file mode 100644 index 00000000..f29f5a91 --- /dev/null +++ b/src/fiber/clean.ts @@ -0,0 +1,114 @@ +import * as fs from 'fs'; +import { confirm } from '@inquirer/prompts'; +import { acquireEnvLock } from './env-lock'; +import { envLockPath, fiberDaemonPaths, fiberNodeIds, fiberNodePaths, fiberRootPath } from './paths'; +import { readLiveRuntime, removeRuntimeFileIfStale } from './runtime'; +import { readPidFile, isProcessAlive } from '../util/daemon'; +import { isStoreLockHeld } from './store-lock'; +import { readSettings, Settings } from '../cfg/setting'; +import { isFolderExists } from '../util/fs'; +import { logger } from '../util/logger'; + +export interface FiberCleanOptions { + data?: boolean; + yes?: boolean; +} + +function existingStoreLockFiles(settings: Settings): string[] { + return fiberNodeIds(settings) + .map((id) => fiberNodePaths(id, settings).storeLockFile) + .filter((lockFile) => fs.existsSync(lockFile)); +} + +/** + * Cleaning is only allowed when every FNN is stopped: no live manager + * (daemon or foreground) and every existing store lock acquirable. Anything + * that cannot be confirmed refuses the clean — a running FNN must never + * watch its store disappear. + */ +export function assertFiberFullyStopped(settings: Settings = readSettings()) { + const live = readLiveRuntime(settings); + if (live) { + throw new Error( + `Fiber nodes are still managed by OffCKB process ${live.managerPid}. ` + + 'Stop them first (`offckb fiber stop` for a daemon, or Ctrl+C in its terminal).', + ); + } + const { pidFile } = fiberDaemonPaths(settings); + const daemon = readPidFile(pidFile); + if (daemon && Number.isInteger(daemon.pid) && daemon.pid > 0 && isProcessAlive(daemon.pid)) { + throw new Error(`A fiber daemon is still running (PID ${daemon.pid}). Stop it first with: offckb fiber stop`); + } + + const heldLocks = existingStoreLockFiles(settings).filter((lockFile) => isStoreLockHeld(lockFile) !== false); + if (heldLocks.length > 0) { + throw new Error( + `Cannot confirm all Fiber stores are closed (lock(s) still held or unverifiable: ${heldLocks.join(', ')}). ` + + 'Stop every FNN process and try again.', + ); + } +} + +async function confirmOrAbort(message: string, yes?: boolean) { + if (yes) return; + const answer = await confirm({ message, default: false }); + if (!answer) { + throw new Error('Aborted.'); + } +} + +export async function fiberClean(options: FiberCleanOptions, settings: Settings = readSettings()) { + const lock = acquireEnvLock(options.data ? 'offckb fiber clean --data' : 'offckb fiber clean', envLockPath(settings)); + try { + const root = fiberRootPath(settings); + if (!isFolderExists(root)) { + logger.info('Nothing to clean. No fiber environment found.'); + logger.result({ command: 'fiber.clean', cleaned: false, reason: 'not-found' }); + return; + } + + assertFiberFullyStopped(settings); + + if (options.data) { + const stores = fiberNodeIds(settings) + .map((id) => fiberNodePaths(id, settings).fiberStoreDir) + .filter((storeDir) => isFolderExists(storeDir)); + logger.warn( + 'This permanently deletes every FNN store (channels, payments and other node data). ' + + 'Deleted data cannot be recovered. Node accounts, identity keys, passwords and logs are kept.', + ); + for (const store of stores) { + logger.info(` will delete: ${store}`); + } + await confirmOrAbort('Delete all FNN stores?', options.yes); + + // The confirmation prompt can sit open for an arbitrary time; re-verify + // nothing started in that window before deleting anything. + assertFiberFullyStopped(settings); + removeRuntimeFileIfStale(settings); + for (const store of stores) { + fs.rmSync(store, { recursive: true, force: true }); + logger.info(`Deleted ${store}`); + } + logger.success('All FNN stores cleaned. Node accounts and network identities are unchanged.'); + logger.result({ command: 'fiber.clean', cleaned: true, dataOnly: true, removed: stores }); + return; + } + + logger.warn( + 'This deletes the whole fiber environment, including node configs, the CKB account keys, ' + + 'the Fiber network identity keys and passwords of every node. Restarting creates NEW node identities. ' + + 'The downloaded FNN binary and the devnet CKB data are kept.', + ); + logger.info(` will delete: ${root}`); + await confirmOrAbort('Delete the whole fiber environment?', options.yes); + + // Same post-confirmation re-check as the --data path above. + assertFiberFullyStopped(settings); + fs.rmSync(root, { recursive: true, force: true }); + logger.success('Fiber environment cleaned.'); + logger.result({ command: 'fiber.clean', cleaned: true, dataOnly: false, removed: [root] }); + } finally { + lock.release(); + } +} diff --git a/src/fiber/config-gen.ts b/src/fiber/config-gen.ts new file mode 100644 index 00000000..859aecbb --- /dev/null +++ b/src/fiber/config-gen.ts @@ -0,0 +1,104 @@ +import * as fs from 'fs'; +import yaml from 'js-yaml'; +import { fiberNodePaths, fiberP2pAddr, fiberRpcPort } from './paths'; +import { FiberChainScripts } from './scripts'; +import { FiberNodeEntry } from './nodes-yml'; +import { readSettings, Settings } from '../cfg/setting'; + +// The devnet spec file, resolved by FNN relative to the node directory +// (/fiber/nodes//). The specs directory is shared, so the config +// points at the original dev.toml instead of copying it per node. +const DEV_TOML_RELATIVE_TO_NODE = '../../../specs/dev.toml'; + +// RPC modules the devnet environment serves. cch is intentionally off, and +// dev-only modules (only available in debug builds) are not relied upon. +const ENABLED_RPC_MODULES = ['channel', 'payment', 'graph', 'info', 'invoice', 'peer', 'watchtower']; + +function isPlainObject(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +// Deep-merge per-node overrides onto the generated config: objects merge +// recursively, lists replace wholesale (matching FNN's own config layering). +export function mergeNodeConfig( + base: Record, + override: Record, +): Record { + const result: Record = { ...base }; + for (const [key, value] of Object.entries(override)) { + const existing = result[key]; + if (isPlainObject(existing) && isPlainObject(value)) { + result[key] = mergeNodeConfig(existing, value); + } else { + result[key] = value; + } + } + return result; +} + +/** + * Generate one node's config.yml. The template is the testnet config bundled + * with the FNN release, parsed as a generic mapping so config fields added by + * future FNN versions survive the round trip. Chain- and environment-specific + * values are replaced with the devnet ones; everything else is kept. + * + * Hand edits to config.yml do not survive regeneration — persistent + * customization belongs in fiber/nodes.yml. + */ +export function generateNodeConfig(options: { + node: FiberNodeEntry; + chainScripts: FiberChainScripts; + testnetConfigPath: string; + settings?: Settings; +}): string { + const settings = options.settings ?? readSettings(); + const nodeId = options.node.id; + + let template: unknown; + try { + template = yaml.load(fs.readFileSync(options.testnetConfigPath, 'utf8')); + } catch (error) { + throw new Error(`Failed to parse FNN testnet config ${options.testnetConfigPath}: ${(error as Error).message}`); + } + if (!isPlainObject(template)) { + throw new Error(`FNN testnet config ${options.testnetConfigPath} is not a YAML mapping.`); + } + + const fiber = isPlainObject(template.fiber) ? { ...template.fiber } : {}; + fiber.chain = DEV_TOML_RELATIVE_TO_NODE; + fiber.listening_addr = fiberP2pAddr(nodeId); + fiber.bootnode_addrs = []; + fiber.announce_listening_addr = true; + fiber.announce_private_addr = true; + fiber.gossip_network_maintenance_interval_ms = 1000; + fiber.gossip_store_maintenance_interval_ms = 1000; + const nodeName = `offckb-fnn-${nodeId}`; + if (Buffer.byteLength(nodeName, 'utf8') > 32) { + throw new Error(`Fiber node name "${nodeName}" exceeds 32 UTF-8 bytes.`); + } + fiber.announced_node_name = nodeName; + fiber.scripts = options.chainScripts.fiberScripts; + + const rpc = isPlainObject(template.rpc) ? { ...template.rpc } : {}; + rpc.listening_addr = `127.0.0.1:${fiberRpcPort(nodeId)}`; + rpc.enabled_modules = ENABLED_RPC_MODULES; + rpc.cors_enabled = false; + + const ckb = isPlainObject(template.ckb) ? { ...template.ckb } : {}; + ckb.rpc_url = settings.devnet.rpcUrl; + ckb.udt_whitelist = options.chainScripts.udtWhitelist; + + let config: Record = { + ...template, + fiber, + rpc, + ckb, + services: ['fiber', 'rpc', 'ckb'], + }; + config = mergeNodeConfig(config, options.node.config); + + const paths = fiberNodePaths(nodeId, settings); + fs.mkdirSync(paths.dir, { recursive: true }); + fs.writeFileSync(paths.configFile, yaml.dump(config, { noRefs: true, lineWidth: -1 })); + return paths.configFile; +} diff --git a/src/fiber/daemon.ts b/src/fiber/daemon.ts new file mode 100644 index 00000000..4b90f525 --- /dev/null +++ b/src/fiber/daemon.ts @@ -0,0 +1,385 @@ +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import { + cleanupPidFile, + closeFileDescriptors, + isProcessAlive, + nodeDaemonPaths, + readPidFile, + reservePidFile, + resolveCliEntry, + terminateProcess, + verifyDaemonIdentity, + waitForProcessExit, + writePidFile, + PidMetadata, +} from '../util/daemon'; +import { fiberDaemonPaths, fiberNodePaths } from './paths'; +import { readRuntime, readLiveRuntime, isRuntimeStale, removeRuntimeFile } from './runtime'; +import { isStoreLockHeld, waitForStoreLocksReleased } from './store-lock'; +import { readSettings, Settings } from '../cfg/setting'; +import { logger } from '../util/logger'; + +const FIBER_DAEMON_CHILD_ENV = 'OFFCKB_DAEMON_CHILD'; +// First run may download FNN. Exported: the `node --fiber --daemon` readiness +// wait in cmd/node.ts must use exactly this value. +export const FIBER_DAEMON_READY_TIMEOUT_MS = 10 * 60_000; +// The launcher flips a 'starting' PID record to 'running' within the ready +// timeout (or deletes it on failure). Beyond this grace window a 'starting' +// record means the launcher died mid-startup and the record is stoppable. +const FIBER_DAEMON_STARTUP_GRACE_MS = FIBER_DAEMON_READY_TIMEOUT_MS + 60_000; +const STOP_WAIT_TIMEOUT_MS = 15_000; +const STORE_LOCK_WAIT_TIMEOUT_MS = 15_000; + +/** + * Daemonize `fiber start`: the current command spawns a detached manager + * child re-running the same command without --daemon, waits until the child + * reports a running environment in runtime.json, then exits. The child keeps + * managing all FNNs; `offckb fiber stop` signals it later. + */ +export async function startFiberDaemon(childArgs: string[], settings: Settings = readSettings()) { + const { logDir, logFile, pidFile } = fiberDaemonPaths(settings); + fs.mkdirSync(logDir, { recursive: true }); + + const existing = readPidFile(pidFile); + if (existing) { + if (isProcessAlive(existing.pid)) { + const identityOk = await verifyDaemonIdentity(existing.pid, existing); + if (identityOk) { + if (existing.status === 'starting') { + throw new Error(`Another fiber daemon startup is already in progress (PID ${existing.pid}).`); + } + throw new Error( + `A fiber daemon is already running (PID ${existing.pid}). Stop it first with: offckb fiber stop`, + ); + } + // Fail closed: a live process whose identity cannot be verified keeps + // its metadata. Replacing it would strand a possibly-running daemon with + // no PID record for stop/status/clean to find, and the replacement + // child would die at the environment lock anyway. + throw new Error( + `PID ${existing.pid} from ${pidFile} is alive but does not look like the offckb fiber daemon. ` + + `Refusing to replace its metadata. If that process is unrelated, stop it manually and remove ${pidFile}.`, + ); + } + cleanupPidFile(pidFile); + } + + const scriptPath = resolveCliEntry(); + if (!scriptPath) { + throw new Error( + 'Unable to determine the CLI entry point for daemon mode. Set OFFCKB_CLI_PATH to the offckb script.', + ); + } + reservePidFile(pidFile, scriptPath); + + let out: number | undefined; + let err: number | undefined; + try { + out = fs.openSync(logFile, 'a'); + err = fs.openSync(logFile, 'a'); + } catch (error) { + closeFileDescriptors(out, err); + cleanupPidFile(pidFile); + throw new Error(`Failed to prepare daemon log file at ${logFile}: ${(error as Error).message}`); + } + + const childEnv = { ...process.env, [FIBER_DAEMON_CHILD_ENV]: '1' }; + let child; + try { + child = spawn(process.execPath, [scriptPath, ...childArgs], { + detached: true, + stdio: ['ignore', out, err], + env: childEnv, + }); + } catch (error) { + closeFileDescriptors(out, err); + cleanupPidFile(pidFile); + throw new Error(`Failed to spawn fiber daemon process: ${(error as Error).message}`); + } + if (!child.pid) { + closeFileDescriptors(out, err); + cleanupPidFile(pidFile); + throw new Error('Failed to spawn fiber daemon process: no PID returned.'); + } + child.unref(); + child.on('error', (error) => { + logger.error('Fiber daemon child process failed to start:', error); + cleanupPidFile(pidFile); + }); + + const metadata: PidMetadata = { + pid: child.pid, + scriptPath, + startedAt: new Date().toISOString(), + status: 'starting', + }; + try { + writePidFile(pidFile, metadata); + } catch (error) { + closeFileDescriptors(out, err); + return failFiberDaemonStartup(error as Error, child.pid, pidFile); + } + closeFileDescriptors(out, err); + + // Readiness: the child records a running environment in runtime.json only + // after every startup check has passed. + const start = Date.now(); + let ready = false; + while (!ready && Date.now() - start < FIBER_DAEMON_READY_TIMEOUT_MS) { + if (!isProcessAlive(child.pid)) { + return failFiberDaemonStartup( + new Error(`Fiber daemon exited before the environment became ready. See ${logFile}.`), + child.pid, + pidFile, + ); + } + const runtime = readRuntime(settings); + if (runtime && runtime.managerPid === child.pid && runtime.status === 'running') { + ready = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (!ready) { + return failFiberDaemonStartup( + new Error(`Timed out waiting for the fiber environment to become ready. See ${logFile}.`), + child.pid, + pidFile, + ); + } + writePidFile(pidFile, { ...metadata, status: 'running' }); + + logger.success(`Fiber daemon started with PID ${child.pid}; all startup checks passed.`); + logger.info(`Logs: ${logFile}`); + logger.info(`PID file: ${pidFile}`); + logger.info('Stop the daemon with: offckb fiber stop'); + logger.result({ command: 'fiber.start', daemon: true, pid: child.pid, logFile, pidFile }); +} + +async function failFiberDaemonStartup(error: Error, pid: number, pidFile: string): Promise { + let exited = false; + try { + exited = !isProcessAlive(pid); + if (!exited) { + await terminateProcess(pid, 'SIGTERM'); + exited = await waitForProcessExit(pid, 5000); + if (!exited) { + await terminateProcess(pid, 'SIGKILL'); + exited = await waitForProcessExit(pid, 5000); + } + } + } catch { + try { + exited = !isProcessAlive(pid); + } catch { + exited = false; + } + } + + if (exited) { + cleanupPidFile(pidFile); + } else { + error.message += ` Process ${pid} is still running; PID file was preserved.`; + } + throw error; +} + +function storeLockFilesForRuntime(settings: Settings): string[] { + const runtime = readRuntime(settings); + if (!runtime) return []; + return runtime.nodes.map((node) => fiberNodePaths(node.id, settings).storeLockFile); +} + +async function stopManagerAndCleanup(options: { + pid: number; + pidFile: string | null; + label: string; + settings: Settings; +}): Promise<'stopped' | 'unconfirmed'> { + const { pid, pidFile, label, settings } = options; + // Capture the node lock files while runtime.json still exists; the manager + // removes it during its own shutdown. + const lockFiles = storeLockFilesForRuntime(settings); + logger.info(`Stopping ${label} (PID ${pid}); its FNN nodes stop with it...`); + await terminateProcess(pid, 'SIGTERM'); + const exited = await waitForProcessExit(pid, STOP_WAIT_TIMEOUT_MS); + + let locksReleased = await waitForStoreLocksReleased(lockFiles, STORE_LOCK_WAIT_TIMEOUT_MS); + if (!exited || !locksReleased) { + logger.warn(`${label} or its FNN nodes did not finish stopping in time; sending SIGKILL once...`); + try { + await terminateProcess(pid, 'SIGKILL'); + } catch { + // the process group may already be gone + } + await waitForProcessExit(pid, 5000); + locksReleased = await waitForStoreLocksReleased(lockFiles, 5000); + } + if (!locksReleased) { + const held = lockFiles.filter((file) => isStoreLockHeld(file) !== false); + logger.warn( + `Could not confirm all Fiber store locks were released (${held.join(', ') || 'unknown'}). ` + + 'Check for leftover fnn processes before starting Fiber again.', + ); + } + + // Only a confirmed-gone manager loses its ownership records. While the + // process may still be alive, the PID file and runtime.json are the only + // way later commands (fiber stop, node stop, clean) can see the + // environment — deleting them would strand running FNNs. + let managerGone: boolean; + try { + managerGone = !isProcessAlive(pid); + } catch { + managerGone = false; // liveness unverifiable → treat as possibly alive + } + if (!managerGone) { + logger.warn( + `${label} (PID ${pid}) could not be confirmed stopped; keeping its PID file and runtime.json so ` + + 'later commands still see the environment. Stop it manually before starting Fiber again.', + ); + return 'unconfirmed'; + } + if (pidFile) cleanupPidFile(pidFile); + removeRuntimeFile(settings); + return 'stopped'; +} + +/** + * Guard for `node stop`: a live fiber environment managed by a process OTHER + * than the CKB daemon being stopped (a foreground `fiber start` or + * `node --fiber` terminal) would keep running on a chain that no longer + * exists. Refuse the stop unless the caller explicitly forces it. When the + * fiber manager IS the CKB daemon being stopped (`node --fiber --daemon`), + * stopping it stops the FNNs with it — nothing is orphaned. + */ +export function assertNodeStopDoesNotOrphanFiber( + options: { ckbDaemonPid: number; force?: boolean }, + settings: Settings = readSettings(), +): void { + const runtime = readLiveRuntime(settings); + if (runtime == null || runtime.managerPid === options.ckbDaemonPid) return; + if (options.force) { + logger.warn( + `Fiber nodes managed by OffCKB process ${runtime.managerPid} will keep running on a stopped chain (--force). ` + + 'Stop them afterwards: Ctrl+C in that terminal, or `offckb fiber stop` if it is a fiber daemon.', + ); + return; + } + throw new Error( + `Fiber nodes are managed by a foreground OffCKB process (PID ${runtime.managerPid}). ` + + 'Stopping the CKB daemon would orphan them on a stopped chain. ' + + 'Stop them first (Ctrl+C in that terminal), or override with: offckb node stop --force', + ); +} + +/** + * Stop daemon-managed FNNs. Only manager processes recorded in a daemon PID + * file are ever signaled: the fiber daemon of `fiber start --daemon`, or the + * CKB daemon of `node --fiber --daemon` (which manages CKB and FNNs as one + * group, so stopping it stops the whole environment). Foreground managers + * are reported, never signaled. FNNs are never killed individually by + * runtime.json, port, path or version. + */ +export async function stopFiber(settings: Settings = readSettings()) { + const { pidFile } = fiberDaemonPaths(settings); + + const fiberDaemon = readPidFile(pidFile); + if (fiberDaemon && Number.isInteger(fiberDaemon.pid) && fiberDaemon.pid > 0) { + if (isProcessAlive(fiberDaemon.pid)) { + if (fiberDaemon.status === 'starting') { + // A genuine startup finishes (or fails and deletes the record) within + // the ready timeout. A 'starting' record older than that means the + // launcher was interrupted mid-startup; fall through to identity + // verification instead of deadlocking stop and clean forever. + const startedAtMs = Date.parse(fiberDaemon.startedAt ?? ''); + const withinStartupWindow = + Number.isFinite(startedAtMs) && Date.now() - startedAtMs <= FIBER_DAEMON_STARTUP_GRACE_MS; + if (withinStartupWindow) { + throw new Error( + `The fiber daemon startup is still in progress (PID ${fiberDaemon.pid}). Try stopping it again shortly.`, + ); + } + logger.warn( + `The fiber daemon (PID ${fiberDaemon.pid}) has been 'starting' beyond the startup window; ` + + 'its launcher appears to have exited. Verifying its identity before stopping it.', + ); + } + const identityOk = await verifyDaemonIdentity(fiberDaemon.pid, fiberDaemon); + if (!identityOk) { + throw new Error( + `Process ${fiberDaemon.pid} does not appear to be the offckb fiber daemon. Refusing to signal it. ` + + `If you are sure, stop it manually and remove ${pidFile}.`, + ); + } + const outcome = await stopManagerAndCleanup({ pid: fiberDaemon.pid, pidFile, label: 'fiber daemon', settings }); + if (outcome === 'stopped') { + logger.success('Fiber daemon stopped.'); + logger.result({ command: 'fiber.stop', stopped: true, pid: fiberDaemon.pid }); + } else { + logger.result({ command: 'fiber.stop', stopped: false, reason: 'stop-unconfirmed', pid: fiberDaemon.pid }); + } + return; + } + logger.warn(`Fiber daemon process ${fiberDaemon.pid} is not running; removing the stale PID file.`); + cleanupPidFile(pidFile); + } else if (fiberDaemon) { + cleanupPidFile(pidFile); + } + + // No fiber daemon. The FNNs may belong to a `node --fiber --daemon` + // environment, whose CKB daemon manages CKB and FNNs as one group. + const runtime = readLiveRuntime(settings); + if (runtime == null) { + const stale = readRuntime(settings); + if (stale && isRuntimeStale(stale)) { + removeRuntimeFile(settings); + logger.warn( + `The fiber manager process ${stale.managerPid} has already exited. ` + + 'If any FNN processes outlived it they are now unmanaged; stop them manually.', + ); + logger.result({ command: 'fiber.stop', stopped: false, reason: 'stale-runtime' }); + return; + } + logger.info('No running fiber environment found.'); + logger.result({ command: 'fiber.stop', stopped: false, reason: 'not-running' }); + return; + } + + const nodeDaemon = readPidFile(nodeDaemonPaths(settings).pidFile); + if ( + nodeDaemon && + Number.isInteger(nodeDaemon.pid) && + nodeDaemon.pid === runtime.managerPid && + isProcessAlive(nodeDaemon.pid) + ) { + const identityOk = await verifyDaemonIdentity(nodeDaemon.pid, nodeDaemon); + if (!identityOk) { + throw new Error(`Process ${nodeDaemon.pid} does not appear to be the offckb node daemon. Refusing to signal it.`); + } + logger.warn( + 'The FNN nodes are managed by the `offckb node --fiber --daemon` manager; ' + + 'stopping it stops the whole environment (CKB, miner, RPC proxy and FNNs).', + ); + const outcome = await stopManagerAndCleanup({ + pid: nodeDaemon.pid, + pidFile: nodeDaemonPaths(settings).pidFile, + label: 'node --fiber daemon', + settings, + }); + if (outcome === 'stopped') { + logger.success('The node --fiber environment (CKB and FNNs) stopped.'); + logger.result({ command: 'fiber.stop', stopped: true, pid: nodeDaemon.pid, includedCkb: true }); + } else { + logger.result({ command: 'fiber.stop', stopped: false, reason: 'stop-unconfirmed', pid: nodeDaemon.pid }); + } + return; + } + + logger.warn( + `The FNN nodes are managed by a foreground OffCKB process (PID ${runtime.managerPid}). ` + + 'Stop it with Ctrl+C in the terminal where it is running.', + ); + logger.result({ command: 'fiber.stop', stopped: false, reason: 'foreground-manager', pid: runtime.managerPid }); +} diff --git a/src/fiber/env-lock.ts b/src/fiber/env-lock.ts new file mode 100644 index 00000000..24b05204 --- /dev/null +++ b/src/fiber/env-lock.ts @@ -0,0 +1,115 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { envLockPath } from './paths'; +import { isProcessAlive } from '../util/daemon'; +import { logger } from '../util/logger'; + +export interface EnvLockHandle { + lockFile: string; + release: () => void; +} + +interface LockRecord { + pid: number; + acquiredAt: string; +} + +// A held lock marks that this process is currently mutating the devnet +// environment (starting/stopping FNNs, cleaning data). It is released after +// the operation, not for the lifetime of the managed processes. +const heldLocks = new Set(); + +function readLockRecord(lockFile: string): LockRecord | null { + try { + const parsed = JSON.parse(fs.readFileSync(lockFile, 'utf8')) as Partial; + if (Number.isInteger(parsed.pid) && (parsed.pid as number) > 0) { + return { pid: parsed.pid as number, acquiredAt: String(parsed.acquiredAt ?? '') }; + } + } catch { + // Unreadable or invalid content: cannot identify a holder. + } + return null; +} + +/** + * Acquire the devnet environment lock. Throws when another live OffCKB + * process holds it. A leftover lock whose recorded holder no longer exists + * is removed and re-acquired; that is the only condition under which a stale + * lock may be deleted. + */ +export function acquireEnvLock(purpose: string, lockFile: string = envLockPath()): EnvLockHandle { + fs.mkdirSync(path.dirname(lockFile), { recursive: true }); + for (let attempt = 0; attempt < 2; attempt++) { + let fd: number; + try { + fd = fs.openSync(lockFile, 'wx'); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'EEXIST') { + throw new Error(`Failed to acquire environment lock ${lockFile}: ${err.message}`); + } + const record = readLockRecord(lockFile); + if (record) { + let alive = false; + try { + alive = isProcessAlive(record.pid); + } catch { + alive = true; // cannot check → assume held, never break a live lock + } + if (alive) { + throw new Error( + `Another OffCKB process (PID ${record.pid}, since ${record.acquiredAt || 'unknown time'}) is ` + + `modifying this devnet environment. Wait for it to finish before running: ${purpose}.`, + ); + } + logger.debug(`Removing stale environment lock left by dead process ${record.pid}.`); + } else { + logger.debug(`Removing unreadable environment lock file ${lockFile}.`); + } + try { + fs.unlinkSync(lockFile); + } catch (unlinkError) { + throw new Error(`Failed to remove stale environment lock ${lockFile}: ${(unlinkError as Error).message}`); + } + continue; + } + + try { + const record: LockRecord = { pid: process.pid, acquiredAt: new Date().toISOString() }; + fs.writeFileSync(fd, JSON.stringify(record)); + } finally { + fs.closeSync(fd); + } + heldLocks.add(lockFile); + return { + lockFile, + release: () => releaseEnvLock(lockFile), + }; + } + throw new Error(`Failed to acquire environment lock ${lockFile}.`); +} + +export function releaseEnvLock(lockFile: string = envLockPath()) { + if (!heldLocks.has(lockFile)) return; + heldLocks.delete(lockFile); + try { + // Only delete the lock if it still records this process; never remove a + // lock that another process re-acquired after us. + const record = readLockRecord(lockFile); + if (record == null || record.pid === process.pid) { + fs.unlinkSync(lockFile); + } + } catch (error) { + logger.warn(`Failed to release environment lock ${lockFile}: ${(error as Error).message}`); + } +} + +export function isEnvLockHeld(lockFile: string = envLockPath()): boolean { + const record = readLockRecord(lockFile); + if (record == null) return false; + try { + return isProcessAlive(record.pid); + } catch { + return true; + } +} diff --git a/src/fiber/install.ts b/src/fiber/install.ts new file mode 100644 index 00000000..d07bc55f --- /dev/null +++ b/src/fiber/install.ts @@ -0,0 +1,258 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import os from 'os'; +import yaml from 'js-yaml'; +import { Request } from '../util/request'; +import { getVersionFromBinary } from '../node/install'; +import { unZipFile } from '../node/install'; +import { readSettings, Settings } from '../cfg/setting'; +import { bundledFiberTestnetConfigPath } from './paths'; +import { logger } from '../util/logger'; + +// Only FNN versions tested against the contracts and config rules bundled +// with this offckb release may be downloaded. Other versions require +// --binary-path / --fnn-binary-path with a locally built FNN. +export const SUPPORTED_FNN_VERSIONS = ['0.9.0-rc7'] as const; +export const DEFAULT_FNN_VERSION = SUPPORTED_FNN_VERSIONS[0]; + +// Independently pinned SHA-256 digests of the upstream release tarballs, +// keyed by version then package name (same pattern as ckb-tui's +// KNOWN_SHA256). The FNN release publishes no checksums asset, so the +// download is verified against these pins before anything is extracted; +// a version or package without a pin fails closed. +export const KNOWN_FNN_SHA256: Record> = { + '0.9.0-rc7': { + 'fnn_v0.9.0-rc7-x86_64-linux-portable': 'a27627e8cea2304e6075084d2fab72cd1276f512548351d6060e26622cc26faa', + 'fnn_v0.9.0-rc7-aarch64-linux-portable': 'fc25e907f9f24d345397da5794bac09c03fd76456a0f776bf3377192e3689143', + 'fnn_v0.9.0-rc7-x86_64-darwin-portable': '3ffa7ca2e3801e2d549c306200ae3add9ee90ec4a5093dfad6fefe04881e107b', + 'fnn_v0.9.0-rc7-aarch64-darwin-portable': '0127370913d7ec0291c0e1e38a0fff06efb6cdf999bafc87abb5b98e23b5df47', + 'fnn_v0.9.0-rc7-x86_64-windows': '7c9dd492a481aa18079aef17134bc16e8e247bd0535cb0372ab3476d55cb688b', + }, +}; + +export interface ResolvedFnn { + fnnPath: string; + testnetConfigPath: string; + // Where the binary came from: a downloaded release or a user-supplied path. + source: 'download' | 'binary-path'; + // Version reported by the binary, when it can be probed. + version: string | null; +} + +export function getFnnInstallPath(version: string, settings: Settings = readSettings()): string { + return path.join(settings.bins.rootFolder, 'fnn', version); +} + +export function getFnnBinaryPath(version: string, settings: Settings = readSettings()): string { + const binaryName = process.platform === 'win32' ? 'fnn.exe' : 'fnn'; + return path.join(getFnnInstallPath(version, settings), binaryName); +} + +export function getFnnBundledTestnetConfigPath(version: string, settings: Settings = readSettings()): string { + return path.join(getFnnInstallPath(version, settings), 'config', 'testnet', 'config.yml'); +} + +/** + * The release package name for a platform/arch combination. Linux and macOS + * publish x86_64 and aarch64 portable builds; any other architecture there is + * unsupported and must fail clearly instead of silently mapping to x86_64 + * (the checksum pin would pass for the genuine-but-incompatible tarball). + * Windows publishes x86_64 only, which Windows on ARM runs under emulation. + */ +export function buildFnnPackageName( + version: string, + platform: NodeJS.Platform = os.platform(), + arch: string = os.arch(), +): string { + if (platform === 'linux' || platform === 'darwin') { + if (arch !== 'x64' && arch !== 'arm64') { + throw new Error( + `Unsupported CPU architecture for FNN on ${platform}: ${arch}. ` + + 'FNN publishes x86_64 and aarch64 builds only; use --binary-path with a locally built binary.', + ); + } + const fnnArch = arch === 'arm64' ? 'aarch64' : 'x86_64'; + return `fnn_v${version}-${fnnArch}-${platform}-portable`; + } + if (platform === 'win32') { + return `fnn_v${version}-x86_64-windows`; + } + throw new Error(`Unsupported operating system for FNN: ${platform}`); +} + +export function buildFnnDownloadUrl(version: string): string { + const packageName = buildFnnPackageName(version); + return `https://github.com/nervosnetwork/fiber/releases/download/v${version}/${packageName}.tar.gz`; +} + +export function assertSupportedFnnVersion(version: string) { + if (!(SUPPORTED_FNN_VERSIONS as readonly string[]).includes(version)) { + throw new Error( + `FNN version ${version} is not supported by this offckb release. ` + + `Supported versions: ${SUPPORTED_FNN_VERSIONS.join(', ')}. ` + + 'To run a different FNN, use --binary-path with a locally built binary.', + ); + } +} + +// The release tarball must keep its full extracted layout: the bundled +// config/testnet/config.yml is the starting point for the devnet config. +function isInstallComplete(version: string, settings: Settings): boolean { + const configPath = getFnnBundledTestnetConfigPath(version, settings); + if (!fs.existsSync(getFnnBinaryPath(version, settings)) || !fs.existsSync(configPath)) return false; + try { + const parsed = yaml.load(fs.readFileSync(configPath, 'utf8')); + return parsed != null && typeof parsed === 'object'; + } catch { + return false; + } +} + +/** + * Verify a downloaded release tarball against its pinned SHA-256. Fails + * closed: an unsupported version or package has no pin and is rejected, as + * is any digest mismatch — nothing unverified is ever extracted. + */ +export function verifyFnnPackageChecksum(version: string, packageName: string, filePath: string): void { + const pinned = KNOWN_FNN_SHA256[version]?.[packageName]; + if (!pinned) { + throw new Error( + `No trusted SHA-256 checksum is pinned for FNN ${version} (${packageName}). ` + + 'Refusing to install an unverified binary.', + ); + } + const actual = crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); + if (actual !== pinned) { + throw new Error( + `SHA-256 checksum mismatch for ${packageName}.tar.gz.\n` + + `Expected: ${pinned}\nActual: ${actual}\n` + + 'The downloaded file may be corrupted or tampered with; refusing to install it.', + ); + } + logger.info('FNN release checksum verified (SHA-256).'); +} + +export async function downloadFnnAndUnzip(version: string, settings: Settings = readSettings()) { + const packageName = buildFnnPackageName(version); + const downloadURL = buildFnnDownloadUrl(version); + // A private per-run temp dir: a predictable path in the shared tmp lets + // another local user pre-create/symlink it, and two concurrent installs + // would overwrite each other's tarball and extraction tree. + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fnn-')); + const tempFilePath = path.join(tempDir, `${packageName}.tar.gz`); + + logger.info(`downloading ${downloadURL} ..`); + const response = await Request.send(downloadURL); + const arrayBuffer = await response.arrayBuffer(); + fs.writeFileSync(tempFilePath, Buffer.from(arrayBuffer)); + + try { + verifyFnnPackageChecksum(version, packageName, tempFilePath); + const extractDir = path.join(tempDir, 'extract'); + await unZipFile(tempFilePath, extractDir, true); + + // FNN packages ship the binary and config/ flat at the tarball root (unlike + // CKB packages, which nest everything in a package-name directory); accept + // either layout. + const nestedPath = path.join(extractDir, packageName); + const sourcePath = fs.existsSync(nestedPath) ? nestedPath : extractDir; + if (!fs.existsSync(path.join(sourcePath, process.platform === 'win32' ? 'fnn.exe' : 'fnn'))) { + throw new Error(`FNN release package layout is unexpected: no fnn binary found in ${extractDir}.`); + } + const targetPath = getFnnInstallPath(version, settings); + fs.rmSync(targetPath, { recursive: true, force: true }); + fs.mkdirSync(targetPath, { recursive: true }); + for (const entry of fs.readdirSync(sourcePath)) { + fs.cpSync(path.join(sourcePath, entry), path.join(targetPath, entry), { recursive: true, force: true }); + } + if (process.platform !== 'win32') { + fs.chmodSync(getFnnBinaryPath(version, settings), '755'); + } + } finally { + // The tarball and extraction tree are only intermediates; never leave + // them behind, whether the install succeeded or failed. + fs.rmSync(tempDir, { recursive: true, force: true }); + } + logger.info(`FNN ${version} installed successfully.`); +} + +/** + * Ensure a supported FNN release is installed. A cached install whose binary + * is missing, won't run, reports a different version, or lost its bundled + * testnet config is replaced by one fresh download; no retry loop. + */ +export async function installFnnBinary(version: string, settings: Settings = readSettings()) { + assertSupportedFnnVersion(version); + + const binPath = getFnnBinaryPath(version, settings); + const cachedVersion = getVersionFromBinary(binPath); + if (cachedVersion === version && isInstallComplete(version, settings)) { + return; + } + if (cachedVersion && cachedVersion !== version) { + logger.info(`Cached FNN version ${cachedVersion} does not match ${version}; downloading the release build.`); + } else if (!cachedVersion) { + logger.info(`FNN binary not found or unusable, downloading FNN ${version} ..`); + } else { + logger.info(`FNN ${version} installation is incomplete (missing bundled config); downloading again ..`); + } + await downloadFnnAndUnzip(version, settings); + + const installedVersion = getVersionFromBinary(binPath); + if (installedVersion !== version || !isInstallComplete(version, settings)) { + throw new Error( + `FNN ${version} was downloaded but the installed binary reports ` + + `${installedVersion ?? 'no usable version'}; installation failed.`, + ); + } +} + +/** + * Resolve the FNN binary and the testnet config used as the devnet config + * template. A user-supplied binary path skips download and version checks; + * its sibling config/testnet/config.yml is used when present (and must + * parse), otherwise the testnet config shipped with offckb is the fallback. + */ +export async function resolveFnnBinary( + options: { version?: string; binaryPath?: string }, + settings: Settings = readSettings(), +): Promise { + if (options.binaryPath) { + const fnnPath = options.binaryPath; + if (!fs.existsSync(fnnPath)) { + throw new Error(`FNN binary not found at ${fnnPath}`); + } + const siblingConfig = path.join(path.dirname(fnnPath), 'config', 'testnet', 'config.yml'); + let testnetConfigPath: string; + if (fs.existsSync(siblingConfig)) { + try { + const parsed = yaml.load(fs.readFileSync(siblingConfig, 'utf8')); + if (parsed == null || typeof parsed !== 'object') throw new Error('empty or non-object config'); + testnetConfigPath = siblingConfig; + } catch (error) { + throw new Error( + `The testnet config next to the FNN binary (${siblingConfig}) cannot be parsed: ${(error as Error).message}. ` + + 'Fix that file or remove it to fall back to the config shipped with offckb.', + ); + } + } else { + testnetConfigPath = bundledFiberTestnetConfigPath(); + if (!fs.existsSync(testnetConfigPath)) { + throw new Error(`Bundled FNN testnet config is missing at ${testnetConfigPath}.`); + } + logger.info(`No config/testnet/config.yml next to ${fnnPath}; using the testnet config shipped with offckb.`); + } + logger.info(`Using FNN testnet config: ${testnetConfigPath}`); + return { fnnPath, testnetConfigPath, source: 'binary-path', version: getVersionFromBinary(fnnPath) }; + } + + const version = options.version || settings.bins.defaultFnnVersion || DEFAULT_FNN_VERSION; + await installFnnBinary(version, settings); + return { + fnnPath: getFnnBinaryPath(version, settings), + testnetConfigPath: getFnnBundledTestnetConfigPath(version, settings), + source: 'download', + version, + }; +} diff --git a/src/fiber/manager.ts b/src/fiber/manager.ts new file mode 100644 index 00000000..c3ba16f0 --- /dev/null +++ b/src/fiber/manager.ts @@ -0,0 +1,449 @@ +import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import { ccc } from '@ckb-ccc/core'; +import { callJsonRpc } from '../util/json-rpc'; +import { logger } from '../util/logger'; +import { readSettings, Settings } from '../cfg/setting'; +import { fiberNodePaths, fiberRpcUrl, fiberRpcPort, fiberP2pPort, fiberAccountIndex } from './paths'; +import { ensureNodesYml, FiberNodeEntry } from './nodes-yml'; +import { + ensureNodeKeyMaterial, + fiberNodeAccount, + readNodePassword, + fiberPublicKeyFromSecret, + readFiberNodeSecretKey, +} from './accounts'; +import { generateNodeConfig } from './config-gen'; +import { FiberChainScripts } from './scripts'; +import { fnnNodeInfo, fnnConnectPeer, fnnListPeers, checkPortFree, FnnNodeInfo } from './rpc'; +import { lockMatches } from './status'; +import { + writeRuntime, + readRuntime, + readLiveRuntime, + removeRuntimeFile, + removeRuntimeFileIfStale, + FiberRuntime, +} from './runtime'; +import { closeFileDescriptors } from '../util/daemon'; +import { enterGracefulShutdown } from '../util/shutdown'; + +export interface FnnProcessHandle { + id: number; + process: ChildProcess; + rpcUrl: string; + dir: string; + logFile: string; +} + +export interface FiberEnvironment { + nodes: FnnProcessHandle[]; + nodeInfos: Map; + genesisHash: string; +} + +const FNN_RPC_TIMEOUT_MS = 90_000; +const STOP_GRACE_TIMEOUT_MS = 10_000; + +export class FiberStartupError extends Error { + constructor( + message: string, + public readonly startedNodes: FnnProcessHandle[] = [], + ) { + super(message); + this.name = 'FiberStartupError'; + } +} + +/** + * Refuse to touch the environment while another live OffCKB process manages + * FNNs (foreground or daemon). A leftover runtime record whose manager is + * dead is stale and discarded, never used to hunt processes. + */ +export function assertNoLiveFiberManager(settings: Settings = readSettings()) { + const live = readLiveRuntime(settings); + if (live) { + throw new Error( + `Fiber nodes are already managed by OffCKB process ${live.managerPid} (started ${live.startedAt || 'unknown'}). ` + + 'Stop that environment first (`offckb fiber stop` for a daemon, or Ctrl+C in its terminal).', + ); + } + removeRuntimeFileIfStale(settings); +} + +async function assertFiberPortsFree(nodes: FiberNodeEntry[]) { + const conflicts: string[] = []; + for (const node of nodes) { + const rpcPort = fiberRpcPort(node.id); + const p2pPort = fiberP2pPort(node.id); + if (!(await checkPortFree(rpcPort))) { + conflicts.push(`node ${node.id} RPC port ${rpcPort}`); + } + if (!(await checkPortFree(p2pPort))) { + conflicts.push(`node ${node.id} P2P port ${p2pPort}`); + } + } + if (conflicts.length > 0) { + throw new Error( + `Fiber port conflict: ${conflicts.join('; ')} ${conflicts.length === 1 ? 'is' : 'are'} already in use. ` + + 'OffCKB does not stop processes it did not start; free the port(s) or stop the program using them.', + ); + } +} + +function spawnFnn(node: FiberNodeEntry, fnnPath: string, settings: Settings): FnnProcessHandle { + const paths = fiberNodePaths(node.id, settings); + fs.mkdirSync(paths.dir, { recursive: true }); + const logFd = fs.openSync(paths.logFile, 'a'); + const password = readNodePassword(node.id, settings); + const child = spawn(fnnPath, ['-d', paths.dir], { + stdio: ['ignore', logFd, logFd], + env: { + ...process.env, + // FNN stays silent without an explicit filter (EnvFilter::from_default_env); + // respect a user-provided RUST_LOG, default to info otherwise. Its fmt + // layer writes ANSI colors unless NO_COLOR is present — keep the log + // files plain. + RUST_LOG: process.env.RUST_LOG ?? 'info', + NO_COLOR: process.env.NO_COLOR ?? '1', + FIBER_SECRET_KEY_PASSWORD: password, + LOG_PREFIX: `[fiber ${node.id}]`, + }, + }); + // The child's stdio owns the fd now; close our copy so the file is only + // held open by the FNN process. + closeFileDescriptors(logFd); + return { id: node.id, process: child, rpcUrl: fiberRpcUrl(node.id), dir: paths.dir, logFile: paths.logFile }; +} + +async function waitForAllNodeInfo(nodes: FnnProcessHandle[], timeoutMs: number): Promise> { + const start = Date.now(); + const infos = new Map(); + const pending = new Set(nodes.map((n) => n.id)); + const exited = new Map(); + for (const node of nodes) { + node.process.once('exit', (code, signal) => exited.set(node.id, { code, signal })); + node.process.once('error', () => exited.set(node.id, { code: null, signal: null })); + } + + while (pending.size > 0 && Date.now() - start < timeoutMs) { + for (const id of [...pending]) { + if (exited.has(id)) { + const node = nodes.find((n) => n.id === id)!; + throw new FiberStartupError( + `FNN node ${id} exited during startup (code=${exited.get(id)!.code ?? 'null'}, signal=${exited.get(id)!.signal ?? 'none'}). ` + + `See its log: ${node.logFile}`, + nodes, + ); + } + const node = nodes.find((n) => n.id === id)!; + try { + const info = await fnnNodeInfo(node.rpcUrl, 2000); + infos.set(id, info); + pending.delete(id); + } catch { + // RPC not up yet; keep polling while the child is alive. + } + } + if (pending.size > 0) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + if (pending.size > 0) { + const idList = [...pending].join(', '); + const logs = nodes + .filter((n) => pending.has(n.id)) + .map((n) => n.logFile) + .join(', '); + throw new FiberStartupError(`Timed out waiting for FNN RPC of node(s) ${idList}. See log(s): ${logs}`, nodes); + } + return infos; +} + +async function assertChainConsistency( + nodes: FnnProcessHandle[], + nodeInfos: Map, + expectedGenesisHash: string, + settings: Settings, +) { + let ckbGenesis: string; + try { + ckbGenesis = String(await callJsonRpc(settings.devnet.rpcUrl, 'get_block_hash', ['0x0'], 5000)).toLowerCase(); + } catch (error) { + throw new FiberStartupError( + `Failed to read the genesis block hash from CKB RPC ${settings.devnet.rpcUrl}: ${(error as Error).message}`, + nodes, + ); + } + const expected = expectedGenesisHash.toLowerCase(); + if (ckbGenesis !== expected) { + throw new FiberStartupError( + `Chain mismatch: the running CKB node's genesis (${ckbGenesis}) differs from the devnet spec's (${expected}). ` + + 'The devnet chain data and its spec are out of sync; stop CKB and the FNNs, run `offckb clean`, and start again.', + nodes, + ); + } + for (const node of nodes) { + const info = nodeInfos.get(node.id)!; + if (String(info.chain_hash).toLowerCase() !== expected) { + throw new FiberStartupError( + `Chain mismatch: FNN node ${node.id} reports chain_hash ${info.chain_hash}, expected ${expected}.`, + nodes, + ); + } + } +} + +async function assertNodeIdentitiesAndFunds( + nodes: FnnProcessHandle[], + nodeInfos: Map, + settings: Settings, +) { + const client = new ccc.ClientPublicTestnet({ url: settings.devnet.rpcUrl, fallbacks: [] }); + for (const node of nodes) { + const info = nodeInfos.get(node.id)!; + + const secret = readFiberNodeSecretKey(node.id, settings); + if (secret == null) { + throw new FiberStartupError(`Fiber node ${node.id} has no usable fiber/sk identity key after startup.`, nodes); + } + const expectedPubkey = fiberPublicKeyFromSecret(secret); + if (String(info.pubkey).toLowerCase() !== expectedPubkey) { + throw new FiberStartupError( + `Fiber node ${node.id} reports an unexpected network identity (${info.pubkey}); ` + + 'the process answering on its RPC port is not the node OffCKB started.', + nodes, + ); + } + + const account = fiberNodeAccount(node.id); + const expectedLock = account.lockScript; + const actualLock = info.default_funding_lock_script; + if (!lockMatches(actualLock, expectedLock)) { + throw new FiberStartupError( + `Fiber node ${node.id} funds account mismatch: expected built-in account #${fiberAccountIndex(node.id)} ` + + `(lock args ${expectedLock.args}) but the node reports ${JSON.stringify(actualLock)}.`, + nodes, + ); + } + + let balance: bigint; + try { + balance = await client.getBalanceSingle(ccc.Script.from(account.lockScript as ccc.ScriptLike)); + } catch (error) { + throw new FiberStartupError( + `Failed to query the CKB balance of fiber node ${node.id}'s account: ${(error as Error).message}`, + nodes, + ); + } + if (balance <= BigInt(0)) { + throw new FiberStartupError( + `Fiber node ${node.id}'s CKB account #${fiberAccountIndex(node.id)} has no available CKB. ` + + 'Fund the account before starting Fiber.', + nodes, + ); + } + logger.info( + `Fiber node ${node.id}: account #${fiberAccountIndex(node.id)} balance ${ccc.fixedPointToString(balance)} CKB.`, + ); + } +} + +async function connectFiberPeers(nodes: FnnProcessHandle[], nodeInfos: Map) { + if (nodes.length < 2) return; + const [first, ...rest] = nodes; + for (const peer of rest) { + const info = nodeInfos.get(peer.id)!; + const address = info.addresses.find((addr) => addr.includes('/p2p/')); + if (!address) { + throw new FiberStartupError(`FNN node ${peer.id} did not announce a connectable address.`, nodes); + } + try { + await fnnConnectPeer(first.rpcUrl, address); + } catch (error) { + throw new FiberStartupError( + `Failed to connect fiber node ${first.id} to node ${peer.id} at ${address}: ${(error as Error).message}`, + nodes, + ); + } + } + // connect_peer returns once dialing starts; give the P2P handshake a moment + // to settle, then verify once with list_peers. + await new Promise((resolve) => setTimeout(resolve, 3000)); + const peers = await fnnListPeers(first.rpcUrl); + if (peers.length < rest.length) { + throw new FiberStartupError( + `Fiber node ${first.id} has ${peers.length} peer(s) after connect_peer, expected at least ${rest.length}.`, + nodes, + ); + } + logger.info(`Fiber node ${first.id} connected to ${rest.length} peer(s).`); +} + +export interface StartFiberEnvironmentOptions { + fnnPath: string; + testnetConfigPath: string; + chainScripts: FiberChainScripts; + nodeCount?: number; + settings?: Settings; +} + +/** + * The shared Fiber startup flow used by both `offckb fiber start` and + * `offckb node --fiber`: regenerate node configs from the current chain + * spec, spawn all FNNs with their own logs, wait for their RPCs, verify the + * chain/identity/account checks and interconnect the nodes. On any failure + * the FNNs started here are stopped again. + * + * Signal handlers are installed BEFORE the first FNN spawn and stay until + * the environment is ready: a Ctrl+C during the startup window (RPC waits + * can stretch to ~90s) stops the partially started nodes and drops the + * `starting` runtime record instead of orphaning the FNNs and wedging every + * later start/stop/clean. On success the handlers are removed before the + * caller installs its own supervision. + */ +export async function startFiberEnvironment(options: StartFiberEnvironmentOptions): Promise { + const settings = options.settings ?? readSettings(); + assertNoLiveFiberManager(settings); + + const nodes = ensureNodesYml(options.nodeCount, settings); + for (const node of nodes) { + const { created } = ensureNodeKeyMaterial(node.id, settings); + if (created) { + logger.info(`Fiber node ${node.id}: provisioned new CKB key and password.`); + } + generateNodeConfig({ + node, + chainScripts: options.chainScripts, + testnetConfigPath: options.testnetConfigPath, + settings, + }); + } + await assertFiberPortsFree(nodes); + + // Spawn incrementally: if a later spawn fails (e.g. mkdir/open EACCES or + // ENOSPC), the children started so far must not be left running without a + // runtime record — OffCKB would refuse to touch those orphans. + const handles: FnnProcessHandle[] = []; + let signalCleanupStarted = false; + const onStartupSignal = (signal: 'SIGINT' | 'SIGTERM') => { + // Set before the first log line of the cleanup: with piped output the + // reader may die with this same signal, and an EPIPE must not abort the + // shutdown (see util/shutdown.ts). + enterGracefulShutdown(); + void (async () => { + if (signalCleanupStarted) return; + signalCleanupStarted = true; + logger.info(`Received ${signal} during fiber startup; stopping the partially started FNN nodes...`); + // Same cleanup as the post-ready stop path: SIGTERM the children, + // escalate to SIGKILL after the grace period, drop the runtime record. + await stopFiberNodes(handles, settings); + process.exit(signal === 'SIGINT' ? 130 : 143); + })(); + }; + const onSigint = () => onStartupSignal('SIGINT'); + const onSigterm = () => onStartupSignal('SIGTERM'); + process.once('SIGINT', onSigint); + process.once('SIGTERM', onSigterm); + const removeStartupSignalHandlers = () => { + process.removeListener('SIGINT', onSigint); + process.removeListener('SIGTERM', onSigterm); + }; + + try { + try { + for (const node of nodes) { + handles.push(spawnFnn(node, options.fnnPath, settings)); + } + } catch (error) { + await stopFiberNodes(handles, settings); + throw error; + } + const runtime: FiberRuntime = { + managerPid: process.pid, + startedAt: new Date().toISOString(), + status: 'starting', + nodes: handles.map((handle) => ({ + id: handle.id, + pid: handle.process.pid ?? 0, + dir: handle.dir, + rpcUrl: handle.rpcUrl, + })), + }; + writeRuntime(runtime, settings); + + try { + const nodeInfos = await waitForAllNodeInfo(handles, FNN_RPC_TIMEOUT_MS); + await assertChainConsistency(handles, nodeInfos, options.chainScripts.genesisHash, settings); + await assertNodeIdentitiesAndFunds(handles, nodeInfos, settings); + await connectFiberPeers(handles, nodeInfos); + writeRuntime({ ...runtime, status: 'running' }, settings); + return { nodes: handles, nodeInfos, genesisHash: options.chainScripts.genesisHash }; + } catch (error) { + await stopFiberNodes(handles, settings); + if (error instanceof FiberStartupError) { + throw new FiberStartupError(error.message, []); + } + throw error; + } + } finally { + removeStartupSignalHandlers(); + } +} + +/** + * Stop the given FNN child processes: one SIGTERM, wait for exit, a single + * SIGKILL if the grace period expires. Removes runtime.json when this process + * is the recorded manager. Never touches processes it was not handed. + */ +export async function stopFiberNodes( + nodes: FnnProcessHandle[], + settings: Settings = readSettings(), + graceMs: number = STOP_GRACE_TIMEOUT_MS, +): Promise { + for (const node of nodes) { + if (node.process.exitCode == null && node.process.signalCode == null && !node.process.killed) { + try { + node.process.kill('SIGTERM'); + } catch { + // already gone + } + } + } + const deadline = Date.now() + graceMs; + for (const node of nodes) { + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await waitForChildExit(node.process, remaining); + } + for (const node of nodes) { + if (node.process.exitCode == null && node.process.signalCode == null) { + try { + node.process.kill('SIGKILL'); + } catch { + // already gone + } + } + } + removeRuntimeFileIfManager(settings); +} + +function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise { + // A signal-terminated child has exitCode === null with signalCode set; both + // mean "exited", and the exit event may already have fired. + if (child.exitCode != null || child.signalCode != null) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(), timeoutMs); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +function removeRuntimeFileIfManager(settings: Settings) { + const runtime = readRuntime(settings); + if (runtime?.managerPid === process.pid) { + removeRuntimeFile(settings); + } +} diff --git a/src/fiber/nodes-yml.ts b/src/fiber/nodes-yml.ts new file mode 100644 index 00000000..5075cb90 --- /dev/null +++ b/src/fiber/nodes-yml.ts @@ -0,0 +1,156 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import yaml from 'js-yaml'; +import { nodesYmlPath, fiberNodeDir, MIN_FIBER_NODES, MAX_FIBER_NODES, DEFAULT_FIBER_NODES } from './paths'; +import { readSettings, Settings } from '../cfg/setting'; +import { logger } from '../util/logger'; + +export interface FiberNodeEntry { + id: number; + // Per-node FNN config overrides, merged on top of the generated config. + // Objects merge recursively, lists replace. + config: Record; +} + +// These fields are owned by offckb; setting them per node would break the +// environment in ways the startup checks cannot recover from. fiber.store_path +// is managed so the store always lives where fiberNodePaths points — a +// relocated store would make clean's RocksDB LOCK check inspect the wrong +// path and silently lose its fail-closed property. +export const MANAGED_CONFIG_PATHS = [ + 'fiber.chain', + 'fiber.scripts', + 'fiber.listening_addr', + 'fiber.bootnode_addrs', + 'fiber.store_path', + 'rpc.listening_addr', + 'ckb.rpc_url', + 'ckb.udt_whitelist', + 'services', +]; + +function isPlainObject(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +export function validateNodeCount(count: number): number { + if (!Number.isInteger(count) || count < MIN_FIBER_NODES || count > MAX_FIBER_NODES) { + throw new Error(`--nodes must be an integer between ${MIN_FIBER_NODES} and ${MAX_FIBER_NODES}, got: ${count}`); + } + return count; +} + +function assertNoManagedFields(config: Record, nodeId: number) { + for (const dottedPath of MANAGED_CONFIG_PATHS) { + const segments = dottedPath.split('.'); + let current: unknown = config; + for (const segment of segments) { + if (!isPlainObject(current)) { + current = undefined; + break; + } + current = current[segment]; + } + if (current !== undefined) { + throw new Error( + `nodes.yml: node ${nodeId} sets "${dottedPath}", which is managed by offckb and cannot be overridden. ` + + `Managed fields: ${MANAGED_CONFIG_PATHS.join(', ')}.`, + ); + } + } +} + +export function readNodesYml(settings: Settings = readSettings()): FiberNodeEntry[] | null { + const file = nodesYmlPath(settings); + if (!fs.existsSync(file)) return null; + let parsed: unknown; + try { + parsed = yaml.load(fs.readFileSync(file, 'utf8')); + } catch (error) { + throw new Error(`Failed to parse ${file}: ${(error as Error).message}`); + } + if (!isPlainObject(parsed) || !Array.isArray(parsed.nodes)) { + throw new Error(`Invalid ${file}: expected a "nodes" list. Fix the file or remove the fiber environment.`); + } + const entries: FiberNodeEntry[] = parsed.nodes.map((raw: unknown, index: number) => { + if (!isPlainObject(raw) || !Number.isInteger(raw.id) || (raw.id as number) <= 0) { + throw new Error(`Invalid ${file}: nodes[${index}] must have a positive integer "id".`); + } + const config = raw.config == null ? {} : raw.config; + if (!isPlainObject(config)) { + throw new Error(`Invalid ${file}: nodes[${index}].config must be a mapping of FNN config fields.`); + } + return { id: raw.id as number, config: config as Record }; + }); + const ids = new Set(); + for (const entry of entries) { + if (ids.has(entry.id)) { + throw new Error(`Invalid ${file}: duplicate node id ${entry.id}.`); + } + if (entry.id > MAX_FIBER_NODES) { + throw new Error(`Invalid ${file}: node id ${entry.id} exceeds the maximum of ${MAX_FIBER_NODES}.`); + } + ids.add(entry.id); + assertNoManagedFields(entry.config, entry.id); + } + if (entries.length < MIN_FIBER_NODES) { + throw new Error(`Invalid ${file}: at least ${MIN_FIBER_NODES} node must be configured.`); + } + if (entries.length > MAX_FIBER_NODES) { + throw new Error(`Invalid ${file}: ${entries.length} nodes configured, at most ${MAX_FIBER_NODES} are supported.`); + } + return entries.sort((a, b) => a.id - b.id); +} + +export function writeNodesYml(entries: FiberNodeEntry[], settings: Settings = readSettings()) { + const file = nodesYmlPath(settings); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const doc = { + nodes: entries.map((entry) => ({ id: entry.id, config: entry.config })), + }; + fs.writeFileSync(file, yaml.dump(doc, { noRefs: true })); +} + +/** + * Resolve the node list for this start. Without an existing nodes.yml one is + * created from the requested count (default 2). With an existing file the + * stored list wins unless --nodes asks for a different count: surviving ids + * keep their per-node config, new ids start empty, and removed ids are + * reported (their directories are never deleted automatically). + */ +export function ensureNodesYml( + requestedCount: number | undefined, + settings: Settings = readSettings(), +): FiberNodeEntry[] { + const existing = readNodesYml(settings); + if (existing == null) { + const count = requestedCount == null ? DEFAULT_FIBER_NODES : validateNodeCount(requestedCount); + const entries = Array.from({ length: count }, (_, i) => ({ id: i + 1, config: {} })); + writeNodesYml(entries, settings); + logger.debug(`Created ${nodesYmlPath(settings)} with ${count} node(s).`); + return entries; + } + + if (requestedCount == null) return existing; + + const count = validateNodeCount(requestedCount); + if (count === existing.length && existing.every((entry, i) => entry.id === i + 1)) { + return existing; + } + + const byId = new Map(existing.map((entry) => [entry.id, entry])); + const next: FiberNodeEntry[] = []; + for (let id = 1; id <= count; id++) { + next.push(byId.get(id) ?? { id, config: {} }); + } + const removed = existing.filter((entry) => entry.id > count); + for (const entry of removed) { + logger.warn( + `Node ${entry.id} is removed from nodes.yml; its per-node config overrides are discarded. ` + + `Its directory ${fiberNodeDir(entry.id, settings)} is kept; ` + + 'delete it manually or run `offckb fiber clean` to remove it.', + ); + } + writeNodesYml(next, settings); + return next; +} diff --git a/src/fiber/paths.ts b/src/fiber/paths.ts new file mode 100644 index 00000000..4a93da5d --- /dev/null +++ b/src/fiber/paths.ts @@ -0,0 +1,138 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { packageRootPath, readSettings, Settings } from '../cfg/setting'; +import { isFolderExists } from '../util/fs'; + +// Directory layout of a Fiber devnet environment (see docs/fiber.md): +// +// /devnet/fiber/ +// ├── nodes.yml node list and per-node FNN config overrides +// ├── runtime.json current manager process and node addresses +// ├── logs/ fiber start --daemon manager log + pid +// └── nodes// +// ├── config.yml generated on every start, do not hand-edit +// ├── ckb/key CKB secret key (FNN encrypts it on first start) +// ├── fiber/sk Fiber network identity key, generated by FNN +// ├── fiber/store/ FNN database +// ├── fnn.log node stdout/stderr +// └── password random key-encryption password for this node +// +// The environment lock lives next to the devnet directory (not inside it) so +// `offckb clean`/`fiber clean` can hold it while deleting the whole tree. + +export const FIBER_DIR_NAME = 'fiber'; +export const NODES_YML_FILE = 'nodes.yml'; +export const RUNTIME_JSON_FILE = 'runtime.json'; +export const FIBER_LOGS_DIR = 'logs'; +export const FIBER_DAEMON_LOG_FILE = 'daemon.log'; +export const FIBER_DAEMON_PID_FILE = 'daemon.pid'; +export const ENV_LOCK_FILE = '.offckb-devnet.lock'; +export const NODE_CONFIG_FILE = 'config.yml'; +export const NODE_LOG_FILE = 'fnn.log'; +export const NODE_PASSWORD_FILE = 'password'; +export const NODE_CKB_DIR = 'ckb'; +export const NODE_CKB_KEY_FILE = 'key'; +export const NODE_FIBER_DIR = 'fiber'; +export const NODE_FIBER_SK_FILE = 'sk'; +export const NODE_FIBER_STORE_DIR = 'store'; +export const ROCKSDB_LOCK_FILE = 'LOCK'; + +export const MIN_FIBER_NODES = 1; +export const MAX_FIBER_NODES = 16; +export const DEFAULT_FIBER_NODES = 2; + +// Node N uses CKB account N+2 (accounts 3-18 are reserved for Fiber; account +// 19 deploys contracts and issues the test UDTs), RPC port 21713+N and P2P +// port 8343+N. +export const FIBER_ACCOUNT_OFFSET = 2; +export const FIBER_RPC_PORT_BASE = 21713; +export const FIBER_P2P_PORT_BASE = 8343; +export const UDT_ISSUER_ACCOUNT_INDEX = 19; + +export function fiberRootPath(settings: Settings = readSettings()): string { + return path.join(settings.devnet.configPath, FIBER_DIR_NAME); +} + +export function nodesYmlPath(settings: Settings = readSettings()): string { + return path.join(fiberRootPath(settings), NODES_YML_FILE); +} + +export function runtimeJsonPath(settings: Settings = readSettings()): string { + return path.join(fiberRootPath(settings), RUNTIME_JSON_FILE); +} + +export function fiberLogsPath(settings: Settings = readSettings()): string { + return path.join(fiberRootPath(settings), FIBER_LOGS_DIR); +} + +export function fiberDaemonPaths(settings: Settings = readSettings()) { + const logDir = fiberLogsPath(settings); + return { + logDir, + logFile: path.join(logDir, FIBER_DAEMON_LOG_FILE), + pidFile: path.join(logDir, FIBER_DAEMON_PID_FILE), + }; +} + +export function envLockPath(settings: Settings = readSettings()): string { + // Sibling of the devnet directory: /.offckb-devnet.lock + return path.join(path.dirname(settings.devnet.configPath), ENV_LOCK_FILE); +} + +export function fiberNodeDir(id: number, settings: Settings = readSettings()): string { + return path.join(fiberRootPath(settings), 'nodes', String(id)); +} + +// Ids of the node directories present under /nodes, in numeric order. +export function fiberNodeIds(settings: Settings = readSettings()): number[] { + const nodesDir = path.join(fiberRootPath(settings), 'nodes'); + if (!isFolderExists(nodesDir)) return []; + return fs + .readdirSync(nodesDir) + .filter((entry) => /^\d+$/.test(entry)) + .map((entry) => Number(entry)) + .sort((a, b) => a - b); +} + +export function fiberNodePaths(id: number, settings: Settings = readSettings()) { + const dir = fiberNodeDir(id, settings); + return { + dir, + configFile: path.join(dir, NODE_CONFIG_FILE), + logFile: path.join(dir, NODE_LOG_FILE), + passwordFile: path.join(dir, NODE_PASSWORD_FILE), + ckbDir: path.join(dir, NODE_CKB_DIR), + ckbKeyFile: path.join(dir, NODE_CKB_DIR, NODE_CKB_KEY_FILE), + fiberDir: path.join(dir, NODE_FIBER_DIR), + fiberSkFile: path.join(dir, NODE_FIBER_DIR, NODE_FIBER_SK_FILE), + fiberStoreDir: path.join(dir, NODE_FIBER_DIR, NODE_FIBER_STORE_DIR), + storeLockFile: path.join(dir, NODE_FIBER_DIR, NODE_FIBER_STORE_DIR, ROCKSDB_LOCK_FILE), + }; +} + +export function fiberAccountIndex(nodeId: number): number { + return nodeId + FIBER_ACCOUNT_OFFSET; +} + +export function fiberRpcPort(nodeId: number): number { + return FIBER_RPC_PORT_BASE + nodeId; +} + +export function fiberP2pPort(nodeId: number): number { + return FIBER_P2P_PORT_BASE + nodeId; +} + +export function fiberRpcUrl(nodeId: number): string { + return `http://127.0.0.1:${fiberRpcPort(nodeId)}`; +} + +export function fiberP2pAddr(nodeId: number): string { + return `/ip4/127.0.0.1/tcp/${fiberP2pPort(nodeId)}`; +} + +// Fallback FNN testnet config shipped with offckb, used when a local FNN +// binary has no sibling config/testnet/config.yml. The Makefile copies it +// from the pinned ckb/fiber submodule into the devnet specs. +export function bundledFiberTestnetConfigPath(): string { + return path.join(packageRootPath, 'ckb', 'devnet', 'specs', 'fiber', 'testnet-config.yml'); +} diff --git a/src/fiber/rpc.ts b/src/fiber/rpc.ts new file mode 100644 index 00000000..9f51f8eb --- /dev/null +++ b/src/fiber/rpc.ts @@ -0,0 +1,47 @@ +import * as net from 'net'; +import { callJsonRpc } from '../util/json-rpc'; + +export interface FnnNodeInfo { + version: string; + commit_hash: string; + pubkey: string; + node_name?: string | null; + addresses: string[]; + chain_hash: string; + default_funding_lock_script: { + code_hash: string; + hash_type: string; + args: string; + }; + peers_count: string | number; + channel_count: string | number; +} + +export interface FnnPeerInfo { + pubkey: string; + address: string; +} + +export async function fnnNodeInfo(rpcUrl: string, timeoutMs = 3000): Promise { + return (await callJsonRpc(rpcUrl, 'node_info', [], timeoutMs)) as FnnNodeInfo; +} + +export async function fnnConnectPeer(rpcUrl: string, address: string, save = true, timeoutMs = 10000): Promise { + await callJsonRpc(rpcUrl, 'connect_peer', [{ address, save }], timeoutMs); +} + +export async function fnnListPeers(rpcUrl: string, timeoutMs = 3000): Promise { + const result = (await callJsonRpc(rpcUrl, 'list_peers', [], timeoutMs)) as { peers?: FnnPeerInfo[] }; + return result?.peers ?? []; +} + +export function checkPortFree(port: number, host = '127.0.0.1'): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.once('error', () => resolve(false)); + server.listen(port, host, () => { + server.close(() => resolve(true)); + }); + }); +} diff --git a/src/fiber/runtime.ts b/src/fiber/runtime.ts new file mode 100644 index 00000000..b66caf31 --- /dev/null +++ b/src/fiber/runtime.ts @@ -0,0 +1,100 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { runtimeJsonPath } from './paths'; +import { readSettings, Settings } from '../cfg/setting'; +import { isProcessAlive } from '../util/daemon'; +import { logger } from '../util/logger'; + +export interface RuntimeNodeInfo { + id: number; + pid: number; + dir: string; + rpcUrl: string; +} + +export interface FiberRuntime { + managerPid: number; + startedAt: string; + status: 'starting' | 'running'; + nodes: RuntimeNodeInfo[]; +} + +export function writeRuntime(runtime: FiberRuntime, settings: Settings = readSettings()) { + const file = runtimeJsonPath(settings); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(runtime, null, 2)); +} + +export function readRuntime(settings: Settings = readSettings()): FiberRuntime | null { + const file = runtimeJsonPath(settings); + let raw: string; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch { + return null; + } + try { + const parsed = JSON.parse(raw) as Partial; + if (!Number.isInteger(parsed.managerPid) || !Array.isArray(parsed.nodes)) return null; + return { + managerPid: parsed.managerPid as number, + startedAt: String(parsed.startedAt ?? ''), + status: parsed.status === 'running' ? 'running' : 'starting', + nodes: (parsed.nodes as RuntimeNodeInfo[]).map((node) => ({ + id: Number(node.id), + pid: Number(node.pid), + dir: String(node.dir), + rpcUrl: String(node.rpcUrl), + })), + }; + } catch { + return null; + } +} + +/** + * A runtime record is only meaningful while its manager process exists. Once + * the manager is confirmed gone the record is stale — no further inspection + * of program paths, ports or versions (per the Fiber design: leftovers are + * discarded, never used to hunt processes). + * + * A liveness check that cannot be performed (EPERM on a process owned by + * another user, a transient /proc error, ...) proves nothing: the manager may + * still be running. Fail closed — the same rule the environment lock uses — + * and treat the record as live instead of discarding the only reference to a + * potentially running environment. Recovery from a genuine leftover in that + * situation is manual: confirm the manager is gone, then delete runtime.json. + */ +export function isRuntimeStale(runtime: FiberRuntime): boolean { + try { + return !isProcessAlive(runtime.managerPid); + } catch { + return false; + } +} + +export function readLiveRuntime(settings: Settings = readSettings()): FiberRuntime | null { + const runtime = readRuntime(settings); + if (runtime == null) return null; + if (isRuntimeStale(runtime)) return null; + return runtime; +} + +export function removeRuntimeFile(settings: Settings = readSettings()) { + try { + fs.unlinkSync(runtimeJsonPath(settings)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn(`Failed to remove ${runtimeJsonPath(settings)}: ${(error as Error).message}`); + } + } +} + +// Remove the runtime file only when its manager is confirmed dead — used by +// clean commands to discard records that can never be acted on again. +export function removeRuntimeFileIfStale(settings: Settings = readSettings()) { + const runtime = readRuntime(settings); + if (runtime != null && isRuntimeStale(runtime)) { + removeRuntimeFile(settings); + } +} diff --git a/src/fiber/scripts.ts b/src/fiber/scripts.ts new file mode 100644 index 00000000..ea9966a6 --- /dev/null +++ b/src/fiber/scripts.ts @@ -0,0 +1,157 @@ +import { resolveDevnetSystemScripts } from '../scripts/private'; +import { SystemScript, SystemScriptName, SystemScriptsRecord } from '../scripts/type'; +import { udtIssuerLockHash } from './accounts'; + +export interface FnnCellDep { + cell_dep: { + out_point: { + tx_hash: string; + // ckb_jsonrpc_types serializes uint32 as hex strings. + index: string; + }; + dep_type: 'code'; + }; +} + +export interface FnnFiberScript { + name: 'FundingLock' | 'CommitmentLock'; + script: { + code_hash: string; + hash_type: string; + args: string; + }; + cell_deps: FnnCellDep[]; +} + +export interface FnnUdtInfo { + name: string; + script: { + code_hash: string; + hash_type: string; + // FNN treats UDT args as a regex matched against the candidate cell's + // type args; anchor the full issuer-derived args, never a 0x.* wildcard. + args: string; + }; + cell_deps: FnnCellDep[]; +} + +export interface FiberChainScripts { + genesisHash: string; + fiberScripts: FnnFiberScript[]; + udtWhitelist: FnnUdtInfo[]; +} + +function codeCellDep(script: SystemScript): FnnCellDep { + const dep = script.script.cellDeps.find((d) => d.cellDep.depType === 'code'); + if (!dep) { + throw new Error(`System script ${script.name} has no code cell dep in list-hashes output.`); + } + return { + cell_dep: { + out_point: { + tx_hash: dep.cellDep.outPoint.txHash, + index: `0x${dep.cellDep.outPoint.index.toString(16)}`, + }, + dep_type: 'code', + }, + }; +} + +function requireScript(scripts: SystemScriptsRecord, name: SystemScriptName): SystemScript { + const script = scripts[name]; + if (script == null) { + throw new Error( + `The devnet chain spec does not include the system script "${name}". Run \`offckb clean\` to rebuild the devnet.`, + ); + } + return script; +} + +export class FiberContractsMissingError extends Error { + public readonly missing: string[]; + constructor(missing: string[]) { + super( + `The devnet chain spec does not include the Fiber contracts: ${missing.join(', ')}. ` + + 'This devnet was initialized before Fiber support, so its genesis predates the Fiber contracts. ' + + 'To use Fiber, rebuild the devnet: stop CKB and all FNNs, run `offckb clean`, and start again. ' + + 'WARNING: `offckb clean` deletes the local chain data, all Fiber channels and all node data.', + ); + this.name = 'FiberContractsMissingError'; + this.missing = missing; + } +} + +/** + * Build the FNN `fiber.scripts` and `ckb.udt_whitelist` sections from one + * `ckb list-hashes` run against the actual devnet directory. FundingLock and + * CommitmentLock each depend on their own contract cell plus the shared auth + * cell; both test UDTs are issued by built-in account 19, so their whitelist + * args anchor to that account's lock hash. + */ +export function resolveFiberChainScripts(): FiberChainScripts { + const resolved = resolveDevnetSystemScripts(); + if (resolved == null) { + throw new Error( + 'Failed to read the devnet chain spec hashes (ckb list-hashes). Is the CKB binary installed and the devnet initialized?', + ); + } + const scripts = resolved.scripts; + + const required = [SystemScriptName.auth, SystemScriptName.funding_lock, SystemScriptName.commitment_lock]; + const missing = required.filter((name) => scripts[name] == null); + if (missing.length > 0) { + throw new FiberContractsMissingError(missing); + } + + const auth = requireScript(scripts, SystemScriptName.auth); + const fundingLock = requireScript(scripts, SystemScriptName.funding_lock); + const commitmentLock = requireScript(scripts, SystemScriptName.commitment_lock); + const sudt = requireScript(scripts, SystemScriptName.sudt); + const xudt = requireScript(scripts, SystemScriptName.xudt); + + const authDep = codeCellDep(auth); + const fiberScripts: FnnFiberScript[] = [ + { + name: 'FundingLock', + script: { + code_hash: fundingLock.script.codeHash, + hash_type: fundingLock.script.hashType, + args: '0x', + }, + cell_deps: [codeCellDep(fundingLock), authDep], + }, + { + name: 'CommitmentLock', + script: { + code_hash: commitmentLock.script.codeHash, + hash_type: commitmentLock.script.hashType, + args: '0x', + }, + cell_deps: [codeCellDep(commitmentLock), authDep], + }, + ]; + + const issuerArgsPattern = `^${udtIssuerLockHash()}$`; + const udtWhitelist: FnnUdtInfo[] = [ + { + name: 'sudt', + script: { + code_hash: sudt.script.codeHash, + hash_type: sudt.script.hashType, + args: issuerArgsPattern, + }, + cell_deps: [codeCellDep(sudt)], + }, + { + name: 'xudt', + script: { + code_hash: xudt.script.codeHash, + hash_type: xudt.script.hashType, + args: issuerArgsPattern, + }, + cell_deps: [codeCellDep(xudt)], + }, + ]; + + return { genesisHash: resolved.genesisHash, fiberScripts, udtWhitelist }; +} diff --git a/src/fiber/status.ts b/src/fiber/status.ts new file mode 100644 index 00000000..ee9c03f5 --- /dev/null +++ b/src/fiber/status.ts @@ -0,0 +1,250 @@ +import { checkNodeReadiness } from '../devnet/readiness'; +import { + getProcessCommandLine, + isProcessAlive, + nodeDaemonPaths, + readPidFile, + verifyDaemonIdentity, +} from '../util/daemon'; +import { readSettings, Settings } from '../cfg/setting'; +import { fiberAccountIndex, fiberDaemonPaths, fiberP2pAddr, fiberRpcUrl } from './paths'; +import { readNodesYml } from './nodes-yml'; +import { fiberNodeAccount, fiberPublicKeyFromSecret, readFiberNodeSecretKey } from './accounts'; +import { readRuntime, FiberRuntime } from './runtime'; +import { fnnNodeInfo, FnnNodeInfo } from './rpc'; +import { logger } from '../util/logger'; + +export type FiberNodeStatus = 'starting' | 'running' | 'stopped' | 'unknown' | 'conflict'; +export type OffckbManaged = 'yes' | 'no' | 'unknown'; + +export interface FiberNodeStatusEntry { + id: number; + status: FiberNodeStatus; + offckb: OffckbManaged; + rpcUrl: string; + p2pAddr: string; + accountIndex: number; + reasons: string[]; + version?: string; + commitHash?: string; + chainHash?: string; + pubkey?: string; +} + +export interface FiberStatusReport { + ckb: { + status: 'running' | 'stopped'; + rpcUrl: string; + proxyUrl: string; + error?: string; + }; + nodes: FiberNodeStatusEntry[]; +} + +async function resolveOffckbManaged(runtime: FiberRuntime | null, settings: Settings): Promise { + if (runtime == null) return 'no'; + let alive: boolean; + try { + alive = isProcessAlive(runtime.managerPid); + } catch { + return 'unknown'; + } + if (!alive) return 'no'; + + // A daemon PID file that claims fiber management must agree with the + // runtime record, and the process behind it must verify as that daemon — + // the same hardened identity check every other PID-file consumer uses. + // (The fiber daemon PID file always claims management; the node daemon PID + // file only when the fiber manager IS the node daemon, node --fiber + // --daemon. An unrelated CKB daemon does not disqualify.) + const fiberPid = readPidFile(fiberDaemonPaths(settings).pidFile); + if (fiberPid != null && fiberPid.pid === runtime.managerPid) { + return (await verifyDaemonIdentity(fiberPid.pid, fiberPid)) ? 'yes' : 'no'; + } + if (fiberPid != null) return 'no'; + const nodePid = readPidFile(nodeDaemonPaths(settings).pidFile); + if (nodePid != null && nodePid.pid === runtime.managerPid) { + return (await verifyDaemonIdentity(nodePid.pid, nodePid)) ? 'yes' : 'no'; + } + // A foreground manager has no PID file; fall back to a command-line probe. + const cmdline = await getProcessCommandLine(runtime.managerPid); + if (cmdline == null) return 'unknown'; + return cmdline.includes('offckb') ? 'yes' : 'no'; +} + +// Case-insensitive comparison of an FNN-reported funding lock against the +// expected CKB account lock. Shared by the status report and the manager's +// startup validation so the comparison rules cannot diverge. +export function lockMatches( + actual: { code_hash: string; hash_type: string; args: string } | undefined, + expected: { codeHash: string; hashType: string; args: string }, +): boolean { + return ( + actual != null && + actual.code_hash.toLowerCase() === expected.codeHash.toLowerCase() && + actual.hash_type.toLowerCase() === expected.hashType.toLowerCase() && + actual.args.toLowerCase() === expected.args.toLowerCase() + ); +} + +/** + * Check the live state of the devnet and every configured FNN. Status is + * derived only from this moment's RPC answers and key material — no + * list-hashes, no genesis comparison, no port inspection. The single + * exception is process ownership (the OFFCKB column), which only gates the + * 'starting' display of unreachable nodes: a runtime record that says + * 'starting' is trusted unless ownership is disproven. + */ +export async function collectFiberStatus(settings: Settings = readSettings()): Promise { + const ckbReadiness = await checkNodeReadiness(settings.devnet.rpcUrl, 2000); + const report: FiberStatusReport = { + ckb: { + status: ckbReadiness.ready ? 'running' : 'stopped', + rpcUrl: settings.devnet.rpcUrl, + proxyUrl: `http://127.0.0.1:${settings.devnet.rpcProxyPort}`, + ...(ckbReadiness.ready ? {} : { error: ckbReadiness.error ?? 'unavailable' }), + }, + nodes: [], + }; + + const entries = readNodesYml(settings); + if (entries == null) return report; + + const runtime = readRuntime(settings); + const offckb = await resolveOffckbManaged(runtime, settings); + const managerStarting = runtime != null && offckb !== 'no' && runtime.status === 'starting'; + + // Probe every node concurrently: a stopped node costs the full 2s timeout, + // and a sequential loop would block `fiber status` for 2s per down node. + const infos = await Promise.all( + entries.map((entry) => fnnNodeInfo(fiberRpcUrl(entry.id), 2000).catch(() => null as FnnNodeInfo | null)), + ); + + for (const [index, entry] of entries.entries()) { + const statusEntry: FiberNodeStatusEntry = { + id: entry.id, + status: 'unknown', + offckb, + rpcUrl: fiberRpcUrl(entry.id), + p2pAddr: fiberP2pAddr(entry.id), + accountIndex: fiberAccountIndex(entry.id), + reasons: [], + }; + report.nodes.push(statusEntry); + + const info: FnnNodeInfo | null = infos[index]; + + if (info == null) { + statusEntry.status = managerStarting ? 'starting' : 'stopped'; + continue; + } + + statusEntry.version = info.version; + statusEntry.commitHash = info.commit_hash; + statusEntry.chainHash = info.chain_hash; + statusEntry.pubkey = typeof info.pubkey === 'string' ? info.pubkey : undefined; + + const secret = readFiberNodeSecretKey(entry.id, settings); + if (secret == null) { + statusEntry.status = 'unknown'; + statusEntry.reasons.push('cannot read the node identity key (fiber/sk); node may not have started yet'); + continue; + } + const expectedPubkey = fiberPublicKeyFromSecret(secret); + const account = fiberNodeAccount(entry.id); + + let conflict = false; + if (typeof info.pubkey !== 'string' || info.pubkey.length === 0) { + statusEntry.reasons.push('node_info did not return a node public key'); + } else if (info.pubkey.toLowerCase() !== expectedPubkey) { + conflict = true; + statusEntry.reasons.push( + `node public key mismatch: expected ${expectedPubkey} (from fiber/sk), got ${info.pubkey}`, + ); + } + if (!info.default_funding_lock_script) { + statusEntry.reasons.push('node_info did not return default_funding_lock_script'); + } else if (!lockMatches(info.default_funding_lock_script, account.lockScript)) { + conflict = true; + statusEntry.reasons.push( + `CKB account mismatch: expected account #${statusEntry.accountIndex} (lock args ${account.lockScript.args}), ` + + `got ${JSON.stringify(info.default_funding_lock_script)}`, + ); + } + + if (conflict) { + statusEntry.status = 'conflict'; + } else if (statusEntry.reasons.length > 0) { + statusEntry.status = 'unknown'; + } else { + statusEntry.status = 'running'; + } + } + + return report; +} + +function pad(value: string, width: number): string { + return value.length >= width ? value : value + ' '.repeat(width - value.length); +} + +export function printFiberStatus(report: FiberStatusReport) { + const ckbLine = [ + pad('CKB', 10), + pad(report.ckb.status, 9), + `RPC ${report.ckb.rpcUrl}`, + `PROXY ${report.ckb.proxyUrl}`, + ].join(' '); + logger.info(ckbLine); + if (report.ckb.error) { + logger.info(` ${report.ckb.error}`); + } + logger.info(''); + + if (report.nodes.length === 0) { + logger.info('No fiber environment found (no fiber/nodes.yml). Start one with: offckb fiber start'); + return; + } + + const header = ['NODE', 'STATUS', 'OFFCKB', 'RPC', 'P2P', 'ACCOUNT', 'VERSION', 'COMMIT']; + const widths = [6, 9, 8, 26, 26, 9, 12, 10]; + logger.info(header.map((cell, i) => pad(cell, widths[i])).join(' ')); + for (const node of report.nodes) { + const row = [ + pad(String(node.id), widths[0]), + pad(node.status, widths[1]), + pad(node.offckb, widths[2]), + pad(node.rpcUrl, widths[3]), + pad(node.p2pAddr, widths[4]), + pad(String(node.accountIndex), widths[5]), + pad(node.version ?? '-', widths[6]), + pad(node.commitHash ? node.commitHash.slice(0, 7) : '-', widths[7]), + ].join(' '); + logger.info(row); + for (const reason of node.reasons) { + logger.info(` ! ${reason}`); + } + } +} + +export async function fiberStatus(settings: Settings = readSettings()) { + const report = await collectFiberStatus(settings); + printFiberStatus(report); + logger.result({ + command: 'fiber.status', + ckb: report.ckb, + nodes: report.nodes.map((node) => ({ + id: node.id, + status: node.status, + offckbManaged: node.offckb, + rpcUrl: node.rpcUrl, + p2pAddr: node.p2pAddr, + accountIndex: node.accountIndex, + version: node.version, + commitHash: node.commitHash, + chainHash: node.chainHash, + pubkey: node.pubkey, + reasons: node.reasons, + })), + }); +} diff --git a/src/fiber/store-lock.ts b/src/fiber/store-lock.ts new file mode 100644 index 00000000..f90a6a43 --- /dev/null +++ b/src/fiber/store-lock.ts @@ -0,0 +1,72 @@ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; + +/** + * Whether a RocksDB LOCK file is still held by a process. Returns null when + * the check cannot be performed (missing lsof, inspection error), so callers + * can refuse instead of guessing. + * + * RocksDB keeps the LOCK file open (and fcntl-locked) for the store's whole + * lifetime, so "held open by a process" is the signal. Windows has no lsof; + * there a self-rename fails while a process holds the file. + * + * lsofCommand only exists so tests can point at a fake or missing lsof. + */ +export function isStoreLockHeld(lockFile: string, lsofCommand: string = 'lsof'): boolean | null { + if (!fs.existsSync(lockFile)) { + // No lock file means no store was ever opened (or it was removed); + // nothing is holding it. + return false; + } + if (process.platform === 'win32') { + try { + fs.renameSync(lockFile, lockFile); + return false; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'EPERM' || err.code === 'EBUSY') return true; + return null; + } + } + try { + const stdout = execFileSync(lsofCommand, ['--', lockFile], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + encoding: 'utf8', + }); + // Exit 0: lsof printed every process holding the file on stdout. + return stdout.trim().length > 0; + } catch (error) { + const err = error as NodeJS.ErrnoException & { + stdout?: Buffer | string; + status?: number | null; + signal?: NodeJS.Signals | null; + }; + if (err.code === 'ENOENT' || err.code === 'ETIMEDOUT') return null; + // Only exit 1 is a genuine "no holder" answer — matches are printed on + // stdout; stderr may carry unrelated warnings (e.g. an un-stat-able fuse + // mount), so only stdout decides. A timeout kill (signal set) or any + // other exit status is an inspection failure: report "unknown" (null) + // rather than "free" (false), so cleanup refuses instead of deleting a + // live store's metadata. + if (err.signal != null || err.status !== 1) return null; + const stdout = typeof err.stdout === 'string' ? err.stdout.trim() : null; + return stdout == null ? null : stdout.length > 0; + } +} + +export async function waitForStoreLocksReleased(lockFiles: string[], timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + let allReleased = true; + for (const lockFile of lockFiles) { + if (isStoreLockHeld(lockFile) !== false) { + allReleased = false; + break; + } + } + if (allReleased) return true; + await new Promise((resolve) => setTimeout(resolve, 300)); + } + return false; +} diff --git a/src/scripts/public.ts b/src/scripts/public.ts index b92057bf..fbb2fa8b 100644 --- a/src/scripts/public.ts +++ b/src/scripts/public.ts @@ -196,6 +196,9 @@ export const TESTNET_SYSTEM_SCRIPTS: SystemScriptsRecord = { ], }, }, + auth: undefined, + funding_lock: undefined, + commitment_lock: undefined, }; export const MAINNET_SYSTEM_SCRIPTS: SystemScriptsRecord = { @@ -321,6 +324,9 @@ export const MAINNET_SYSTEM_SCRIPTS: SystemScriptsRecord = { ], }, }, + auth: undefined, + funding_lock: undefined, + commitment_lock: undefined, }; export default { diff --git a/src/scripts/type.ts b/src/scripts/type.ts index 36be5286..7299a413 100644 --- a/src/scripts/type.ts +++ b/src/scripts/type.ts @@ -20,6 +20,9 @@ export enum SystemScriptName { secp256k1_keccak256_sighash_all = 'secp256k1_keccak256_sighash_all', secp256k1_keccak256_sighash_all_acpl = 'secp256k1_keccak256_sighash_all_acpl', secp256k1_blake160_multisig_all_v2 = 'secp256k1_blake160_multisig_all_v2', + auth = 'auth', + funding_lock = 'funding_lock', + commitment_lock = 'commitment_lock', } export interface ScriptInfo { diff --git a/src/util/daemon.ts b/src/util/daemon.ts new file mode 100644 index 00000000..d4541777 --- /dev/null +++ b/src/util/daemon.ts @@ -0,0 +1,491 @@ +import { execFile, execFileSync, spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { logger } from './logger'; +import { readSettings, Settings } from '../cfg/setting'; + +// Layout of the CKB devnet daemon's log/PID files under the devnet data dir. +export const NODE_DAEMON_LOG_DIR = 'logs'; +export const NODE_DAEMON_LOG_FILE = 'daemon.log'; +export const NODE_DAEMON_PID_FILE = 'daemon.pid'; + +export function nodeDaemonPaths(settings: Settings = readSettings()) { + const logDir = path.join(settings.devnet.dataPath, NODE_DAEMON_LOG_DIR); + return { + logDir, + logFile: path.join(logDir, NODE_DAEMON_LOG_FILE), + pidFile: path.join(logDir, NODE_DAEMON_PID_FILE), + }; +} + +export interface PidMetadata { + pid: number; + scriptPath: string; + startedAt: string; + status?: 'starting' | 'running'; +} + +export function readPidFile(pidFile: string): PidMetadata | null { + let raw: string; + try { + raw = fs.readFileSync(pidFile, 'utf8').trim(); + } catch { + // Treat a missing or unreadable PID file as "no daemon". + return null; + } + + if (!raw) { + return null; + } + + // Backward compatibility: plain integer PID written by older versions. + const plainPid = Number(raw); + if (Number.isInteger(plainPid) && plainPid > 0) { + return { pid: plainPid, scriptPath: resolveCliEntry() ?? '', startedAt: new Date(0).toISOString() }; + } + + try { + const parsed = JSON.parse(raw) as Partial; + const pid = Number(parsed.pid); + if (Number.isInteger(pid) && pid > 0 && typeof parsed.scriptPath === 'string') { + return { + pid, + scriptPath: parsed.scriptPath, + startedAt: parsed.startedAt ?? new Date(0).toISOString(), + status: parsed.status, + }; + } + } catch { + // fall through to sentinel below + } + + // Content exists but is neither a valid plain PID nor valid metadata. + // Return a sentinel so stop commands can report an invalid PID and clean up. + return { pid: NaN, scriptPath: '', startedAt: new Date(0).toISOString() }; +} + +export function writePidFile(pidFile: string, metadata: PidMetadata) { + fs.writeFileSync(pidFile, JSON.stringify(metadata, null, 2)); +} + +export function reservePidFile(pidFile: string, scriptPath: string): void { + let fd: number; + try { + fd = fs.openSync(pidFile, 'wx'); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'EEXIST') { + throw new Error('A daemon startup is already in progress. Try again after it completes.'); + } + throw new Error(`Failed to reserve daemon PID file ${pidFile}: ${err.message}`); + } + + let writeError: Error | undefined; + try { + const reservation: PidMetadata = { + pid: process.pid, + scriptPath, + startedAt: new Date().toISOString(), + status: 'starting', + }; + fs.writeFileSync(fd, JSON.stringify(reservation, null, 2)); + } catch (error) { + writeError = error as Error; + } finally { + fs.closeSync(fd); + } + if (writeError) { + cleanupPidFile(pidFile); + throw new Error(`Failed to initialize daemon PID reservation ${pidFile}: ${writeError.message}`); + } +} + +export function resolveCliEntry(): string | null { + // In priority order. process.argv[1] is the most reliable for a Node CLI. + // OFFCKB_CLI_PATH is an escape hatch for packaged/npx/weird environments. + // require.main?.filename is a final fallback when argv is unavailable. + const candidates = [process.env.OFFCKB_CLI_PATH, process.argv[1], require.main?.filename].filter( + (c): c is string => typeof c === 'string' && c.length > 0, + ); + + for (const candidate of candidates) { + try { + const resolved = path.resolve(candidate); + const stats = fs.statSync(resolved); + if (stats.isFile()) { + return resolved; + } + } catch { + // Candidate is missing or not a file; try the next one. + } + } + + return null; +} + +export function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ESRCH') return false; + if (err.code === 'EPERM') throw new Error(`Permission denied when checking daemon process ${pid}.`); + throw error; + } +} + +export function cleanupPidFile(pidFile: string) { + try { + fs.unlinkSync(pidFile); + } catch (error) { + // Already gone (e.g. the manager removed it before the stopper could) is + // the goal state, not a problem. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + logger.warn(`Failed to remove PID file:`, error); + } +} + +export function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + const check = () => { + try { + if (!isProcessAlive(pid)) { + resolve(true); + return; + } + } catch (error) { + reject(error); + return; + } + if (Date.now() - start >= timeoutMs) { + resolve(false); + return; + } + setTimeout(check, 100); + }; + check(); + }); +} + +export interface ProcessInfo { + // Exact argv where the platform exposes it (Linux /proc), else null. + argv: string[] | null; + // Flat command line (ps / Windows CIM fallback), else null. + cmdline: string | null; + // Process start time as wall-clock milliseconds, or null when unavailable. + startTimeMs: number | null; +} + +// Executables allowed to host the offckb CLI entry script. Compared by exact +// basename — never by substring, which any path containing "node" would pass. +const NODE_EXECUTABLE_NAMES = new Set(['node', 'nodejs', 'node.exe']); + +// How closely the live process start time must match the pid file's +// startedAt. The file is written immediately after spawn, so the true delta +// is well under a second; the generous bound only needs to catch PID reuse +// and stale pid files, and to absorb ps lstart's one-second resolution. +export const DAEMON_START_TIME_TOLERANCE_MS = 30_000; + +let cachedBootTimeMs: number | null | undefined; +let cachedClockTicksPerSecond: number | undefined; + +function readBootTimeMs(): number | null { + if (cachedBootTimeMs !== undefined) return cachedBootTimeMs; + cachedBootTimeMs = null; + try { + const stat = fs.readFileSync('/proc/stat', 'utf8'); + const match = stat.match(/^btime (\d+)$/m); + if (match) cachedBootTimeMs = Number(match[1]) * 1000; + } catch { + // /proc unavailable — leave the cache at null. + } + return cachedBootTimeMs; +} + +function clockTicksPerSecond(): number { + if (cachedClockTicksPerSecond !== undefined) return cachedClockTicksPerSecond; + cachedClockTicksPerSecond = 100; // USER_HZ on every common Linux arch + try { + const out = execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8', timeout: 5000 }).trim(); + const parsed = Number(out); + if (Number.isInteger(parsed) && parsed > 0) cachedClockTicksPerSecond = parsed; + } catch { + // getconf missing/failed — keep the default. + } + return cachedClockTicksPerSecond; +} + +// /proc//stat: the comm field (2) may itself contain spaces and ')', so +// fields are counted from the last ')'. starttime (field 22, clock ticks +// since boot) sits at index 19 of the remainder. +function readProcStartTimeMs(pid: number): number | null { + let raw: string; + try { + raw = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + } catch { + return null; + } + const closeParen = raw.lastIndexOf(')'); + if (closeParen < 0) return null; + const fields = raw.slice(closeParen + 2).split(' '); + const startTicks = Number(fields[19]); + if (!Number.isFinite(startTicks) || startTicks < 0) return null; + const bootTimeMs = readBootTimeMs(); + if (bootTimeMs == null) return null; + return bootTimeMs + (startTicks / clockTicksPerSecond()) * 1000; +} + +function readProcProcessInfo(pid: number): ProcessInfo | null { + let rawCmdline: unknown; + try { + rawCmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8'); + } catch { + // Process gone, /proc not mounted, or hidden (hidepid): fall back to ps. + return null; + } + if (typeof rawCmdline !== 'string') { + return null; + } + const argv = rawCmdline.split('\0').filter((part) => part.length > 0); + if (argv.length < 2) { + // Fewer tokens than "executable + script" can never identify our daemon; + // let the ps fallback take a second opinion (an empty cmdline also means + // a zombie or kernel thread, which ps reports as defunct). + return null; + } + return { + argv, + cmdline: null, + startTimeMs: readProcStartTimeMs(pid), + }; +} + +// ps lstart looks like "Wed Aug 13 12:36:26 2026" (ctime without timezone). +// Parsed manually because Date.parse of that shape is implementation-defined. +const LSTART_MONTHS: Record = { + Jan: 0, + Feb: 1, + Mar: 2, + Apr: 3, + May: 4, + Jun: 5, + Jul: 6, + Aug: 7, + Sep: 8, + Oct: 9, + Nov: 10, + Dec: 11, +}; + +function parsePsLstart(text: string): number | null { + const match = text.trim().match(/^\w{3} (\w{3}) +(\d{1,2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/); + if (!match) return null; + const month = LSTART_MONTHS[match[1]]; + if (month === undefined) return null; + const ms = new Date( + Number(match[6]), + month, + Number(match[2]), + Number(match[3]), + Number(match[4]), + Number(match[5]), + ).getTime(); + return Number.isFinite(ms) ? ms : null; +} + +function execFileText(command: string, args: string[]): Promise { + return new Promise((resolve) => { + execFile(command, args, { timeout: 5000 }, (error, stdout) => { + if (error) { + resolve(null); + return; + } + const text = stdout.trim(); + resolve(text.length > 0 ? text : null); + }); + }); +} + +async function readPosixProcessInfo(pid: number): Promise { + const cmdline = await execFileText('ps', ['-p', String(pid), '-o', 'args=']); + const lstart = await execFileText('ps', ['-p', String(pid), '-o', 'lstart=']); + return { argv: null, cmdline, startTimeMs: lstart == null ? null : parsePsLstart(lstart) }; +} + +async function readWindowsProcessInfo(pid: number): Promise { + // wmic is deprecated and absent from recent Windows builds; the PowerShell + // CIM cmdlets ship with every supported Windows version. CommandLine and + // CreationDate are fetched in one invocation and returned as JSON. + const script = + `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; ` + + 'if ($null -ne $p) { ' + + '$ms = 0; if ($null -ne $p.CreationDate) { $ms = [DateTimeOffset]::new($p.CreationDate).ToUnixTimeMilliseconds() }; ' + + '@{ cmdline = $p.CommandLine; startMs = $ms } | ConvertTo-Json -Compress }'; + const text = await execFileText('powershell', ['-NoProfile', '-Command', script]); + if (text == null) return { argv: null, cmdline: null, startTimeMs: null }; + try { + const parsed = JSON.parse(text) as { cmdline?: unknown; startMs?: unknown }; + const cmdline = typeof parsed.cmdline === 'string' && parsed.cmdline.trim().length > 0 ? parsed.cmdline : null; + const startMs = Number(parsed.startMs); + return { argv: null, cmdline, startTimeMs: Number.isFinite(startMs) && startMs > 0 ? startMs : null }; + } catch { + return { argv: null, cmdline: null, startTimeMs: null }; + } +} + +/** + * Inspect a live process. Prefers /proc on Linux (exact argv, tick-precision + * start time) and falls back to ps when /proc is unreadable (hidepid mounts, + * minimally configured containers). Returns null only when no source can + * inspect the process at all; individual fields fall back to null per + * platform. + */ +export function getProcessInfo(pid: number): Promise { + if (!Number.isInteger(pid) || pid <= 0) { + return Promise.resolve(null); + } + if (process.platform === 'win32') { + return readWindowsProcessInfo(pid); + } + if (process.platform === 'linux') { + const procInfo = readProcProcessInfo(pid); + if (procInfo) { + return Promise.resolve(procInfo); + } + } + return readPosixProcessInfo(pid); +} + +export async function getProcessCommandLine(pid: number): Promise { + const info = await getProcessInfo(pid); + if (info == null) return null; + if (info.argv != null) return info.argv.join(' '); + return info.cmdline; +} + +// Split a flat command line into tokens, honoring single/double quotes. Used +// on platforms without /proc; the first two tokens (executable, script) are +// all the identity check consumes, and both are spawned by us without shell +// metacharacters, so a simple tokenizer suffices. +function splitCommandLine(cmdline: string): string[] { + const tokens: string[] = []; + const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(cmdline)) !== null) { + tokens.push(match[1] ?? match[2] ?? match[3]); + } + return tokens; +} + +// Resolve symlinks on both sides before comparing (nvm shims, +// /usr/bin/node → /etc/alternatives, symlinked install prefixes). A path +// that cannot be resolved still compares by its absolute form. +function normalizePathForCompare(candidate: string): string { + const resolved = path.resolve(candidate); + try { + return fs.realpathSync(resolved); + } catch { + return resolved; + } +} + +function executableLooksLikeNode(executable: string): boolean { + if (normalizePathForCompare(executable) === normalizePathForCompare(process.execPath)) { + return true; + } + return NODE_EXECUTABLE_NAMES.has(path.basename(executable).toLowerCase()); +} + +/** + * Whether the live process `pid` is really an offckb daemon manager. The pid + * file is only a claim: identity comes from the live process — + * + * 1. its executable must be this Node runtime (exact path match against + * process.execPath, or an exact node/nodejs basename — no substrings); + * 2. its first argument must be THIS installation's CLI entry script + * (resolveCliEntry of the verifying process, realpath-normalized) — + * the pid file's scriptPath is never consulted, so a stale or forged + * pid file cannot lend our identity to an unrelated process; + * 3. when the pid file carries a real startedAt (everything written by + * current versions does), the process start time must match it, which + * defeats PID reuse. If the process start time cannot be determined + * while a recorded one exists, the check fails closed. + * + * Legacy plain-integer pid files carry no startedAt (epoch sentinel): they + * pass on checks 1-2 alone. + */ +export async function verifyDaemonIdentity(pid: number, metadata: PidMetadata): Promise { + const cliEntry = resolveCliEntry(); + if (!cliEntry) { + // Without our own entry point we cannot establish identity at all. + return false; + } + const expectedScript = normalizePathForCompare(cliEntry); + + const info = await getProcessInfo(pid); + if (!info) return false; + + let tokens: string[] | null = null; + if (info.argv != null) { + tokens = info.argv; + } else if (info.cmdline != null) { + tokens = splitCommandLine(info.cmdline); + } + if (tokens == null || tokens.length < 2) return false; + + if (!executableLooksLikeNode(tokens[0])) return false; + if (normalizePathForCompare(tokens[1]) !== expectedScript) return false; + + const recordedMs = Date.parse(metadata.startedAt); + if (Number.isFinite(recordedMs) && recordedMs > 0) { + if (info.startTimeMs == null) return false; + if (Math.abs(info.startTimeMs - recordedMs) > DAEMON_START_TIME_TOLERANCE_MS) { + return false; + } + } + return true; +} + +export function terminateProcess(pid: number, signal: 'SIGTERM' | 'SIGKILL'): Promise { + return new Promise((resolve, reject) => { + if (process.platform === 'win32') { + // Windows has no POSIX signals and process.kill(pid) only terminates the + // single process. Use taskkill to terminate the whole tree. + // /T kills the process and all child processes. + // /F forces termination when SIGKILL is requested. + const args = signal === 'SIGKILL' ? ['/T', '/F', '/PID', String(pid)] : ['/T', '/PID', String(pid)]; + const taskkill = spawn('taskkill', args, { stdio: 'ignore' }); + taskkill.on('error', reject); + taskkill.on('exit', () => { + // taskkill may return non-zero if the process is already gone, which + // is acceptable for our purposes. + resolve(); + }); + return; + } + + // On POSIX, detached: true makes the child a session/process group leader. + // A negative pid sends the signal to the entire process group, ensuring + // the managed child processes all receive it. + try { + process.kill(-pid, signal); + resolve(); + } catch (error) { + reject(error); + } + }); +} + +export function closeFileDescriptors(...fds: (number | undefined)[]) { + for (const fd of fds) { + if (fd === undefined) continue; + try { + fs.closeSync(fd); + } catch { + // ignore + } + } +} diff --git a/src/util/shutdown.ts b/src/util/shutdown.ts new file mode 100644 index 00000000..9f858224 --- /dev/null +++ b/src/util/shutdown.ts @@ -0,0 +1,47 @@ +import process from 'node:process'; + +/** + * Process-wide graceful-shutdown state shared by the CLI entry point and the + * long-running node/fiber commands. + * + * Signal handlers and component-exit teardowns mark the process as shutting + * down before they start their asynchronous cleanup, so the broken-pipe + * policy below knows not to cut that cleanup short. + */ +let gracefulShutdownInProgress = false; + +/** + * Mark that the process has committed to a graceful shutdown (SIGINT/SIGTERM + * or a component-exit teardown). One-way on purpose: the process exits when + * the shutdown completes, so there is no reset. + */ +export function enterGracefulShutdown(): void { + gracefulShutdownInProgress = true; +} + +export function isGracefulShutdownInProgress(): boolean { + return gracefulShutdownInProgress; +} + +/** + * Exit quietly when a downstream pipe closes (the standard `| head` case). + * + * The exception is a graceful shutdown in progress: its teardown keeps + * logging (e.g. "Received SIGINT, stopping...") and, with piped output, the + * reader may already be gone — Ctrl+C is delivered to the whole pipeline, so + * `tee`/`head` exit together with the CLI. Exiting here on EPIPE would + * truncate the async cleanup (fiber runtime.json left behind) and report the + * wrong exit code (0 instead of 130/143). The shutdown path exits itself once + * cleanup has finished. + */ +export function installBrokenPipeHandlers(): void { + for (const stream of [process.stdout, process.stderr]) { + stream.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EPIPE') { + if (isGracefulShutdownInProgress()) return; + process.exit(0); + } + throw error; + }); + } +} diff --git a/tests/broken-pipe.test.ts b/tests/broken-pipe.test.ts new file mode 100644 index 00000000..22be314c --- /dev/null +++ b/tests/broken-pipe.test.ts @@ -0,0 +1,90 @@ +/** + * The CLI installs broken-pipe handlers so `offckb ... | head` exits quietly. + * Regression guard for the piped-Ctrl+C case: while a graceful shutdown is + * running, an EPIPE from a dead downstream reader (Ctrl+C kills the whole + * pipeline) must NOT exit the process — that would truncate the async fiber + * cleanup (runtime.json left behind) and mask the 130/143 exit code. + */ + +// Thrown by the mocked process.exit so a test observes "the process would +// have exited here" instead of falling through to the code after exit(). +class ProcessExit extends Error { + constructor(public readonly code?: number) { + super(`process.exit(${code ?? 'undefined'})`); + } +} + +type ShutdownModule = typeof import('../src/util/shutdown'); + +function epipe(): NodeJS.ErrnoException { + const error = new Error('write EPIPE') as NodeJS.ErrnoException; + error.code = 'EPIPE'; + return error; +} + +describe('util/shutdown broken-pipe policy', () => { + let shutdown: ShutdownModule; + let exitSpy: jest.SpyInstance; + let stdoutListeners: unknown[]; + let stderrListeners: unknown[]; + + beforeEach(() => { + jest.resetModules(); + shutdown = require('../src/util/shutdown') as ShutdownModule; + stdoutListeners = process.stdout.rawListeners('error'); + stderrListeners = process.stderr.rawListeners('error'); + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation(((code?: number) => { + throw new ProcessExit(code); + }) as (code?: number) => never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + for (const [stream, original] of [ + [process.stdout, stdoutListeners], + [process.stderr, stderrListeners], + ] as const) { + for (const listener of stream.rawListeners('error')) { + if (!original.includes(listener)) { + stream.removeListener('error', listener as (error: Error) => void); + } + } + } + }); + + it('starts outside a graceful shutdown', () => { + expect(shutdown.isGracefulShutdownInProgress()).toBe(false); + }); + + it('exits 0 on EPIPE during normal operation (the `| head` case)', () => { + shutdown.installBrokenPipeHandlers(); + expect(() => process.stdout.emit('error', epipe())).toThrow(ProcessExit); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('handles stderr the same way as stdout', () => { + shutdown.installBrokenPipeHandlers(); + expect(() => process.stderr.emit('error', epipe())).toThrow(ProcessExit); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('swallows EPIPE once a graceful shutdown is in progress', () => { + shutdown.installBrokenPipeHandlers(); + shutdown.enterGracefulShutdown(); + expect(shutdown.isGracefulShutdownInProgress()).toBe(true); + // Repeated writes to the dead pipe keep erroring; none may exit. + expect(() => process.stdout.emit('error', epipe())).not.toThrow(); + expect(() => process.stderr.emit('error', epipe())).not.toThrow(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('still rethrows non-EPIPE stream errors during a shutdown', () => { + shutdown.installBrokenPipeHandlers(); + shutdown.enterGracefulShutdown(); + const error = new Error('some other stream failure'); + expect(() => process.stdout.emit('error', error)).toThrow(error); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/fiber-accounts.test.ts b/tests/fiber-accounts.test.ts new file mode 100644 index 00000000..5e23aa64 --- /dev/null +++ b/tests/fiber-accounts.test.ts @@ -0,0 +1,97 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { defaultSettings, Settings } from '../src/cfg/setting'; +import { fiberNodePaths } from '../src/fiber/paths'; +import { + ensureNodeKeyMaterial, + fiberNodeAccount, + fiberPublicKeyFromSecret, + readFiberNodeSecretKey, + readNodePassword, + udtIssuerAccount, + udtIssuerLockHash, +} from '../src/fiber/accounts'; + +const tempRoots: string[] = []; +afterEach(() => { + while (tempRoots.length) fs.rmSync(tempRoots.pop() as string, { recursive: true, force: true }); +}); + +function fixture(): Settings { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-keys-')); + tempRoots.push(root); + const settings = JSON.parse(JSON.stringify(defaultSettings)) as Settings; + settings.devnet.configPath = path.join(root, 'devnet'); + return settings; +} + +describe('fiber accounts', () => { + it('maps node N to built-in account N+2', () => { + expect(fiberNodeAccount(1).args).toBe('0xe65f823bc5a48a38515690604e503dba4eb15d61'); // account #3 + expect(fiberNodeAccount(2).args).toBe('0x9665e6bc1966ec2bfcca4f11782d2b906f38438f'); // account #4 + }); + + it('uses account 19 as the UDT issuer with a stable lock hash', () => { + expect(udtIssuerAccount().args).toBe('0x4118c8c16749bf126b22468d030bf9de7da3717b'); + expect(udtIssuerLockHash()).toMatch(/^0x[0-9a-f]{64}$/); + expect(udtIssuerLockHash()).toBe('0x4472b33b4e1845ebe82f2ce5f511bbe012f144c5f3d7b539909adffc83ccda61'); + }); +}); + +describe('ensureNodeKeyMaterial', () => { + it('writes the CKB key (hex, no 0x) and a random password, both owner-only', () => { + const settings = fixture(); + const { created } = ensureNodeKeyMaterial(1, settings); + expect(created).toBe(true); + + const paths = fiberNodePaths(1, settings); + const key = fs.readFileSync(paths.ckbKeyFile, 'utf8'); + expect(key).toBe(fiberNodeAccount(1).privkey.replace(/^0x/, '')); + expect(key.startsWith('0x')).toBe(false); + + const password = readNodePassword(1, settings); + expect(password.length).toBeGreaterThan(16); + + if (process.platform !== 'win32') { + expect(fs.statSync(paths.ckbKeyFile).mode & 0o777).toBe(0o600); + expect(fs.statSync(paths.passwordFile).mode & 0o777).toBe(0o600); + } + }); + + it('keeps existing key material on later starts', () => { + const settings = fixture(); + ensureNodeKeyMaterial(1, settings); + const paths = fiberNodePaths(1, settings); + const password = fs.readFileSync(paths.passwordFile, 'utf8'); + + const { created } = ensureNodeKeyMaterial(1, settings); + expect(created).toBe(false); + expect(fs.readFileSync(paths.passwordFile, 'utf8')).toBe(password); + }); + + it('refuses to provision a node with half-missing key material', () => { + const settings = fixture(); + ensureNodeKeyMaterial(1, settings); + fs.unlinkSync(fiberNodePaths(1, settings).passwordFile); + expect(() => ensureNodeKeyMaterial(1, settings)).toThrow('incomplete key material'); + }); +}); + +describe('fiber identity key', () => { + it('derives the compressed pubkey from a raw 32-byte secret', () => { + // account #3's known privkey/pubkey pair doubles as a test vector. + const secret = Buffer.from(fiberNodeAccount(1).privkey.replace(/^0x/, ''), 'hex'); + expect(fiberPublicKeyFromSecret(secret)).toBe(fiberNodeAccount(1).pubkey.replace(/^0x/, '').toLowerCase()); + }); + + it('reads fiber/sk as raw bytes and tolerates a missing file', () => { + const settings = fixture(); + expect(readFiberNodeSecretKey(1, settings)).toBeNull(); + + const paths = fiberNodePaths(1, settings); + fs.mkdirSync(paths.fiberDir, { recursive: true }); + fs.writeFileSync(paths.fiberSkFile, Buffer.alloc(32, 7)); + expect(readFiberNodeSecretKey(1, settings)).toEqual(Buffer.alloc(32, 7)); + }); +}); diff --git a/tests/fiber-ckb-env.test.ts b/tests/fiber-ckb-env.test.ts new file mode 100644 index 00000000..3c6654ae --- /dev/null +++ b/tests/fiber-ckb-env.test.ts @@ -0,0 +1,43 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { defaultSettings, Settings } from '../src/cfg/setting'; +import { assertPlainDevnet } from '../src/fiber/ckb-env'; +import { FORK_STATE_FILE } from '../src/devnet/fork'; + +/** + * Fiber only runs on a plain local devnet: any fork.json — valid or not — + * rejects startup, because a forked devnet's data belongs to its source chain. + */ +describe('assertPlainDevnet', () => { + let root: string; + let settings: Settings; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-ckb-env-')); + settings = JSON.parse(JSON.stringify(defaultSettings)) as Settings; + settings.devnet.configPath = path.join(root, 'devnet'); + fs.mkdirSync(settings.devnet.configPath, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('passes when no fork.json exists', () => { + expect(() => assertPlainDevnet(settings)).not.toThrow(); + }); + + it('rejects a devnet recorded as a fork', () => { + fs.writeFileSync( + path.join(settings.devnet.configPath, FORK_STATE_FILE), + JSON.stringify({ source: 'testnet', sourceDir: '/nonexistent', firstRunPending: false }), + ); + expect(() => assertPlainDevnet(settings)).toThrow('forked devnet'); + }); + + it('rejects an unparseable fork.json — cannot verify this is a plain chain', () => { + fs.writeFileSync(path.join(settings.devnet.configPath, FORK_STATE_FILE), 'not json'); + expect(() => assertPlainDevnet(settings)).toThrow('cannot verify'); + }); +}); diff --git a/tests/fiber-config-gen.test.ts b/tests/fiber-config-gen.test.ts new file mode 100644 index 00000000..1ef448e9 --- /dev/null +++ b/tests/fiber-config-gen.test.ts @@ -0,0 +1,152 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import yaml from 'js-yaml'; +import { defaultSettings, Settings } from '../src/cfg/setting'; +import { fiberNodePaths } from '../src/fiber/paths'; +import { generateNodeConfig, mergeNodeConfig } from '../src/fiber/config-gen'; +import { FiberChainScripts } from '../src/fiber/scripts'; + +const tempRoots: string[] = []; +afterEach(() => { + while (tempRoots.length) fs.rmSync(tempRoots.pop() as string, { recursive: true, force: true }); +}); + +function fixture(): { settings: Settings; testnetConfigPath: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-config-')); + tempRoots.push(root); + const settings = JSON.parse(JSON.stringify(defaultSettings)) as Settings; + settings.devnet.configPath = path.join(root, 'devnet'); + settings.devnet.rpcUrl = 'http://127.0.0.1:8114'; + + const testnetConfigPath = path.join(root, 'testnet-config.yml'); + fs.writeFileSync( + testnetConfigPath, + [ + 'fiber:', + ' listening_addr: "/ip4/0.0.0.0/tcp/8228"', + ' bootnode_addrs:', + ' - "/ip4/54.179.226.154/tcp/8228/p2p/Qmes1EBD4yNo9Ywkfe6eRw9tG1nVNGLDmMud1xJMsoYFKy"', + ' chain: testnet', + ' tlc_expiry_delta: 86400000', + ' future_field_from_new_fnn: keep-me', + 'rpc:', + ' listening_addr: "127.0.0.1:8227"', + 'ckb:', + ' rpc_url: "https://testnet.ckbapp.dev/"', + ' udt_whitelist:', + ' - name: RUSD', + ' script:', + ' code_hash: 0x1142', + ' hash_type: type', + ' args: 0x878f', + 'services:', + ' - fiber', + ' - rpc', + ' - ckb', + ' - cch', + '', + ].join('\n'), + ); + return { settings, testnetConfigPath }; +} + +function chainScripts(): FiberChainScripts { + const dep = (index: number) => ({ + cell_dep: { out_point: { tx_hash: '0xgenesis', index: `0x${index.toString(16)}` }, dep_type: 'code' as const }, + }); + return { + genesisHash: '0xgenesis', + fiberScripts: [ + { + name: 'FundingLock', + script: { code_hash: '0xfunding', hash_type: 'data2', args: '0x' }, + cell_deps: [dep(21), dep(20)], + }, + { + name: 'CommitmentLock', + script: { code_hash: '0xcommitment', hash_type: 'data2', args: '0x' }, + cell_deps: [dep(22), dep(20)], + }, + ], + udtWhitelist: [ + { + name: 'sudt', + script: { code_hash: '0xsudt', hash_type: 'type', args: '^0xissuer$' }, + cell_deps: [dep(5)], + }, + ], + }; +} + +describe('generateNodeConfig', () => { + it('replaces chain-related fields and keeps unknown ones', () => { + const { settings, testnetConfigPath } = fixture(); + const configFile = generateNodeConfig({ + node: { id: 2, config: {} }, + chainScripts: chainScripts(), + testnetConfigPath, + settings, + }); + expect(configFile).toBe(fiberNodePaths(2, settings).configFile); + + const config = yaml.load(fs.readFileSync(configFile, 'utf8')) as Record; + expect(config.fiber.chain).toBe('../../../specs/dev.toml'); + expect(config.fiber.listening_addr).toBe('/ip4/127.0.0.1/tcp/8345'); + expect(config.fiber.bootnode_addrs).toEqual([]); + expect(config.fiber.announce_listening_addr).toBe(true); + expect(config.fiber.announce_private_addr).toBe(true); + expect(config.fiber.gossip_network_maintenance_interval_ms).toBe(1000); + expect(config.fiber.gossip_store_maintenance_interval_ms).toBe(1000); + expect(config.fiber.announced_node_name).toBe('offckb-fnn-2'); + expect(config.fiber.scripts).toHaveLength(2); + expect(config.fiber.scripts[0].name).toBe('FundingLock'); + expect(config.fiber.scripts[0].cell_deps[1].cell_dep.out_point.index).toBe('0x14'); + // unknown fields survive + expect(config.fiber.tlc_expiry_delta).toBe(86400000); + expect(config.fiber.future_field_from_new_fnn).toBe('keep-me'); + + expect(config.rpc.listening_addr).toBe('127.0.0.1:21715'); + expect(config.rpc.enabled_modules).toEqual(['channel', 'payment', 'graph', 'info', 'invoice', 'peer', 'watchtower']); + expect(config.rpc.cors_enabled).toBe(false); + + expect(config.ckb.rpc_url).toBe('http://127.0.0.1:8114'); + expect(config.ckb.udt_whitelist).toHaveLength(1); + expect(config.ckb.udt_whitelist[0].script.args).toBe('^0xissuer$'); + + expect(config.services).toEqual(['fiber', 'rpc', 'ckb']); + }); + + it('merges per-node config recursively and replaces lists', () => { + const { settings, testnetConfigPath } = fixture(); + const configFile = generateNodeConfig({ + node: { + id: 1, + config: { + fiber: { auto_accept_channel_ckb_funding_amount: 99, announced_node_name: 'custom-name' }, + rpc: { enabled_modules: ['info'] }, + }, + }, + chainScripts: chainScripts(), + testnetConfigPath, + settings, + }); + const config = yaml.load(fs.readFileSync(configFile, 'utf8')) as Record; + expect(config.fiber.auto_accept_channel_ckb_funding_amount).toBe(99); + expect(config.fiber.announced_node_name).toBe('custom-name'); + // managed values merged around the override stay + expect(config.fiber.chain).toBe('../../../specs/dev.toml'); + // lists replace + expect(config.rpc.enabled_modules).toEqual(['info']); + }); +}); + +describe('mergeNodeConfig', () => { + it('merges objects deeply and replaces scalars and arrays', () => { + const merged = mergeNodeConfig( + { a: { b: 1, c: [1, 2], d: { e: 1 } }, x: 1 }, + { a: { c: [3], d: { f: 2 } }, y: 2 }, + ); + expect(merged).toEqual({ a: { b: 1, c: [3], d: { e: 1, f: 2 } }, x: 1, y: 2 }); + }); +}); diff --git a/tests/fiber-env-lock.test.ts b/tests/fiber-env-lock.test.ts new file mode 100644 index 00000000..f76acaab --- /dev/null +++ b/tests/fiber-env-lock.test.ts @@ -0,0 +1,60 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { acquireEnvLock, isEnvLockHeld, releaseEnvLock } from '../src/fiber/env-lock'; + +const tempRoots: string[] = []; +afterEach(() => { + while (tempRoots.length) fs.rmSync(tempRoots.pop() as string, { recursive: true, force: true }); +}); + +function lockFile(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-lock-')); + tempRoots.push(root); + return path.join(root, '.offckb-devnet.lock'); +} + +describe('env lock', () => { + it('acquires, records the holder, and releases', () => { + const file = lockFile(); + const handle = acquireEnvLock('test', file); + expect(fs.existsSync(file)).toBe(true); + const record = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(record.pid).toBe(process.pid); + expect(isEnvLockHeld(file)).toBe(true); + + handle.release(); + expect(fs.existsSync(file)).toBe(false); + expect(isEnvLockHeld(file)).toBe(false); + }); + + it('refuses a second acquire while held by a live process', () => { + const file = lockFile(); + acquireEnvLock('first', file); + try { + expect(() => acquireEnvLock('second', file)).toThrow('Another OffCKB process'); + } finally { + releaseEnvLock(file); + } + }); + + it('re-acquires a lock whose holder is dead', () => { + const file = lockFile(); + fs.writeFileSync(file, JSON.stringify({ pid: 99999999, acquiredAt: new Date().toISOString() })); + const handle = acquireEnvLock('test', file); + const record = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(record.pid).toBe(process.pid); + handle.release(); + }); + + it('does not remove a lock re-acquired by someone else on release', () => { + const file = lockFile(); + const handle = acquireEnvLock('test', file); + // Simulate another holder taking over (content replaced). + fs.writeFileSync(file, JSON.stringify({ pid: 99999999, acquiredAt: 'later' })); + handle.release(); + expect(fs.existsSync(file)).toBe(true); + // Clean up the foreign record for the temp-dir removal. + fs.unlinkSync(file); + }); +}); diff --git a/tests/fiber-install.test.ts b/tests/fiber-install.test.ts new file mode 100644 index 00000000..fde41b6f --- /dev/null +++ b/tests/fiber-install.test.ts @@ -0,0 +1,139 @@ +import { createHash } from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + assertSupportedFnnVersion, + buildFnnDownloadUrl, + buildFnnPackageName, + KNOWN_FNN_SHA256, + SUPPORTED_FNN_VERSIONS, + verifyFnnPackageChecksum, +} from '../src/fiber/install'; + +/** + * FNN release integrity: downloads are verified against pinned SHA-256 + * digests before extraction, and fail closed when no pin exists. + */ +describe('assertSupportedFnnVersion', () => { + it('accepts every supported version', () => { + for (const version of SUPPORTED_FNN_VERSIONS) { + expect(() => assertSupportedFnnVersion(version)).not.toThrow(); + } + }); + + it('rejects an untested version and points at --binary-path', () => { + expect(() => assertSupportedFnnVersion('0.8.0')).toThrow('--binary-path'); + }); +}); + +describe('buildFnnDownloadUrl', () => { + it('targets the pinned GitHub release and a package with a pinned digest', () => { + for (const version of SUPPORTED_FNN_VERSIONS) { + const url = buildFnnDownloadUrl(version); + expect(url).toMatch( + new RegExp(`^https://github\\.com/nervosnetwork/fiber/releases/download/v${version}/fnn_v${version}-[a-z0-9_-]+\\.tar\\.gz$`), + ); + const packageName = url.split('/').pop()!.replace(/\.tar\.gz$/, ''); + // The package for THIS platform must have a pinned digest, or installs + // here would fail closed at verification time. + expect(KNOWN_FNN_SHA256[version]?.[packageName]).toMatch(/^[0-9a-f]{64}$/); + } + }); +}); + +describe('buildFnnPackageName', () => { + it('names the linux/darwin packages for both supported architectures', () => { + expect(buildFnnPackageName('0.9.0-rc7', 'linux', 'x64')).toBe('fnn_v0.9.0-rc7-x86_64-linux-portable'); + expect(buildFnnPackageName('0.9.0-rc7', 'linux', 'arm64')).toBe('fnn_v0.9.0-rc7-aarch64-linux-portable'); + expect(buildFnnPackageName('0.9.0-rc7', 'darwin', 'x64')).toBe('fnn_v0.9.0-rc7-x86_64-darwin-portable'); + expect(buildFnnPackageName('0.9.0-rc7', 'darwin', 'arm64')).toBe('fnn_v0.9.0-rc7-aarch64-darwin-portable'); + }); + + it('maps every Windows arch to the only published x86_64 package', () => { + // FNN publishes no aarch64 Windows build; Windows on ARM runs x64 under + // emulation, so this mapping is intentional. + expect(buildFnnPackageName('0.9.0-rc7', 'win32', 'x64')).toBe('fnn_v0.9.0-rc7-x86_64-windows'); + expect(buildFnnPackageName('0.9.0-rc7', 'win32', 'arm64')).toBe('fnn_v0.9.0-rc7-x86_64-windows'); + }); + + it('rejects unsupported architectures instead of silently mapping to x86_64', () => { + expect(() => buildFnnPackageName('0.9.0-rc7', 'linux', 'ppc64')).toThrow('Unsupported CPU architecture'); + expect(() => buildFnnPackageName('0.9.0-rc7', 'darwin', 'ia32')).toThrow('Unsupported CPU architecture'); + }); + + it('rejects unsupported operating systems', () => { + expect(() => buildFnnPackageName('0.9.0-rc7', 'freebsd' as NodeJS.Platform, 'x64')).toThrow( + 'Unsupported operating system', + ); + }); +}); + +describe('verifyFnnPackageChecksum', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fnn-checksum-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + delete KNOWN_FNN_SHA256['9.9.9-test']; + }); + + function writeTarball(content: string): string { + const file = path.join(tempDir, 'pkg.tar.gz'); + fs.writeFileSync(file, content); + return file; + } + + it('accepts a tarball whose digest matches the pin', () => { + const file = writeTarball('fnn release bytes'); + const digest = createHash('sha256').update('fnn release bytes').digest('hex'); + KNOWN_FNN_SHA256['9.9.9-test'] = { 'fnn_v9.9.9-test-x86_64-linux-portable': digest }; + expect(() => verifyFnnPackageChecksum('9.9.9-test', 'fnn_v9.9.9-test-x86_64-linux-portable', file)).not.toThrow(); + }); + + it('rejects a tarball whose digest differs from the pin', () => { + const file = writeTarball('tampered bytes'); + const digest = createHash('sha256').update('honest bytes').digest('hex'); + KNOWN_FNN_SHA256['9.9.9-test'] = { 'fnn_v9.9.9-test-x86_64-linux-portable': digest }; + expect(() => verifyFnnPackageChecksum('9.9.9-test', 'fnn_v9.9.9-test-x86_64-linux-portable', file)).toThrow( + 'checksum mismatch', + ); + }); + + it('fails closed when no pin exists for the version', () => { + const file = writeTarball('fnn release bytes'); + expect(() => verifyFnnPackageChecksum('9.9.9-test', 'fnn_v9.9.9-test-x86_64-linux-portable', file)).toThrow( + 'No trusted SHA-256 checksum is pinned', + ); + }); + + it('fails closed when no pin exists for the package', () => { + const file = writeTarball('fnn release bytes'); + KNOWN_FNN_SHA256['9.9.9-test'] = { 'some-other-package': 'x'.repeat(64) }; + expect(() => verifyFnnPackageChecksum('9.9.9-test', 'fnn_v9.9.9-test-x86_64-linux-portable', file)).toThrow( + 'No trusted SHA-256 checksum is pinned', + ); + }); + + it('pins a well-formed digest for every package of every supported version', () => { + // Every platform/arch combination the installer can resolve; a version + // bump that forgets the pins fails here. Names come from the production + // helper so the test cannot drift from the real naming scheme. + const combinations: Array<[NodeJS.Platform, string]> = [ + ['linux', 'x64'], + ['linux', 'arm64'], + ['darwin', 'x64'], + ['darwin', 'arm64'], + ['win32', 'x64'], + ]; + for (const version of SUPPORTED_FNN_VERSIONS) { + for (const [platform, arch] of combinations) { + const name = buildFnnPackageName(version, platform, arch); + expect(KNOWN_FNN_SHA256[version]?.[name]).toMatch(/^[0-9a-f]{64}$/); + } + } + }); +}); diff --git a/tests/fiber-lifecycle.test.ts b/tests/fiber-lifecycle.test.ts new file mode 100644 index 00000000..b8416f5a --- /dev/null +++ b/tests/fiber-lifecycle.test.ts @@ -0,0 +1,680 @@ +import { spawn, ChildProcess } from 'child_process'; +import { once } from 'events'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { defaultSettings, Settings } from '../src/cfg/setting'; +import * as daemonUtil from '../src/util/daemon'; +import { + isProcessAlive, + PidMetadata, + verifyDaemonIdentity, + writePidFile, +} from '../src/util/daemon'; +import { isStoreLockHeld } from '../src/fiber/store-lock'; +import { assertFiberFullyStopped } from '../src/fiber/clean'; +import { startFiberEnvironment, stopFiberNodes, FnnProcessHandle } from '../src/fiber/manager'; +import { fiberDaemonPaths, fiberNodePaths, fiberRootPath, runtimeJsonPath } from '../src/fiber/paths'; +import { writeRuntime, isRuntimeStale, FiberRuntime } from '../src/fiber/runtime'; +import { assertNodeStopDoesNotOrphanFiber, startFiberDaemon, stopFiber } from '../src/fiber/daemon'; +import { fiberClean } from '../src/fiber/clean'; +import { FiberChainScripts } from '../src/fiber/scripts'; + +/** + * Safety-property tests for the fiber lifecycle layer: daemon identity + * verification (fail-closed), store-lock inspection states, clean's + * fully-stopped assertion and stop's SIGTERM→SIGKILL escalation. + */ + +// These tests spawn real processes; the Windows identity path shells out to +// PowerShell CIM, which needs a cold-start second or two per probe. +jest.setTimeout(30000); + +const tempRoots: string[] = []; +const children: ChildProcess[] = []; + +afterEach(async () => { + while (children.length) { + const child = children.pop() as ChildProcess; + if (child.exitCode == null && child.signalCode == null) { + try { + child.kill('SIGKILL'); + } catch { + // already gone + } + await Promise.race([once(child, 'exit'), new Promise((resolve) => setTimeout(resolve, 2000))]); + } + } + while (tempRoots.length) fs.rmSync(tempRoots.pop() as string, { recursive: true, force: true }); +}); + +function tempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-lifecycle-')); + tempRoots.push(root); + return root; +} + +function makeSettings(root: string): Settings { + const settings = JSON.parse(JSON.stringify(defaultSettings)) as Settings; + settings.devnet.configPath = path.join(root, 'devnet'); + settings.devnet.rpcUrl = 'http://127.0.0.1:8114'; + return settings; +} + +function track(child: ChildProcess): ChildProcess { + children.push(child); + return child; +} + +// A node process running the given script file, staying alive until killed. +function spawnScriptProcess(scriptFile: string): ChildProcess { + fs.mkdirSync(path.dirname(scriptFile), { recursive: true }); + fs.writeFileSync(scriptFile, 'setInterval(() => {}, 1000);\n'); + return track(spawn(process.execPath, [scriptFile], { stdio: 'ignore' })); +} + +// A live process whose executable is NOT node, with a command line full of +// "node" substrings (the old substring heuristic matched exactly this). +function spawnNonNodeProcess(dir: string): ChildProcess { + const script = path.join(dir, 'node_modules-like', 'runner.sh'); + fs.mkdirSync(path.dirname(script), { recursive: true }); + fs.writeFileSync(script, '#!/bin/sh\nsleep 60\n'); + fs.chmodSync(script, 0o755); + return track(spawn(script, [], { stdio: 'ignore' })); +} + +function pidMetadata(pid: number, overrides: Partial = {}): PidMetadata { + return { + pid, + scriptPath: overrides.scriptPath ?? '', + startedAt: overrides.startedAt ?? new Date().toISOString(), + status: overrides.status, + }; +} + +const describePosix = process.platform === 'win32' ? describe.skip : describe; + +describe('verifyDaemonIdentity', () => { + const savedCliPath = process.env.OFFCKB_CLI_PATH; + let cliEntry: string; + + beforeEach(() => { + // Pin our CLI entry to a real file so resolveCliEntry is deterministic. + cliEntry = path.join(tempRoot(), 'offckb', 'build', 'index.js'); + fs.mkdirSync(path.dirname(cliEntry), { recursive: true }); + fs.writeFileSync(cliEntry, '// offckb CLI entry stub\n'); + process.env.OFFCKB_CLI_PATH = cliEntry; + }); + + afterEach(() => { + if (savedCliPath === undefined) { + delete process.env.OFFCKB_CLI_PATH; + } else { + process.env.OFFCKB_CLI_PATH = savedCliPath; + } + }); + + it('accepts a live process running this CLI entry with a matching start time', async () => { + const child = spawnScriptProcess(cliEntry); + expect(await verifyDaemonIdentity(child.pid as number, pidMetadata(child.pid as number))).toBe(true); + }); + + it('rejects a node process when the pid file scriptPath merely contains "offckb"', async () => { + // The J1 exploit shape: a forged pid file claiming an offckb-looking + // scriptPath, pointing at an unrelated node process. + const victim = spawnScriptProcess(path.join(tempRoot(), 'other', 'victim.js')); + const forged = pidMetadata(victim.pid as number, { + scriptPath: path.join(tempRoot(), 'offckb-fake', 'index.js'), + }); + expect(await verifyDaemonIdentity(victim.pid as number, forged)).toBe(false); + }); + + it('rejects a node process whose script only shares our entry basename (index.js)', async () => { + const foreign = spawnScriptProcess(path.join(tempRoot(), 'attacker', 'index.js')); + const metadata = pidMetadata(foreign.pid as number, { scriptPath: cliEntry }); + expect(await verifyDaemonIdentity(foreign.pid as number, metadata)).toBe(false); + }); + + it('rejects when the pid file start time does not match the process start time (PID reuse)', async () => { + const child = spawnScriptProcess(cliEntry); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const metadata = pidMetadata(child.pid as number, { scriptPath: cliEntry, startedAt: oneHourAgo }); + expect(await verifyDaemonIdentity(child.pid as number, metadata)).toBe(false); + }); + + it('rejects a dead pid', async () => { + expect(await verifyDaemonIdentity(99999999, pidMetadata(99999999, { scriptPath: cliEntry }))).toBe(false); + }); + + it('accepts a legacy plain-pid record (no real startedAt) on script match alone', async () => { + const child = spawnScriptProcess(cliEntry); + const legacy = pidMetadata(child.pid as number, { + scriptPath: cliEntry, + startedAt: new Date(0).toISOString(), + }); + expect(await verifyDaemonIdentity(child.pid as number, legacy)).toBe(true); + }); + + describePosix('non-node executables', () => { + it('rejects a non-node process even with "node" substrings in its command line', async () => { + const foreign = spawnNonNodeProcess(tempRoot()); + const metadata = pidMetadata(foreign.pid as number, { scriptPath: cliEntry }); + expect(await verifyDaemonIdentity(foreign.pid as number, metadata)).toBe(false); + }); + }); +}); + +describe('isStoreLockHeld', () => { + it('returns false for a missing lock file', () => { + expect(isStoreLockHeld(path.join(tempRoot(), 'LOCK'))).toBe(false); + }); + + describePosix('with lsof', () => { + it('returns false for a lock file no process holds', () => { + const lockFile = path.join(tempRoot(), 'LOCK'); + fs.writeFileSync(lockFile, ''); + expect(isStoreLockHeld(lockFile)).toBe(false); + }); + + it('returns true while another process holds the lock file open', async () => { + const lockFile = path.join(tempRoot(), 'LOCK'); + fs.writeFileSync(lockFile, ''); + const holder = track( + spawn( + process.execPath, + ['-e', `require('fs').openSync(${JSON.stringify(lockFile)}, 'r'); setInterval(() => {}, 1000);`], + { stdio: 'ignore' }, + ), + ); + // Give the holder a moment to open the file. + let held: boolean | null = null; + for (let i = 0; i < 50 && held !== true; i++) { + held = isStoreLockHeld(lockFile); + if (held !== true) await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(held).toBe(true); + + holder.kill('SIGKILL'); + await once(holder, 'exit'); + expect(isStoreLockHeld(lockFile)).toBe(false); + }); + + it('returns null when lsof cannot be run (fail-closed "unknown")', () => { + const lockFile = path.join(tempRoot(), 'LOCK'); + fs.writeFileSync(lockFile, ''); + const missingLsof = path.join(tempRoot(), 'no-such-lsof'); + expect(isStoreLockHeld(lockFile, missingLsof)).toBeNull(); + }); + + it('returns null when lsof exits with an unexpected status', () => { + const fakeLsof = path.join(tempRoot(), 'lsof'); + fs.writeFileSync(fakeLsof, '#!/bin/sh\nexit 2\n'); + fs.chmodSync(fakeLsof, 0o755); + const lockFile = path.join(tempRoot(), 'LOCK'); + fs.writeFileSync(lockFile, ''); + expect(isStoreLockHeld(lockFile, fakeLsof)).toBeNull(); + }); + }); +}); + +describe('isRuntimeStale', () => { + const runtimeFor = (managerPid: number): FiberRuntime => ({ + managerPid, + startedAt: new Date().toISOString(), + status: 'running', + nodes: [], + }); + + it('treats a record whose manager process is gone as stale', () => { + expect(isRuntimeStale(runtimeFor(99999999))).toBe(true); + }); + + it('treats a record whose manager process is alive as not stale', () => { + expect(isRuntimeStale(runtimeFor(process.pid))).toBe(false); + }); + + it('fails closed when the liveness check itself fails (EPERM) — the record is NOT stale', () => { + // A manager we cannot inspect (owned by another user, transient /proc + // error) may still be running; discarding its record would orphan the + // environment. Same rule as the environment lock: unverifiable == held. + jest.isolateModules(() => { + jest.doMock('../src/util/daemon', () => ({ + ...jest.requireActual('../src/util/daemon'), + isProcessAlive: () => { + throw new Error('Permission denied when checking daemon process 1234.'); + }, + })); + try { + const isolated = require('../src/fiber/runtime') as typeof import('../src/fiber/runtime'); + expect(isolated.isRuntimeStale(runtimeFor(1234))).toBe(false); + } finally { + jest.dontMock('../src/util/daemon'); + } + }); + }); +}); + +describe('assertNodeStopDoesNotOrphanFiber', () => { + const liveRuntime = (managerPid: number): FiberRuntime => ({ + managerPid, + startedAt: new Date().toISOString(), + status: 'running', + nodes: [], + }); + + it('refuses to stop CKB while a live fiber environment is managed by another (foreground) process', () => { + const settings = makeSettings(tempRoot()); + writeRuntime(liveRuntime(process.pid), settings); + expect(() => assertNodeStopDoesNotOrphanFiber({ ckbDaemonPid: 424242 }, settings)).toThrow( + 'offckb node stop --force', + ); + }); + + it('permits the stop when the fiber manager is the CKB daemon being stopped (node --fiber --daemon)', () => { + const settings = makeSettings(tempRoot()); + writeRuntime(liveRuntime(process.pid), settings); + expect(() => + assertNodeStopDoesNotOrphanFiber({ ckbDaemonPid: process.pid }, settings), + ).not.toThrow(); + }); + + it('permits the stop with --force even while a foreground fiber manager is live', () => { + const settings = makeSettings(tempRoot()); + writeRuntime(liveRuntime(process.pid), settings); + expect(() => assertNodeStopDoesNotOrphanFiber({ ckbDaemonPid: 424242, force: true }, settings)).not.toThrow(); + }); + + it('permits the stop when no fiber runtime exists', () => { + const settings = makeSettings(tempRoot()); + expect(() => assertNodeStopDoesNotOrphanFiber({ ckbDaemonPid: 424242 }, settings)).not.toThrow(); + }); + + it('permits the stop when the recorded fiber manager is already dead (stale runtime)', () => { + const settings = makeSettings(tempRoot()); + writeRuntime(liveRuntime(99999999), settings); + expect(() => assertNodeStopDoesNotOrphanFiber({ ckbDaemonPid: 424242 }, settings)).not.toThrow(); + }); +}); + +describe('assertFiberFullyStopped', () => { + it('refuses while a live manager runtime exists', () => { + const settings = makeSettings(tempRoot()); + writeRuntime( + { managerPid: process.pid, startedAt: new Date().toISOString(), status: 'running', nodes: [] }, + settings, + ); + expect(() => assertFiberFullyStopped(settings)).toThrow('still managed by OffCKB process'); + }); + + it('refuses while a fiber daemon pid file points at a live process', () => { + const settings = makeSettings(tempRoot()); + const { pidFile } = fiberDaemonPaths(settings); + fs.mkdirSync(path.dirname(pidFile), { recursive: true }); + writePidFile(pidFile, pidMetadata(process.pid)); + expect(() => assertFiberFullyStopped(settings)).toThrow('fiber daemon is still running'); + }); + + it('passes when nothing is running and no store locks exist', () => { + const settings = makeSettings(tempRoot()); + expect(() => assertFiberFullyStopped(settings)).not.toThrow(); + }); + + describePosix('store lock fail-closed behavior', () => { + it('refuses while a store lock is held by a live process', async () => { + const settings = makeSettings(tempRoot()); + const { storeLockFile } = fiberNodePaths(1, settings); + fs.mkdirSync(path.dirname(storeLockFile), { recursive: true }); + fs.writeFileSync(storeLockFile, ''); + track( + spawn( + process.execPath, + ['-e', `require('fs').openSync(${JSON.stringify(storeLockFile)}, 'r'); setInterval(() => {}, 1000);`], + { stdio: 'ignore' }, + ), + ); + // Wait until the holder is visible to lsof. + for (let i = 0; i < 50 && isStoreLockHeld(storeLockFile) !== true; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(() => assertFiberFullyStopped(settings)).toThrow('Cannot confirm all Fiber stores are closed'); + }); + }); +}); + +describe('stopFiber', () => { + it('reports not-running when neither a daemon pid file nor a runtime record exists', async () => { + const settings = makeSettings(tempRoot()); + await expect(stopFiber(settings)).resolves.toBeUndefined(); + expect(fs.existsSync(runtimeJsonPath(settings))).toBe(false); + }); + + it('discards a stale runtime record whose manager is dead, signaling nothing', async () => { + const settings = makeSettings(tempRoot()); + writeRuntime( + { managerPid: 99999999, startedAt: new Date().toISOString(), status: 'running', nodes: [] }, + settings, + ); + await stopFiber(settings); + expect(fs.existsSync(runtimeJsonPath(settings))).toBe(false); + }); + + it('removes an unparseable daemon pid file and reports not-running', async () => { + const settings = makeSettings(tempRoot()); + const { pidFile } = fiberDaemonPaths(settings); + fs.mkdirSync(path.dirname(pidFile), { recursive: true }); + fs.writeFileSync(pidFile, 'this is not a pid'); + await stopFiber(settings); + expect(fs.existsSync(pidFile)).toBe(false); + }); + + it('refuses to signal a live daemon pid file whose identity cannot be verified', async () => { + const settings = makeSettings(tempRoot()); + const { pidFile } = fiberDaemonPaths(settings); + fs.mkdirSync(path.dirname(pidFile), { recursive: true }); + // A live process that is not this CLI: identity verification must fail + // closed and stopFiber must refuse without sending any signal. + const victim = spawnScriptProcess(path.join(tempRoot(), 'unrelated', 'victim.js')); + writePidFile(pidFile, pidMetadata(victim.pid as number)); + await expect(stopFiber(settings)).rejects.toThrow('Refusing to signal'); + expect(isProcessAlive(victim.pid as number)).toBe(true); + }); + + it('still refuses a recently-started daemon whose startup is in progress', async () => { + const settings = makeSettings(tempRoot()); + const { pidFile } = fiberDaemonPaths(settings); + fs.mkdirSync(path.dirname(pidFile), { recursive: true }); + const victim = spawnScriptProcess(path.join(tempRoot(), 'unrelated', 'victim.js')); + writePidFile(pidFile, pidMetadata(victim.pid as number, { status: 'starting' })); + await expect(stopFiber(settings)).rejects.toThrow('startup is still in progress'); + expect(isProcessAlive(victim.pid as number)).toBe(true); + }); + + it('treats a long-starting pid record as abandoned and reaches identity verification', async () => { + // A launcher interrupted mid-startup leaves status 'starting' forever; + // past the startup grace window the record must become stoppable instead + // of deadlocking stop and clean. Identity verification still gates the + // signal, so this foreign process is refused, not killed. + const settings = makeSettings(tempRoot()); + const { pidFile } = fiberDaemonPaths(settings); + fs.mkdirSync(path.dirname(pidFile), { recursive: true }); + const victim = spawnScriptProcess(path.join(tempRoot(), 'unrelated', 'victim.js')); + writePidFile( + pidFile, + pidMetadata(victim.pid as number, { + status: 'starting', + startedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + }), + ); + await expect(stopFiber(settings)).rejects.toThrow('does not appear to be the offckb fiber daemon'); + expect(isProcessAlive(victim.pid as number)).toBe(true); + }); + + describePosix('verified manager shutdown', () => { + const savedCliPath = process.env.OFFCKB_CLI_PATH; + let cliEntry: string; + + beforeEach(() => { + // Pin our CLI entry to a real script so the spawned stub verifies as + // the daemon; detached makes it a process-group leader, which is what + // terminateProcess signals. + cliEntry = path.join(tempRoot(), 'offckb', 'build', 'index.js'); + fs.mkdirSync(path.dirname(cliEntry), { recursive: true }); + fs.writeFileSync(cliEntry, 'setInterval(() => {}, 1000);\n'); + process.env.OFFCKB_CLI_PATH = cliEntry; + }); + + afterEach(() => { + if (savedCliPath === undefined) { + delete process.env.OFFCKB_CLI_PATH; + } else { + process.env.OFFCKB_CLI_PATH = savedCliPath; + } + }); + + function spawnVerifiedManager(): ChildProcess { + return track(spawn(process.execPath, [cliEntry], { stdio: 'ignore', detached: true })); + } + + it('stops a verified daemon and removes its pid file and runtime record', async () => { + const settings = makeSettings(tempRoot()); + const { pidFile } = fiberDaemonPaths(settings); + fs.mkdirSync(path.dirname(pidFile), { recursive: true }); + const manager = spawnVerifiedManager(); + writePidFile(pidFile, pidMetadata(manager.pid as number, { scriptPath: cliEntry, status: 'running' })); + writeRuntime( + { managerPid: manager.pid as number, startedAt: new Date().toISOString(), status: 'running', nodes: [] }, + settings, + ); + + await stopFiber(settings); + + expect(isProcessAlive(manager.pid as number)).toBe(false); + expect(fs.existsSync(pidFile)).toBe(false); + expect(fs.existsSync(runtimeJsonPath(settings))).toBe(false); + }); + + it('keeps the pid file and runtime record when the manager cannot be confirmed stopped', async () => { + const settings = makeSettings(tempRoot()); + const { pidFile } = fiberDaemonPaths(settings); + fs.mkdirSync(path.dirname(pidFile), { recursive: true }); + const manager = spawnVerifiedManager(); + writePidFile(pidFile, pidMetadata(manager.pid as number, { scriptPath: cliEntry, status: 'running' })); + writeRuntime( + { managerPid: manager.pid as number, startedAt: new Date().toISOString(), status: 'running', nodes: [] }, + settings, + ); + + // The SIGTERM really lands, but the final liveness probe reports alive: + // cleanup must keep the ownership records and report the stop as + // unconfirmed instead of erasing the environment's only trace. + const aliveSpy = jest.spyOn(daemonUtil, 'isProcessAlive').mockReturnValue(true); + try { + await stopFiber(settings); + } finally { + aliveSpy.mockRestore(); + } + + expect(fs.existsSync(pidFile)).toBe(true); + expect(fs.existsSync(runtimeJsonPath(settings))).toBe(true); + }); + }); +}); + +describe('startFiberDaemon', () => { + it('refuses to replace live pid metadata whose identity cannot be verified', async () => { + const settings = makeSettings(tempRoot()); + const { pidFile } = fiberDaemonPaths(settings); + fs.mkdirSync(path.dirname(pidFile), { recursive: true }); + const victim = spawnScriptProcess(path.join(tempRoot(), 'unrelated', 'victim.js')); + writePidFile(pidFile, pidMetadata(victim.pid as number)); + + await expect(startFiberDaemon([], settings)).rejects.toThrow('does not look like the offckb fiber daemon'); + + // The live process keeps the metadata it owns and is never signaled; no + // replacement startup is attempted (which would die at the env lock and + // strand the real daemon without any PID record). + expect(fs.existsSync(pidFile)).toBe(true); + expect(isProcessAlive(victim.pid as number)).toBe(true); + }); +}); + +describe('fiberClean', () => { + it('reports nothing-to-clean when no fiber environment exists', async () => { + const settings = makeSettings(tempRoot()); + await expect(fiberClean({ yes: true }, settings)).resolves.toBeUndefined(); + }); + + it('refuses to clean while a live manager runtime exists', async () => { + const settings = makeSettings(tempRoot()); + fs.mkdirSync(fiberRootPath(settings), { recursive: true }); + writeRuntime( + { managerPid: process.pid, startedAt: new Date().toISOString(), status: 'running', nodes: [] }, + settings, + ); + await expect(fiberClean({ yes: true }, settings)).rejects.toThrow('still managed by OffCKB process'); + expect(fs.existsSync(fiberRootPath(settings))).toBe(true); + }); + + it('deletes the fiber root when everything is stopped', async () => { + const settings = makeSettings(tempRoot()); + fs.mkdirSync(fiberNodePaths(1, settings).dir, { recursive: true }); + await fiberClean({ yes: true }, settings); + expect(fs.existsSync(fiberRootPath(settings))).toBe(false); + }); +}); + +function fakeHandle(child: ChildProcess, dir: string): FnnProcessHandle { + return { id: 1, process: child, rpcUrl: 'http://127.0.0.1:1', dir, logFile: path.join(dir, 'fnn.log') }; +} + +function stayAliveChild(): ChildProcess { + return track(spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], { stdio: 'ignore' })); +} + +describe('stopFiberNodes', () => { + it('stops a cooperative child with SIGTERM and removes a runtime record it owns', async () => { + const settings = makeSettings(tempRoot()); + writeRuntime( + { managerPid: process.pid, startedAt: new Date().toISOString(), status: 'running', nodes: [] }, + settings, + ); + const child = stayAliveChild(); + await stopFiberNodes([fakeHandle(child, tempRoot())], settings, 3000); + expect(child.signalCode).toBe('SIGTERM'); + expect(fs.existsSync(runtimeJsonPath(settings))).toBe(false); + }); + + it('keeps a runtime record owned by another manager', async () => { + const settings = makeSettings(tempRoot()); + writeRuntime( + { managerPid: 99999999, startedAt: new Date().toISOString(), status: 'running', nodes: [] }, + settings, + ); + await stopFiberNodes([], settings, 100); + expect(fs.existsSync(runtimeJsonPath(settings))).toBe(true); + }); + + it('treats an already-signaled child (null exitCode, signalCode set) as exited', async () => { + const settings = makeSettings(tempRoot()); + const child = stayAliveChild(); + child.kill('SIGKILL'); + await once(child, 'exit'); + expect(child.exitCode).toBeNull(); + expect(child.signalCode).toBe('SIGKILL'); + // Must resolve promptly instead of waiting out the grace period. + const start = Date.now(); + await stopFiberNodes([fakeHandle(child, tempRoot())], settings, 10_000); + expect(Date.now() - start).toBeLessThan(2000); + }); + + describePosix('SIGKILL escalation', () => { + it('SIGKILLs a child that ignores SIGTERM after the grace period', async () => { + const settings = makeSettings(tempRoot()); + const child = track( + spawn( + process.execPath, + // Announce readiness only after the SIGTERM handler is installed, + // so the stop below cannot race its registration. + ['-e', 'process.on("SIGTERM", () => {}); setInterval(() => {}, 1000); process.stdout.write("ready\\n");'], + { stdio: ['ignore', 'pipe', 'ignore'] }, + ), + ); + await once(child.stdout as NodeJS.ReadableStream, 'data'); + const exited = once(child, 'exit'); + await stopFiberNodes([fakeHandle(child, tempRoot())], settings, 400); + await exited; + expect(child.signalCode).toBe('SIGKILL'); + }, 15000); + }); +}); + +describe('startFiberEnvironment signal handling', () => { + const chainScripts: FiberChainScripts = { + genesisHash: `0x${'11'.repeat(32)}`, + fiberScripts: [], + udtWhitelist: [], + }; + + function fiberFixture(): { settings: Settings; testnetConfigPath: string } { + const root = tempRoot(); + const settings = makeSettings(root); + const testnetConfigPath = path.join(root, 'testnet-config.yml'); + fs.writeFileSync(testnetConfigPath, 'fiber:\n tlc_expiry_delta: 86400000\n'); + return { settings, testnetConfigPath }; + } + + it('removes its startup signal handlers after a failed start', async () => { + const { settings, testnetConfigPath } = fiberFixture(); + const baselineSigint = process.listenerCount('SIGINT'); + const baselineSigterm = process.listenerCount('SIGTERM'); + // process.execPath as the "FNN binary" exits immediately (node: bad option + // -d), driving the startup down the failure path. + await expect( + startFiberEnvironment({ + fnnPath: process.execPath, + testnetConfigPath, + chainScripts, + nodeCount: 1, + settings, + }), + ).rejects.toThrow('exited during startup'); + expect(process.listenerCount('SIGINT')).toBe(baselineSigint); + expect(process.listenerCount('SIGTERM')).toBe(baselineSigterm); + // The failure path also drops the runtime record it wrote. + expect(fs.existsSync(runtimeJsonPath(settings))).toBe(false); + }); + + describePosix('startup window', () => { + it('SIGINT before readiness stops the spawned FNNs, drops the starting runtime and exits 130', async () => { + const { settings, testnetConfigPath } = fiberFixture(); + // A stub FNN that stays alive but never serves RPC, so startup parks in + // the readiness wait — the window the signal must cover. + const stubFnn = path.join(tempRoot(), 'fnn-stub.sh'); + fs.writeFileSync(stubFnn, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} -e "setInterval(() => {}, 1000)"\n`); + fs.chmodSync(stubFnn, 0o755); + + const started = startFiberEnvironment({ + fnnPath: stubFnn, + testnetConfigPath, + chainScripts, + nodeCount: 1, + settings, + }); + // The assertions below can throw before the final await reaches this + // promise; park a no-op handler now so an early failure does not also + // surface as an unhandled rejection that hides the real cause. + started.catch(() => {}); + // Wait for the spawn + starting runtime record. + let fnnPid: number | null = null; + for (let i = 0; i < 50 && fnnPid == null; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + try { + const raw = JSON.parse(fs.readFileSync(runtimeJsonPath(settings), 'utf8')); + if (raw.status === 'starting' && raw.nodes?.[0]?.pid > 0) fnnPid = raw.nodes[0].pid; + } catch { + // runtime not written yet + } + } + expect(fnnPid).not.toBeNull(); + expect(isProcessAlive(fnnPid as number)).toBe(true); + + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as unknown as (code?: string | number | null) => never); + try { + process.emit('SIGINT'); + // Let the async cleanup run: SIGTERM the stub, drop runtime.json. + for (let i = 0; i < 50 && exitSpy.mock.calls.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(exitSpy).toHaveBeenCalledWith(130); + expect(isProcessAlive(fnnPid as number)).toBe(false); + expect(fs.existsSync(runtimeJsonPath(settings))).toBe(false); + } finally { + exitSpy.mockRestore(); + } + // The readiness wait notices the dead child and rejects. + await expect(started).rejects.toThrow('exited during startup'); + }, 20000); + }); +}); diff --git a/tests/fiber-nodes-yml.test.ts b/tests/fiber-nodes-yml.test.ts new file mode 100644 index 00000000..cd341318 --- /dev/null +++ b/tests/fiber-nodes-yml.test.ts @@ -0,0 +1,130 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import yaml from 'js-yaml'; +import { defaultSettings, Settings } from '../src/cfg/setting'; +import { nodesYmlPath } from '../src/fiber/paths'; +import { ensureNodesYml, readNodesYml, validateNodeCount } from '../src/fiber/nodes-yml'; + +const tempRoots: string[] = []; +afterEach(() => { + while (tempRoots.length) fs.rmSync(tempRoots.pop() as string, { recursive: true, force: true }); +}); + +function fixture(): Settings { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-fiber-nodes-')); + tempRoots.push(root); + const settings = JSON.parse(JSON.stringify(defaultSettings)) as Settings; + settings.devnet.configPath = path.join(root, 'devnet'); + return settings; +} + +describe('validateNodeCount', () => { + it('accepts 1-16 and rejects everything else', () => { + expect(validateNodeCount(1)).toBe(1); + expect(validateNodeCount(16)).toBe(16); + expect(() => validateNodeCount(0)).toThrow('between 1 and 16'); + expect(() => validateNodeCount(17)).toThrow('between 1 and 16'); + expect(() => validateNodeCount(2.5)).toThrow('between 1 and 16'); + }); +}); + +describe('ensureNodesYml', () => { + it('creates two nodes by default', () => { + const settings = fixture(); + const entries = ensureNodesYml(undefined, settings); + expect(entries).toEqual([ + { id: 1, config: {} }, + { id: 2, config: {} }, + ]); + expect(fs.existsSync(nodesYmlPath(settings))).toBe(true); + }); + + it('creates the requested number of nodes on first start', () => { + const settings = fixture(); + const entries = ensureNodesYml(4, settings); + expect(entries.map((e) => e.id)).toEqual([1, 2, 3, 4]); + }); + + it('keeps per-node config when growing and warns when shrinking', () => { + const settings = fixture(); + ensureNodesYml(3, settings); + const file = nodesYmlPath(settings); + const doc = yaml.load(fs.readFileSync(file, 'utf8')) as { nodes: { id: number; config: object }[] }; + doc.nodes[1].config = { fiber: { auto_accept_channel_ckb_funding_amount: 99 } }; + fs.writeFileSync(file, yaml.dump(doc)); + + const grown = ensureNodesYml(4, settings); + expect(grown.map((e) => e.id)).toEqual([1, 2, 3, 4]); + expect(grown[1].config).toEqual({ fiber: { auto_accept_channel_ckb_funding_amount: 99 } }); + expect(grown[3].config).toEqual({}); + + const shrunk = ensureNodesYml(2, settings); + expect(shrunk.map((e) => e.id)).toEqual([1, 2]); + expect(shrunk[1].config).toEqual({ fiber: { auto_accept_channel_ckb_funding_amount: 99 } }); + }); + + it('uses the stored list when no count is requested', () => { + const settings = fixture(); + ensureNodesYml(5, settings); + const entries = ensureNodesYml(undefined, settings); + expect(entries.map((e) => e.id)).toEqual([1, 2, 3, 4, 5]); + }); +}); + +describe('readNodesYml', () => { + it('returns null when the file does not exist', () => { + expect(readNodesYml(fixture())).toBeNull(); + }); + + it('rejects duplicate ids', () => { + const settings = fixture(); + fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); + fs.writeFileSync(nodesYmlPath(settings), yaml.dump({ nodes: [{ id: 1 }, { id: 1 }] })); + expect(() => readNodesYml(settings)).toThrow('duplicate node id 1'); + }); + + it('rejects an empty node list', () => { + // An empty stored list must not bypass the minimum-node constraint that + // validateNodeCount enforces for the --nodes flag. + const settings = fixture(); + fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); + fs.writeFileSync(nodesYmlPath(settings), yaml.dump({ nodes: [] })); + expect(() => readNodesYml(settings)).toThrow('at least 1 node must be configured'); + }); + + it('rejects managed config fields', () => { + const settings = fixture(); + fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); + fs.writeFileSync( + nodesYmlPath(settings), + yaml.dump({ nodes: [{ id: 1, config: { fiber: { chain: 'evil.toml' } } }] }), + ); + expect(() => readNodesYml(settings)).toThrow('fiber.chain'); + }); + + it('rejects a per-node fiber.store_path override', () => { + // A relocated store would make clean's RocksDB LOCK check watch the wrong + // path, silently dropping its fail-closed property. + const settings = fixture(); + fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); + fs.writeFileSync( + nodesYmlPath(settings), + yaml.dump({ nodes: [{ id: 1, config: { fiber: { store_path: '/tmp/elsewhere' } } }] }), + ); + expect(() => readNodesYml(settings)).toThrow('fiber.store_path'); + }); + + it('rejects managed rpc and ckb fields', () => { + const settings = fixture(); + fs.mkdirSync(path.dirname(nodesYmlPath(settings)), { recursive: true }); + fs.writeFileSync( + nodesYmlPath(settings), + yaml.dump({ nodes: [{ id: 1, config: { rpc: { listening_addr: '0.0.0.0:1' } } }] }), + ); + expect(() => readNodesYml(settings)).toThrow('rpc.listening_addr'); + + fs.writeFileSync(nodesYmlPath(settings), yaml.dump({ nodes: [{ id: 1, config: { services: ['cch'] } }] })); + expect(() => readNodesYml(settings)).toThrow('services'); + }); +}); diff --git a/tests/fiber-scripts.test.ts b/tests/fiber-scripts.test.ts new file mode 100644 index 00000000..c112ac58 --- /dev/null +++ b/tests/fiber-scripts.test.ts @@ -0,0 +1,96 @@ +import { FiberContractsMissingError, resolveFiberChainScripts } from '../src/fiber/scripts'; +import { SystemScript } from '../src/scripts/type'; + +const mockResolve = jest.fn(); +jest.mock('../src/scripts/private', () => ({ + resolveDevnetSystemScripts: () => mockResolve(), +})); + +function script(name: string, txHash: string, index: number, codeHash: string, hashType: 'type' | 'data2'): SystemScript { + return { + name, + script: { + codeHash: codeHash as `0x${string}`, + hashType, + cellDeps: [ + { + cellDep: { + outPoint: { txHash: txHash as `0x${string}`, index }, + depType: 'code', + }, + }, + ], + }, + }; +} + +const GENESIS_TX = '0xaaaa'; + +function fullRecord(): Record { + return { + auth: script('auth', GENESIS_TX, 20, '0xauth', 'data2'), + funding_lock: script('funding_lock', GENESIS_TX, 21, '0xfunding', 'data2'), + commitment_lock: script('commitment_lock', GENESIS_TX, 22, '0xcommitment', 'data2'), + sudt: script('sudt', GENESIS_TX, 5, '0xsudt', 'type'), + xudt: script('xudt', GENESIS_TX, 6, '0xxudt', 'type'), + }; +} + +describe('resolveFiberChainScripts', () => { + beforeEach(() => mockResolve.mockReset()); + + it('builds FundingLock/CommitmentLock with their own cell plus the auth cell', () => { + mockResolve.mockReturnValue({ scripts: fullRecord(), forkedFrom: null, genesisHash: '0xgenesis' }); + const result = resolveFiberChainScripts(); + + expect(result.genesisHash).toBe('0xgenesis'); + expect(result.fiberScripts).toHaveLength(2); + + const [funding, commitment] = result.fiberScripts; + expect(funding.name).toBe('FundingLock'); + expect(funding.script).toEqual({ code_hash: '0xfunding', hash_type: 'data2', args: '0x' }); + expect(funding.cell_deps).toEqual([ + { cell_dep: { out_point: { tx_hash: GENESIS_TX, index: '0x15' }, dep_type: 'code' } }, + { cell_dep: { out_point: { tx_hash: GENESIS_TX, index: '0x14' }, dep_type: 'code' } }, + ]); + expect(commitment.name).toBe('CommitmentLock'); + expect(commitment.cell_deps[0].cell_dep.out_point.index).toBe('0x16'); + expect(commitment.cell_deps[1].cell_dep.out_point.index).toBe('0x14'); + }); + + it('anchors the UDT whitelist to the issuer lock hash with ^ and $', () => { + mockResolve.mockReturnValue({ scripts: fullRecord(), forkedFrom: null, genesisHash: '0xgenesis' }); + const result = resolveFiberChainScripts(); + + expect(result.udtWhitelist).toHaveLength(2); + for (const udt of result.udtWhitelist) { + expect(udt.script.args).toMatch(/^\^0x[0-9a-f]{64}\$$/); + } + expect(result.udtWhitelist[0].name).toBe('sudt'); + expect(result.udtWhitelist[1].name).toBe('xudt'); + expect(result.udtWhitelist[0].cell_deps[0].cell_dep.out_point).toEqual({ tx_hash: GENESIS_TX, index: '0x5' }); + }); + + it('reports missing fiber contracts', () => { + const record = fullRecord(); + delete (record as Record).funding_lock; + delete (record as Record).commitment_lock; + mockResolve.mockReturnValue({ scripts: record, forkedFrom: null, genesisHash: '0xgenesis' }); + + try { + resolveFiberChainScripts(); + throw new Error('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(FiberContractsMissingError); + expect((error as FiberContractsMissingError).missing).toEqual(['funding_lock', 'commitment_lock']); + // The upgrade path for a pre-Fiber devnet: the error itself must carry + // the migration guidance, whichever command surfaces it. + expect((error as Error).message).toContain('offckb clean'); + } + }); + + it('throws when list-hashes is unavailable', () => { + mockResolve.mockReturnValue(null); + expect(() => resolveFiberChainScripts()).toThrow('list-hashes'); + }); +}); diff --git a/tests/fiber-status.test.ts b/tests/fiber-status.test.ts new file mode 100644 index 00000000..5ab1779d --- /dev/null +++ b/tests/fiber-status.test.ts @@ -0,0 +1,29 @@ +import { lockMatches } from '../src/fiber/status'; + +/** + * The funding-lock comparison shared by `fiber status` and the manager's + * startup validation: case-insensitive, all three fields, no partial match. + */ +describe('lockMatches', () => { + const expected = { codeHash: '0xAbC123', hashType: 'type', args: '0xDEADbeef' }; + + it('matches a lock equal up to hex casing', () => { + expect(lockMatches({ code_hash: '0xabc123', hash_type: 'TYPE', args: '0xdeadbeef' }, expected)).toBe(true); + }); + + it('rejects a missing lock', () => { + expect(lockMatches(undefined, expected)).toBe(false); + }); + + it('rejects a differing code hash', () => { + expect(lockMatches({ code_hash: '0x000000', hash_type: 'type', args: '0xdeadbeef' }, expected)).toBe(false); + }); + + it('rejects a differing hash type', () => { + expect(lockMatches({ code_hash: '0xabc123', hash_type: 'data', args: '0xdeadbeef' }, expected)).toBe(false); + }); + + it('rejects differing args', () => { + expect(lockMatches({ code_hash: '0xabc123', hash_type: 'type', args: '0x1234' }, expected)).toBe(false); + }); +}); diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index c3e189ba..9b7e661f 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -76,21 +76,69 @@ jest.mock('../src/util/logger', () => ({ })); import { logger } from '../src/util/logger'; +import { fiberDaemonPaths } from '../src/fiber/paths'; +import { Settings } from '../src/cfg/setting'; const dataPath = '/tmp/offckb-devnet-data'; const logDir = path.join(dataPath, 'logs'); const pidFile = path.join(logDir, 'daemon.pid'); +// Format a Date the way `ps -o lstart=` prints it ("Wed Aug 13 12:36:26 2026"), +// which verifyDaemonIdentity parses for the start-time consistency check. +function formatPsLstart(date: Date): string { + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + `${days[date.getDay()]} ${months[date.getMonth()]} ${pad(date.getDate())} ` + + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())} ${date.getFullYear()}` + ); +} + +// execFile callbacks in the code under test are attached either directly or +// after an options object; normalize both arities. +function execFileCallback(optionsOrCallback: unknown, maybeCallback: unknown) { + return (typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback) as ( + err: Error | null, + stdout?: string, + ) => void; +} + function mockDaemonCommandLine(scriptPath: string) { mockExecFile.mockImplementation( - (file: string, _args: string[], callback: (err: Error | null, stdout?: string) => void) => { + (file: string, args: string[], optionsOrCallback: unknown, maybeCallback?: unknown) => { + const callback = execFileCallback(optionsOrCallback, maybeCallback); if (file === 'ps') { + if (args.includes('lstart=')) { + callback(null, formatPsLstart(new Date())); + return undefined as unknown as ReturnType; + } callback(null, `/usr/bin/node ${scriptPath} node`); return undefined as unknown as ReturnType; } - if (file === 'wmic') { - // WMIC returns key/value pairs, e.g. "CommandLine=..." - callback(null, `CommandLine=/usr/bin/node ${scriptPath} node`); + callback(null, ''); + return undefined as unknown as ReturnType; + }, + ); +} + +// A process whose command line is NOT our CLI: the ps lstart query still gets +// a valid answer so identity verification reaches the executable/CLI-entry +// comparison (an unparseable lstart would fail the start-time check first and +// the test would prove nothing about the command-line rules). `pidCmdline` +// may map pids to different command lines to verify one process while another +// stays foreign. +function mockUnrelatedCommandLine(pidCmdline: Record = {}) { + mockExecFile.mockImplementation( + (file: string, args: string[], optionsOrCallback: unknown, maybeCallback?: unknown) => { + const callback = execFileCallback(optionsOrCallback, maybeCallback); + if (file === 'ps') { + if (args.includes('lstart=')) { + callback(null, formatPsLstart(new Date())); + return undefined as unknown as ReturnType; + } + const pid = Number(args[args.indexOf('-p') + 1]); + callback(null, pidCmdline[pid] ?? '/usr/bin/some-unrelated-process'); return undefined as unknown as ReturnType; } callback(null, ''); @@ -196,12 +244,7 @@ describe('node command daemon mode', () => { mockReadFileSync.mockReturnValue( JSON.stringify({ pid: 9999, scriptPath: '/path/to/offckb', startedAt: new Date().toISOString() }), ); - mockExecFile.mockImplementation( - (_file: string, _args: string[], callback: (err: Error | null, stdout?: string) => void) => { - callback(null, '/usr/bin/some-unrelated-process'); - return undefined as unknown as ReturnType; - }, - ); + mockUnrelatedCommandLine(); await startNode({ network: Network.devnet, daemon: true }); @@ -401,6 +444,19 @@ describe('node command stop', () => { let processAlive = true; const scriptPath = '/path/to/offckb'; const originalPlatform = process.platform; + const originalArgv = process.argv; + + // Serve the given content only for the CKB daemon PID file; other files + // (fiber daemon PID, fiber runtime.json) read as absent, matching a + // machine with no fiber environment. + function mockPidFileContent(content: string) { + mockReadFileSync.mockImplementation((file: string) => { + if (file === pidFile) return content; + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + } function setPlatform(value: string) { Object.defineProperty(process, 'platform', { value }); @@ -412,8 +468,11 @@ describe('node command stop', () => { processAlive = true; mockExecFile.mockReset(); mockStatSync.mockReturnValue({ isFile: () => true }); - mockReadFileSync.mockReturnValue(JSON.stringify({ pid: 12345, scriptPath, startedAt: new Date().toISOString() })); + mockPidFileContent(JSON.stringify({ pid: 12345, scriptPath, startedAt: new Date().toISOString() })); mockDaemonCommandLine(scriptPath); + // The stop command runs from the same CLI installation as the daemon; + // identity verification resolves our entry from argv. + process.argv = ['node', scriptPath, 'node', 'stop']; // Normalize to POSIX for deterministic signal-based assertions. The // implementation has a separate Windows path (taskkill) that is exercised @@ -440,6 +499,7 @@ describe('node command stop', () => { afterEach(() => { killSpy.mockRestore(); setPlatform(originalPlatform); + process.argv = originalArgv; jest.useRealTimers(); }); @@ -456,7 +516,7 @@ describe('node command stop', () => { }); it('errors when the PID file contains an invalid PID', async () => { - mockReadFileSync.mockReturnValue('not-a-number'); + mockPidFileContent('not-a-number'); await expect(stopNode()).rejects.toThrow('Invalid PID'); expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); }); @@ -469,7 +529,7 @@ describe('node command stop', () => { }); it('does not signal the CLI process while daemon startup is in progress', async () => { - mockReadFileSync.mockReturnValue( + mockPidFileContent( JSON.stringify({ pid: 12345, scriptPath, startedAt: new Date().toISOString(), status: 'starting' }), ); @@ -507,12 +567,7 @@ describe('node command stop', () => { }); it('refuses to kill a process that does not look like the daemon', async () => { - mockExecFile.mockImplementation( - (_file: string, _args: string[], callback: (err: Error | null, stdout?: string) => void) => { - callback(null, '/usr/bin/some-other-process'); - return undefined as unknown as ReturnType; - }, - ); + mockUnrelatedCommandLine(); await expect(stopNode()).rejects.toThrow('does not appear to be the offckb daemon'); @@ -565,4 +620,55 @@ describe('node command stop', () => { expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('is not running')); expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); }); + + describe('fiber daemon guard', () => { + const fiberPidFile = fiberDaemonPaths({ + devnet: { configPath: '/tmp/offckb-devnet-config' }, + } as unknown as Settings).pidFile; + const fiberMetadata = (pid: number) => + JSON.stringify({ pid, scriptPath, startedAt: new Date().toISOString(), status: 'running' }); + + function mockNodeAndFiberPidFiles(fiberContent: string) { + mockReadFileSync.mockImplementation((file: string) => { + if (file === pidFile) { + return JSON.stringify({ pid: 12345, scriptPath, startedAt: new Date().toISOString() }); + } + if (file === fiberPidFile) return fiberContent; + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + } + + it('refuses to stop while a verified fiber daemon manages FNNs, hinting at fiber stop and --force', async () => { + mockNodeAndFiberPidFiles(fiberMetadata(23456)); + // The default mockDaemonCommandLine verifies BOTH daemons' identity. + await expect(stopNode()).rejects.toThrow( + 'Fiber nodes are managed by a separate fiber daemon (PID 23456)', + ); + await expect(stopNode()).rejects.toThrow('offckb node stop --force'); + expect(killSpy).not.toHaveBeenCalledWith(-12345, 'SIGTERM'); + }); + + it('stops the CKB daemon when the fiber daemon PID file points at an unverifiable process', async () => { + mockNodeAndFiberPidFiles(fiberMetadata(23456)); + // PID 23456 was recycled by something foreign; the CKB daemon still verifies. + mockUnrelatedCommandLine({ 12345: `/usr/bin/node ${scriptPath} node` }); + + await stopNode(); + + expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + expect(logger.success).toHaveBeenCalledWith('CKB devnet daemon stopped.'); + }); + + it('warns and proceeds with --force while a verified fiber daemon is live', async () => { + mockNodeAndFiberPidFiles(fiberMetadata(23456)); + + await stopNode({ force: true }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('will keep running on a stopped chain')); + expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); + }); + }); }); From e8c2968acfd9facbfe3b2b89ca7abcd1f10f2bcb Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Mon, 24 Aug 2026 13:30:14 +0800 Subject: [PATCH 2/3] test: fix macos/windows CI flakes (broken-pipe worker crash + Windows identity probe timeout) (#506) * test: stop broken-pipe test from crashing jest worker on macOS/Windows * fix: raise daemon-identity probe timeout to stop Windows CI flake * test: stub stream.on with mockReturnThis so broken-pipe handlers never touch live streams --- .changeset/ci-broken-pipe-worker-crash.md | 8 +++ src/util/daemon.ts | 7 ++- tests/broken-pipe.test.ts | 72 +++++++++++++++-------- 3 files changed, 60 insertions(+), 27 deletions(-) create mode 100644 .changeset/ci-broken-pipe-worker-crash.md diff --git a/.changeset/ci-broken-pipe-worker-crash.md b/.changeset/ci-broken-pipe-worker-crash.md new file mode 100644 index 00000000..1b3e533d --- /dev/null +++ b/.changeset/ci-broken-pipe-worker-crash.md @@ -0,0 +1,8 @@ +--- +'@offckb/cli': patch +--- + +Fix two CI failures on the macOS/Windows test matrix: + +- The broken-pipe regression test no longer crashes the jest worker ("jest worker process crashed for an unknown reason: exitCode=0"). It captures the EPIPE handlers installed by `installBrokenPipeHandlers` through a spy on `stream.on` instead of binding them to the live process stdout/stderr streams, so a real stream error can no longer reach a handler with the real (unmocked) `process.exit` and terminate the worker. The covered policy is unchanged: EPIPE exits 0 during normal operation, is swallowed during a graceful shutdown, and non-EPIPE errors are rethrown. +- The Windows daemon-identity probe no longer times out during the `verifyDaemonIdentity` test. The PowerShell + CIM query needs a cold start of a second or two, which routinely exceeded the old 5s probe bound on a loaded runner, making the identity check fail closed against a live process it should have accepted. The probe timeout is now 15s — generous enough for a slow machine, still bounded so a genuinely hung probe fails closed. diff --git a/src/util/daemon.ts b/src/util/daemon.ts index d4541777..bfaa55f6 100644 --- a/src/util/daemon.ts +++ b/src/util/daemon.ts @@ -296,9 +296,14 @@ function parsePsLstart(text: string): number | null { return Number.isFinite(ms) ? ms : null; } +// Generous so the Windows daemon-identity probe (PowerShell + CIM cold start) +// does not fail closed against a legitimate daemon on a slow machine; it only +// needs to bound a genuinely hung probe. +const PROCESS_PROBE_TIMEOUT_MS = 15_000; + function execFileText(command: string, args: string[]): Promise { return new Promise((resolve) => { - execFile(command, args, { timeout: 5000 }, (error, stdout) => { + execFile(command, args, { timeout: PROCESS_PROBE_TIMEOUT_MS }, (error, stdout) => { if (error) { resolve(null); return; diff --git a/tests/broken-pipe.test.ts b/tests/broken-pipe.test.ts index 22be314c..3ddfdac0 100644 --- a/tests/broken-pipe.test.ts +++ b/tests/broken-pipe.test.ts @@ -25,66 +25,86 @@ function epipe(): NodeJS.ErrnoException { describe('util/shutdown broken-pipe policy', () => { let shutdown: ShutdownModule; let exitSpy: jest.SpyInstance; - let stdoutListeners: unknown[]; - let stderrListeners: unknown[]; beforeEach(() => { jest.resetModules(); shutdown = require('../src/util/shutdown') as ShutdownModule; - stdoutListeners = process.stdout.rawListeners('error'); - stderrListeners = process.stderr.rawListeners('error'); - exitSpy = jest - .spyOn(process, 'exit') - .mockImplementation(((code?: number) => { - throw new ProcessExit(code); - }) as (code?: number) => never); + exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ProcessExit(code); + }) as (code?: number) => never); }); afterEach(() => { exitSpy.mockRestore(); - for (const [stream, original] of [ - [process.stdout, stdoutListeners], - [process.stderr, stderrListeners], - ] as const) { - for (const listener of stream.rawListeners('error')) { - if (!original.includes(listener)) { - stream.removeListener('error', listener as (error: Error) => void); + }); + + /** + * Run installBrokenPipeHandlers without binding anything to the live + * process.stdout/stderr streams. Installing error handlers on the real + * streams inside a shared jest worker is unsafe: a genuine stream error + * (EPIPE on a closed pipe while running with piped output on macOS/Windows) + * can fire asynchronously and reach the handler after the mocked + * process.exit has been restored, causing the worker itself to exit — the + * "jest worker process crashed for an unknown reason: exitCode=0" CI + * failure. Capturing the handlers through a spy on `stream.on` tests the + * exact same policy logic (including that both streams get a handler) with + * no global side effects. + */ + function captureHandlers(): Array<(error: NodeJS.ErrnoException) => void> { + const handlers: Array<(error: NodeJS.ErrnoException) => void> = []; + // jest.spyOn calls through to the original method by default, so a plain + // spy would still register the handlers on the live streams (and + // mockRestore does not remove them). mockReturnThis makes `.on()` a no-op + // that only records the call, keeping the streams untouched. + const stdoutOn = jest.spyOn(process.stdout, 'on').mockReturnThis(); + const stderrOn = jest.spyOn(process.stderr, 'on').mockReturnThis(); + shutdown.installBrokenPipeHandlers(); + for (const streamOn of [stdoutOn, stderrOn]) { + for (const [event, handler] of streamOn.mock.calls) { + if (event === 'error') { + handlers.push(handler as (error: NodeJS.ErrnoException) => void); } } + streamOn.mockRestore(); } - }); + return handlers; + } it('starts outside a graceful shutdown', () => { expect(shutdown.isGracefulShutdownInProgress()).toBe(false); }); + it('installs an error handler on both stdout and stderr', () => { + expect(captureHandlers()).toHaveLength(2); + }); + it('exits 0 on EPIPE during normal operation (the `| head` case)', () => { - shutdown.installBrokenPipeHandlers(); - expect(() => process.stdout.emit('error', epipe())).toThrow(ProcessExit); + const [stdoutHandler] = captureHandlers(); + expect(() => stdoutHandler(epipe())).toThrow(ProcessExit); expect(exitSpy).toHaveBeenCalledWith(0); }); it('handles stderr the same way as stdout', () => { - shutdown.installBrokenPipeHandlers(); - expect(() => process.stderr.emit('error', epipe())).toThrow(ProcessExit); + const [, stderrHandler] = captureHandlers(); + expect(() => stderrHandler(epipe())).toThrow(ProcessExit); expect(exitSpy).toHaveBeenCalledWith(0); }); it('swallows EPIPE once a graceful shutdown is in progress', () => { - shutdown.installBrokenPipeHandlers(); + const [stdoutHandler, stderrHandler] = captureHandlers(); shutdown.enterGracefulShutdown(); expect(shutdown.isGracefulShutdownInProgress()).toBe(true); // Repeated writes to the dead pipe keep erroring; none may exit. - expect(() => process.stdout.emit('error', epipe())).not.toThrow(); - expect(() => process.stderr.emit('error', epipe())).not.toThrow(); + expect(() => stdoutHandler(epipe())).not.toThrow(); + expect(() => stderrHandler(epipe())).not.toThrow(); expect(exitSpy).not.toHaveBeenCalled(); }); it('still rethrows non-EPIPE stream errors during a shutdown', () => { - shutdown.installBrokenPipeHandlers(); + const [stdoutHandler] = captureHandlers(); shutdown.enterGracefulShutdown(); const error = new Error('some other stream failure'); - expect(() => process.stdout.emit('error', error)).toThrow(error); + expect(() => stdoutHandler(error)).toThrow(error); expect(exitSpy).not.toHaveBeenCalled(); }); }); From 06b9c2e0f19250e4a09667c15f5e9bae873b427c Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Mon, 24 Aug 2026 13:40:46 +0800 Subject: [PATCH 3/3] chore: version packages for 0.5.0 release (#505) --- .changeset/fiber-devnet.md | 5 ----- CHANGELOG.md | 6 ++++++ package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/fiber-devnet.md diff --git a/.changeset/fiber-devnet.md b/.changeset/fiber-devnet.md deleted file mode 100644 index 52234e6c..00000000 --- a/.changeset/fiber-devnet.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@offckb/cli': minor ---- - -Add Fiber (FNN) support to the local devnet. The devnet genesis now carries the Fiber contracts `auth`, `funding_lock` and `commitment_lock` (pinned to the FNN v0.9.0-rc7 source), and a new `offckb fiber` command family manages a local Fiber environment: `offckb node --fiber` starts CKB, miner, RPC proxy and FNN nodes in one go, while `offckb fiber start` adds FNN nodes to an already-running devnet (with `--daemon`/`offckb fiber stop` for background operation). Each node gets its own built-in CKB account (node N → account N+2), network identity, RPC port 21713+N, P2P port 8343+N and a `fnn.log`; `offckb fiber status [--json]` reports live node health, `offckb fiber logs --node ` reads node logs, and `offckb fiber clean [--data]` removes stores or the whole fiber environment. Startup verifies the devnet spec, CKB and every FNN agree on the same chain and checks node identity keys, funding accounts and balances. Plain local devnet only — mainnet/testnet and forked devnets are rejected. `offckb clean` now also removes fiber stores with `--data` and refuses to delete data while a CKB/fiber daemon or a live FNN store lock can be confirmed. New devnets only: devnets initialized by earlier offckb versions lack the Fiber contracts and must be rebuilt (`offckb clean`) to use Fiber, which changes the genesis hash. diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d607f7..4e7fff2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @offckb/cli +## 0.5.0 + +### Minor Changes + +- 2054548: Add Fiber (FNN) support to the local devnet. The devnet genesis now carries the Fiber contracts `auth`, `funding_lock` and `commitment_lock` (pinned to the FNN v0.9.0-rc7 source), and a new `offckb fiber` command family manages a local Fiber environment: `offckb node --fiber` starts CKB, miner, RPC proxy and FNN nodes in one go, while `offckb fiber start` adds FNN nodes to an already-running devnet (with `--daemon`/`offckb fiber stop` for background operation). Each node gets its own built-in CKB account (node N → account N+2), network identity, RPC port 21713+N, P2P port 8343+N and a `fnn.log`; `offckb fiber status [--json]` reports live node health, `offckb fiber logs --node ` reads node logs, and `offckb fiber clean [--data]` removes stores or the whole fiber environment. Startup verifies the devnet spec, CKB and every FNN agree on the same chain and checks node identity keys, funding accounts and balances. Plain local devnet only — mainnet/testnet and forked devnets are rejected. `offckb clean` now also removes fiber stores with `--data` and refuses to delete data while a CKB/fiber daemon or a live FNN store lock can be confirmed. New devnets only: devnets initialized by earlier offckb versions lack the Fiber contracts and must be rebuilt (`offckb clean`) to use Fiber, which changes the genesis hash. + ## 0.4.13 ### Patch Changes diff --git a/package.json b/package.json index ae0a1bf0..0dcf1676 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@offckb/cli", - "version": "0.4.13", + "version": "0.5.0", "description": "ckb development network for your first try", "author": "CKB EcoFund", "license": "MIT",