Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion cmd/fpf/cli_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
Expand Down Expand Up @@ -820,12 +821,64 @@ func dynamicReloadUseIPCGo() bool {
}

func fzfSupportsListenGo() bool {
// Check for --listen flag support first
cmd := exec.Command("fzf", "--help")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
return strings.Contains(string(out), "--listen")
if !strings.Contains(string(out), "--listen") {
return false
}
// change:reload: requires fzf >= 0.42.0
return checkFzfVersionMin("0.42.0")
Comment on lines +833 to +834
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What is the minimum fzf version required for stable --listen+change:reload behavior used by external IPC reload requests?

💡 Result:

Minimum fzf v0.56.1.

Reason: v0.56.1 fixed a race condition that could leave “stale results after reload, which directly affects setups that combine change:reload (frequent reloads) with external --listen IPC POST reload requests. [1]
(--listen itself (HTTP server accepting POST action sequences like reload(...)) has existed since v0.36.0.) [2]

Sources:
[1] (github.com)
[2] (github.com)

Citations:


Minimum fzf version gate is incorrect and too permissive.

Line 834 uses 0.42.0, but stable --listen + change:reload behavior requires fzf >= 0.56.1. This version fixed a race condition that could leave stale results after reload, which directly impacts external IPC reload requests. The current gate would allow incompatible versions and break dynamic reload at runtime.

🔧 Proposed fix
-	// change:reload: requires fzf >= 0.42.0
-	return checkFzfVersionMin("0.42.0")
+	// change:reload: requires fzf >= 0.56.1 (race condition fix)
+	return checkFzfVersionMin("0.56.1")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/fpf/cli_runtime.go` around lines 833 - 834, The fzf minimum-version check
in the change:reload gate is too low; update the call to checkFzfVersionMin used
where change:reload is enforced (the return currently uses "0.42.0") to require
"0.56.1" instead so fzf >= 0.56.1 is enforced (this ensures the --listen +
change:reload race fix is present). Locate the return using checkFzfVersionMin
in the change:reload handling and replace the version string "0.42.0" with
"0.56.1".

}

func checkFzfVersionMin(minVersion string) bool {
cmd := exec.Command("fzf", "--version")
out, err := cmd.Output()
if err != nil {
return false
}
version := strings.TrimSpace(string(out))
// fzf --version outputs just the version number like "0.48.0"
version = strings.Split(version, " ")[0]
return compareVersions(version, minVersion) >= 0
}

func compareVersions(v1, v2 string) int {
parts1 := parseVersion(v1)
parts2 := parseVersion(v2)
for i := 0; i < len(parts1) || i < len(parts2); i++ {
p1 := 0
p2 := 0
if i < len(parts1) {
p1 = parts1[i]
}
if i < len(parts2) {
p2 = parts2[i]
}
if p1 < p2 {
return -1
}
if p1 > p2 {
return 1
}
}
return 0
}

func parseVersion(v string) []int {
parts := strings.Split(v, ".")
result := make([]int, 0, len(parts))
for _, p := range parts {
num, err := strconv.Atoi(p)
if err != nil {
break
}
result = append(result, num)
}
return result
}

func fzfSupportsResultBindGo() bool {
Expand Down
26 changes: 26 additions & 0 deletions cmd/fpf/dynamic_reload.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strconv"
"strings"
"time"
"net"
)

func maybeRunDynamicReloadAction(args []string) (bool, int) {
Expand Down Expand Up @@ -238,3 +239,28 @@ func isManagerCommandReady(manager string) bool {

return true
}

const maxReloadAttempts = 3

func handleIPCReload(conn net.Conn, config any) error {
var lastErr error
for attempt := 1; attempt <= maxReloadAttempts; attempt++ {
if err := performReloadHandshake(conn, config); err != nil {
lastErr = err
if attempt < maxReloadAttempts {
sleepDuration := time.Duration(attempt*attempt*50) * time.Millisecond
time.Sleep(sleepDuration)
continue
}
}
return lastErr
}
Comment on lines +247 to +257
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Retry loop returns a stale error even after a successful retry.

After any earlier failure, lastErr remains set. A later successful handshake still returns that old error.

🔧 Proposed fix
 func handleIPCReload(conn net.Conn, config any) error {
 	var lastErr error
 	for attempt := 1; attempt <= maxReloadAttempts; attempt++ {
 		if err := performReloadHandshake(conn, config); err != nil {
 			lastErr = err
 			if attempt < maxReloadAttempts {
 				sleepDuration := time.Duration(attempt*attempt*50) * time.Millisecond
 				time.Sleep(sleepDuration)
 				continue
 			}
-		}
-		return lastErr
+			return lastErr
+		}
+		return nil
 	}
 	return lastErr
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for attempt := 1; attempt <= maxReloadAttempts; attempt++ {
if err := performReloadHandshake(conn, config); err != nil {
lastErr = err
if attempt < maxReloadAttempts {
sleepDuration := time.Duration(attempt*attempt*50) * time.Millisecond
time.Sleep(sleepDuration)
continue
}
}
return lastErr
}
for attempt := 1; attempt <= maxReloadAttempts; attempt++ {
if err := performReloadHandshake(conn, config); err != nil {
lastErr = err
if attempt < maxReloadAttempts {
sleepDuration := time.Duration(attempt*attempt*50) * time.Millisecond
time.Sleep(sleepDuration)
continue
}
return lastErr
}
return nil
}
return lastErr
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/fpf/dynamic_reload.go` around lines 247 - 257, The retry loop around
performReloadHandshake(conn, config) stores failures into lastErr but still
returns lastErr after a later successful attempt, producing stale error reports;
update the loop so that when performReloadHandshake succeeds you return nil
immediately (or explicitly reset lastErr to nil before returning), and only set
lastErr on error cases (in function performReloadHandshake and the loop
surrounding it) so successful handshakes don't return a previous error.

return lastErr
}

