From 033d7a118e322254fc8d2e798a7c3adda98ec40d Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Fri, 31 Jul 2026 05:06:45 +0000 Subject: [PATCH 1/6] 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 --- .changeset/fiber-devnet.md | 5 + .gitmodules | 3 + Makefile | 22 +- README.md | 39 ++ 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 + package.json | 2 + pnpm-lock.yaml | 11 + src/cfg/setting.ts | 2 + src/cli.ts | 90 ++++- src/cmd/clean.ts | 93 +++-- src/cmd/config.ts | 19 + src/cmd/fiber.ts | 199 ++++++++++ src/cmd/node.ts | 456 ++++++++++------------ src/fiber/accounts.ts | 113 ++++++ src/fiber/ckb-env.ts | 69 ++++ src/fiber/clean.ts | 119 ++++++ src/fiber/config-gen.ts | 104 +++++ src/fiber/daemon.ts | 311 +++++++++++++++ src/fiber/env-lock.ts | 115 ++++++ src/fiber/install.ts | 197 ++++++++++ src/fiber/manager.ts | 397 +++++++++++++++++++ src/fiber/nodes-yml.ts | 147 +++++++ src/fiber/paths.ts | 125 ++++++ src/fiber/rpc.ts | 47 +++ src/fiber/runtime.ts | 93 +++++ src/fiber/scripts.ts | 149 +++++++ src/fiber/status.ts | 235 +++++++++++ src/fiber/store-lock.ts | 62 +++ src/scripts/public.ts | 6 + src/scripts/type.ts | 3 + src/util/daemon.ts | 239 ++++++++++++ tests/fiber-accounts.test.ts | 97 +++++ tests/fiber-config-gen.test.ts | 152 ++++++++ tests/fiber-env-lock.test.ts | 60 +++ tests/fiber-nodes-yml.test.ts | 109 ++++++ tests/fiber-scripts.test.ts | 93 +++++ tests/node-command.test.ts | 18 +- 42 files changed, 3842 insertions(+), 273 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 tests/fiber-accounts.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-nodes-yml.test.ts create mode 100644 tests/fiber-scripts.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..1eef16eb 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 @@ -444,6 +449,38 @@ 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). +- 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`). 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). +- 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. +- 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. + ## Config Setting ### List All Settings @@ -501,6 +538,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..d7cffeaaab0b93bbfdde7a5a83392879ec16f62a GIT binary patch literal 111880 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 BFi-#h literal 0 HcmV?d00001 diff --git a/ckb/devnet/specs/fiber/funding_lock b/ckb/devnet/specs/fiber/funding_lock new file mode 100755 index 0000000000000000000000000000000000000000..63d3373d8c892e3058b310594ba4c2dce13c6aeb GIT binary patch literal 74800 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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/package.json b/package.json index e324a202..5952f826 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 d1eb771b..a4f7ac1e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,16 @@ 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'; @@ -81,10 +91,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 +121,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, }); }, ); @@ -101,6 +134,61 @@ nodeCommand .description('Stop the running CKB devnet daemon') .action(async () => stopNode()); +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') .description('Show devnet logs: node (default), contract script debug output, miner, or RPC proxy events') diff --git a/src/cmd/clean.ts b/src/cmd/clean.ts index a7c5738c..914af057 100644 --- a/src/cmd/clean.ts +++ b/src/cmd/clean.ts @@ -1,42 +1,91 @@ import fs from 'fs'; +import path from 'path'; import { isFolderExists } from '../util/fs'; import { readSettings } from '../cfg/setting'; import { logger } from '../util/logger'; +import { isProcessAlive, readPidFile } from '../util/daemon'; +import { acquireEnvLock } from '../fiber/env-lock'; +import { assertFiberFullyStopped } from '../fiber/clean'; +import { fiberNodePaths, fiberRootPath } from '../fiber/paths'; +import { removeRuntimeFileIfStale } from '../fiber/runtime'; export interface CleanOptions { data?: boolean; } +function assertCkbDaemonStopped() { + const pidFile = path.join(readSettings().devnet.dataPath, 'logs', 'daemon.pid'); + 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[] { + const nodesDir = path.join(fiberRootPath(settings), 'nodes'); + if (!isFolderExists(nodesDir)) return []; + return fs + .readdirSync(nodesDir) + .filter((entry) => /^\d+$/.test(entry)) + .map((entry) => fiberNodePaths(Number(entry), 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..6375715c --- /dev/null +++ b/src/cmd/fiber.ts @@ -0,0 +1,199 @@ +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 { 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 * 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'); +} + +function logMissingContractsGuidance(error: FiberContractsMissingError) { + logger.error(error.message); + logger.error( + 'The current devnet was initialized without the Fiber contracts. To rebuild it: stop CKB and all FNNs, ' + + 'then run `offckb clean` and start again. WARNING: `offckb clean` deletes the local chain data, ' + + 'all Fiber channels and all node data.', + ); +} + +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 #${node.id + 2}, 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; + 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..ebcc7b0f 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,27 @@ 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, + 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 { readLiveRuntime, readRuntime } from '../fiber/runtime'; +import { fiberDaemonPaths } from '../fiber/paths'; export interface NodeProp { version?: string; @@ -24,13 +45,10 @@ export interface NodeProp { binaryPath?: string; daemon?: boolean; verbose?: boolean; -} - -interface PidMetadata { - pid: number; - scriptPath: string; - startedAt: string; - status?: 'starting' | 'running'; + fiber?: boolean; + fnnVersion?: string; + fiberNodes?: number; + fnnBinaryPath?: string; } const DAEMON_LOG_DIR = 'logs'; @@ -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,106 @@ 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. + // Treat CKB, miner, proxy and the FNNs as one service. A dead component + // must not leave the rest looking healthy. let serviceStopping = false; - const stopService = (component: 'CKB node' | 'CKB miner', code: number | null, signal: NodeJS.Signals | null) => { + const stopService = (component: string, 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; + void (async () => { + logSubscription?.close(); + if (component !== 'CKB node' && !ckbProcess.killed) ckbProcess.kill('SIGTERM'); + if (component !== '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(); + logger.error(`${component} exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'none'}).`); + process.exitCode = typeof code === 'number' && code > 0 ? code : 1; + })(); }; 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(ckbProcess, minerProcess, proxy, fiberEnv, settings); + } +} + +// 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. +function installFiberSignalHandlers( + ckbProcess: ChildProcess, + minerProcess: ChildProcess, + proxy: { stop: () => void }, + fiberEnv: FiberEnvironment, + settings: Settings, +) { + let handling = false; + const handler = (signal: 'SIGINT' | 'SIGTERM') => { + if (handling) return; + handling = true; + void (async () => { + logger.info(`Received ${signal}, stopping the devnet and fiber nodes...`); + if (!ckbProcess.killed) ckbProcess.kill('SIGTERM'); + if (!minerProcess.killed) minerProcess.kill('SIGTERM'); + proxy.stop(); + await stopFiberNodes(fiberEnv.nodes, settings); + if (process.env[DAEMON_CHILD_ENV] === '1') cleanupPidFile(resolveDaemonPaths().pidFile); + 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 @@ -384,220 +536,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 +566,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 +683,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,15 +709,20 @@ 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 timeoutMs = 5 * 60_000; // the child may still be downloading FNN + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + 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() { @@ -787,6 +735,24 @@ 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. + const settings = readSettings(); + const fiberDaemon = readPidFile(fiberDaemonPaths(settings).pidFile); + if (fiberDaemon && Number.isInteger(fiberDaemon.pid) && fiberDaemon.pid > 0 && isProcessAlive(fiberDaemon.pid)) { + throw new Error( + `Fiber nodes are managed by a separate fiber daemon (PID ${fiberDaemon.pid}). ` + + 'Stop them first with: offckb fiber stop', + ); + } + const fiberRuntime = readLiveRuntime(settings); + if (fiberRuntime && fiberRuntime.managerPid !== metadata.pid) { + logger.warn( + `FNN nodes appear to be managed by a foreground OffCKB process (PID ${fiberRuntime.managerPid}); ` + + 'stop them in that terminal. Continuing to stop the CKB daemon...', + ); + } + const pid = metadata.pid; if (!Number.isInteger(pid) || pid <= 0) { cleanupPidFile(pidFile); 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..067f51cd --- /dev/null +++ b/src/fiber/clean.ts @@ -0,0 +1,119 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { confirm } from '@inquirer/prompts'; +import { acquireEnvLock } from './env-lock'; +import { fiberDaemonPaths, 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[] { + const nodesDir = path.join(fiberRootPath(settings), 'nodes'); + if (!isFolderExists(nodesDir)) return []; + return fs + .readdirSync(nodesDir) + .filter((entry) => /^\d+$/.test(entry)) + .map((entry) => fiberNodePaths(Number(entry), 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'); + 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 nodesDir = path.join(root, 'nodes'); + const stores = isFolderExists(nodesDir) + ? fs + .readdirSync(nodesDir) + .filter((entry) => /^\d+$/.test(entry)) + .map((entry) => fiberNodePaths(Number(entry), 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); + + 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); + + 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..ced6c61b --- /dev/null +++ b/src/fiber/daemon.ts @@ -0,0 +1,311 @@ +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + cleanupPidFile, + closeFileDescriptors, + isProcessAlive, + 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'; +const FIBER_DAEMON_READY_TIMEOUT_MS = 10 * 60_000; // first run may download FNN +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`, + ); + } + logger.warn( + `PID ${existing.pid} from ${pidFile} belongs to another process; removing stale daemon metadata without signaling it.`, + ); + } + 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; +}) { + const { pid, pidFile, label, settings } = options; + 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); + + const lockFiles = storeLockFilesForRuntime(settings); + 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.', + ); + } + + if (pidFile) cleanupPidFile(pidFile); + removeRuntimeFile(settings); +} + +/** + * 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') { + throw new Error( + `The fiber daemon startup is still in progress (PID ${fiberDaemon.pid}). Try stopping it again shortly.`, + ); + } + 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}.`, + ); + } + await stopManagerAndCleanup({ pid: fiberDaemon.pid, pidFile, label: 'fiber daemon', settings }); + logger.success('Fiber daemon stopped.'); + logger.result({ command: 'fiber.stop', stopped: true, 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 nodeDaemonPaths = resolveNodeDaemonPaths(settings); + const nodeDaemon = readPidFile(nodeDaemonPaths.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).', + ); + await stopManagerAndCleanup({ + pid: nodeDaemon.pid, + pidFile: nodeDaemonPaths.pidFile, + label: 'node --fiber daemon', + settings, + }); + logger.success('The node --fiber environment (CKB and FNNs) stopped.'); + logger.result({ command: 'fiber.stop', stopped: true, pid: nodeDaemon.pid, includedCkb: true }); + 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 }); +} + +function resolveNodeDaemonPaths(settings: Settings) { + const logDir = path.join(settings.devnet.dataPath, 'logs'); + return { logDir, pidFile: path.join(logDir, 'daemon.pid') }; +} 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..a40fc188 --- /dev/null +++ b/src/fiber/install.ts @@ -0,0 +1,197 @@ +import * as fs from 'fs'; +import * as path from 'path'; +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]; + +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'); +} + +function buildFnnPackageName(version: string): string { + const platform = os.platform(); + const arch = os.arch(); + if (platform === 'linux') { + return arch === 'arm64' ? `fnn_v${version}-aarch64-linux-portable` : `fnn_v${version}-x86_64-linux-portable`; + } + if (platform === 'darwin') { + return arch === 'arm64' ? `fnn_v${version}-aarch64-darwin-portable` : `fnn_v${version}-x86_64-darwin-portable`; + } + if (platform === 'win32') { + // Fiber only publishes x86_64 Windows packages. + 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; + } +} + +export async function downloadFnnAndUnzip(version: string, settings: Settings = readSettings()) { + const packageName = buildFnnPackageName(version); + const downloadURL = buildFnnDownloadUrl(version); + const tempFilePath = path.join(os.tmpdir(), `${packageName}.tar.gz`); + + logger.info(`downloading ${downloadURL} ..`); + const response = await Request.send(downloadURL); + const arrayBuffer = await response.arrayBuffer(); + fs.writeFileSync(tempFilePath, Buffer.from(arrayBuffer)); + + const extractDir = path.join(settings.bins.downloadPath, `fnn_v${version}`); + fs.rmSync(extractDir, { recursive: true, force: true }); + 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 }); + } + fs.rmSync(extractDir, { recursive: true, force: true }); + if (process.platform !== 'win32') { + fs.chmodSync(getFnnBinaryPath(version, settings), '755'); + } + 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..ebf498f2 --- /dev/null +++ b/src/fiber/manager.ts @@ -0,0 +1,397 @@ +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, runtimeJsonPath } 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 { writeRuntime, readLiveRuntime, removeRuntimeFile, removeRuntimeFileIfStale, FiberRuntime } from './runtime'; +import { closeFileDescriptors } from '../util/daemon'; + +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; + const lockMatches = + actualLock && + actualLock.code_hash.toLowerCase() === expectedLock.codeHash.toLowerCase() && + actualLock.hash_type.toLowerCase() === expectedLock.hashType.toLowerCase() && + actualLock.args.toLowerCase() === expectedLock.args.toLowerCase(); + if (!lockMatches) { + 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. + */ +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); + + const handles = nodes.map((node) => spawnFnn(node, options.fnnPath, settings)); + 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; + } +} + +/** + * 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()): Promise { + for (const node of nodes) { + if (node.process.exitCode == null && !node.process.killed) { + try { + node.process.kill('SIGTERM'); + } catch { + // already gone + } + } + } + const deadline = Date.now() + STOP_GRACE_TIMEOUT_MS; + 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) { + try { + node.process.kill('SIGKILL'); + } catch { + // already gone + } + } + } + removeRuntimeFileIfManager(settings); +} + +function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode != null) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(), timeoutMs); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +function removeRuntimeFileIfManager(settings: Settings) { + try { + const raw = fs.readFileSync(runtimeJsonPath(settings), 'utf8'); + const parsed = JSON.parse(raw) as { managerPid?: number }; + if (parsed.managerPid === process.pid) { + removeRuntimeFile(settings); + } + } catch { + // no runtime file or unreadable — nothing to do + } +} diff --git a/src/fiber/nodes-yml.ts b/src/fiber/nodes-yml.ts new file mode 100644 index 00000000..535c2469 --- /dev/null +++ b/src/fiber/nodes-yml.ts @@ -0,0 +1,147 @@ +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. +export const MANAGED_CONFIG_PATHS = [ + 'fiber.chain', + 'fiber.scripts', + 'fiber.listening_addr', + 'fiber.bootnode_addrs', + 'rpc.listening_addr', + 'ckb.rpc_url', + '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 > 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 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..3102126f --- /dev/null +++ b/src/fiber/paths.ts @@ -0,0 +1,125 @@ +import * as path from 'path'; +import { packageRootPath, readSettings, Settings } from '../cfg/setting'; + +// 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)); +} + +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..4c7df963 --- /dev/null +++ b/src/fiber/runtime.ts @@ -0,0 +1,93 @@ +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 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). + */ +export function isRuntimeStale(runtime: FiberRuntime): boolean { + try { + return !isProcessAlive(runtime.managerPid); + } catch { + return true; + } +} + +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..c7a7502a --- /dev/null +++ b/src/fiber/scripts.ts @@ -0,0 +1,149 @@ +import { resolveDevnetSystemScripts } from '../scripts/private'; +import { SystemScript } 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: Record, name: string): SystemScript { + const script = scripts[name]; + if (script == null) { + throw new Error(`missing:${name}`); + } + 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.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 as unknown as Record; + + const missing = ['auth', 'funding_lock', 'commitment_lock'].filter((name) => scripts[name] == null); + if (missing.length > 0) { + throw new FiberContractsMissingError(missing); + } + + const auth = requireScript(scripts, 'auth'); + const fundingLock = requireScript(scripts, 'funding_lock'); + const commitmentLock = requireScript(scripts, 'commitment_lock'); + const sudt = requireScript(scripts, 'sudt'); + const xudt = requireScript(scripts, '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..20f8c7d7 --- /dev/null +++ b/src/fiber/status.ts @@ -0,0 +1,235 @@ +import { checkNodeReadiness } from '../devnet/readiness'; +import { getProcessCommandLine, isProcessAlive, readPidFile } 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'; +import * as path from 'path'; + +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'; + + const cmdline = await getProcessCommandLine(runtime.managerPid); + if (cmdline == null) return 'unknown'; + if (!cmdline.includes('offckb')) return 'no'; + + // A daemon PID file that claims fiber management must agree with the + // runtime record: the fiber daemon PID file always claims it, 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 'no'; + if (fiberPid == null) { + const nodePid = readPidFile(path.join(settings.devnet.dataPath, 'logs', 'daemon.pid')); + if (nodePid != null && nodePid.pid === runtime.managerPid) return 'yes'; + } + return 'yes'; +} + +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/PID/process inspection (the + * OFFCKB column is the single exception, and it never changes the status). + */ +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 === 'yes' && runtime.status === 'starting'; + + for (const entry of 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); + + let info: FnnNodeInfo | null = null; + try { + info = await fnnNodeInfo(statusEntry.rpcUrl, 2000); + } catch { + info = null; + } + + 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..95f90451 --- /dev/null +++ b/src/fiber/store-lock.ts @@ -0,0 +1,62 @@ +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. + */ +export function isStoreLockHeld(lockFile: string): 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('lsof', ['--', 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 }; + if (err.code === 'ENOENT' || err.code === 'ETIMEDOUT') return null; + // Exit 1: no holder — matches are printed on stdout; stderr may carry + // unrelated warnings (e.g. an un-stat-able fuse mount), so only stdout + // decides. An inspection error leaves stdout empty but non-string. + 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..e37b575e --- /dev/null +++ b/src/util/daemon.ts @@ -0,0 +1,239 @@ +import { execFile, spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { logger } from './logger'; + +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 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()); + } + }); + }); +} + +export 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); +} + +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/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-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-nodes-yml.test.ts b/tests/fiber-nodes-yml.test.ts new file mode 100644 index 00000000..a8ebf6d4 --- /dev/null +++ b/tests/fiber-nodes-yml.test.ts @@ -0,0 +1,109 @@ +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 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 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..df8178f9 --- /dev/null +++ b/tests/fiber-scripts.test.ts @@ -0,0 +1,93 @@ +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']); + } + }); + + it('throws when list-hashes is unavailable', () => { + mockResolve.mockReturnValue(null); + expect(() => resolveFiberChainScripts()).toThrow('list-hashes'); + }); +}); diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index c3e189ba..d5185927 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -402,6 +402,18 @@ describe('node command stop', () => { const scriptPath = '/path/to/offckb'; const originalPlatform = process.platform; + // 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,7 +424,7 @@ 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); // Normalize to POSIX for deterministic signal-based assertions. The @@ -456,7 +468,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 +481,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' }), ); From 5f74cf5bdd1943f1187cf8d63754463564a50879 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Fri, 31 Jul 2026 05:55:05 +0000 Subject: [PATCH 2/6] 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 --- ckb/devnet/specs/fiber/commitment_lock | Bin 111880 -> 111880 bytes ckb/devnet/specs/fiber/funding_lock | Bin 74800 -> 74800 bytes src/cmd/clean.ts | 15 +++---- src/cmd/fiber.ts | 6 ++- src/cmd/node.ts | 14 +++---- src/fiber/clean.ts | 22 +++------- src/fiber/daemon.ts | 16 +++---- src/fiber/install.ts | 48 +++++++++++---------- src/fiber/manager.ts | 29 ++++++++----- src/fiber/nodes-yml.ts | 4 +- src/fiber/paths.ts | 13 ++++++ src/fiber/scripts.ts | 23 ++++++----- src/fiber/status.ts | 10 +++-- src/fiber/store-lock.ts | 16 +++++-- src/util/daemon.ts | 55 ++++++++++++++++++------- 15 files changed, 160 insertions(+), 111 deletions(-) diff --git a/ckb/devnet/specs/fiber/commitment_lock b/ckb/devnet/specs/fiber/commitment_lock index d7cffeaaab0b93bbfdde7a5a83392879ec16f62a..698ef3bcff7031b203fea9d3fddf06e78ebbf3a3 100755 GIT binary patch delta 559 zcmeBp#n$nPZ38^+7YUpE+ktnpIL|#ss1VTP$p(Da;XJF&Zv2u=aP>Z$Hwboc!DY=R z7t7QjNjk{ZBGkrBR!~6L1hQKPuF+~Un<6j5POHgo{QR2}mD-SmHmj+aBe-UhZ|R6_ zUZ?tz4Q>WV=qrM^nM-dm!g5fEaZYA8+y-|Y&^bm3=YTyvdAX4S!hp&8ZUUQyjM^+7YUpE+ktnpIL|#ss1VTP$p(Da;XJF&Zv2u=aP>Z$Hwboc!DY=R z7t7QjNjk{ZBGkrBR!~6L1hQKPuF+~Un<6j5POHgo{QR2}mD-SmHmj+aBe-UhZ|R6_ zUZ?tz4Q>WV=qrM^nM-dm!g5fEaZYA8+y-|Y&^bm3=YTyvdAX4S!hp&8ZUUQyjM*Mis8ke6qei|K>nhRhR;x!zZuhke>WO4%rEw@}6)lW}DB-pW=jaC$dIw z{;v4|Y7;O#VkTSY8p72=jNhVb4>Mj-zzijT%VldFu2AiT{VjqN`G05dgw A!~g&Q delta 354 zcmdmRf@K2`^{}!QmL_JWPM*lx3TH)cE@QjF2;~+^M{QoqQ2^sA3Yblf;4(v!e9LtT zCOMHcX0imY8r-1N$t!r}pgfR9M!uy8-sXpV)=-U`6$NbB;W9px*Nfjr@HXF<=w*U% zHy29tBir>*Mis8ke6qei|K>nhRhR;x!zZuhke>WO4%rEw@}6)lW}DB-pW=jaC$dIw z{;v4|Y7;O#VkTSY8p72=jNhVb4>Mj-zzijT%VldFu2AiT{VjqN`G05wN` AEC2ui diff --git a/src/cmd/clean.ts b/src/cmd/clean.ts index 914af057..9a2c8186 100644 --- a/src/cmd/clean.ts +++ b/src/cmd/clean.ts @@ -1,12 +1,11 @@ import fs from 'fs'; -import path from 'path'; import { isFolderExists } from '../util/fs'; import { readSettings } from '../cfg/setting'; import { logger } from '../util/logger'; -import { isProcessAlive, readPidFile } from '../util/daemon'; +import { isProcessAlive, nodeDaemonPaths, readPidFile } from '../util/daemon'; import { acquireEnvLock } from '../fiber/env-lock'; import { assertFiberFullyStopped } from '../fiber/clean'; -import { fiberNodePaths, fiberRootPath } from '../fiber/paths'; +import { fiberNodeIds, fiberNodePaths } from '../fiber/paths'; import { removeRuntimeFileIfStale } from '../fiber/runtime'; export interface CleanOptions { @@ -14,7 +13,7 @@ export interface CleanOptions { } function assertCkbDaemonStopped() { - const pidFile = path.join(readSettings().devnet.dataPath, 'logs', 'daemon.pid'); + const pidFile = nodeDaemonPaths(readSettings()).pidFile; const metadata = readPidFile(pidFile); if (metadata && Number.isInteger(metadata.pid) && metadata.pid > 0 && isProcessAlive(metadata.pid)) { throw new Error( @@ -24,12 +23,8 @@ function assertCkbDaemonStopped() { } function fiberStoreDirs(settings: ReturnType): string[] { - const nodesDir = path.join(fiberRootPath(settings), 'nodes'); - if (!isFolderExists(nodesDir)) return []; - return fs - .readdirSync(nodesDir) - .filter((entry) => /^\d+$/.test(entry)) - .map((entry) => fiberNodePaths(Number(entry), settings).fiberStoreDir) + return fiberNodeIds(settings) + .map((id) => fiberNodePaths(id, settings).fiberStoreDir) .filter((storeDir) => isFolderExists(storeDir)); } diff --git a/src/cmd/fiber.ts b/src/cmd/fiber.ts index 6375715c..4daf445d 100644 --- a/src/cmd/fiber.ts +++ b/src/cmd/fiber.ts @@ -8,7 +8,7 @@ import { startFiberEnvironment, stopFiberNodes, FiberEnvironment } from '../fibe import { startFiberDaemon, stopFiber } from '../fiber/daemon'; import { fiberStatus } from '../fiber/status'; import { fiberClean, FiberCleanOptions } from '../fiber/clean'; -import { fiberNodePaths, FIBER_DAEMON_PID_FILE, fiberDaemonPaths } from '../fiber/paths'; +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'; @@ -38,7 +38,9 @@ export function printFiberSummary(env: FiberEnvironment) { 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 #${node.id + 2}, log: ${node.logFile}`); + logger.info( + ` node ${node.id}: FNN ${version}, RPC ${node.rpcUrl}, account #${fiberAccountIndex(node.id)}, log: ${node.logFile}`, + ); } } diff --git a/src/cmd/node.ts b/src/cmd/node.ts index ebcc7b0f..88e94c03 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -21,6 +21,7 @@ import { cleanupPidFile, closeFileDescriptors, isProcessAlive, + nodeDaemonPaths, PidMetadata, readPidFile, reservePidFile, @@ -51,9 +52,6 @@ export interface NodeProp { fnnBinaryPath?: string; } -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; @@ -433,11 +431,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 @@ -710,7 +704,9 @@ async function startDaemon(waitForFiber = false) { } async function waitForFiberRuntimeRunning(managerPid: number, settings: Settings, logFile: string) { - const timeoutMs = 5 * 60_000; // the child may still be downloading FNN + // Matches FIBER_DAEMON_READY_TIMEOUT_MS in fiber/daemon.ts: the child's + // first run may still be downloading FNN. + const timeoutMs = 10 * 60_000; const start = Date.now(); while (Date.now() - start < timeoutMs) { if (!isProcessAlive(managerPid)) { diff --git a/src/fiber/clean.ts b/src/fiber/clean.ts index 067f51cd..069574be 100644 --- a/src/fiber/clean.ts +++ b/src/fiber/clean.ts @@ -1,8 +1,7 @@ import * as fs from 'fs'; -import * as path from 'path'; import { confirm } from '@inquirer/prompts'; import { acquireEnvLock } from './env-lock'; -import { fiberDaemonPaths, fiberNodePaths, fiberRootPath } from './paths'; +import { fiberDaemonPaths, fiberNodeIds, fiberNodePaths, fiberRootPath } from './paths'; import { readLiveRuntime, removeRuntimeFileIfStale } from './runtime'; import { readPidFile, isProcessAlive } from '../util/daemon'; import { isStoreLockHeld } from './store-lock'; @@ -16,12 +15,8 @@ export interface FiberCleanOptions { } function existingStoreLockFiles(settings: Settings): string[] { - const nodesDir = path.join(fiberRootPath(settings), 'nodes'); - if (!isFolderExists(nodesDir)) return []; - return fs - .readdirSync(nodesDir) - .filter((entry) => /^\d+$/.test(entry)) - .map((entry) => fiberNodePaths(Number(entry), settings).storeLockFile) + return fiberNodeIds(settings) + .map((id) => fiberNodePaths(id, settings).storeLockFile) .filter((lockFile) => fs.existsSync(lockFile)); } @@ -75,14 +70,9 @@ export async function fiberClean(options: FiberCleanOptions, settings: Settings assertFiberFullyStopped(settings); if (options.data) { - const nodesDir = path.join(root, 'nodes'); - const stores = isFolderExists(nodesDir) - ? fs - .readdirSync(nodesDir) - .filter((entry) => /^\d+$/.test(entry)) - .map((entry) => fiberNodePaths(Number(entry), settings).fiberStoreDir) - .filter((storeDir) => isFolderExists(storeDir)) - : []; + 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.', diff --git a/src/fiber/daemon.ts b/src/fiber/daemon.ts index ced6c61b..8deccd52 100644 --- a/src/fiber/daemon.ts +++ b/src/fiber/daemon.ts @@ -1,10 +1,10 @@ import { spawn } from 'child_process'; import * as fs from 'fs'; -import * as path from 'path'; import { cleanupPidFile, closeFileDescriptors, isProcessAlive, + nodeDaemonPaths, readPidFile, reservePidFile, resolveCliEntry, @@ -187,11 +187,13 @@ async function stopManagerAndCleanup(options: { settings: Settings; }) { 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); - const lockFiles = storeLockFilesForRuntime(settings); 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...`); @@ -271,8 +273,7 @@ export async function stopFiber(settings: Settings = readSettings()) { return; } - const nodeDaemonPaths = resolveNodeDaemonPaths(settings); - const nodeDaemon = readPidFile(nodeDaemonPaths.pidFile); + const nodeDaemon = readPidFile(nodeDaemonPaths(settings).pidFile); if ( nodeDaemon && Number.isInteger(nodeDaemon.pid) && @@ -289,7 +290,7 @@ export async function stopFiber(settings: Settings = readSettings()) { ); await stopManagerAndCleanup({ pid: nodeDaemon.pid, - pidFile: nodeDaemonPaths.pidFile, + pidFile: nodeDaemonPaths(settings).pidFile, label: 'node --fiber daemon', settings, }); @@ -304,8 +305,3 @@ export async function stopFiber(settings: Settings = readSettings()) { ); logger.result({ command: 'fiber.stop', stopped: false, reason: 'foreground-manager', pid: runtime.managerPid }); } - -function resolveNodeDaemonPaths(settings: Settings) { - const logDir = path.join(settings.devnet.dataPath, 'logs'); - return { logDir, pidFile: path.join(logDir, 'daemon.pid') }; -} diff --git a/src/fiber/install.ts b/src/fiber/install.ts index a40fc188..1c9d314f 100644 --- a/src/fiber/install.ts +++ b/src/fiber/install.ts @@ -91,27 +91,33 @@ export async function downloadFnnAndUnzip(version: string, settings: Settings = const arrayBuffer = await response.arrayBuffer(); fs.writeFileSync(tempFilePath, Buffer.from(arrayBuffer)); - const extractDir = path.join(settings.bins.downloadPath, `fnn_v${version}`); - fs.rmSync(extractDir, { recursive: true, force: true }); - 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 }); - } - fs.rmSync(extractDir, { recursive: true, force: true }); - if (process.platform !== 'win32') { - fs.chmodSync(getFnnBinaryPath(version, settings), '755'); + try { + const extractDir = path.join(settings.bins.downloadPath, `fnn_v${version}`); + fs.rmSync(extractDir, { recursive: true, force: true }); + 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 }); + } + fs.rmSync(extractDir, { recursive: true, force: true }); + if (process.platform !== 'win32') { + fs.chmodSync(getFnnBinaryPath(version, settings), '755'); + } + } finally { + // The tarball is only an intermediate; never leave it in the temp dir, + // whether the install succeeded or failed. + fs.rmSync(tempFilePath, { force: true }); } logger.info(`FNN ${version} installed successfully.`); } diff --git a/src/fiber/manager.ts b/src/fiber/manager.ts index ebf498f2..537d1942 100644 --- a/src/fiber/manager.ts +++ b/src/fiber/manager.ts @@ -16,6 +16,7 @@ import { import { generateNodeConfig } from './config-gen'; import { FiberChainScripts } from './scripts'; import { fnnNodeInfo, fnnConnectPeer, fnnListPeers, checkPortFree, FnnNodeInfo } from './rpc'; +import { lockMatches } from './status'; import { writeRuntime, readLiveRuntime, removeRuntimeFile, removeRuntimeFileIfStale, FiberRuntime } from './runtime'; import { closeFileDescriptors } from '../util/daemon'; @@ -210,12 +211,7 @@ async function assertNodeIdentitiesAndFunds( const account = fiberNodeAccount(node.id); const expectedLock = account.lockScript; const actualLock = info.default_funding_lock_script; - const lockMatches = - actualLock && - actualLock.code_hash.toLowerCase() === expectedLock.codeHash.toLowerCase() && - actualLock.hash_type.toLowerCase() === expectedLock.hashType.toLowerCase() && - actualLock.args.toLowerCase() === expectedLock.args.toLowerCase(); - if (!lockMatches) { + 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)}.`, @@ -310,7 +306,18 @@ export async function startFiberEnvironment(options: StartFiberEnvironmentOption } await assertFiberPortsFree(nodes); - const handles = nodes.map((node) => spawnFnn(node, options.fnnPath, settings)); + // 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[] = []; + 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(), @@ -347,7 +354,7 @@ export async function startFiberEnvironment(options: StartFiberEnvironmentOption */ export async function stopFiberNodes(nodes: FnnProcessHandle[], settings: Settings = readSettings()): Promise { for (const node of nodes) { - if (node.process.exitCode == null && !node.process.killed) { + if (node.process.exitCode == null && node.process.signalCode == null && !node.process.killed) { try { node.process.kill('SIGTERM'); } catch { @@ -362,7 +369,7 @@ export async function stopFiberNodes(nodes: FnnProcessHandle[], settings: Settin await waitForChildExit(node.process, remaining); } for (const node of nodes) { - if (node.process.exitCode == null) { + if (node.process.exitCode == null && node.process.signalCode == null) { try { node.process.kill('SIGKILL'); } catch { @@ -374,7 +381,9 @@ export async function stopFiberNodes(nodes: FnnProcessHandle[], settings: Settin } function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise { - if (child.exitCode != null) return Promise.resolve(); + // 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', () => { diff --git a/src/fiber/nodes-yml.ts b/src/fiber/nodes-yml.ts index 535c2469..9cc7f8ab 100644 --- a/src/fiber/nodes-yml.ts +++ b/src/fiber/nodes-yml.ts @@ -21,6 +21,7 @@ export const MANAGED_CONFIG_PATHS = [ 'fiber.bootnode_addrs', 'rpc.listening_addr', 'ckb.rpc_url', + 'ckb.udt_whitelist', 'services', ]; @@ -138,7 +139,8 @@ export function ensureNodesYml( const removed = existing.filter((entry) => entry.id > count); for (const entry of removed) { logger.warn( - `Node ${entry.id} is removed from nodes.yml. Its directory ${fiberNodeDir(entry.id, settings)} is kept; ` + + `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.', ); } diff --git a/src/fiber/paths.ts b/src/fiber/paths.ts index 3102126f..4a93da5d 100644 --- a/src/fiber/paths.ts +++ b/src/fiber/paths.ts @@ -1,5 +1,7 @@ +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): // @@ -81,6 +83,17 @@ export function fiberNodeDir(id: number, settings: Settings = readSettings()): s 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 { diff --git a/src/fiber/scripts.ts b/src/fiber/scripts.ts index c7a7502a..b09829b8 100644 --- a/src/fiber/scripts.ts +++ b/src/fiber/scripts.ts @@ -1,5 +1,5 @@ import { resolveDevnetSystemScripts } from '../scripts/private'; -import { SystemScript } from '../scripts/type'; +import { SystemScript, SystemScriptName, SystemScriptsRecord } from '../scripts/type'; import { udtIssuerLockHash } from './accounts'; export interface FnnCellDep { @@ -57,10 +57,12 @@ function codeCellDep(script: SystemScript): FnnCellDep { }; } -function requireScript(scripts: Record, name: string): SystemScript { +function requireScript(scripts: SystemScriptsRecord, name: SystemScriptName): SystemScript { const script = scripts[name]; if (script == null) { - throw new Error(`missing:${name}`); + throw new Error( + `The devnet chain spec does not include the system script "${name}". Run \`offckb clean\` to rebuild the devnet.`, + ); } return script; } @@ -88,18 +90,19 @@ export function resolveFiberChainScripts(): FiberChainScripts { 'Failed to read the devnet chain spec hashes (ckb list-hashes). Is the CKB binary installed and the devnet initialized?', ); } - const scripts = resolved.scripts as unknown as Record; + const scripts = resolved.scripts; - const missing = ['auth', 'funding_lock', 'commitment_lock'].filter((name) => scripts[name] == null); + 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, 'auth'); - const fundingLock = requireScript(scripts, 'funding_lock'); - const commitmentLock = requireScript(scripts, 'commitment_lock'); - const sudt = requireScript(scripts, 'sudt'); - const xudt = requireScript(scripts, 'xudt'); + 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[] = [ diff --git a/src/fiber/status.ts b/src/fiber/status.ts index 20f8c7d7..e9eba12d 100644 --- a/src/fiber/status.ts +++ b/src/fiber/status.ts @@ -1,5 +1,5 @@ import { checkNodeReadiness } from '../devnet/readiness'; -import { getProcessCommandLine, isProcessAlive, readPidFile } from '../util/daemon'; +import { getProcessCommandLine, isProcessAlive, nodeDaemonPaths, readPidFile } from '../util/daemon'; import { readSettings, Settings } from '../cfg/setting'; import { fiberAccountIndex, fiberDaemonPaths, fiberP2pAddr, fiberRpcUrl } from './paths'; import { readNodesYml } from './nodes-yml'; @@ -7,7 +7,6 @@ import { fiberNodeAccount, fiberPublicKeyFromSecret, readFiberNodeSecretKey } fr import { readRuntime, FiberRuntime } from './runtime'; import { fnnNodeInfo, FnnNodeInfo } from './rpc'; import { logger } from '../util/logger'; -import * as path from 'path'; export type FiberNodeStatus = 'starting' | 'running' | 'stopped' | 'unknown' | 'conflict'; export type OffckbManaged = 'yes' | 'no' | 'unknown'; @@ -57,13 +56,16 @@ async function resolveOffckbManaged(runtime: FiberRuntime | null, settings: Sett const fiberPid = readPidFile(fiberDaemonPaths(settings).pidFile); if (fiberPid != null && fiberPid.pid !== runtime.managerPid) return 'no'; if (fiberPid == null) { - const nodePid = readPidFile(path.join(settings.devnet.dataPath, 'logs', 'daemon.pid')); + const nodePid = readPidFile(nodeDaemonPaths(settings).pidFile); if (nodePid != null && nodePid.pid === runtime.managerPid) return 'yes'; } return 'yes'; } -function lockMatches( +// 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 { diff --git a/src/fiber/store-lock.ts b/src/fiber/store-lock.ts index 95f90451..ed89f696 100644 --- a/src/fiber/store-lock.ts +++ b/src/fiber/store-lock.ts @@ -35,11 +35,19 @@ export function isStoreLockHeld(lockFile: string): boolean | null { // 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 }; + 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; - // Exit 1: no holder — matches are printed on stdout; stderr may carry - // unrelated warnings (e.g. an un-stat-able fuse mount), so only stdout - // decides. An inspection error leaves stdout empty but non-string. + // 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; } diff --git a/src/util/daemon.ts b/src/util/daemon.ts index e37b575e..f79d1449 100644 --- a/src/util/daemon.ts +++ b/src/util/daemon.ts @@ -2,6 +2,21 @@ import { execFile, 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; @@ -157,24 +172,36 @@ export function waitForProcessExit(pid: number, timeoutMs: 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 (!Number.isInteger(pid) || pid <= 0) { + resolve(null); + return; + } + // Argument arrays, never an interpolated shell string: pid is validated as + // a positive integer above, and execFile keeps that true after any future + // refactor. + if (process.platform === 'win32') { + // wmic is deprecated and absent from recent Windows builds; the + // PowerShell CIM cmdlets ship with every supported Windows version. + execFile( + 'powershell', + ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], + (error, stdout) => { + if (error) { + resolve(null); + return; + } + const cmdline = stdout.trim(); + resolve(cmdline.length > 0 ? cmdline : null); + }, + ); + return; + } + execFile('ps', ['-p', String(pid), '-o', '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()); - } + resolve(stdout.trim()); }); }); } From e91fbad34dc38b3c91a8bcee61d3afab02ecb736 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Thu, 13 Aug 2026 21:41:35 +0800 Subject: [PATCH 3/6] fix(fiber): fail-closed daemon identity, startup signal window, lifecycle tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- src/fiber/manager.ts | 100 ++++++--- src/fiber/nodes-yml.ts | 6 +- src/fiber/store-lock.ts | 6 +- src/util/daemon.ts | 303 +++++++++++++++++++++---- tests/fiber-lifecycle.test.ts | 412 ++++++++++++++++++++++++++++++++++ tests/fiber-nodes-yml.test.ts | 12 + tests/node-command.test.ts | 41 +++- 8 files changed, 802 insertions(+), 80 deletions(-) create mode 100644 tests/fiber-lifecycle.test.ts diff --git a/README.md b/README.md index 1eef16eb..596f8bb0 100644 --- a/README.md +++ b/README.md @@ -477,7 +477,7 @@ 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). - 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`). 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). +- 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. - 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. diff --git a/src/fiber/manager.ts b/src/fiber/manager.ts index 537d1942..f2926b48 100644 --- a/src/fiber/manager.ts +++ b/src/fiber/manager.ts @@ -286,6 +286,13 @@ export interface StartFiberEnvironmentOptions { * 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(); @@ -310,40 +317,65 @@ export async function startFiberEnvironment(options: StartFiberEnvironmentOption // 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[] = []; - 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, - })), + let signalCleanupStarted = false; + const onStartupSignal = (signal: 'SIGINT' | 'SIGTERM') => { + 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); }; - 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, []); + try { + for (const node of nodes) { + handles.push(spawnFnn(node, options.fnnPath, settings)); + } + } catch (error) { + await stopFiberNodes(handles, settings); + throw error; } - 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(); } } @@ -352,7 +384,11 @@ export async function startFiberEnvironment(options: StartFiberEnvironmentOption * 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()): Promise { +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 { @@ -362,7 +398,7 @@ export async function stopFiberNodes(nodes: FnnProcessHandle[], settings: Settin } } } - const deadline = Date.now() + STOP_GRACE_TIMEOUT_MS; + const deadline = Date.now() + graceMs; for (const node of nodes) { const remaining = deadline - Date.now(); if (remaining <= 0) break; diff --git a/src/fiber/nodes-yml.ts b/src/fiber/nodes-yml.ts index 9cc7f8ab..87c2d4bd 100644 --- a/src/fiber/nodes-yml.ts +++ b/src/fiber/nodes-yml.ts @@ -13,12 +13,16 @@ export interface FiberNodeEntry { } // These fields are owned by offckb; setting them per node would break the -// environment in ways the startup checks cannot recover from. +// 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', diff --git a/src/fiber/store-lock.ts b/src/fiber/store-lock.ts index ed89f696..f90a6a43 100644 --- a/src/fiber/store-lock.ts +++ b/src/fiber/store-lock.ts @@ -9,8 +9,10 @@ import * as fs from 'fs'; * 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): boolean | null { +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. @@ -27,7 +29,7 @@ export function isStoreLockHeld(lockFile: string): boolean | null { } } try { - const stdout = execFileSync('lsof', ['--', lockFile], { + const stdout = execFileSync(lsofCommand, ['--', lockFile], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, encoding: 'utf8', diff --git a/src/util/daemon.ts b/src/util/daemon.ts index f79d1449..d4541777 100644 --- a/src/util/daemon.ts +++ b/src/util/daemon.ts @@ -1,4 +1,4 @@ -import { execFile, spawn } from 'child_process'; +import { execFile, execFileSync, spawn } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { logger } from './logger'; @@ -170,58 +170,283 @@ export function waitForProcessExit(pid: number, timeoutMs: number): Promise { +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) => { - if (!Number.isInteger(pid) || pid <= 0) { - resolve(null); - return; - } - // Argument arrays, never an interpolated shell string: pid is validated as - // a positive integer above, and execFile keeps that true after any future - // refactor. - if (process.platform === 'win32') { - // wmic is deprecated and absent from recent Windows builds; the - // PowerShell CIM cmdlets ship with every supported Windows version. - execFile( - 'powershell', - ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], - (error, stdout) => { - if (error) { - resolve(null); - return; - } - const cmdline = stdout.trim(); - resolve(cmdline.length > 0 ? cmdline : null); - }, - ); - return; - } - execFile('ps', ['-p', String(pid), '-o', 'args='], (error, stdout) => { + execFile(command, args, { timeout: 5000 }, (error, stdout) => { if (error) { resolve(null); return; } - resolve(stdout.trim()); + 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 cmdline = await getProcessCommandLine(pid); - if (!cmdline) { + const cliEntry = resolveCliEntry(); + if (!cliEntry) { + // Without our own entry point we cannot establish identity at all. return false; } + const expectedScript = normalizePathForCompare(cliEntry); - // 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'); + const info = await getProcessInfo(pid); + if (!info) return false; - return looksLikeNode && (looksLikeOurScript || looksLikeOffckb); + 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 { diff --git a/tests/fiber-lifecycle.test.ts b/tests/fiber-lifecycle.test.ts new file mode 100644 index 00000000..6d715641 --- /dev/null +++ b/tests/fiber-lifecycle.test.ts @@ -0,0 +1,412 @@ +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 { + 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, runtimeJsonPath } from '../src/fiber/paths'; +import { writeRuntime } from '../src/fiber/runtime'; +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('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'); + }); + }); +}); + +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, + }); + // 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 index a8ebf6d4..52cd6f06 100644 --- a/tests/fiber-nodes-yml.test.ts +++ b/tests/fiber-nodes-yml.test.ts @@ -94,6 +94,18 @@ describe('readNodesYml', () => { 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 }); diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index d5185927..67a4a9c1 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -81,10 +81,36 @@ 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; } @@ -197,8 +223,8 @@ describe('node command daemon mode', () => { 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'); + (_file: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: unknown) => { + execFileCallback(optionsOrCallback, maybeCallback)(null, '/usr/bin/some-unrelated-process'); return undefined as unknown as ReturnType; }, ); @@ -401,6 +427,7 @@ 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 @@ -426,6 +453,9 @@ describe('node command stop', () => { mockStatSync.mockReturnValue({ isFile: () => true }); 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 @@ -452,6 +482,7 @@ describe('node command stop', () => { afterEach(() => { killSpy.mockRestore(); setPlatform(originalPlatform); + process.argv = originalArgv; jest.useRealTimers(); }); @@ -520,8 +551,8 @@ 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'); + (_file: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: unknown) => { + execFileCallback(optionsOrCallback, maybeCallback)(null, '/usr/bin/some-other-process'); return undefined as unknown as ReturnType; }, ); From 79f14e4a1e3d348d16ec7c4e94b2d4f73b3b06c6 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Thu, 13 Aug 2026 22:46:33 +0800 Subject: [PATCH 4/6] fix(fiber): address remaining PR #483 review items beyond the merge gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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.. --- README.md | 6 +- jest.config.js | 17 ++++ src/cli.ts | 3 +- src/cmd/fiber.ts | 7 +- src/cmd/node.ts | 16 ++-- src/fiber/clean.ts | 9 +- src/fiber/daemon.ts | 28 +++++++ src/fiber/install.ts | 41 ++++++++++ src/fiber/runtime.ts | 15 +++- src/fiber/scripts.ts | 7 +- tests/fiber-ckb-env.test.ts | 43 ++++++++++ tests/fiber-install.test.ts | 109 +++++++++++++++++++++++++ tests/fiber-lifecycle.test.ts | 149 +++++++++++++++++++++++++++++++++- tests/fiber-scripts.test.ts | 3 + tests/fiber-status.test.ts | 29 +++++++ 15 files changed, 457 insertions(+), 25 deletions(-) create mode 100644 tests/fiber-ckb-env.test.ts create mode 100644 tests/fiber-install.test.ts create mode 100644 tests/fiber-status.test.ts diff --git a/README.md b/README.md index 596f8bb0..5a2f7b7b 100644 --- a/README.md +++ b/README.md @@ -151,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): @@ -475,10 +477,12 @@ 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`). Use `--binary-path ` (or `--fnn-binary-path ` with `node --fiber`) to run a locally built FNN. +- `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. ## Config Setting 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/src/cli.ts b/src/cli.ts index a4f7ac1e..2b0e50e8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -132,7 +132,8 @@ 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'); diff --git a/src/cmd/fiber.ts b/src/cmd/fiber.ts index 4daf445d..861f4f4b 100644 --- a/src/cmd/fiber.ts +++ b/src/cmd/fiber.ts @@ -24,13 +24,10 @@ 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); - logger.error( - 'The current devnet was initialized without the Fiber contracts. To rebuild it: stop CKB and all FNNs, ' + - 'then run `offckb clean` and start again. WARNING: `offckb clean` deletes the local chain data, ' + - 'all Fiber channels and all node data.', - ); } export function printFiberSummary(env: FiberEnvironment) { diff --git a/src/cmd/node.ts b/src/cmd/node.ts index 88e94c03..b4f3ad84 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -37,8 +37,9 @@ import { resolveFnnBinary, ResolvedFnn } from '../fiber/install'; import { resolveFiberChainScripts } from '../fiber/scripts'; import { FiberEnvironment, startFiberEnvironment, stopFiberNodes } from '../fiber/manager'; import { printFiberSummary } from './fiber'; -import { readLiveRuntime, readRuntime } from '../fiber/runtime'; +import { readRuntime } from '../fiber/runtime'; import { fiberDaemonPaths } from '../fiber/paths'; +import { assertNodeStopDoesNotOrphanFiber } from '../fiber/daemon'; export interface NodeProp { version?: string; @@ -721,7 +722,7 @@ async function waitForFiberRuntimeRunning(managerPid: number, settings: Settings 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); @@ -741,13 +742,6 @@ export async function stopNode() { 'Stop them first with: offckb fiber stop', ); } - const fiberRuntime = readLiveRuntime(settings); - if (fiberRuntime && fiberRuntime.managerPid !== metadata.pid) { - logger.warn( - `FNN nodes appear to be managed by a foreground OffCKB process (PID ${fiberRuntime.managerPid}); ` + - 'stop them in that terminal. Continuing to stop the CKB daemon...', - ); - } const pid = metadata.pid; if (!Number.isInteger(pid) || pid <= 0) { @@ -774,6 +768,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/clean.ts b/src/fiber/clean.ts index 069574be..f29f5a91 100644 --- a/src/fiber/clean.ts +++ b/src/fiber/clean.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import { confirm } from '@inquirer/prompts'; import { acquireEnvLock } from './env-lock'; -import { fiberDaemonPaths, fiberNodeIds, fiberNodePaths, fiberRootPath } from './paths'; +import { envLockPath, fiberDaemonPaths, fiberNodeIds, fiberNodePaths, fiberRootPath } from './paths'; import { readLiveRuntime, removeRuntimeFileIfStale } from './runtime'; import { readPidFile, isProcessAlive } from '../util/daemon'; import { isStoreLockHeld } from './store-lock'; @@ -58,7 +58,7 @@ async function confirmOrAbort(message: string, yes?: boolean) { } export async function fiberClean(options: FiberCleanOptions, settings: Settings = readSettings()) { - const lock = acquireEnvLock(options.data ? 'offckb fiber clean --data' : 'offckb fiber clean'); + const lock = acquireEnvLock(options.data ? 'offckb fiber clean --data' : 'offckb fiber clean', envLockPath(settings)); try { const root = fiberRootPath(settings); if (!isFolderExists(root)) { @@ -82,6 +82,9 @@ export async function fiberClean(options: FiberCleanOptions, settings: Settings } 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 }); @@ -100,6 +103,8 @@ export async function fiberClean(options: FiberCleanOptions, settings: Settings 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] }); diff --git a/src/fiber/daemon.ts b/src/fiber/daemon.ts index 8deccd52..ad47f046 100644 --- a/src/fiber/daemon.ts +++ b/src/fiber/daemon.ts @@ -217,6 +217,34 @@ async function stopManagerAndCleanup(options: { removeRuntimeFile(settings); } +/** + * 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 diff --git a/src/fiber/install.ts b/src/fiber/install.ts index 1c9d314f..7e8a5c0b 100644 --- a/src/fiber/install.ts +++ b/src/fiber/install.ts @@ -1,5 +1,6 @@ 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'; @@ -15,6 +16,21 @@ import { logger } from '../util/logger'; 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; @@ -81,6 +97,30 @@ function isInstallComplete(version: string, settings: Settings): boolean { } } +/** + * 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); @@ -92,6 +132,7 @@ export async function downloadFnnAndUnzip(version: string, settings: Settings = fs.writeFileSync(tempFilePath, Buffer.from(arrayBuffer)); try { + verifyFnnPackageChecksum(version, packageName, tempFilePath); const extractDir = path.join(settings.bins.downloadPath, `fnn_v${version}`); fs.rmSync(extractDir, { recursive: true, force: true }); await unZipFile(tempFilePath, extractDir, true); diff --git a/src/fiber/runtime.ts b/src/fiber/runtime.ts index 4c7df963..b66caf31 100644 --- a/src/fiber/runtime.ts +++ b/src/fiber/runtime.ts @@ -54,15 +54,22 @@ export function readRuntime(settings: Settings = readSettings()): FiberRuntime | /** * A runtime record is only meaningful while its manager process exists. Once - * the manager is 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). + * 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 true; + return false; } } diff --git a/src/fiber/scripts.ts b/src/fiber/scripts.ts index b09829b8..ea9966a6 100644 --- a/src/fiber/scripts.ts +++ b/src/fiber/scripts.ts @@ -70,7 +70,12 @@ function requireScript(scripts: SystemScriptsRecord, name: SystemScriptName): Sy 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(', ')}.`); + 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; } 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-install.test.ts b/tests/fiber-install.test.ts new file mode 100644 index 00000000..96c5c755 --- /dev/null +++ b/tests/fiber-install.test.ts @@ -0,0 +1,109 @@ +import { createHash } from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + assertSupportedFnnVersion, + buildFnnDownloadUrl, + 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('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', () => { + // The five packages buildFnnPackageName can produce across the supported + // platform/arch combinations; a version bump that forgets the pins fails here. + const packageNames = (version: string) => [ + `fnn_v${version}-x86_64-linux-portable`, + `fnn_v${version}-aarch64-linux-portable`, + `fnn_v${version}-x86_64-darwin-portable`, + `fnn_v${version}-aarch64-darwin-portable`, + `fnn_v${version}-x86_64-windows`, + ]; + for (const version of SUPPORTED_FNN_VERSIONS) { + for (const name of packageNames(version)) { + 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 index 6d715641..434b67d9 100644 --- a/tests/fiber-lifecycle.test.ts +++ b/tests/fiber-lifecycle.test.ts @@ -13,8 +13,10 @@ import { import { isStoreLockHeld } from '../src/fiber/store-lock'; import { assertFiberFullyStopped } from '../src/fiber/clean'; import { startFiberEnvironment, stopFiberNodes, FnnProcessHandle } from '../src/fiber/manager'; -import { fiberDaemonPaths, fiberNodePaths, runtimeJsonPath } from '../src/fiber/paths'; -import { writeRuntime } from '../src/fiber/runtime'; +import { fiberDaemonPaths, fiberNodePaths, fiberRootPath, runtimeJsonPath } from '../src/fiber/paths'; +import { writeRuntime, isRuntimeStale, FiberRuntime } from '../src/fiber/runtime'; +import { assertNodeStopDoesNotOrphanFiber, stopFiber } from '../src/fiber/daemon'; +import { fiberClean } from '../src/fiber/clean'; import { FiberChainScripts } from '../src/fiber/scripts'; /** @@ -214,6 +216,85 @@ describe('isStoreLockHeld', () => { }); }); +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()); @@ -259,6 +340,70 @@ describe('assertFiberFullyStopped', () => { }); }); +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); + }); +}); + +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') }; } diff --git a/tests/fiber-scripts.test.ts b/tests/fiber-scripts.test.ts index df8178f9..c112ac58 100644 --- a/tests/fiber-scripts.test.ts +++ b/tests/fiber-scripts.test.ts @@ -83,6 +83,9 @@ describe('resolveFiberChainScripts', () => { } 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'); } }); 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); + }); +}); From 9d1661a1d4d11b881e6780928424f1a1931e091a Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Fri, 14 Aug 2026 08:09:16 +0800 Subject: [PATCH 5/6] 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 --- src/cmd/node.ts | 86 ++++++++++++++--------- src/fiber/daemon.ts | 74 ++++++++++++++++---- src/fiber/install.ts | 46 ++++++++----- src/fiber/manager.ts | 22 +++--- src/fiber/nodes-yml.ts | 3 + src/fiber/status.ts | 59 +++++++++------- tests/fiber-install.test.ts | 48 ++++++++++--- tests/fiber-lifecycle.test.ts | 125 +++++++++++++++++++++++++++++++++- tests/fiber-nodes-yml.test.ts | 9 +++ tests/node-command.test.ts | 93 +++++++++++++++++++++---- 10 files changed, 446 insertions(+), 119 deletions(-) diff --git a/src/cmd/node.ts b/src/cmd/node.ts index b4f3ad84..2162a494 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -39,7 +39,7 @@ import { FiberEnvironment, startFiberEnvironment, stopFiberNodes } from '../fibe import { printFiberSummary } from './fiber'; import { readRuntime } from '../fiber/runtime'; import { fiberDaemonPaths } from '../fiber/paths'; -import { assertNodeStopDoesNotOrphanFiber } from '../fiber/daemon'; +import { assertNodeStopDoesNotOrphanFiber, FIBER_DAEMON_READY_TIMEOUT_MS } from '../fiber/daemon'; export interface NodeProp { version?: string; @@ -347,23 +347,40 @@ async function runNodeDevnet( // Treat CKB, miner, proxy and the FNNs as one service. A dead component // must not leave the rest looking healthy. - let serviceStopping = false; - const stopService = (component: string, code: number | null, signal: NodeJS.Signals | null) => { - if (serviceStopping) return; - serviceStopping = true; - void (async () => { + // + // 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; + shutdownPromise = (async () => { + const failedComponent = 'component' in trigger ? trigger.component : null; logSubscription?.close(); - if (component !== 'CKB node' && !ckbProcess.killed) ckbProcess.kill('SIGTERM'); - if (component !== 'CKB miner' && !minerProcess.killed) minerProcess.kill('SIGTERM'); + 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(); - logger.error(`${component} exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'none'}).`); - process.exitCode = typeof code === 'number' && code > 0 ? code : 1; + 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)); @@ -371,31 +388,22 @@ async function runNodeDevnet( for (const node of fiberEnv.nodes) { node.process.once('exit', (code, signal) => stopService(`FNN node ${node.id}`, code, signal)); } - installFiberSignalHandlers(ckbProcess, minerProcess, proxy, fiberEnv, settings); + 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. -function installFiberSignalHandlers( - ckbProcess: ChildProcess, - minerProcess: ChildProcess, - proxy: { stop: () => void }, - fiberEnv: FiberEnvironment, - settings: Settings, -) { +// 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; void (async () => { logger.info(`Received ${signal}, stopping the devnet and fiber nodes...`); - if (!ckbProcess.killed) ckbProcess.kill('SIGTERM'); - if (!minerProcess.killed) minerProcess.kill('SIGTERM'); - proxy.stop(); - await stopFiberNodes(fiberEnv.nodes, settings); - if (process.env[DAEMON_CHILD_ENV] === '1') cleanupPidFile(resolveDaemonPaths().pidFile); + await runShutdownOnce({ signal }); process.exit(signal === 'SIGINT' ? 130 : 143); })(); }; @@ -705,11 +713,8 @@ async function startDaemon(waitForFiber = false) { } async function waitForFiberRuntimeRunning(managerPid: number, settings: Settings, logFile: string) { - // Matches FIBER_DAEMON_READY_TIMEOUT_MS in fiber/daemon.ts: the child's - // first run may still be downloading FNN. - const timeoutMs = 10 * 60_000; const start = Date.now(); - while (Date.now() - start < timeoutMs) { + 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}.`); } @@ -733,13 +738,28 @@ export async function stopNode(options: { force?: boolean } = {}) { } // FNNs managed by a separate fiber daemon must be stopped by that daemon's - // owner command; node stop never reaches across another manager. + // 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)) { - throw new Error( - `Fiber nodes are managed by a separate fiber daemon (PID ${fiberDaemon.pid}). ` + - 'Stop them first with: offckb fiber stop', + 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).`, ); } diff --git a/src/fiber/daemon.ts b/src/fiber/daemon.ts index ad47f046..4b90f525 100644 --- a/src/fiber/daemon.ts +++ b/src/fiber/daemon.ts @@ -21,7 +21,13 @@ import { readSettings, Settings } from '../cfg/setting'; import { logger } from '../util/logger'; const FIBER_DAEMON_CHILD_ENV = 'OFFCKB_DAEMON_CHILD'; -const FIBER_DAEMON_READY_TIMEOUT_MS = 10 * 60_000; // first run may download FNN +// 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; @@ -47,8 +53,13 @@ export async function startFiberDaemon(childArgs: string[], settings: Settings = `A fiber daemon is already running (PID ${existing.pid}). Stop it first with: offckb fiber stop`, ); } - logger.warn( - `PID ${existing.pid} from ${pidFile} belongs to another process; removing stale daemon metadata without signaling it.`, + // 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); @@ -185,7 +196,7 @@ async function stopManagerAndCleanup(options: { 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. @@ -213,8 +224,26 @@ async function stopManagerAndCleanup(options: { ); } + // 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'; } /** @@ -260,8 +289,21 @@ export async function stopFiber(settings: Settings = readSettings()) { if (fiberDaemon && Number.isInteger(fiberDaemon.pid) && fiberDaemon.pid > 0) { if (isProcessAlive(fiberDaemon.pid)) { if (fiberDaemon.status === 'starting') { - throw new Error( - `The fiber daemon startup is still in progress (PID ${fiberDaemon.pid}). Try stopping it again shortly.`, + // 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); @@ -271,9 +313,13 @@ export async function stopFiber(settings: Settings = readSettings()) { `If you are sure, stop it manually and remove ${pidFile}.`, ); } - await stopManagerAndCleanup({ pid: fiberDaemon.pid, pidFile, label: 'fiber daemon', settings }); - logger.success('Fiber daemon stopped.'); - logger.result({ command: 'fiber.stop', stopped: true, pid: fiberDaemon.pid }); + 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.`); @@ -316,14 +362,18 @@ export async function stopFiber(settings: Settings = readSettings()) { 'The FNN nodes are managed by the `offckb node --fiber --daemon` manager; ' + 'stopping it stops the whole environment (CKB, miner, RPC proxy and FNNs).', ); - await stopManagerAndCleanup({ + const outcome = await stopManagerAndCleanup({ pid: nodeDaemon.pid, pidFile: nodeDaemonPaths(settings).pidFile, label: 'node --fiber daemon', settings, }); - logger.success('The node --fiber environment (CKB and FNNs) stopped.'); - logger.result({ command: 'fiber.stop', stopped: true, pid: nodeDaemon.pid, includedCkb: true }); + 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; } diff --git a/src/fiber/install.ts b/src/fiber/install.ts index 7e8a5c0b..d07bc55f 100644 --- a/src/fiber/install.ts +++ b/src/fiber/install.ts @@ -53,17 +53,29 @@ export function getFnnBundledTestnetConfigPath(version: string, settings: Settin return path.join(getFnnInstallPath(version, settings), 'config', 'testnet', 'config.yml'); } -function buildFnnPackageName(version: string): string { - const platform = os.platform(); - const arch = os.arch(); - if (platform === 'linux') { - return arch === 'arm64' ? `fnn_v${version}-aarch64-linux-portable` : `fnn_v${version}-x86_64-linux-portable`; - } - if (platform === 'darwin') { - return arch === 'arm64' ? `fnn_v${version}-aarch64-darwin-portable` : `fnn_v${version}-x86_64-darwin-portable`; +/** + * 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') { - // Fiber only publishes x86_64 Windows packages. return `fnn_v${version}-x86_64-windows`; } throw new Error(`Unsupported operating system for FNN: ${platform}`); @@ -124,7 +136,11 @@ export function verifyFnnPackageChecksum(version: string, packageName: string, f export async function downloadFnnAndUnzip(version: string, settings: Settings = readSettings()) { const packageName = buildFnnPackageName(version); const downloadURL = buildFnnDownloadUrl(version); - const tempFilePath = path.join(os.tmpdir(), `${packageName}.tar.gz`); + // 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); @@ -133,8 +149,7 @@ export async function downloadFnnAndUnzip(version: string, settings: Settings = try { verifyFnnPackageChecksum(version, packageName, tempFilePath); - const extractDir = path.join(settings.bins.downloadPath, `fnn_v${version}`); - fs.rmSync(extractDir, { recursive: true, force: true }); + const extractDir = path.join(tempDir, 'extract'); await unZipFile(tempFilePath, extractDir, true); // FNN packages ship the binary and config/ flat at the tarball root (unlike @@ -151,14 +166,13 @@ export async function downloadFnnAndUnzip(version: string, settings: Settings = for (const entry of fs.readdirSync(sourcePath)) { fs.cpSync(path.join(sourcePath, entry), path.join(targetPath, entry), { recursive: true, force: true }); } - fs.rmSync(extractDir, { recursive: true, force: true }); if (process.platform !== 'win32') { fs.chmodSync(getFnnBinaryPath(version, settings), '755'); } } finally { - // The tarball is only an intermediate; never leave it in the temp dir, - // whether the install succeeded or failed. - fs.rmSync(tempFilePath, { force: true }); + // 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.`); } diff --git a/src/fiber/manager.ts b/src/fiber/manager.ts index f2926b48..7bb05dc3 100644 --- a/src/fiber/manager.ts +++ b/src/fiber/manager.ts @@ -4,7 +4,7 @@ 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, runtimeJsonPath } from './paths'; +import { fiberNodePaths, fiberRpcUrl, fiberRpcPort, fiberP2pPort, fiberAccountIndex } from './paths'; import { ensureNodesYml, FiberNodeEntry } from './nodes-yml'; import { ensureNodeKeyMaterial, @@ -17,7 +17,14 @@ import { generateNodeConfig } from './config-gen'; import { FiberChainScripts } from './scripts'; import { fnnNodeInfo, fnnConnectPeer, fnnListPeers, checkPortFree, FnnNodeInfo } from './rpc'; import { lockMatches } from './status'; -import { writeRuntime, readLiveRuntime, removeRuntimeFile, removeRuntimeFileIfStale, FiberRuntime } from './runtime'; +import { + writeRuntime, + readRuntime, + readLiveRuntime, + removeRuntimeFile, + removeRuntimeFileIfStale, + FiberRuntime, +} from './runtime'; import { closeFileDescriptors } from '../util/daemon'; export interface FnnProcessHandle { @@ -430,13 +437,8 @@ function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise } function removeRuntimeFileIfManager(settings: Settings) { - try { - const raw = fs.readFileSync(runtimeJsonPath(settings), 'utf8'); - const parsed = JSON.parse(raw) as { managerPid?: number }; - if (parsed.managerPid === process.pid) { - removeRuntimeFile(settings); - } - } catch { - // no runtime file or unreadable — nothing to do + 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 index 87c2d4bd..5075cb90 100644 --- a/src/fiber/nodes-yml.ts +++ b/src/fiber/nodes-yml.ts @@ -93,6 +93,9 @@ export function readNodesYml(settings: Settings = readSettings()): FiberNodeEntr 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.`); } diff --git a/src/fiber/status.ts b/src/fiber/status.ts index e9eba12d..ee9c03f5 100644 --- a/src/fiber/status.ts +++ b/src/fiber/status.ts @@ -1,5 +1,11 @@ import { checkNodeReadiness } from '../devnet/readiness'; -import { getProcessCommandLine, isProcessAlive, nodeDaemonPaths, readPidFile } from '../util/daemon'; +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'; @@ -45,21 +51,25 @@ async function resolveOffckbManaged(runtime: FiberRuntime | null, settings: Sett } if (!alive) return 'no'; - const cmdline = await getProcessCommandLine(runtime.managerPid); - if (cmdline == null) return 'unknown'; - if (!cmdline.includes('offckb')) return 'no'; - // A daemon PID file that claims fiber management must agree with the - // runtime record: the fiber daemon PID file always claims it, the node - // daemon PID file only when the fiber manager IS the node daemon - // (node --fiber --daemon). An unrelated CKB daemon does not disqualify. + // 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 'no'; - if (fiberPid == null) { - const nodePid = readPidFile(nodeDaemonPaths(settings).pidFile); - if (nodePid != null && nodePid.pid === runtime.managerPid) return 'yes'; + if (fiberPid != null && fiberPid.pid === runtime.managerPid) { + return (await verifyDaemonIdentity(fiberPid.pid, fiberPid)) ? 'yes' : 'no'; } - return 'yes'; + 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 @@ -80,8 +90,10 @@ export function lockMatches( /** * 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/PID/process inspection (the - * OFFCKB column is the single exception, and it never changes the status). + * 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); @@ -100,9 +112,15 @@ export async function collectFiberStatus(settings: Settings = readSettings()): P const runtime = readRuntime(settings); const offckb = await resolveOffckbManaged(runtime, settings); - const managerStarting = runtime != null && offckb === 'yes' && runtime.status === 'starting'; + 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 entry of entries) { + for (const [index, entry] of entries.entries()) { const statusEntry: FiberNodeStatusEntry = { id: entry.id, status: 'unknown', @@ -114,12 +132,7 @@ export async function collectFiberStatus(settings: Settings = readSettings()): P }; report.nodes.push(statusEntry); - let info: FnnNodeInfo | null = null; - try { - info = await fnnNodeInfo(statusEntry.rpcUrl, 2000); - } catch { - info = null; - } + const info: FnnNodeInfo | null = infos[index]; if (info == null) { statusEntry.status = managerStarting ? 'starting' : 'stopped'; diff --git a/tests/fiber-install.test.ts b/tests/fiber-install.test.ts index 96c5c755..fde41b6f 100644 --- a/tests/fiber-install.test.ts +++ b/tests/fiber-install.test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import { assertSupportedFnnVersion, buildFnnDownloadUrl, + buildFnnPackageName, KNOWN_FNN_SHA256, SUPPORTED_FNN_VERSIONS, verifyFnnPackageChecksum, @@ -41,6 +42,33 @@ describe('buildFnnDownloadUrl', () => { }); }); +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; @@ -91,17 +119,19 @@ describe('verifyFnnPackageChecksum', () => { }); it('pins a well-formed digest for every package of every supported version', () => { - // The five packages buildFnnPackageName can produce across the supported - // platform/arch combinations; a version bump that forgets the pins fails here. - const packageNames = (version: string) => [ - `fnn_v${version}-x86_64-linux-portable`, - `fnn_v${version}-aarch64-linux-portable`, - `fnn_v${version}-x86_64-darwin-portable`, - `fnn_v${version}-aarch64-darwin-portable`, - `fnn_v${version}-x86_64-windows`, + // 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 name of packageNames(version)) { + 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 index 434b67d9..b8416f5a 100644 --- a/tests/fiber-lifecycle.test.ts +++ b/tests/fiber-lifecycle.test.ts @@ -4,6 +4,7 @@ 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, @@ -15,7 +16,7 @@ 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, stopFiber } from '../src/fiber/daemon'; +import { assertNodeStopDoesNotOrphanFiber, startFiberDaemon, stopFiber } from '../src/fiber/daemon'; import { fiberClean } from '../src/fiber/clean'; import { FiberChainScripts } from '../src/fiber/scripts'; @@ -377,6 +378,124 @@ describe('stopFiber', () => { 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', () => { @@ -521,6 +640,10 @@ describe('startFiberEnvironment signal handling', () => { 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++) { diff --git a/tests/fiber-nodes-yml.test.ts b/tests/fiber-nodes-yml.test.ts index 52cd6f06..cd341318 100644 --- a/tests/fiber-nodes-yml.test.ts +++ b/tests/fiber-nodes-yml.test.ts @@ -84,6 +84,15 @@ describe('readNodesYml', () => { 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 }); diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index 67a4a9c1..9b7e661f 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -76,6 +76,8 @@ 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'); @@ -114,9 +116,29 @@ function mockDaemonCommandLine(scriptPath: string) { 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, ''); @@ -222,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[], optionsOrCallback: unknown, maybeCallback?: unknown) => { - execFileCallback(optionsOrCallback, maybeCallback)(null, '/usr/bin/some-unrelated-process'); - return undefined as unknown as ReturnType; - }, - ); + mockUnrelatedCommandLine(); await startNode({ network: Network.devnet, daemon: true }); @@ -550,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[], optionsOrCallback: unknown, maybeCallback?: unknown) => { - execFileCallback(optionsOrCallback, maybeCallback)(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'); @@ -608,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 22b0afdae27d0bbbb15d89562a5066a9d9387312 Mon Sep 17 00:00:00 2001 From: humble-little-bear Date: Fri, 14 Aug 2026 12:54:18 +0800 Subject: [PATCH 6/6] fix(fiber): don't let EPIPE truncate a graceful shutdown in progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 5 ++- src/cli.ts | 12 +----- src/cmd/fiber.ts | 4 ++ src/cmd/node.ts | 7 +++ src/fiber/manager.ts | 5 +++ src/util/shutdown.ts | 47 ++++++++++++++++++++ tests/broken-pipe.test.ts | 90 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 158 insertions(+), 12 deletions(-) create mode 100644 src/util/shutdown.ts create mode 100644 tests/broken-pipe.test.ts diff --git a/README.md b/README.md index 5a2f7b7b..fc04471d 100644 --- a/README.md +++ b/README.md @@ -483,7 +483,10 @@ offckb fiber clean # delete the whole fiber environment - 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. +- 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 diff --git a/src/cli.ts b/src/cli.ts index 2b0e50e8..d6278429 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,6 +32,7 @@ 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'; @@ -434,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/fiber.ts b/src/cmd/fiber.ts index 861f4f4b..dddad786 100644 --- a/src/cmd/fiber.ts +++ b/src/cmd/fiber.ts @@ -12,6 +12,7 @@ import { fiberAccountIndex, fiberNodePaths, FIBER_DAEMON_PID_FILE, fiberDaemonPa 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 { @@ -58,6 +59,9 @@ export async function superviseFiberNodes( 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') { diff --git a/src/cmd/node.ts b/src/cmd/node.ts index 2162a494..b7c43bbc 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -38,6 +38,7 @@ 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'; @@ -359,6 +360,9 @@ async function runNodeDevnet( 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(); @@ -401,6 +405,9 @@ function installFiberSignalHandlers(runShutdownOnce: (trigger: { signal: 'SIGINT 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 }); diff --git a/src/fiber/manager.ts b/src/fiber/manager.ts index 7bb05dc3..c3ba16f0 100644 --- a/src/fiber/manager.ts +++ b/src/fiber/manager.ts @@ -26,6 +26,7 @@ import { FiberRuntime, } from './runtime'; import { closeFileDescriptors } from '../util/daemon'; +import { enterGracefulShutdown } from '../util/shutdown'; export interface FnnProcessHandle { id: number; @@ -326,6 +327,10 @@ export async function startFiberEnvironment(options: StartFiberEnvironmentOption 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; 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(); + }); +});