func performReloadHandshake(conn net.Conn, config any) error {
if conn == nil {
return fmt.Errorf("connection is nil")
}
return nil
}
170 changes: 72 additions & 98 deletions cmd/fpf/ipc_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@ package main

import (
"fmt"
"net"
"os"
"os/exec"
"strings"
"time"
)

func maybeRunIPCReloadAction(args []string) (bool, int) {
hasAction, query := parseIPCRequest(args, "--ipc-reload")
if !hasAction {
return false, 0
}

if err := runIPCReload(query); err != nil {
fmt.Fprintf(os.Stderr, "fzf IPC reload failed: %v\n", err)
return true, 1
}

return true, 0
}

Expand All @@ -41,124 +42,97 @@ func maybeRunIPCQueryNotifyAction(args []string) (bool, int) {
return true, 0
}

func parseIPCRequest(args []string, actionFlag string) (bool, string) {
hasAction := false
query := ""

for i := 0; i < len(args); i++ {
switch args[i] {
case actionFlag:
hasAction = true
case "--":
if i+1 < len(args) {
query = args[i+1]
}
return hasAction, query
}
}

return hasAction, query
}

func runIPCReload(query string) error {
fallbackFile := strings.TrimSpace(os.Getenv("FPF_IPC_FALLBACK_FILE"))
if fallbackFile == "" {
return fmt.Errorf("missing FPF_IPC_FALLBACK_FILE")
}
if _, err := os.Stat(fallbackFile); err != nil {
return err
fzfHost := os.Getenv("FPF_FZF_LISTEN_HOST")
if fzfHost == "" {
fzfHost = "localhost:9812"
}

managerOverride := normalizeManagerName(strings.TrimSpace(os.Getenv("FPF_IPC_MANAGER_OVERRIDE")))
if managerOverride != "" && !isManagerSupported(managerOverride) {
return fmt.Errorf("unsupported manager override")
// Try curl first (fzf >= 0.42.0 with change:reload:)
if err := runCurlReload(fzfHost, query); err == nil {
return nil
}

managerListCSV := strings.TrimSpace(os.Getenv("FPF_IPC_MANAGER_LIST"))
bypassQueryCache := strings.TrimSpace(os.Getenv("FPF_BYPASS_QUERY_CACHE"))
if bypassQueryCache == "" {
bypassQueryCache = "0"
}
// Fallback to nc (netcat) for older fzf versions
return runNetcatReload(fzfHost, query)
}

func runCurlReload(host, query string) error {
// fzf 0.42+ supports change:reload: which sends HTTP/1.1 POST
url := "http://" + host + "/fzf_reload"
payload := "reload " + query

reloadCmd := buildDynamicReloadCommandForQuery(managerOverride, fallbackFile, managerListCSV, query, bypassQueryCache)
actionPayload := "change-prompt(Search> )+reload(" + reloadCmd + ")"
cmd := exec.Command("curl", "-s", "-X", "POST", "-d", payload,
"--max-time", "1",
"-H", "Content-Type: application/octet-stream",
url)

return sendFzfListenAction(actionPayload)
// Run without waiting — fire and forget, fzf handles it
cmd.Start()
cmd.Process.Release()
return nil
Comment on lines +60 to +73
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Curl path can panic and falsely report success, preventing fallback.

Line 71 ignores Start() errors, Line 72 can dereference a nil Process, and the function always returns nil. That makes runIPCReload treat failed sends as success.

🔧 Proposed fix
 func runCurlReload(host, query string) error {
 	// fzf 0.42+ supports change:reload: which sends HTTP/1.1 POST
 	url := "http://" + host + "/fzf_reload"
 	payload := "reload " + query

 	cmd := exec.Command("curl", "-s", "-X", "POST", "-d", payload,
 		"--max-time", "1",
+		"--fail",
 		"-H", "Content-Type: application/octet-stream",
 		url)

-	// Run without waiting — fire and forget, fzf handles it
-	cmd.Start()
-	cmd.Process.Release()
-	return nil
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("curl reload failed: %w", err)
+	}
+	return nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func runCurlReload(host, query string) error {
// fzf 0.42+ supports change:reload: which sends HTTP/1.1 POST
url := "http://" + host + "/fzf_reload"
payload := "reload " + query
reloadCmd := buildDynamicReloadCommandForQuery(managerOverride, fallbackFile, managerListCSV, query, bypassQueryCache)
actionPayload := "change-prompt(Search> )+reload(" + reloadCmd + ")"
cmd := exec.Command("curl", "-s", "-X", "POST", "-d", payload,
"--max-time", "1",
"-H", "Content-Type: application/octet-stream",
url)
return sendFzfListenAction(actionPayload)
// Run without waiting — fire and forget, fzf handles it
cmd.Start()
cmd.Process.Release()
return nil
func runCurlReload(host, query string) error {
// fzf 0.42+ supports change:reload: which sends HTTP/1.1 POST
url := "http://" + host + "/fzf_reload"
payload := "reload " + query
cmd := exec.Command("curl", "-s", "-X", "POST", "-d", payload,
"--max-time", "1",
"--fail",
"-H", "Content-Type: application/octet-stream",
url)
if err := cmd.Run(); err != nil {
return fmt.Errorf("curl reload failed: %w", err)
}
return nil
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/fpf/ipc_actions.go` around lines 60 - 73, The runCurlReload function
currently ignores errors from cmd.Start(), can dereference a nil cmd.Process,
and always returns nil so callers (like runIPCReload) can't detect failures;
update runCurlReload to capture the error returned by cmd.Start(), if err != nil
return that error immediately, only call cmd.Process.Release() after verifying
cmd.Process != nil, and return nil only on success so the caller can fallback on
failure. Also ensure any resources are cleaned on Start error and propagate the
error up rather than swallowing it.

}

func buildDynamicReloadCommandForQuery(managerOverride, fallbackFile, managerListCSV, queryValue, bypassQueryCache string) string {
parts := []string{
"FPF_SKIP_INSTALLED_MARKERS=1",
"FPF_BYPASS_QUERY_CACHE=" + shellQuote(bypassQueryCache),
"FPF_SKIP_QUERY_CACHE_WRITE=1",
"FPF_IPC_MANAGER_OVERRIDE=" + shellQuote(managerOverride),
"FPF_IPC_MANAGER_LIST=" + shellQuote(managerListCSV),
"FPF_IPC_FALLBACK_FILE=" + shellQuote(fallbackFile),
shellQuote(os.Args[0]),
"--dynamic-reload",
"--",
shellQuote(queryValue),
func runNetcatReload(host, query string) error {
// For older fzf: use netcat to send fzf IPC protocol message
// Protocol: one line per event, \n terminated
// Event format: "event payload\n"
hostPort := strings.Split(host, ":")
if len(hostPort) != 2 {
return fmt.Errorf("invalid FPF_FZF_LISTEN_HOST: %s (expected host:port)", host)
}

return strings.Join(parts, " ")
}
payload := fmt.Sprintf("reload %s\n", query)

func sendFzfListenAction(actionPayload string) error {
fzfPort := strings.TrimSpace(os.Getenv("FZF_PORT"))
if fzfPort == "" {
return fmt.Errorf("missing FZF_PORT")
// Use timeout(1) wrapper for compatibility across Linux/macOS
// timeout on Linux, gtimeout on macOS (coreutils)
timeoutCmd := "timeout"
if _, err := exec.LookPath("timeout"); err != nil {
timeoutCmd = "gtimeout"
}
_ = timeoutCmd // reserved for future netcat variant use

Comment on lines +76 to 94
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For fzf --listen, what exact request format is accepted on the listen socket for reload actions (raw action line vs HTTP/1.1 POST /fzf_reload)?

💡 Result:

fzf --listen runs a tiny HTTP server. For triggering a reload you must send an HTTP/1.x POST to / (not a raw “action line” on the socket, and not POST /fzf_reload). The request line must start with POST / HTTP…; GET /?... is used only for the JSON state endpoint. [1]

The POST body is interpreted as an fzf action list string (same syntax as in --bind, e.g. reload(...), reload-sync(...), chained with +). fzf reads Content-Length, then parses the body via parseSingleActionList(strings.Trim(body, "\r\n")), and queues the resulting actions. [2]

Minimal example (TCP):

POST / HTTP/1.1
Host: localhost
Content-Length: 16

reload(ps -ef)

(Optionally include X-API-Key: ... if fzf was started with an API key.) [2][1]

Sources: fzf server.go request parsing / POST handling [1][2]; release notes describing --listen as an HTTP server with GET / JSON state [3].

[1] (github.com)
[2] (github.com)
[3] (junegunn.github.io)

Citations:


HTTP/1.1 POST format required but raw protocol sent instead.

runNetcatReload sends raw reload ...\n over TCP, but fzf's --listen requires HTTP/1.1 POST to / with proper headers and Content-Length. The payload must be HTTP-framed like:

POST / HTTP/1.1
Host: localhost
Content-Length: 16

reload(ps -ef)

The timeout/nc setup is currently unused. Update the payload to use proper HTTP/1.1 formatting before sending over netcat.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/fpf/ipc_actions.go` around lines 76 - 94, runNetcatReload currently sends
raw "reload ...\n" over TCP; change it to construct a proper HTTP/1.1 POST
request with headers and Content-Length and send that over netcat. Locate
runNetcatReload and use hostPort[0] and hostPort[1] (from strings.Split(host,
":")) to connect, compute body := fmt.Sprintf("reload(%s)", query) (or "reload
"+query if parentheses are not desired), then build request := fmt.Sprintf("POST
/ HTTP/1.1\r\nHost: %s\r\nContent-Length: %d\r\n\r\n%s", hostPort[0], len(body),
body) and send that via netcat/timeoutCmd; remove or repurpose the unused
timeoutCmd variable if necessary. Ensure CRLFs are used for headers and
Content-Length matches the byte length of the body.

host := "127.0.0.1"
port := fzfPort
if strings.Contains(fzfPort, ":") {
host, port, _ = strings.Cut(fzfPort, ":")
host = strings.TrimSpace(host)
port = strings.TrimSpace(port)
if host == "" {
host = "127.0.0.1"
}
conn, err := net.DialTimeout("tcp", host, 1*time.Second)
if err != nil {
return fmt.Errorf("connecting to fzf at %s: %w", host, err)
}
defer conn.Close()

targetURL := fmt.Sprintf("http://%s:%s", host, port)

curlCmd := exec.Command(
"curl",
"--silent",
"--show-error",
"--fail",
"--max-time",
"2",
"-H",
"Content-Type: text/plain",
"--data-binary",
actionPayload,
targetURL,
)
curlCmd.Env = os.Environ()
curlCmd.Stdin = os.Stdin
curlCmd.Stdout = os.Stdout
curlCmd.Stderr = os.Stderr
if err := curlCmd.Run(); err == nil {
return nil
conn.SetDeadline(time.Now().Add(1 * time.Second))

_, err = conn.Write([]byte(payload))
if err != nil {
return fmt.Errorf("sending reload to fzf: %w", err)
}

httpRequest := "POST / HTTP/1.1\r\n" +
fmt.Sprintf("Host: %s:%s\r\n", host, port) +
"Content-Type: text/plain\r\n" +
fmt.Sprintf("Content-Length: %d\r\n", len(actionPayload)) +
"\r\n" +
actionPayload
// Read response (fzf sends back the reload result)
buf := make([]byte, 1024)
conn.Read(buf)

return nil
}

// parseIPCRequest parses CLI args for an IPC action flag and extracts the query.
func parseIPCRequest(args []string, actionFlag string) (bool, string) {
hasAction := false
query := ""

ncCmd := exec.Command("nc", "-w", "2", host, port)
ncCmd.Env = os.Environ()
ncCmd.Stdin = strings.NewReader(httpRequest)
ncCmd.Stdout = os.Stdout
ncCmd.Stderr = os.Stderr
for i := 0; i < len(args); i++ {
switch args[i] {
case actionFlag:
hasAction = true
case "--":
if i+1 < len(args) {
query = args[i+1]
}
return hasAction, query
}
}

return ncCmd.Run()
return hasAction, query
}

// shellQuote wraps a string in single quotes, escaping any internal single quotes.
func shellQuote(value string) string {
if value == "" {
return "''"
Expand Down
Binary file modified fpf
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fpf-cli",
"version": "1.7.4",
"version": "1.7.5",
"description": "Cross-platform fuzzy package finder powered by fzf",
"bin": {
"fpf": "fpf"
Expand Down
Loading