Merge pull request #117 from bnema/fix/bar-script-session-env
fix(daemon): run bar scripts with the session environment
11 files · +610 -71
M docs/configuration.md +3 -1
| @@ -110,4 +110,6 @@ The command runs through your shell. A changed command applies on the next launc | |||
| 110 | 110 | ||
| 111 | 111 | Anchor commands run on the daemon host every `bar.interval` (minimum 1s). vev reads the first line of stdout, strips ANSI codes, and keeps the last good value on failure. Scripts get `VEV_ANCHOR`, `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`. | |
| 112 | 112 | ||
| 113 | - | Both anchors are disabled by default, so `go install github.com/bnema/vev@latest` needs no companion scripts. Set either command to opt in; explicit commands are run unchanged. Checkout and release installs include `vev-bar-top-right` and `vev-bar-bottom-right` example scripts, which can be configured by name when they are on the daemon host's `PATH`. The bottom-right example runs `git status --porcelain` on each refresh; raise the interval or replace it if that is too heavy for your repository. | |
| 113 | + | Commands resolve against the environment of the client currently attached to the session, the same environment new panes inherit. A command that works in your shell will work as an anchor without restarting the daemon. When an anchor fails, the daemon logs the exit code and the command's stderr; exit 127 means the command was not found on that `PATH`. | |
| 114 | + | ||
| 115 | + | Both anchors are disabled by default, so `go install github.com/bnema/vev@latest` needs no companion scripts. Set either command to opt in; explicit commands are run unchanged. Checkout and release installs include `vev-bar-top-right` and `vev-bar-bottom-right` example scripts, which can be configured by name when they are on the attaching client's `PATH`. The bottom-right example runs `git status --porcelain` on each refresh; raise the interval or replace it if that is too heavy for your repository. | |
| @@ -110,4 +110,6 @@ The command runs through your shell. A changed command applies on the next launc | |||||
| 110 | 110 | ||||
| 111 | Anchor commands run on the daemon host every `bar.interval` (minimum 1s). vev reads the first line of stdout, strips ANSI codes, and keeps the last good value on failure. Scripts get `VEV_ANCHOR`, `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`. | 111 | Anchor commands run on the daemon host every `bar.interval` (minimum 1s). vev reads the first line of stdout, strips ANSI codes, and keeps the last good value on failure. Scripts get `VEV_ANCHOR`, `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`. | ||
| 112 | 112 | ||||
| 113 | - | Both anchors are disabled by default, so `go install github.com/bnema/vev@latest` needs no companion scripts. Set either command to opt in; explicit commands are run unchanged. Checkout and release installs include `vev-bar-top-right` and `vev-bar-bottom-right` example scripts, which can be configured by name when they are on the daemon host's `PATH`. The bottom-right example runs `git status --porcelain` on each refresh; raise the interval or replace it if that is too heavy for your repository. | 113 | + | Commands resolve against the environment of the client currently attached to the session, the same environment new panes inherit. A command that works in your shell will work as an anchor without restarting the daemon. When an anchor fails, the daemon logs the exit code and the command's stderr; exit 127 means the command was not found on that `PATH`. |
| 114 | + | ||||
| 115 | + | Both anchors are disabled by default, so `go install github.com/bnema/vev@latest` needs no companion scripts. Set either command to opt in; explicit commands are run unchanged. Checkout and release installs include `vev-bar-top-right` and `vev-bar-bottom-right` example scripts, which can be configured by name when they are on the attaching client's `PATH`. The bottom-right example runs `git status --porcelain` on each refresh; raise the interval or replace it if that is too heavy for your repository. | |||
M internal/adapters/shellcmd/runner.go +18 -7
| @@ -3,6 +3,7 @@ package shellcmd | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | "io" | |
| 7 | 8 | "os/exec" | |
| 8 | 9 | "syscall" | |
| @@ -16,8 +17,8 @@ type Runner struct{} | |||
| 16 | 17 | // New returns a production shell command runner. | |
| 17 | 18 | func New() Runner { return Runner{} } | |
| 18 | 19 | ||
| 19 | - | // Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr. | |
| 20 | - | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | |
| 20 | + | // Run executes spec.Command through sh -c, capturing bounded stdout and stderr. | |
| 21 | + | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) { | |
| 21 | 22 | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | |
| 22 | 23 | cmd.Env = spec.Env | |
| 23 | 24 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| @@ -28,14 +29,24 @@ func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | |||
| 28 | 29 | } | |
| 29 | 30 | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 30 | 31 | } | |
| 31 | - | var stdout boundedBuffer | |
| 32 | + | var stdout, stderr boundedBuffer | |
| 32 | 33 | stdout.limit = spec.StdoutLimit | |
| 34 | + | stderr.limit = spec.StderrLimit | |
| 33 | 35 | cmd.Stdout = &stdout | |
| 34 | - | cmd.Stderr = io.Discard | |
| 35 | - | if err := cmd.Run(); err != nil { | |
| 36 | - | return stdout.Bytes(), err | |
| 36 | + | cmd.Stderr = &stderr | |
| 37 | + | ||
| 38 | + | err := cmd.Run() | |
| 39 | + | res := ports.CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} | |
| 40 | + | if err != nil { | |
| 41 | + | var exitErr *exec.ExitError | |
| 42 | + | if errors.As(err, &exitErr) { | |
| 43 | + | res.ExitCode = exitErr.ExitCode() | |
| 44 | + | } else { | |
| 45 | + | res.ExitCode = -1 | |
| 46 | + | } | |
| 47 | + | return res, err | |
| 37 | 48 | } | |
| 38 | - | return stdout.Bytes(), nil | |
| 49 | + | return res, nil | |
| 39 | 50 | } | |
| 40 | 51 | ||
| 41 | 52 | type boundedBuffer struct { | |
| @@ -3,6 +3,7 @@ package shellcmd | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 6 | "io" | 7 | "io" | ||
| 7 | "os/exec" | 8 | "os/exec" | ||
| 8 | "syscall" | 9 | "syscall" | ||
| @@ -16,8 +17,8 @@ type Runner struct{} | |||||
| 16 | // New returns a production shell command runner. | 17 | // New returns a production shell command runner. | ||
| 17 | func New() Runner { return Runner{} } | 18 | func New() Runner { return Runner{} } | ||
| 18 | 19 | ||||
| 19 | - | // Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr. | 20 | + | // Run executes spec.Command through sh -c, capturing bounded stdout and stderr. |
| 20 | - | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | 21 | + | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) { |
| 21 | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | 22 | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | ||
| 22 | cmd.Env = spec.Env | 23 | cmd.Env = spec.Env | ||
| 23 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | 24 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | ||
| @@ -28,14 +29,24 @@ func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | |||||
| 28 | } | 29 | } | ||
| 29 | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | 30 | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | ||
| 30 | } | 31 | } | ||
| 31 | - | var stdout boundedBuffer | 32 | + | var stdout, stderr boundedBuffer |
| 32 | stdout.limit = spec.StdoutLimit | 33 | stdout.limit = spec.StdoutLimit | ||
| 34 | + | stderr.limit = spec.StderrLimit | |||
| 33 | cmd.Stdout = &stdout | 35 | cmd.Stdout = &stdout | ||
| 34 | - | cmd.Stderr = io.Discard | 36 | + | cmd.Stderr = &stderr |
| 35 | - | if err := cmd.Run(); err != nil { | 37 | + | |
| 36 | - | return stdout.Bytes(), err | 38 | + | err := cmd.Run() |
| 39 | + | res := ports.CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} | |||
| 40 | + | if err != nil { | |||
| 41 | + | var exitErr *exec.ExitError | |||
| 42 | + | if errors.As(err, &exitErr) { | |||
| 43 | + | res.ExitCode = exitErr.ExitCode() | |||
| 44 | + | } else { | |||
| 45 | + | res.ExitCode = -1 | |||
| 46 | + | } | |||
| 47 | + | return res, err | |||
| 37 | } | 48 | } | ||
| 38 | - | return stdout.Bytes(), nil | 49 | + | return res, nil |
| 39 | } | 50 | } | ||
| 40 | 51 | ||||
| 41 | type boundedBuffer struct { | 52 | type boundedBuffer struct { | ||
M internal/adapters/shellcmd/runner_test.go +70 -7
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | func TestRunnerRun(t *testing.T) { | |
| @@ -19,7 +20,7 @@ func TestRunnerRun(t *testing.T) { | |||
| 19 | 20 | }{ | |
| 20 | 21 | {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"}, | |
| 21 | 22 | {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"}, | |
| 22 | - | {name: "stderr discarded", command: "printf out; printf err >&2", limit: 1024, want: "out"}, | |
| 23 | + | {name: "stderr does not leak into stdout", command: "printf out; printf err >&2", limit: 1024, want: "out"}, | |
| 23 | 24 | {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""}, | |
| 24 | 25 | {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""}, | |
| 25 | 26 | } | |
| @@ -32,8 +33,8 @@ func TestRunnerRun(t *testing.T) { | |||
| 32 | 33 | if err != nil { | |
| 33 | 34 | t.Fatalf("Run() error = %v", err) | |
| 34 | 35 | } | |
| 35 | - | if string(got) != tt.want { | |
| 36 | - | t.Fatalf("Run() = %q, want %q", string(got), tt.want) | |
| 36 | + | if string(got.Stdout) != tt.want { | |
| 37 | + | t.Fatalf("Run() = %q, want %q", string(got.Stdout), tt.want) | |
| 37 | 38 | } | |
| 38 | 39 | }) | |
| 39 | 40 | } | |
| @@ -47,8 +48,8 @@ func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) { | |||
| 47 | 48 | if err == nil { | |
| 48 | 49 | t.Fatal("Run() error = nil, want non-zero exit error") | |
| 49 | 50 | } | |
| 50 | - | if string(got) != "abc" { | |
| 51 | - | t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got), "abc") | |
| 51 | + | if string(got.Stdout) != "abc" { | |
| 52 | + | t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got.Stdout), "abc") | |
| 52 | 53 | } | |
| 53 | 54 | } | |
| 54 | 55 | ||
| @@ -101,7 +102,69 @@ func TestRunnerRunUsesProvidedEnvironment(t *testing.T) { | |||
| 101 | 102 | if err != nil { | |
| 102 | 103 | t.Fatalf("Run() error = %v", err) | |
| 103 | 104 | } | |
| 104 | - | if strings.TrimSpace(string(got)) != "from-env" { | |
| 105 | - | t.Fatalf("Run() env output = %q, want from-env", string(got)) | |
| 105 | + | if strings.TrimSpace(string(got.Stdout)) != "from-env" { | |
| 106 | + | t.Fatalf("Run() env output = %q, want from-env", string(got.Stdout)) | |
| 106 | 107 | } | |
| 107 | 108 | } | |
| 109 | + | ||
| 110 | + | func TestRunnerCapturesStderrAndExitCode(t *testing.T) { | |
| 111 | + | tests := []struct { | |
| 112 | + | name string | |
| 113 | + | command string | |
| 114 | + | wantExitCode int | |
| 115 | + | wantStderr string | |
| 116 | + | wantStdout string | |
| 117 | + | }{ | |
| 118 | + | { | |
| 119 | + | name: "command not found reports 127 and stderr", | |
| 120 | + | command: "definitely-not-a-real-command-xyz", | |
| 121 | + | wantExitCode: 127, | |
| 122 | + | wantStderr: "not found", | |
| 123 | + | }, | |
| 124 | + | { | |
| 125 | + | name: "explicit exit code with stderr", | |
| 126 | + | command: "echo oops >&2; exit 3", | |
| 127 | + | wantExitCode: 3, | |
| 128 | + | wantStderr: "oops", | |
| 129 | + | }, | |
| 130 | + | { | |
| 131 | + | name: "success reports zero and stdout", | |
| 132 | + | command: "echo hello", | |
| 133 | + | wantExitCode: 0, | |
| 134 | + | wantStdout: "hello", | |
| 135 | + | }, | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | for _, tc := range tests { | |
| 139 | + | t.Run(tc.name, func(t *testing.T) { | |
| 140 | + | res, err := New().Run(context.Background(), ports.CommandSpec{ | |
| 141 | + | Command: tc.command, | |
| 142 | + | Timeout: 5 * time.Second, | |
| 143 | + | StdoutLimit: 1024, | |
| 144 | + | StderrLimit: 1024, | |
| 145 | + | }) | |
| 146 | + | if tc.wantExitCode == 0 { | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | } else { | |
| 149 | + | require.Error(t, err) | |
| 150 | + | } | |
| 151 | + | require.Equal(t, tc.wantExitCode, res.ExitCode) | |
| 152 | + | if tc.wantStderr != "" { | |
| 153 | + | require.Contains(t, string(res.Stderr), tc.wantStderr) | |
| 154 | + | } | |
| 155 | + | if tc.wantStdout != "" { | |
| 156 | + | require.Contains(t, string(res.Stdout), tc.wantStdout) | |
| 157 | + | } | |
| 158 | + | }) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestRunnerBoundsStderr(t *testing.T) { | |
| 163 | + | res, _ := New().Run(context.Background(), ports.CommandSpec{ | |
| 164 | + | Command: "yes badness 2>/dev/null | head -c 100000 >&2", | |
| 165 | + | Timeout: 5 * time.Second, | |
| 166 | + | StdoutLimit: 1024, | |
| 167 | + | StderrLimit: 64, | |
| 168 | + | }) | |
| 169 | + | require.Equal(t, 64, len(res.Stderr), "stderr must be captured and truncated to StderrLimit") | |
| 170 | + | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| 10 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 13 | func TestRunnerRun(t *testing.T) { | 14 | func TestRunnerRun(t *testing.T) { | ||
| @@ -19,7 +20,7 @@ func TestRunnerRun(t *testing.T) { | |||||
| 19 | }{ | 20 | }{ | ||
| 20 | {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"}, | 21 | {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"}, | ||
| 21 | {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"}, | 22 | {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"}, | ||
| 22 | - | {name: "stderr discarded", command: "printf out; printf err >&2", limit: 1024, want: "out"}, | 23 | + | {name: "stderr does not leak into stdout", command: "printf out; printf err >&2", limit: 1024, want: "out"}, |
| 23 | {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""}, | 24 | {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""}, | ||
| 24 | {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""}, | 25 | {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""}, | ||
| 25 | } | 26 | } | ||
| @@ -32,8 +33,8 @@ func TestRunnerRun(t *testing.T) { | |||||
| 32 | if err != nil { | 33 | if err != nil { | ||
| 33 | t.Fatalf("Run() error = %v", err) | 34 | t.Fatalf("Run() error = %v", err) | ||
| 34 | } | 35 | } | ||
| 35 | - | if string(got) != tt.want { | 36 | + | if string(got.Stdout) != tt.want { |
| 36 | - | t.Fatalf("Run() = %q, want %q", string(got), tt.want) | 37 | + | t.Fatalf("Run() = %q, want %q", string(got.Stdout), tt.want) |
| 37 | } | 38 | } | ||
| 38 | }) | 39 | }) | ||
| 39 | } | 40 | } | ||
| @@ -47,8 +48,8 @@ func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) { | |||||
| 47 | if err == nil { | 48 | if err == nil { | ||
| 48 | t.Fatal("Run() error = nil, want non-zero exit error") | 49 | t.Fatal("Run() error = nil, want non-zero exit error") | ||
| 49 | } | 50 | } | ||
| 50 | - | if string(got) != "abc" { | 51 | + | if string(got.Stdout) != "abc" { |
| 51 | - | t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got), "abc") | 52 | + | t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got.Stdout), "abc") |
| 52 | } | 53 | } | ||
| 53 | } | 54 | } | ||
| 54 | 55 | ||||
| @@ -101,7 +102,69 @@ func TestRunnerRunUsesProvidedEnvironment(t *testing.T) { | |||||
| 101 | if err != nil { | 102 | if err != nil { | ||
| 102 | t.Fatalf("Run() error = %v", err) | 103 | t.Fatalf("Run() error = %v", err) | ||
| 103 | } | 104 | } | ||
| 104 | - | if strings.TrimSpace(string(got)) != "from-env" { | 105 | + | if strings.TrimSpace(string(got.Stdout)) != "from-env" { |
| 105 | - | t.Fatalf("Run() env output = %q, want from-env", string(got)) | 106 | + | t.Fatalf("Run() env output = %q, want from-env", string(got.Stdout)) |
| 106 | } | 107 | } | ||
| 107 | } | 108 | } | ||
| 109 | + | ||||
| 110 | + | func TestRunnerCapturesStderrAndExitCode(t *testing.T) { | |||
| 111 | + | tests := []struct { | |||
| 112 | + | name string | |||
| 113 | + | command string | |||
| 114 | + | wantExitCode int | |||
| 115 | + | wantStderr string | |||
| 116 | + | wantStdout string | |||
| 117 | + | }{ | |||
| 118 | + | { | |||
| 119 | + | name: "command not found reports 127 and stderr", | |||
| 120 | + | command: "definitely-not-a-real-command-xyz", | |||
| 121 | + | wantExitCode: 127, | |||
| 122 | + | wantStderr: "not found", | |||
| 123 | + | }, | |||
| 124 | + | { | |||
| 125 | + | name: "explicit exit code with stderr", | |||
| 126 | + | command: "echo oops >&2; exit 3", | |||
| 127 | + | wantExitCode: 3, | |||
| 128 | + | wantStderr: "oops", | |||
| 129 | + | }, | |||
| 130 | + | { | |||
| 131 | + | name: "success reports zero and stdout", | |||
| 132 | + | command: "echo hello", | |||
| 133 | + | wantExitCode: 0, | |||
| 134 | + | wantStdout: "hello", | |||
| 135 | + | }, | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | for _, tc := range tests { | |||
| 139 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 140 | + | res, err := New().Run(context.Background(), ports.CommandSpec{ | |||
| 141 | + | Command: tc.command, | |||
| 142 | + | Timeout: 5 * time.Second, | |||
| 143 | + | StdoutLimit: 1024, | |||
| 144 | + | StderrLimit: 1024, | |||
| 145 | + | }) | |||
| 146 | + | if tc.wantExitCode == 0 { | |||
| 147 | + | require.NoError(t, err) | |||
| 148 | + | } else { | |||
| 149 | + | require.Error(t, err) | |||
| 150 | + | } | |||
| 151 | + | require.Equal(t, tc.wantExitCode, res.ExitCode) | |||
| 152 | + | if tc.wantStderr != "" { | |||
| 153 | + | require.Contains(t, string(res.Stderr), tc.wantStderr) | |||
| 154 | + | } | |||
| 155 | + | if tc.wantStdout != "" { | |||
| 156 | + | require.Contains(t, string(res.Stdout), tc.wantStdout) | |||
| 157 | + | } | |||
| 158 | + | }) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestRunnerBoundsStderr(t *testing.T) { | |||
| 163 | + | res, _ := New().Run(context.Background(), ports.CommandSpec{ | |||
| 164 | + | Command: "yes badness 2>/dev/null | head -c 100000 >&2", | |||
| 165 | + | Timeout: 5 * time.Second, | |||
| 166 | + | StdoutLimit: 1024, | |||
| 167 | + | StderrLimit: 64, | |||
| 168 | + | }) | |||
| 169 | + | require.Equal(t, 64, len(res.Stderr), "stderr must be captured and truncated to StderrLimit") | |||
| 170 | + | } | |||
M internal/ports/mocks/shell_mock.go +8 -10
| @@ -39,24 +39,22 @@ func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter { | |||
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | 41 | // Run provides a mock function for the type MockShellCommandRunner | |
| 42 | - | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | |
| 42 | + | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) { | |
| 43 | 43 | ret := _mock.Called(ctx, spec) | |
| 44 | 44 | ||
| 45 | 45 | if len(ret) == 0 { | |
| 46 | 46 | panic("no return value specified for Run") | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | - | var r0 []byte | |
| 49 | + | var r0 ports.CommandResult | |
| 50 | 50 | var r1 error | |
| 51 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok { | |
| 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.CommandResult, error)); ok { | |
| 52 | 52 | return returnFunc(ctx, spec) | |
| 53 | 53 | } | |
| 54 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok { | |
| 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.CommandResult); ok { | |
| 55 | 55 | r0 = returnFunc(ctx, spec) | |
| 56 | 56 | } else { | |
| 57 | - | if ret.Get(0) != nil { | |
| 58 | - | r0 = ret.Get(0).([]byte) | |
| 59 | - | } | |
| 57 | + | r0 = ret.Get(0).(ports.CommandResult) | |
| 60 | 58 | } | |
| 61 | 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | |
| 62 | 60 | r1 = returnFunc(ctx, spec) | |
| @@ -96,12 +94,12 @@ func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spe | |||
| 96 | 94 | return _c | |
| 97 | 95 | } | |
| 98 | 96 | ||
| 99 | - | func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call { | |
| 100 | - | _c.Call.Return(bytes, err) | |
| 97 | + | func (_c *MockShellCommandRunner_Run_Call) Return(commandResult ports.CommandResult, err error) *MockShellCommandRunner_Run_Call { | |
| 98 | + | _c.Call.Return(commandResult, err) | |
| 101 | 99 | return _c | |
| 102 | 100 | } | |
| 103 | 101 | ||
| 104 | - | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call { | |
| 102 | + | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error)) *MockShellCommandRunner_Run_Call { | |
| 105 | 103 | _c.Call.Return(run) | |
| 106 | 104 | return _c | |
| 107 | 105 | } | |
| @@ -39,24 +39,22 @@ func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter { | |||||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | // Run provides a mock function for the type MockShellCommandRunner | 41 | // Run provides a mock function for the type MockShellCommandRunner | ||
| 42 | - | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | 42 | + | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) { |
| 43 | ret := _mock.Called(ctx, spec) | 43 | ret := _mock.Called(ctx, spec) | ||
| 44 | 44 | ||||
| 45 | if len(ret) == 0 { | 45 | if len(ret) == 0 { | ||
| 46 | panic("no return value specified for Run") | 46 | panic("no return value specified for Run") | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | - | var r0 []byte | 49 | + | var r0 ports.CommandResult |
| 50 | var r1 error | 50 | var r1 error | ||
| 51 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok { | 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.CommandResult, error)); ok { |
| 52 | return returnFunc(ctx, spec) | 52 | return returnFunc(ctx, spec) | ||
| 53 | } | 53 | } | ||
| 54 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok { | 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.CommandResult); ok { |
| 55 | r0 = returnFunc(ctx, spec) | 55 | r0 = returnFunc(ctx, spec) | ||
| 56 | } else { | 56 | } else { | ||
| 57 | - | if ret.Get(0) != nil { | 57 | + | r0 = ret.Get(0).(ports.CommandResult) |
| 58 | - | r0 = ret.Get(0).([]byte) | |||
| 59 | - | } | |||
| 60 | } | 58 | } | ||
| 61 | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | ||
| 62 | r1 = returnFunc(ctx, spec) | 60 | r1 = returnFunc(ctx, spec) | ||
| @@ -96,12 +94,12 @@ func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spe | |||||
| 96 | return _c | 94 | return _c | ||
| 97 | } | 95 | } | ||
| 98 | 96 | ||||
| 99 | - | func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call { | 97 | + | func (_c *MockShellCommandRunner_Run_Call) Return(commandResult ports.CommandResult, err error) *MockShellCommandRunner_Run_Call { |
| 100 | - | _c.Call.Return(bytes, err) | 98 | + | _c.Call.Return(commandResult, err) |
| 101 | return _c | 99 | return _c | ||
| 102 | } | 100 | } | ||
| 103 | 101 | ||||
| 104 | - | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call { | 102 | + | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error)) *MockShellCommandRunner_Run_Call { |
| 105 | _c.Call.Return(run) | 103 | _c.Call.Return(run) | ||
| 106 | return _c | 104 | return _c | ||
| 107 | } | 105 | } | ||
M internal/ports/shell.go +13 -2
| @@ -11,9 +11,20 @@ type CommandSpec struct { | |||
| 11 | 11 | Env []string | |
| 12 | 12 | Timeout time.Duration | |
| 13 | 13 | StdoutLimit int | |
| 14 | + | StderrLimit int | |
| 14 | 15 | } | |
| 15 | 16 | ||
| 16 | - | // ShellCommandRunner runs shell commands and returns bounded stdout bytes. | |
| 17 | + | // CommandResult carries bounded output and the process exit status. ExitCode is | |
| 18 | + | // 0 when the command ran to completion successfully, the process exit code when | |
| 19 | + | // it ran and failed, and -1 when the command could not be started or was | |
| 20 | + | // terminated by a signal. | |
| 21 | + | type CommandResult struct { | |
| 22 | + | Stdout []byte | |
| 23 | + | Stderr []byte | |
| 24 | + | ExitCode int | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // ShellCommandRunner runs shell commands and returns bounded output. | |
| 17 | 28 | type ShellCommandRunner interface { | |
| 18 | - | Run(ctx context.Context, spec CommandSpec) ([]byte, error) | |
| 29 | + | Run(ctx context.Context, spec CommandSpec) (CommandResult, error) | |
| 19 | 30 | } | |
| @@ -11,9 +11,20 @@ type CommandSpec struct { | |||||
| 11 | Env []string | 11 | Env []string | ||
| 12 | Timeout time.Duration | 12 | Timeout time.Duration | ||
| 13 | StdoutLimit int | 13 | StdoutLimit int | ||
| 14 | + | StderrLimit int | |||
| 14 | } | 15 | } | ||
| 15 | 16 | ||||
| 16 | - | // ShellCommandRunner runs shell commands and returns bounded stdout bytes. | 17 | + | // CommandResult carries bounded output and the process exit status. ExitCode is |
| 18 | + | // 0 when the command ran to completion successfully, the process exit code when | |||
| 19 | + | // it ran and failed, and -1 when the command could not be started or was | |||
| 20 | + | // terminated by a signal. | |||
| 21 | + | type CommandResult struct { | |||
| 22 | + | Stdout []byte | |||
| 23 | + | Stderr []byte | |||
| 24 | + | ExitCode int | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // ShellCommandRunner runs shell commands and returns bounded output. | |||
| 17 | type ShellCommandRunner interface { | 28 | type ShellCommandRunner interface { | ||
| 18 | - | Run(ctx context.Context, spec CommandSpec) ([]byte, error) | 29 | + | Run(ctx context.Context, spec CommandSpec) (CommandResult, error) |
| 19 | } | 30 | } | ||
M internal/usecase/daemon/bar_refresh.go +129 -18
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -9,9 +10,20 @@ import ( | |||
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | type barScriptExecutor interface { | |
| 12 | - | run(context.Context, string, barScriptContext) (string, error) | |
| 13 | + | run(ctx context.Context, command string, env []string, scriptCtx barScriptContext) (string, error) | |
| 13 | 14 | } | |
| 14 | 15 | ||
| 16 | + | // Anchor names, exported to bar scripts as VEV_ANCHOR and documented in | |
| 17 | + | // docs/configuration.md. barAnchors must list every anchor so cleanup in | |
| 18 | + | // clearBarScriptsForSession stays in sync with the anchors run in | |
| 19 | + | // runBarScripts. | |
| 20 | + | const ( | |
| 21 | + | barAnchorTopRight = "top-right" | |
| 22 | + | barAnchorBottomRight = "bottom-right" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | var barAnchors = []string{barAnchorTopRight, barAnchorBottomRight} | |
| 26 | + | ||
| 15 | 27 | type barScriptConfig struct { | |
| 16 | 28 | topRight string | |
| 17 | 29 | bottomRight string | |
| @@ -23,6 +35,11 @@ type barScriptOutputs struct { | |||
| 23 | 35 | bottomRight string | |
| 24 | 36 | } | |
| 25 | 37 | ||
| 38 | + | type barScriptFailureKey struct { | |
| 39 | + | id domain.SessionID | |
| 40 | + | anchor string | |
| 41 | + | } | |
| 42 | + | ||
| 26 | 43 | type barScriptState struct { | |
| 27 | 44 | mu sync.Mutex | |
| 28 | 45 | cfg barScriptConfig | |
| @@ -32,7 +49,12 @@ type barScriptState struct { | |||
| 32 | 49 | lastContext map[domain.SessionID]barScriptContext | |
| 33 | 50 | running map[domain.SessionID]bool | |
| 34 | 51 | pending map[domain.SessionID]bool | |
| 52 | + | lastFailure map[barScriptFailureKey]string | |
| 35 | 53 | version uint64 | |
| 54 | + | ||
| 55 | + | // reload wakes barScriptPoller so an interval change takes effect without | |
| 56 | + | // waiting out the timer armed under the previous interval. | |
| 57 | + | reload chan struct{} | |
| 36 | 58 | } | |
| 37 | 59 | ||
| 38 | 60 | func effectiveBarInterval(d time.Duration) time.Duration { | |
| @@ -65,6 +87,9 @@ func (s *barScriptState) initLocked() { | |||
| 65 | 87 | if s.pending == nil { | |
| 66 | 88 | s.pending = make(map[domain.SessionID]bool) | |
| 67 | 89 | } | |
| 90 | + | if s.lastFailure == nil { | |
| 91 | + | s.lastFailure = make(map[barScriptFailureKey]string) | |
| 92 | + | } | |
| 68 | 93 | } | |
| 69 | 94 | ||
| 70 | 95 | func (d *Daemon) barScriptSnapshot(sess *session) (string, string) { | |
| @@ -86,13 +111,17 @@ func (d *Daemon) barScriptInterval() time.Duration { | |||
| 86 | 111 | return effectiveBarInterval(d.barScripts.cfg.interval) | |
| 87 | 112 | } | |
| 88 | 113 | ||
| 89 | - | func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) { | |
| 114 | + | func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, []string, bool) { | |
| 90 | 115 | ctx := barScriptContext{Anchor: anchor} | |
| 91 | 116 | if sess == nil { | |
| 92 | - | return ctx, false | |
| 117 | + | return ctx, nil, false | |
| 93 | 118 | } | |
| 94 | 119 | sess.mu.Lock() | |
| 95 | 120 | ctx.Session = sess.name | |
| 121 | + | // No copy needed: writers always replace sess.env with a whole fresh | |
| 122 | + | // slice under sess.mu (daemon.go, resume.go), and nothing mutates the | |
| 123 | + | // backing array in place, so capturing the header here is safe. | |
| 124 | + | env := sess.env | |
| 96 | 125 | active := sess.active | |
| 97 | 126 | var tb *tab | |
| 98 | 127 | if active >= 0 && active < len(sess.tabs) { | |
| @@ -102,7 +131,7 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip | |||
| 102 | 131 | ctx.PaneCWD = sess.cwd | |
| 103 | 132 | sess.mu.Unlock() | |
| 104 | 133 | if ac == nil || tb == nil { | |
| 105 | - | return ctx, false | |
| 134 | + | return ctx, env, false | |
| 106 | 135 | } | |
| 107 | 136 | tb.mu.Lock() | |
| 108 | 137 | ctx.Tab = tb.stableID | |
| @@ -120,25 +149,54 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip | |||
| 120 | 149 | ctx.PaneCWD = cwd | |
| 121 | 150 | } | |
| 122 | 151 | } | |
| 123 | - | return ctx, true | |
| 152 | + | return ctx, env, true | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // signalBarPollerReload wakes the poller without blocking. The channel is | |
| 156 | + | // buffered to one, so a pending signal already conveys "config changed". | |
| 157 | + | func (d *Daemon) signalBarPollerReload() { | |
| 158 | + | if d == nil || d.barScripts == nil || d.barScripts.reload == nil { | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | select { | |
| 162 | + | case d.barScripts.reload <- struct{}{}: | |
| 163 | + | default: | |
| 164 | + | } | |
| 124 | 165 | } | |
| 125 | 166 | ||
| 126 | 167 | func (d *Daemon) barScriptPoller(ctx context.Context) { | |
| 127 | 168 | timer := d.clock.NewTimer(d.barScriptInterval()) | |
| 128 | - | defer timer.Stop() | |
| 169 | + | defer func() { timer.Stop() }() | |
| 129 | 170 | for { | |
| 130 | 171 | select { | |
| 131 | 172 | case <-ctx.Done(): | |
| 132 | 173 | return | |
| 174 | + | case <-d.barScripts.reload: | |
| 175 | + | timer.Stop() | |
| 176 | + | timer = d.clock.NewTimer(d.barScriptInterval()) | |
| 133 | 177 | case now := <-timer.C(): | |
| 134 | 178 | for _, sess := range d.sessionsSnapshot() { | |
| 135 | 179 | d.refreshBarScriptsIfDue(sess, now, false) | |
| 136 | 180 | } | |
| 137 | - | timer.Reset(d.barScriptInterval()) | |
| 181 | + | timer.Stop() | |
| 182 | + | timer = d.clock.NewTimer(d.barScriptInterval()) | |
| 138 | 183 | } | |
| 139 | 184 | } | |
| 140 | 185 | } | |
| 141 | 186 | ||
| 187 | + | // refreshBarScriptsAllSessions forces a bar-script run for every live session. | |
| 188 | + | // Called after a config change so a new command takes effect immediately rather | |
| 189 | + | // than leaving the anchor blank until the poller's next tick. | |
| 190 | + | func (d *Daemon) refreshBarScriptsAllSessions() { | |
| 191 | + | if d == nil || d.barScripts == nil || d.clock == nil { | |
| 192 | + | return | |
| 193 | + | } | |
| 194 | + | now := d.clock.Now() | |
| 195 | + | for _, sess := range d.sessionsSnapshot() { | |
| 196 | + | d.refreshBarScriptsIfDue(sess, now, true) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 142 | 200 | func (d *Daemon) sessionsSnapshot() []*session { | |
| 143 | 201 | d.mu.Lock() | |
| 144 | 202 | defer d.mu.Unlock() | |
| @@ -160,16 +218,44 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | |||
| 160 | 218 | delete(d.barScripts.lastContext, id) | |
| 161 | 219 | delete(d.barScripts.running, id) | |
| 162 | 220 | delete(d.barScripts.pending, id) | |
| 221 | + | for _, anchor := range barAnchors { | |
| 222 | + | delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor}) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // shouldLogBarFailure reports whether this failure differs from the last one | |
| 227 | + | // logged for the same session and anchor, so a persistently broken script | |
| 228 | + | // warns once instead of every refresh interval. | |
| 229 | + | func (d *Daemon) shouldLogBarFailure(id domain.SessionID, anchor, signature string) bool { | |
| 230 | + | d.barScripts.mu.Lock() | |
| 231 | + | defer d.barScripts.mu.Unlock() | |
| 232 | + | d.barScripts.initLocked() | |
| 233 | + | key := barScriptFailureKey{id: id, anchor: anchor} | |
| 234 | + | if d.barScripts.lastFailure[key] == signature { | |
| 235 | + | return false | |
| 236 | + | } | |
| 237 | + | d.barScripts.lastFailure[key] = signature | |
| 238 | + | return true | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func (d *Daemon) clearBarFailure(id domain.SessionID, anchor string) { | |
| 242 | + | d.barScripts.mu.Lock() | |
| 243 | + | defer d.barScripts.mu.Unlock() | |
| 244 | + | d.barScripts.initLocked() | |
| 245 | + | delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor}) | |
| 163 | 246 | } | |
| 164 | 247 | ||
| 165 | 248 | func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool { | |
| 166 | 249 | if d == nil || d.barScripts == nil || sess == nil { | |
| 167 | 250 | return false | |
| 168 | 251 | } | |
| 169 | - | baseCtx, ok := d.collectBarScriptContext(sess, "") | |
| 252 | + | baseCtx, env, ok := d.collectBarScriptContext(sess, "") | |
| 170 | 253 | if !ok { | |
| 171 | 254 | return false | |
| 172 | 255 | } | |
| 256 | + | if len(env) == 0 { | |
| 257 | + | env = d.baseEnv | |
| 258 | + | } | |
| 173 | 259 | d.barScripts.mu.Lock() | |
| 174 | 260 | d.barScripts.initLocked() | |
| 175 | 261 | if d.barScripts.lastContext[sess.id] != baseCtx { | |
| @@ -213,7 +299,7 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool | |||
| 213 | 299 | runner := d.barScripts.runner | |
| 214 | 300 | version := d.barScripts.version | |
| 215 | 301 | d.barScripts.mu.Unlock() | |
| 216 | - | go d.runBarScripts(sess, runner, cfg, baseCtx, version) | |
| 302 | + | go d.runBarScripts(sess, runner, cfg, baseCtx, env, version) | |
| 217 | 303 | return true | |
| 218 | 304 | } | |
| 219 | 305 | ||
| @@ -247,16 +333,16 @@ func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Durati | |||
| 247 | 333 | }() | |
| 248 | 334 | } | |
| 249 | 335 | ||
| 250 | - | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) { | |
| 336 | + | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, env []string, version uint64) { | |
| 251 | 337 | if runner == nil { | |
| 252 | - | runner = barScriptRunner{baseEnv: d.baseEnv} | |
| 338 | + | runner = barScriptRunner{} | |
| 253 | 339 | } | |
| 254 | 340 | ctx := sess.ctx | |
| 255 | 341 | if ctx == nil { | |
| 256 | 342 | ctx = context.Background() | |
| 257 | 343 | } | |
| 258 | - | top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name) | |
| 259 | - | bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name) | |
| 344 | + | top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, barAnchorTopRight, sess) | |
| 345 | + | bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, barAnchorBottomRight, sess) | |
| 260 | 346 | d.barScripts.mu.Lock() | |
| 261 | 347 | d.barScripts.initLocked() | |
| 262 | 348 | if !d.barScripts.running[sess.id] || d.barScripts.version != version { | |
| @@ -287,21 +373,46 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS | |||
| 287 | 373 | } | |
| 288 | 374 | } | |
| 289 | 375 | ||
| 290 | - | func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) { | |
| 376 | + | func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor string, sess *session) (string, bool) { | |
| 291 | 377 | if command == "" { | |
| 292 | 378 | return "", true | |
| 293 | 379 | } | |
| 294 | 380 | base.Anchor = anchor | |
| 295 | - | out, err := runner.run(ctx, command, base) | |
| 381 | + | out, err := runner.run(ctx, command, env, base) | |
| 296 | 382 | if err != nil { | |
| 297 | - | if d.log != nil { | |
| 298 | - | d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err) | |
| 299 | - | } | |
| 383 | + | d.logBarScriptFailure(sess, base.Session, anchor, command, env, err) | |
| 300 | 384 | return "", false | |
| 301 | 385 | } | |
| 386 | + | d.clearBarFailure(sess.id, anchor) | |
| 302 | 387 | return out, true | |
| 303 | 388 | } | |
| 304 | 389 | ||
| 390 | + | // logBarScriptFailure logs a bar script failure. sessionName must come from a | |
| 391 | + | // synchronized read of sess.name (e.g. base.Session, captured under sess.mu | |
| 392 | + | // in collectBarScriptContext) since sess.name is mutable via session rename. | |
| 393 | + | func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command string, env []string, err error) { | |
| 394 | + | if d.log == nil { | |
| 395 | + | return | |
| 396 | + | } | |
| 397 | + | if !d.shouldLogBarFailure(sess.id, anchor, err.Error()) { | |
| 398 | + | return | |
| 399 | + | } | |
| 400 | + | attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err} | |
| 401 | + | var scriptErr *barScriptError | |
| 402 | + | if errors.As(err, &scriptErr) { | |
| 403 | + | attrs = append(attrs, "exit_code", scriptErr.exitCode) | |
| 404 | + | if scriptErr.stderr != "" { | |
| 405 | + | attrs = append(attrs, "stderr", scriptErr.stderr) | |
| 406 | + | } | |
| 407 | + | if scriptErr.exitCode == 127 { | |
| 408 | + | attrs = append(attrs, "hint", | |
| 409 | + | "command not found on the daemon's PATH; use an absolute path or ensure it is on the attaching client's PATH", | |
| 410 | + | "PATH", pathFromEnv(env)) | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | d.log.Warn("bar script failed; keeping last good output", attrs...) | |
| 414 | + | } | |
| 415 | + | ||
| 305 | 416 | func (d *Daemon) pokeSessionRender(sess *session) { | |
| 306 | 417 | if sess == nil { | |
| 307 | 418 | return | |
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "sync" | 6 | "sync" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| @@ -9,9 +10,20 @@ import ( | |||||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | type barScriptExecutor interface { | 12 | type barScriptExecutor interface { | ||
| 12 | - | run(context.Context, string, barScriptContext) (string, error) | 13 | + | run(ctx context.Context, command string, env []string, scriptCtx barScriptContext) (string, error) |
| 13 | } | 14 | } | ||
| 14 | 15 | ||||
| 16 | + | // Anchor names, exported to bar scripts as VEV_ANCHOR and documented in | |||
| 17 | + | // docs/configuration.md. barAnchors must list every anchor so cleanup in | |||
| 18 | + | // clearBarScriptsForSession stays in sync with the anchors run in | |||
| 19 | + | // runBarScripts. | |||
| 20 | + | const ( | |||
| 21 | + | barAnchorTopRight = "top-right" | |||
| 22 | + | barAnchorBottomRight = "bottom-right" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | var barAnchors = []string{barAnchorTopRight, barAnchorBottomRight} | |||
| 26 | + | ||||
| 15 | type barScriptConfig struct { | 27 | type barScriptConfig struct { | ||
| 16 | topRight string | 28 | topRight string | ||
| 17 | bottomRight string | 29 | bottomRight string | ||
| @@ -23,6 +35,11 @@ type barScriptOutputs struct { | |||||
| 23 | bottomRight string | 35 | bottomRight string | ||
| 24 | } | 36 | } | ||
| 25 | 37 | ||||
| 38 | + | type barScriptFailureKey struct { | |||
| 39 | + | id domain.SessionID | |||
| 40 | + | anchor string | |||
| 41 | + | } | |||
| 42 | + | ||||
| 26 | type barScriptState struct { | 43 | type barScriptState struct { | ||
| 27 | mu sync.Mutex | 44 | mu sync.Mutex | ||
| 28 | cfg barScriptConfig | 45 | cfg barScriptConfig | ||
| @@ -32,7 +49,12 @@ type barScriptState struct { | |||||
| 32 | lastContext map[domain.SessionID]barScriptContext | 49 | lastContext map[domain.SessionID]barScriptContext | ||
| 33 | running map[domain.SessionID]bool | 50 | running map[domain.SessionID]bool | ||
| 34 | pending map[domain.SessionID]bool | 51 | pending map[domain.SessionID]bool | ||
| 52 | + | lastFailure map[barScriptFailureKey]string | |||
| 35 | version uint64 | 53 | version uint64 | ||
| 54 | + | ||||
| 55 | + | // reload wakes barScriptPoller so an interval change takes effect without | |||
| 56 | + | // waiting out the timer armed under the previous interval. | |||
| 57 | + | reload chan struct{} | |||
| 36 | } | 58 | } | ||
| 37 | 59 | ||||
| 38 | func effectiveBarInterval(d time.Duration) time.Duration { | 60 | func effectiveBarInterval(d time.Duration) time.Duration { | ||
| @@ -65,6 +87,9 @@ func (s *barScriptState) initLocked() { | |||||
| 65 | if s.pending == nil { | 87 | if s.pending == nil { | ||
| 66 | s.pending = make(map[domain.SessionID]bool) | 88 | s.pending = make(map[domain.SessionID]bool) | ||
| 67 | } | 89 | } | ||
| 90 | + | if s.lastFailure == nil { | |||
| 91 | + | s.lastFailure = make(map[barScriptFailureKey]string) | |||
| 92 | + | } | |||
| 68 | } | 93 | } | ||
| 69 | 94 | ||||
| 70 | func (d *Daemon) barScriptSnapshot(sess *session) (string, string) { | 95 | func (d *Daemon) barScriptSnapshot(sess *session) (string, string) { | ||
| @@ -86,13 +111,17 @@ func (d *Daemon) barScriptInterval() time.Duration { | |||||
| 86 | return effectiveBarInterval(d.barScripts.cfg.interval) | 111 | return effectiveBarInterval(d.barScripts.cfg.interval) | ||
| 87 | } | 112 | } | ||
| 88 | 113 | ||||
| 89 | - | func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) { | 114 | + | func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, []string, bool) { |
| 90 | ctx := barScriptContext{Anchor: anchor} | 115 | ctx := barScriptContext{Anchor: anchor} | ||
| 91 | if sess == nil { | 116 | if sess == nil { | ||
| 92 | - | return ctx, false | 117 | + | return ctx, nil, false |
| 93 | } | 118 | } | ||
| 94 | sess.mu.Lock() | 119 | sess.mu.Lock() | ||
| 95 | ctx.Session = sess.name | 120 | ctx.Session = sess.name | ||
| 121 | + | // No copy needed: writers always replace sess.env with a whole fresh | |||
| 122 | + | // slice under sess.mu (daemon.go, resume.go), and nothing mutates the | |||
| 123 | + | // backing array in place, so capturing the header here is safe. | |||
| 124 | + | env := sess.env | |||
| 96 | active := sess.active | 125 | active := sess.active | ||
| 97 | var tb *tab | 126 | var tb *tab | ||
| 98 | if active >= 0 && active < len(sess.tabs) { | 127 | if active >= 0 && active < len(sess.tabs) { | ||
| @@ -102,7 +131,7 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip | |||||
| 102 | ctx.PaneCWD = sess.cwd | 131 | ctx.PaneCWD = sess.cwd | ||
| 103 | sess.mu.Unlock() | 132 | sess.mu.Unlock() | ||
| 104 | if ac == nil || tb == nil { | 133 | if ac == nil || tb == nil { | ||
| 105 | - | return ctx, false | 134 | + | return ctx, env, false |
| 106 | } | 135 | } | ||
| 107 | tb.mu.Lock() | 136 | tb.mu.Lock() | ||
| 108 | ctx.Tab = tb.stableID | 137 | ctx.Tab = tb.stableID | ||
| @@ -120,25 +149,54 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip | |||||
| 120 | ctx.PaneCWD = cwd | 149 | ctx.PaneCWD = cwd | ||
| 121 | } | 150 | } | ||
| 122 | } | 151 | } | ||
| 123 | - | return ctx, true | 152 | + | return ctx, env, true |
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // signalBarPollerReload wakes the poller without blocking. The channel is | |||
| 156 | + | // buffered to one, so a pending signal already conveys "config changed". | |||
| 157 | + | func (d *Daemon) signalBarPollerReload() { | |||
| 158 | + | if d == nil || d.barScripts == nil || d.barScripts.reload == nil { | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | select { | |||
| 162 | + | case d.barScripts.reload <- struct{}{}: | |||
| 163 | + | default: | |||
| 164 | + | } | |||
| 124 | } | 165 | } | ||
| 125 | 166 | ||||
| 126 | func (d *Daemon) barScriptPoller(ctx context.Context) { | 167 | func (d *Daemon) barScriptPoller(ctx context.Context) { | ||
| 127 | timer := d.clock.NewTimer(d.barScriptInterval()) | 168 | timer := d.clock.NewTimer(d.barScriptInterval()) | ||
| 128 | - | defer timer.Stop() | 169 | + | defer func() { timer.Stop() }() |
| 129 | for { | 170 | for { | ||
| 130 | select { | 171 | select { | ||
| 131 | case <-ctx.Done(): | 172 | case <-ctx.Done(): | ||
| 132 | return | 173 | return | ||
| 174 | + | case <-d.barScripts.reload: | |||
| 175 | + | timer.Stop() | |||
| 176 | + | timer = d.clock.NewTimer(d.barScriptInterval()) | |||
| 133 | case now := <-timer.C(): | 177 | case now := <-timer.C(): | ||
| 134 | for _, sess := range d.sessionsSnapshot() { | 178 | for _, sess := range d.sessionsSnapshot() { | ||
| 135 | d.refreshBarScriptsIfDue(sess, now, false) | 179 | d.refreshBarScriptsIfDue(sess, now, false) | ||
| 136 | } | 180 | } | ||
| 137 | - | timer.Reset(d.barScriptInterval()) | 181 | + | timer.Stop() |
| 182 | + | timer = d.clock.NewTimer(d.barScriptInterval()) | |||
| 138 | } | 183 | } | ||
| 139 | } | 184 | } | ||
| 140 | } | 185 | } | ||
| 141 | 186 | ||||
| 187 | + | // refreshBarScriptsAllSessions forces a bar-script run for every live session. | |||
| 188 | + | // Called after a config change so a new command takes effect immediately rather | |||
| 189 | + | // than leaving the anchor blank until the poller's next tick. | |||
| 190 | + | func (d *Daemon) refreshBarScriptsAllSessions() { | |||
| 191 | + | if d == nil || d.barScripts == nil || d.clock == nil { | |||
| 192 | + | return | |||
| 193 | + | } | |||
| 194 | + | now := d.clock.Now() | |||
| 195 | + | for _, sess := range d.sessionsSnapshot() { | |||
| 196 | + | d.refreshBarScriptsIfDue(sess, now, true) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 142 | func (d *Daemon) sessionsSnapshot() []*session { | 200 | func (d *Daemon) sessionsSnapshot() []*session { | ||
| 143 | d.mu.Lock() | 201 | d.mu.Lock() | ||
| 144 | defer d.mu.Unlock() | 202 | defer d.mu.Unlock() | ||
| @@ -160,16 +218,44 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | |||||
| 160 | delete(d.barScripts.lastContext, id) | 218 | delete(d.barScripts.lastContext, id) | ||
| 161 | delete(d.barScripts.running, id) | 219 | delete(d.barScripts.running, id) | ||
| 162 | delete(d.barScripts.pending, id) | 220 | delete(d.barScripts.pending, id) | ||
| 221 | + | for _, anchor := range barAnchors { | |||
| 222 | + | delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor}) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // shouldLogBarFailure reports whether this failure differs from the last one | |||
| 227 | + | // logged for the same session and anchor, so a persistently broken script | |||
| 228 | + | // warns once instead of every refresh interval. | |||
| 229 | + | func (d *Daemon) shouldLogBarFailure(id domain.SessionID, anchor, signature string) bool { | |||
| 230 | + | d.barScripts.mu.Lock() | |||
| 231 | + | defer d.barScripts.mu.Unlock() | |||
| 232 | + | d.barScripts.initLocked() | |||
| 233 | + | key := barScriptFailureKey{id: id, anchor: anchor} | |||
| 234 | + | if d.barScripts.lastFailure[key] == signature { | |||
| 235 | + | return false | |||
| 236 | + | } | |||
| 237 | + | d.barScripts.lastFailure[key] = signature | |||
| 238 | + | return true | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func (d *Daemon) clearBarFailure(id domain.SessionID, anchor string) { | |||
| 242 | + | d.barScripts.mu.Lock() | |||
| 243 | + | defer d.barScripts.mu.Unlock() | |||
| 244 | + | d.barScripts.initLocked() | |||
| 245 | + | delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor}) | |||
| 163 | } | 246 | } | ||
| 164 | 247 | ||||
| 165 | func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool { | 248 | func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool { | ||
| 166 | if d == nil || d.barScripts == nil || sess == nil { | 249 | if d == nil || d.barScripts == nil || sess == nil { | ||
| 167 | return false | 250 | return false | ||
| 168 | } | 251 | } | ||
| 169 | - | baseCtx, ok := d.collectBarScriptContext(sess, "") | 252 | + | baseCtx, env, ok := d.collectBarScriptContext(sess, "") |
| 170 | if !ok { | 253 | if !ok { | ||
| 171 | return false | 254 | return false | ||
| 172 | } | 255 | } | ||
| 256 | + | if len(env) == 0 { | |||
| 257 | + | env = d.baseEnv | |||
| 258 | + | } | |||
| 173 | d.barScripts.mu.Lock() | 259 | d.barScripts.mu.Lock() | ||
| 174 | d.barScripts.initLocked() | 260 | d.barScripts.initLocked() | ||
| 175 | if d.barScripts.lastContext[sess.id] != baseCtx { | 261 | if d.barScripts.lastContext[sess.id] != baseCtx { | ||
| @@ -213,7 +299,7 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool | |||||
| 213 | runner := d.barScripts.runner | 299 | runner := d.barScripts.runner | ||
| 214 | version := d.barScripts.version | 300 | version := d.barScripts.version | ||
| 215 | d.barScripts.mu.Unlock() | 301 | d.barScripts.mu.Unlock() | ||
| 216 | - | go d.runBarScripts(sess, runner, cfg, baseCtx, version) | 302 | + | go d.runBarScripts(sess, runner, cfg, baseCtx, env, version) |
| 217 | return true | 303 | return true | ||
| 218 | } | 304 | } | ||
| 219 | 305 | ||||
| @@ -247,16 +333,16 @@ func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Durati | |||||
| 247 | }() | 333 | }() | ||
| 248 | } | 334 | } | ||
| 249 | 335 | ||||
| 250 | - | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) { | 336 | + | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, env []string, version uint64) { |
| 251 | if runner == nil { | 337 | if runner == nil { | ||
| 252 | - | runner = barScriptRunner{baseEnv: d.baseEnv} | 338 | + | runner = barScriptRunner{} |
| 253 | } | 339 | } | ||
| 254 | ctx := sess.ctx | 340 | ctx := sess.ctx | ||
| 255 | if ctx == nil { | 341 | if ctx == nil { | ||
| 256 | ctx = context.Background() | 342 | ctx = context.Background() | ||
| 257 | } | 343 | } | ||
| 258 | - | top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name) | 344 | + | top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, barAnchorTopRight, sess) |
| 259 | - | bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name) | 345 | + | bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, barAnchorBottomRight, sess) |
| 260 | d.barScripts.mu.Lock() | 346 | d.barScripts.mu.Lock() | ||
| 261 | d.barScripts.initLocked() | 347 | d.barScripts.initLocked() | ||
| 262 | if !d.barScripts.running[sess.id] || d.barScripts.version != version { | 348 | if !d.barScripts.running[sess.id] || d.barScripts.version != version { | ||
| @@ -287,21 +373,46 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS | |||||
| 287 | } | 373 | } | ||
| 288 | } | 374 | } | ||
| 289 | 375 | ||||
| 290 | - | func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) { | 376 | + | func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor string, sess *session) (string, bool) { |
| 291 | if command == "" { | 377 | if command == "" { | ||
| 292 | return "", true | 378 | return "", true | ||
| 293 | } | 379 | } | ||
| 294 | base.Anchor = anchor | 380 | base.Anchor = anchor | ||
| 295 | - | out, err := runner.run(ctx, command, base) | 381 | + | out, err := runner.run(ctx, command, env, base) |
| 296 | if err != nil { | 382 | if err != nil { | ||
| 297 | - | if d.log != nil { | 383 | + | d.logBarScriptFailure(sess, base.Session, anchor, command, env, err) |
| 298 | - | d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err) | |||
| 299 | - | } | |||
| 300 | return "", false | 384 | return "", false | ||
| 301 | } | 385 | } | ||
| 386 | + | d.clearBarFailure(sess.id, anchor) | |||
| 302 | return out, true | 387 | return out, true | ||
| 303 | } | 388 | } | ||
| 304 | 389 | ||||
| 390 | + | // logBarScriptFailure logs a bar script failure. sessionName must come from a | |||
| 391 | + | // synchronized read of sess.name (e.g. base.Session, captured under sess.mu | |||
| 392 | + | // in collectBarScriptContext) since sess.name is mutable via session rename. | |||
| 393 | + | func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command string, env []string, err error) { | |||
| 394 | + | if d.log == nil { | |||
| 395 | + | return | |||
| 396 | + | } | |||
| 397 | + | if !d.shouldLogBarFailure(sess.id, anchor, err.Error()) { | |||
| 398 | + | return | |||
| 399 | + | } | |||
| 400 | + | attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err} | |||
| 401 | + | var scriptErr *barScriptError | |||
| 402 | + | if errors.As(err, &scriptErr) { | |||
| 403 | + | attrs = append(attrs, "exit_code", scriptErr.exitCode) | |||
| 404 | + | if scriptErr.stderr != "" { | |||
| 405 | + | attrs = append(attrs, "stderr", scriptErr.stderr) | |||
| 406 | + | } | |||
| 407 | + | if scriptErr.exitCode == 127 { | |||
| 408 | + | attrs = append(attrs, "hint", | |||
| 409 | + | "command not found on the daemon's PATH; use an absolute path or ensure it is on the attaching client's PATH", | |||
| 410 | + | "PATH", pathFromEnv(env)) | |||
| 411 | + | } | |||
| 412 | + | } | |||
| 413 | + | d.log.Warn("bar script failed; keeping last good output", attrs...) | |||
| 414 | + | } | |||
| 415 | + | ||||
| 305 | func (d *Daemon) pokeSessionRender(sess *session) { | 416 | func (d *Daemon) pokeSessionRender(sess *session) { | ||
| 306 | if sess == nil { | 417 | if sess == nil { | ||
| 307 | return | 418 | return | ||
M internal/usecase/daemon/bar_refresh_test.go +303 -5
| @@ -12,7 +12,9 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | 14 | "github.com/bnema/vev/internal/ports" | |
| 15 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | + | "github.com/stretchr/testify/mock" | |
| 16 | 18 | "github.com/stretchr/testify/require" | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| @@ -23,7 +25,7 @@ type fakeBarRunner struct { | |||
| 23 | 25 | errs []error | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 26 | - | func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) { | |
| 28 | + | func (r *fakeBarRunner) run(_ context.Context, _ string, _ []string, ctx barScriptContext) (string, error) { | |
| 27 | 29 | r.mu.Lock() | |
| 28 | 30 | defer r.mu.Unlock() | |
| 29 | 31 | r.calls = append(r.calls, ctx) | |
| @@ -48,14 +50,14 @@ func newBlockingBarRunner(outs []string) *blockingBarRunner { | |||
| 48 | 50 | return &blockingBarRunner{fakeBarRunner: &fakeBarRunner{outs: outs}, entered: make(chan struct{}), release: make(chan struct{})} | |
| 49 | 51 | } | |
| 50 | 52 | ||
| 51 | - | func (r *blockingBarRunner) run(ctx context.Context, command string, barCtx barScriptContext) (string, error) { | |
| 53 | + | func (r *blockingBarRunner) run(ctx context.Context, command string, env []string, barCtx barScriptContext) (string, error) { | |
| 52 | 54 | r.once.Do(func() { close(r.entered) }) | |
| 53 | 55 | select { | |
| 54 | 56 | case <-r.release: | |
| 55 | 57 | case <-ctx.Done(): | |
| 56 | 58 | return "", ctx.Err() | |
| 57 | 59 | } | |
| 58 | - | return r.fakeBarRunner.run(ctx, command, barCtx) | |
| 60 | + | return r.fakeBarRunner.run(ctx, command, env, barCtx) | |
| 59 | 61 | } | |
| 60 | 62 | ||
| 61 | 63 | func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) { | |
| @@ -213,6 +215,37 @@ func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) { | |||
| 213 | 215 | require.Equal(t, "bottom-good", state.bottomRight) | |
| 214 | 216 | } | |
| 215 | 217 | ||
| 218 | + | func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) { | |
| 219 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1"}} | |
| 220 | + | d := newBarRefreshTestDaemon(r, 60*time.Second) | |
| 221 | + | sess := newBarRefreshTestSession() | |
| 222 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 223 | + | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |
| 224 | + | ||
| 225 | + | // Verifies refreshBarScriptsAllSessions iterates every live session and | |
| 226 | + | // dispatches a run for each. This does not exercise force specifically: | |
| 227 | + | // lastRefresh is zero on a fresh session, so refreshBarScriptsIfDue skips | |
| 228 | + | // the interval check regardless of force, and a non-forcing call would | |
| 229 | + | // pass this test too. | |
| 230 | + | d.refreshBarScriptsAllSessions() | |
| 231 | + | waitBarRefreshIdle(t, d) | |
| 232 | + | ||
| 233 | + | require.Len(t, r.calls, 2, "should force a run for every live session") | |
| 234 | + | require.Equal(t, "top1", d.barStateFor(sess, "").topRight) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func TestApplyConfigBumpsVersionOnlyWhenBarConfigChanges(t *testing.T) { | |
| 238 | + | r := &fakeBarRunner{} | |
| 239 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 240 | + | ||
| 241 | + | before := d.barScripts.version | |
| 242 | + | d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}}) | |
| 243 | + | require.Equal(t, before, d.barScripts.version, "identical bar config must not invalidate") | |
| 244 | + | ||
| 245 | + | d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "changed", BottomRight: "bottom", Interval: time.Second}}) | |
| 246 | + | require.Equal(t, before+1, d.barScripts.version, "changed bar config must invalidate") | |
| 247 | + | } | |
| 248 | + | ||
| 216 | 249 | func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | |
| 217 | 250 | var logs bytes.Buffer | |
| 218 | 251 | r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}} | |
| @@ -243,7 +276,7 @@ func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) { | |||
| 243 | 276 | d.barScripts.mu.Unlock() | |
| 244 | 277 | ||
| 245 | 278 | d.clearBarScriptsForSession(sess.id) | |
| 246 | - | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 0) | |
| 279 | + | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, nil, 0) | |
| 247 | 280 | ||
| 248 | 281 | state := d.barStateFor(sess, "") | |
| 249 | 282 | require.Empty(t, state.topRight) | |
| @@ -272,6 +305,87 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) { | |||
| 272 | 305 | }, time.Second, time.Millisecond) | |
| 273 | 306 | } | |
| 274 | 307 | ||
| 308 | + | // TestBarScriptFailureLogsOnceUntilSignatureChanges covers the full | |
| 309 | + | // shouldLogBarFailure/clearBarFailure contract for a single anchor | |
| 310 | + | // (top-right; bottom-right always succeeds in this test so it never | |
| 311 | + | // contributes a log line): | |
| 312 | + | // | |
| 313 | + | // 1. an identical repeated failure does not log a second time, | |
| 314 | + | // 2. a different failure for the same session+anchor logs again, and | |
| 315 | + | // 3. a successful run clears the recorded signature, so a subsequent | |
| 316 | + | // identical failure logs again afterward. | |
| 317 | + | // | |
| 318 | + | // Each refresh consumes two fakeBarRunner call-count entries (top-right then | |
| 319 | + | // bottom-right), so the stages are order-dependent and asserted sequentially | |
| 320 | + | // rather than table-driven. | |
| 321 | + | func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) { | |
| 322 | + | var buf bytes.Buffer | |
| 323 | + | var mu sync.Mutex | |
| 324 | + | logger := slog.New(slog.NewTextHandler(&syncWriter{w: &buf, mu: &mu}, nil)) | |
| 325 | + | ||
| 326 | + | notFound := &barScriptError{exitCode: 127, stderr: "sh: vev-bar-top-right: not found", err: errors.New("exit status 127")} | |
| 327 | + | permissionDenied := &barScriptError{exitCode: 126, stderr: "sh: vev-bar-top-right: permission denied", err: errors.New("exit status 126")} | |
| 328 | + | ||
| 329 | + | r := &fakeBarRunner{errs: []error{ | |
| 330 | + | notFound, nil, // refresh 1: top fails | |
| 331 | + | notFound, nil, // refresh 2: top fails identically -> must not log again | |
| 332 | + | permissionDenied, nil, // refresh 3: top fails differently -> must log again | |
| 333 | + | nil, nil, // refresh 4: top succeeds -> clears recorded signature | |
| 334 | + | notFound, nil, // refresh 5: top fails with the original signature -> must log (cleared) | |
| 335 | + | }} | |
| 336 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 337 | + | d.log = logger | |
| 338 | + | d.baseEnv = []string{"PATH=/usr/bin"} | |
| 339 | + | sess := newBarRefreshTestSession() | |
| 340 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 341 | + | ||
| 342 | + | readBuf := func() string { | |
| 343 | + | mu.Lock() | |
| 344 | + | defer mu.Unlock() | |
| 345 | + | return buf.String() | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 349 | + | waitBarRefreshIdle(t, d) | |
| 350 | + | out := readBuf() | |
| 351 | + | require.Equal(t, 1, strings.Count(out, "anchor=top-right"), "first failure should log") | |
| 352 | + | require.Contains(t, out, "not found") | |
| 353 | + | require.Contains(t, out, "PATH=/usr/bin") | |
| 354 | + | ||
| 355 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 356 | + | waitBarRefreshIdle(t, d) | |
| 357 | + | out = readBuf() | |
| 358 | + | require.Equal(t, 1, strings.Count(out, "anchor=top-right"), "identical repeated failure should not log again") | |
| 359 | + | ||
| 360 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(20, 0), true)) | |
| 361 | + | waitBarRefreshIdle(t, d) | |
| 362 | + | out = readBuf() | |
| 363 | + | require.Equal(t, 2, strings.Count(out, "anchor=top-right"), "a different failure for the same anchor should log again") | |
| 364 | + | require.Contains(t, out, "permission denied") | |
| 365 | + | ||
| 366 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(30, 0), true)) | |
| 367 | + | waitBarRefreshIdle(t, d) | |
| 368 | + | out = readBuf() | |
| 369 | + | require.Equal(t, 2, strings.Count(out, "anchor=top-right"), "a successful run must not log and must clear the recorded signature") | |
| 370 | + | ||
| 371 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(40, 0), true)) | |
| 372 | + | waitBarRefreshIdle(t, d) | |
| 373 | + | out = readBuf() | |
| 374 | + | require.Equal(t, 3, strings.Count(out, "anchor=top-right"), | |
| 375 | + | "an identical failure after a successful run should log again since the recorded signature was cleared") | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | type syncWriter struct { | |
| 379 | + | w *bytes.Buffer | |
| 380 | + | mu *sync.Mutex | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func (s *syncWriter) Write(p []byte) (int, error) { | |
| 384 | + | s.mu.Lock() | |
| 385 | + | defer s.mu.Unlock() | |
| 386 | + | return s.w.Write(p) | |
| 387 | + | } | |
| 388 | + | ||
| 275 | 389 | func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | |
| 276 | 390 | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |
| 277 | 391 | d := newBarRefreshTestDaemon(r, time.Second) | |
| @@ -281,7 +395,7 @@ func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | |||
| 281 | 395 | d.barScripts.version = 2 | |
| 282 | 396 | d.barScripts.mu.Unlock() | |
| 283 | 397 | ||
| 284 | - | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 1) | |
| 398 | + | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, nil, 1) | |
| 285 | 399 | ||
| 286 | 400 | state := d.barStateFor(sess, "") | |
| 287 | 401 | require.Empty(t, state.topRight) | |
| @@ -325,6 +439,130 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||
| 325 | 439 | require.Equal(t, "bottom-b", stateB.bottomRight) | |
| 326 | 440 | } | |
| 327 | 441 | ||
| 442 | + | // pollerMockClock and pollerMockTimer wrap the generated portsmocks.MockClock | |
| 443 | + | // and portsmocks.MockTimer (see internal/ports/mocks/clock_mock.go), following | |
| 444 | + | // the coordinatorMockClock pattern in render_coordinator_test.go: the mocks | |
| 445 | + | // supply the ports.Clock/ports.Timer implementations, while a small | |
| 446 | + | // hand-owned struct retains the real channel and requested duration for each | |
| 447 | + | // timer so the test goroutine can fire ticks and assert on re-arming. | |
| 448 | + | type pollerMockClock struct { | |
| 449 | + | clock *portsmocks.MockClock | |
| 450 | + | timers chan *pollerMockTimer | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | type pollerMockTimer struct { | |
| 454 | + | mock *portsmocks.MockTimer | |
| 455 | + | ch chan time.Time | |
| 456 | + | duration time.Duration | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | func newPollerMockClock(t *testing.T, capacity int) *pollerMockClock { | |
| 460 | + | t.Helper() | |
| 461 | + | clk := &pollerMockClock{ | |
| 462 | + | clock: portsmocks.NewMockClock(t), | |
| 463 | + | timers: make(chan *pollerMockTimer, capacity), | |
| 464 | + | } | |
| 465 | + | clk.clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe() | |
| 466 | + | clk.clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer { | |
| 467 | + | timer := &pollerMockTimer{ | |
| 468 | + | mock: portsmocks.NewMockTimer(t), | |
| 469 | + | duration: d, | |
| 470 | + | ch: make(chan time.Time, 1), | |
| 471 | + | } | |
| 472 | + | // Stop is called 0, 1, or 2 times per timer depending on whether it is | |
| 473 | + | // replaced mid-loop or torn down by the poller's deferred Stop on | |
| 474 | + | // exit, so its expectation is unconstrained. | |
| 475 | + | timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch)) | |
| 476 | + | timer.mock.EXPECT().Stop().Maybe().Return(true) | |
| 477 | + | clk.timers <- timer | |
| 478 | + | return timer.mock | |
| 479 | + | }).Maybe() | |
| 480 | + | return clk | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | // TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller | |
| 484 | + | // directly (not through Serve) with a mock clock, covering the three | |
| 485 | + | // behaviors the Task 5 rewrite touched but left untested: | |
| 486 | + | // | |
| 487 | + | // 1. after a tick fires, the loop arms a NEW timer instead of blocking | |
| 488 | + | // forever (the regression that would silently stop bar refreshes), | |
| 489 | + | // 2. a reload signal re-arms the timer at the newly configured interval | |
| 490 | + | // rather than the one the poller started with, and | |
| 491 | + | // 3. cancelling the context makes the goroutine return instead of leaking. | |
| 492 | + | func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) { | |
| 493 | + | clk := newPollerMockClock(t, 8) | |
| 494 | + | d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second) | |
| 495 | + | d.clock = clk.clock | |
| 496 | + | d.barScripts.reload = make(chan struct{}, 1) | |
| 497 | + | ||
| 498 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 499 | + | done := make(chan struct{}) | |
| 500 | + | go func() { | |
| 501 | + | d.barScriptPoller(ctx) | |
| 502 | + | close(done) | |
| 503 | + | }() | |
| 504 | + | ||
| 505 | + | // Stage 1: the poller arms its first timer at the configured interval. | |
| 506 | + | var timer0 *pollerMockTimer | |
| 507 | + | select { | |
| 508 | + | case timer0 = <-clk.timers: | |
| 509 | + | case <-time.After(time.Second): | |
| 510 | + | t.Fatal("timed out waiting for the poller's initial timer") | |
| 511 | + | } | |
| 512 | + | require.Equal(t, 2*time.Second, timer0.duration) | |
| 513 | + | ||
| 514 | + | // Stage 2: firing that timer processes the tick and re-arms a new one. | |
| 515 | + | timer0.ch <- time.Unix(0, 0) | |
| 516 | + | var timer1 *pollerMockTimer | |
| 517 | + | select { | |
| 518 | + | case timer1 = <-clk.timers: | |
| 519 | + | case <-time.After(time.Second): | |
| 520 | + | t.Fatal("timed out waiting for the poller to re-arm after a tick") | |
| 521 | + | } | |
| 522 | + | require.Equal(t, 2*time.Second, timer1.duration, "tick re-arm should keep the same interval when config is unchanged") | |
| 523 | + | ||
| 524 | + | // Stage 3: changing the interval and signaling reload re-arms at the NEW | |
| 525 | + | // interval instead of waiting out the timer armed under the old one. | |
| 526 | + | d.barScripts.mu.Lock() | |
| 527 | + | d.barScripts.cfg.interval = 7 * time.Second | |
| 528 | + | d.barScripts.mu.Unlock() | |
| 529 | + | d.barScripts.reload <- struct{}{} | |
| 530 | + | var timer2 *pollerMockTimer | |
| 531 | + | select { | |
| 532 | + | case timer2 = <-clk.timers: | |
| 533 | + | case <-time.After(time.Second): | |
| 534 | + | t.Fatal("timed out waiting for the poller to re-arm after reload") | |
| 535 | + | } | |
| 536 | + | require.Equal(t, 7*time.Second, timer2.duration, "reload should re-arm at the newly configured interval") | |
| 537 | + | ||
| 538 | + | // Stage 4: cancelling the context exits the goroutine instead of leaking it. | |
| 539 | + | cancel() | |
| 540 | + | select { | |
| 541 | + | case <-done: | |
| 542 | + | case <-time.After(time.Second): | |
| 543 | + | t.Fatal("barScriptPoller did not exit after context cancellation") | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | func TestApplyConfigSignalsPollerReload(t *testing.T) { | |
| 548 | + | r := &fakeBarRunner{} | |
| 549 | + | d := newBarRefreshTestDaemon(r, 60*time.Second) | |
| 550 | + | d.barScripts.reload = make(chan struct{}, 1) | |
| 551 | + | ||
| 552 | + | cfg := domain.Defaults() | |
| 553 | + | cfg.Bar.TopRight = "t" | |
| 554 | + | cfg.Bar.BottomRight = "b" | |
| 555 | + | cfg.Bar.Interval = time.Second | |
| 556 | + | d.ApplyConfig(cfg) | |
| 557 | + | ||
| 558 | + | select { | |
| 559 | + | case <-d.barScripts.reload: | |
| 560 | + | default: | |
| 561 | + | t.Fatal("ApplyConfig should signal the poller to re-arm its timer") | |
| 562 | + | } | |
| 563 | + | require.Equal(t, time.Second, d.barScriptInterval()) | |
| 564 | + | } | |
| 565 | + | ||
| 328 | 566 | func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon { | |
| 329 | 567 | d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{ | |
| 330 | 568 | cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)}, | |
| @@ -360,3 +598,63 @@ func waitBarRefreshIdle(t *testing.T, d *Daemon) { | |||
| 360 | 598 | return len(d.barScripts.running) == 0 | |
| 361 | 599 | }, time.Second, time.Millisecond) | |
| 362 | 600 | } | |
| 601 | + | ||
| 602 | + | type envRecordingRunner struct { | |
| 603 | + | mu sync.Mutex | |
| 604 | + | envs [][]string | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | func (r *envRecordingRunner) run(_ context.Context, _ string, env []string, _ barScriptContext) (string, error) { | |
| 608 | + | r.mu.Lock() | |
| 609 | + | defer r.mu.Unlock() | |
| 610 | + | r.envs = append(r.envs, append([]string(nil), env...)) | |
| 611 | + | return "out", nil | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | func (r *envRecordingRunner) captured() [][]string { | |
| 615 | + | r.mu.Lock() | |
| 616 | + | defer r.mu.Unlock() | |
| 617 | + | return append([][]string(nil), r.envs...) | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) { | |
| 621 | + | tests := []struct { | |
| 622 | + | name string | |
| 623 | + | sessEnv []string | |
| 624 | + | baseEnv []string | |
| 625 | + | wantPath string | |
| 626 | + | }{ | |
| 627 | + | { | |
| 628 | + | name: "session env wins over stale daemon env", | |
| 629 | + | sessEnv: []string{"PATH=/home/u/.local/bin:/usr/bin"}, | |
| 630 | + | baseEnv: []string{"PATH=/usr/bin"}, | |
| 631 | + | wantPath: "PATH=/home/u/.local/bin:/usr/bin", | |
| 632 | + | }, | |
| 633 | + | { | |
| 634 | + | name: "falls back to daemon env when session env empty", | |
| 635 | + | sessEnv: nil, | |
| 636 | + | baseEnv: []string{"PATH=/usr/bin"}, | |
| 637 | + | wantPath: "PATH=/usr/bin", | |
| 638 | + | }, | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | for _, tc := range tests { | |
| 642 | + | t.Run(tc.name, func(t *testing.T) { | |
| 643 | + | r := &envRecordingRunner{} | |
| 644 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 645 | + | d.baseEnv = tc.baseEnv | |
| 646 | + | sess := newBarRefreshTestSession() | |
| 647 | + | sess.env = tc.sessEnv | |
| 648 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 649 | + | ||
| 650 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 651 | + | waitBarRefreshIdle(t, d) | |
| 652 | + | ||
| 653 | + | envs := r.captured() | |
| 654 | + | require.Len(t, envs, 2, "one call per anchor") | |
| 655 | + | for _, env := range envs { | |
| 656 | + | require.Contains(t, env, tc.wantPath) | |
| 657 | + | } | |
| 658 | + | }) | |
| 659 | + | } | |
| 660 | + | } | |
| @@ -12,7 +12,9 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 14 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 15 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 15 | "github.com/bnema/vev/internal/usecase/layout" | 16 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 17 | + | "github.com/stretchr/testify/mock" | |||
| 16 | "github.com/stretchr/testify/require" | 18 | "github.com/stretchr/testify/require" | ||
| 17 | ) | 19 | ) | ||
| 18 | 20 | ||||
| @@ -23,7 +25,7 @@ type fakeBarRunner struct { | |||||
| 23 | errs []error | 25 | errs []error | ||
| 24 | } | 26 | } | ||
| 25 | 27 | ||||
| 26 | - | func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) { | 28 | + | func (r *fakeBarRunner) run(_ context.Context, _ string, _ []string, ctx barScriptContext) (string, error) { |
| 27 | r.mu.Lock() | 29 | r.mu.Lock() | ||
| 28 | defer r.mu.Unlock() | 30 | defer r.mu.Unlock() | ||
| 29 | r.calls = append(r.calls, ctx) | 31 | r.calls = append(r.calls, ctx) | ||
| @@ -48,14 +50,14 @@ func newBlockingBarRunner(outs []string) *blockingBarRunner { | |||||
| 48 | return &blockingBarRunner{fakeBarRunner: &fakeBarRunner{outs: outs}, entered: make(chan struct{}), release: make(chan struct{})} | 50 | return &blockingBarRunner{fakeBarRunner: &fakeBarRunner{outs: outs}, entered: make(chan struct{}), release: make(chan struct{})} | ||
| 49 | } | 51 | } | ||
| 50 | 52 | ||||
| 51 | - | func (r *blockingBarRunner) run(ctx context.Context, command string, barCtx barScriptContext) (string, error) { | 53 | + | func (r *blockingBarRunner) run(ctx context.Context, command string, env []string, barCtx barScriptContext) (string, error) { |
| 52 | r.once.Do(func() { close(r.entered) }) | 54 | r.once.Do(func() { close(r.entered) }) | ||
| 53 | select { | 55 | select { | ||
| 54 | case <-r.release: | 56 | case <-r.release: | ||
| 55 | case <-ctx.Done(): | 57 | case <-ctx.Done(): | ||
| 56 | return "", ctx.Err() | 58 | return "", ctx.Err() | ||
| 57 | } | 59 | } | ||
| 58 | - | return r.fakeBarRunner.run(ctx, command, barCtx) | 60 | + | return r.fakeBarRunner.run(ctx, command, env, barCtx) |
| 59 | } | 61 | } | ||
| 60 | 62 | ||||
| 61 | func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) { | 63 | func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) { | ||
| @@ -213,6 +215,37 @@ func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) { | |||||
| 213 | require.Equal(t, "bottom-good", state.bottomRight) | 215 | require.Equal(t, "bottom-good", state.bottomRight) | ||
| 214 | } | 216 | } | ||
| 215 | 217 | ||||
| 218 | + | func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) { | |||
| 219 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1"}} | |||
| 220 | + | d := newBarRefreshTestDaemon(r, 60*time.Second) | |||
| 221 | + | sess := newBarRefreshTestSession() | |||
| 222 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 223 | + | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |||
| 224 | + | ||||
| 225 | + | // Verifies refreshBarScriptsAllSessions iterates every live session and | |||
| 226 | + | // dispatches a run for each. This does not exercise force specifically: | |||
| 227 | + | // lastRefresh is zero on a fresh session, so refreshBarScriptsIfDue skips | |||
| 228 | + | // the interval check regardless of force, and a non-forcing call would | |||
| 229 | + | // pass this test too. | |||
| 230 | + | d.refreshBarScriptsAllSessions() | |||
| 231 | + | waitBarRefreshIdle(t, d) | |||
| 232 | + | ||||
| 233 | + | require.Len(t, r.calls, 2, "should force a run for every live session") | |||
| 234 | + | require.Equal(t, "top1", d.barStateFor(sess, "").topRight) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func TestApplyConfigBumpsVersionOnlyWhenBarConfigChanges(t *testing.T) { | |||
| 238 | + | r := &fakeBarRunner{} | |||
| 239 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 240 | + | ||||
| 241 | + | before := d.barScripts.version | |||
| 242 | + | d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}}) | |||
| 243 | + | require.Equal(t, before, d.barScripts.version, "identical bar config must not invalidate") | |||
| 244 | + | ||||
| 245 | + | d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "changed", BottomRight: "bottom", Interval: time.Second}}) | |||
| 246 | + | require.Equal(t, before+1, d.barScripts.version, "changed bar config must invalidate") | |||
| 247 | + | } | |||
| 248 | + | ||||
| 216 | func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | 249 | func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | ||
| 217 | var logs bytes.Buffer | 250 | var logs bytes.Buffer | ||
| 218 | r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}} | 251 | r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}} | ||
| @@ -243,7 +276,7 @@ func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) { | |||||
| 243 | d.barScripts.mu.Unlock() | 276 | d.barScripts.mu.Unlock() | ||
| 244 | 277 | ||||
| 245 | d.clearBarScriptsForSession(sess.id) | 278 | d.clearBarScriptsForSession(sess.id) | ||
| 246 | - | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 0) | 279 | + | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, nil, 0) |
| 247 | 280 | ||||
| 248 | state := d.barStateFor(sess, "") | 281 | state := d.barStateFor(sess, "") | ||
| 249 | require.Empty(t, state.topRight) | 282 | require.Empty(t, state.topRight) | ||
| @@ -272,6 +305,87 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) { | |||||
| 272 | }, time.Second, time.Millisecond) | 305 | }, time.Second, time.Millisecond) | ||
| 273 | } | 306 | } | ||
| 274 | 307 | ||||
| 308 | + | // TestBarScriptFailureLogsOnceUntilSignatureChanges covers the full | |||
| 309 | + | // shouldLogBarFailure/clearBarFailure contract for a single anchor | |||
| 310 | + | // (top-right; bottom-right always succeeds in this test so it never | |||
| 311 | + | // contributes a log line): | |||
| 312 | + | // | |||
| 313 | + | // 1. an identical repeated failure does not log a second time, | |||
| 314 | + | // 2. a different failure for the same session+anchor logs again, and | |||
| 315 | + | // 3. a successful run clears the recorded signature, so a subsequent | |||
| 316 | + | // identical failure logs again afterward. | |||
| 317 | + | // | |||
| 318 | + | // Each refresh consumes two fakeBarRunner call-count entries (top-right then | |||
| 319 | + | // bottom-right), so the stages are order-dependent and asserted sequentially | |||
| 320 | + | // rather than table-driven. | |||
| 321 | + | func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) { | |||
| 322 | + | var buf bytes.Buffer | |||
| 323 | + | var mu sync.Mutex | |||
| 324 | + | logger := slog.New(slog.NewTextHandler(&syncWriter{w: &buf, mu: &mu}, nil)) | |||
| 325 | + | ||||
| 326 | + | notFound := &barScriptError{exitCode: 127, stderr: "sh: vev-bar-top-right: not found", err: errors.New("exit status 127")} | |||
| 327 | + | permissionDenied := &barScriptError{exitCode: 126, stderr: "sh: vev-bar-top-right: permission denied", err: errors.New("exit status 126")} | |||
| 328 | + | ||||
| 329 | + | r := &fakeBarRunner{errs: []error{ | |||
| 330 | + | notFound, nil, // refresh 1: top fails | |||
| 331 | + | notFound, nil, // refresh 2: top fails identically -> must not log again | |||
| 332 | + | permissionDenied, nil, // refresh 3: top fails differently -> must log again | |||
| 333 | + | nil, nil, // refresh 4: top succeeds -> clears recorded signature | |||
| 334 | + | notFound, nil, // refresh 5: top fails with the original signature -> must log (cleared) | |||
| 335 | + | }} | |||
| 336 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 337 | + | d.log = logger | |||
| 338 | + | d.baseEnv = []string{"PATH=/usr/bin"} | |||
| 339 | + | sess := newBarRefreshTestSession() | |||
| 340 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 341 | + | ||||
| 342 | + | readBuf := func() string { | |||
| 343 | + | mu.Lock() | |||
| 344 | + | defer mu.Unlock() | |||
| 345 | + | return buf.String() | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |||
| 349 | + | waitBarRefreshIdle(t, d) | |||
| 350 | + | out := readBuf() | |||
| 351 | + | require.Equal(t, 1, strings.Count(out, "anchor=top-right"), "first failure should log") | |||
| 352 | + | require.Contains(t, out, "not found") | |||
| 353 | + | require.Contains(t, out, "PATH=/usr/bin") | |||
| 354 | + | ||||
| 355 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |||
| 356 | + | waitBarRefreshIdle(t, d) | |||
| 357 | + | out = readBuf() | |||
| 358 | + | require.Equal(t, 1, strings.Count(out, "anchor=top-right"), "identical repeated failure should not log again") | |||
| 359 | + | ||||
| 360 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(20, 0), true)) | |||
| 361 | + | waitBarRefreshIdle(t, d) | |||
| 362 | + | out = readBuf() | |||
| 363 | + | require.Equal(t, 2, strings.Count(out, "anchor=top-right"), "a different failure for the same anchor should log again") | |||
| 364 | + | require.Contains(t, out, "permission denied") | |||
| 365 | + | ||||
| 366 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(30, 0), true)) | |||
| 367 | + | waitBarRefreshIdle(t, d) | |||
| 368 | + | out = readBuf() | |||
| 369 | + | require.Equal(t, 2, strings.Count(out, "anchor=top-right"), "a successful run must not log and must clear the recorded signature") | |||
| 370 | + | ||||
| 371 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(40, 0), true)) | |||
| 372 | + | waitBarRefreshIdle(t, d) | |||
| 373 | + | out = readBuf() | |||
| 374 | + | require.Equal(t, 3, strings.Count(out, "anchor=top-right"), | |||
| 375 | + | "an identical failure after a successful run should log again since the recorded signature was cleared") | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | type syncWriter struct { | |||
| 379 | + | w *bytes.Buffer | |||
| 380 | + | mu *sync.Mutex | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | func (s *syncWriter) Write(p []byte) (int, error) { | |||
| 384 | + | s.mu.Lock() | |||
| 385 | + | defer s.mu.Unlock() | |||
| 386 | + | return s.w.Write(p) | |||
| 387 | + | } | |||
| 388 | + | ||||
| 275 | func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | 389 | func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | ||
| 276 | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | 390 | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | ||
| 277 | d := newBarRefreshTestDaemon(r, time.Second) | 391 | d := newBarRefreshTestDaemon(r, time.Second) | ||
| @@ -281,7 +395,7 @@ func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | |||||
| 281 | d.barScripts.version = 2 | 395 | d.barScripts.version = 2 | ||
| 282 | d.barScripts.mu.Unlock() | 396 | d.barScripts.mu.Unlock() | ||
| 283 | 397 | ||||
| 284 | - | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 1) | 398 | + | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, nil, 1) |
| 285 | 399 | ||||
| 286 | state := d.barStateFor(sess, "") | 400 | state := d.barStateFor(sess, "") | ||
| 287 | require.Empty(t, state.topRight) | 401 | require.Empty(t, state.topRight) | ||
| @@ -325,6 +439,130 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||||
| 325 | require.Equal(t, "bottom-b", stateB.bottomRight) | 439 | require.Equal(t, "bottom-b", stateB.bottomRight) | ||
| 326 | } | 440 | } | ||
| 327 | 441 | ||||
| 442 | + | // pollerMockClock and pollerMockTimer wrap the generated portsmocks.MockClock | |||
| 443 | + | // and portsmocks.MockTimer (see internal/ports/mocks/clock_mock.go), following | |||
| 444 | + | // the coordinatorMockClock pattern in render_coordinator_test.go: the mocks | |||
| 445 | + | // supply the ports.Clock/ports.Timer implementations, while a small | |||
| 446 | + | // hand-owned struct retains the real channel and requested duration for each | |||
| 447 | + | // timer so the test goroutine can fire ticks and assert on re-arming. | |||
| 448 | + | type pollerMockClock struct { | |||
| 449 | + | clock *portsmocks.MockClock | |||
| 450 | + | timers chan *pollerMockTimer | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | type pollerMockTimer struct { | |||
| 454 | + | mock *portsmocks.MockTimer | |||
| 455 | + | ch chan time.Time | |||
| 456 | + | duration time.Duration | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | func newPollerMockClock(t *testing.T, capacity int) *pollerMockClock { | |||
| 460 | + | t.Helper() | |||
| 461 | + | clk := &pollerMockClock{ | |||
| 462 | + | clock: portsmocks.NewMockClock(t), | |||
| 463 | + | timers: make(chan *pollerMockTimer, capacity), | |||
| 464 | + | } | |||
| 465 | + | clk.clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe() | |||
| 466 | + | clk.clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer { | |||
| 467 | + | timer := &pollerMockTimer{ | |||
| 468 | + | mock: portsmocks.NewMockTimer(t), | |||
| 469 | + | duration: d, | |||
| 470 | + | ch: make(chan time.Time, 1), | |||
| 471 | + | } | |||
| 472 | + | // Stop is called 0, 1, or 2 times per timer depending on whether it is | |||
| 473 | + | // replaced mid-loop or torn down by the poller's deferred Stop on | |||
| 474 | + | // exit, so its expectation is unconstrained. | |||
| 475 | + | timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch)) | |||
| 476 | + | timer.mock.EXPECT().Stop().Maybe().Return(true) | |||
| 477 | + | clk.timers <- timer | |||
| 478 | + | return timer.mock | |||
| 479 | + | }).Maybe() | |||
| 480 | + | return clk | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | // TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller | |||
| 484 | + | // directly (not through Serve) with a mock clock, covering the three | |||
| 485 | + | // behaviors the Task 5 rewrite touched but left untested: | |||
| 486 | + | // | |||
| 487 | + | // 1. after a tick fires, the loop arms a NEW timer instead of blocking | |||
| 488 | + | // forever (the regression that would silently stop bar refreshes), | |||
| 489 | + | // 2. a reload signal re-arms the timer at the newly configured interval | |||
| 490 | + | // rather than the one the poller started with, and | |||
| 491 | + | // 3. cancelling the context makes the goroutine return instead of leaking. | |||
| 492 | + | func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) { | |||
| 493 | + | clk := newPollerMockClock(t, 8) | |||
| 494 | + | d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second) | |||
| 495 | + | d.clock = clk.clock | |||
| 496 | + | d.barScripts.reload = make(chan struct{}, 1) | |||
| 497 | + | ||||
| 498 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 499 | + | done := make(chan struct{}) | |||
| 500 | + | go func() { | |||
| 501 | + | d.barScriptPoller(ctx) | |||
| 502 | + | close(done) | |||
| 503 | + | }() | |||
| 504 | + | ||||
| 505 | + | // Stage 1: the poller arms its first timer at the configured interval. | |||
| 506 | + | var timer0 *pollerMockTimer | |||
| 507 | + | select { | |||
| 508 | + | case timer0 = <-clk.timers: | |||
| 509 | + | case <-time.After(time.Second): | |||
| 510 | + | t.Fatal("timed out waiting for the poller's initial timer") | |||
| 511 | + | } | |||
| 512 | + | require.Equal(t, 2*time.Second, timer0.duration) | |||
| 513 | + | ||||
| 514 | + | // Stage 2: firing that timer processes the tick and re-arms a new one. | |||
| 515 | + | timer0.ch <- time.Unix(0, 0) | |||
| 516 | + | var timer1 *pollerMockTimer | |||
| 517 | + | select { | |||
| 518 | + | case timer1 = <-clk.timers: | |||
| 519 | + | case <-time.After(time.Second): | |||
| 520 | + | t.Fatal("timed out waiting for the poller to re-arm after a tick") | |||
| 521 | + | } | |||
| 522 | + | require.Equal(t, 2*time.Second, timer1.duration, "tick re-arm should keep the same interval when config is unchanged") | |||
| 523 | + | ||||
| 524 | + | // Stage 3: changing the interval and signaling reload re-arms at the NEW | |||
| 525 | + | // interval instead of waiting out the timer armed under the old one. | |||
| 526 | + | d.barScripts.mu.Lock() | |||
| 527 | + | d.barScripts.cfg.interval = 7 * time.Second | |||
| 528 | + | d.barScripts.mu.Unlock() | |||
| 529 | + | d.barScripts.reload <- struct{}{} | |||
| 530 | + | var timer2 *pollerMockTimer | |||
| 531 | + | select { | |||
| 532 | + | case timer2 = <-clk.timers: | |||
| 533 | + | case <-time.After(time.Second): | |||
| 534 | + | t.Fatal("timed out waiting for the poller to re-arm after reload") | |||
| 535 | + | } | |||
| 536 | + | require.Equal(t, 7*time.Second, timer2.duration, "reload should re-arm at the newly configured interval") | |||
| 537 | + | ||||
| 538 | + | // Stage 4: cancelling the context exits the goroutine instead of leaking it. | |||
| 539 | + | cancel() | |||
| 540 | + | select { | |||
| 541 | + | case <-done: | |||
| 542 | + | case <-time.After(time.Second): | |||
| 543 | + | t.Fatal("barScriptPoller did not exit after context cancellation") | |||
| 544 | + | } | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | func TestApplyConfigSignalsPollerReload(t *testing.T) { | |||
| 548 | + | r := &fakeBarRunner{} | |||
| 549 | + | d := newBarRefreshTestDaemon(r, 60*time.Second) | |||
| 550 | + | d.barScripts.reload = make(chan struct{}, 1) | |||
| 551 | + | ||||
| 552 | + | cfg := domain.Defaults() | |||
| 553 | + | cfg.Bar.TopRight = "t" | |||
| 554 | + | cfg.Bar.BottomRight = "b" | |||
| 555 | + | cfg.Bar.Interval = time.Second | |||
| 556 | + | d.ApplyConfig(cfg) | |||
| 557 | + | ||||
| 558 | + | select { | |||
| 559 | + | case <-d.barScripts.reload: | |||
| 560 | + | default: | |||
| 561 | + | t.Fatal("ApplyConfig should signal the poller to re-arm its timer") | |||
| 562 | + | } | |||
| 563 | + | require.Equal(t, time.Second, d.barScriptInterval()) | |||
| 564 | + | } | |||
| 565 | + | ||||
| 328 | func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon { | 566 | func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon { | ||
| 329 | d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{ | 567 | d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{ | ||
| 330 | cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)}, | 568 | cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)}, | ||
| @@ -360,3 +598,63 @@ func waitBarRefreshIdle(t *testing.T, d *Daemon) { | |||||
| 360 | return len(d.barScripts.running) == 0 | 598 | return len(d.barScripts.running) == 0 | ||
| 361 | }, time.Second, time.Millisecond) | 599 | }, time.Second, time.Millisecond) | ||
| 362 | } | 600 | } | ||
| 601 | + | ||||
| 602 | + | type envRecordingRunner struct { | |||
| 603 | + | mu sync.Mutex | |||
| 604 | + | envs [][]string | |||
| 605 | + | } | |||
| 606 | + | ||||
| 607 | + | func (r *envRecordingRunner) run(_ context.Context, _ string, env []string, _ barScriptContext) (string, error) { | |||
| 608 | + | r.mu.Lock() | |||
| 609 | + | defer r.mu.Unlock() | |||
| 610 | + | r.envs = append(r.envs, append([]string(nil), env...)) | |||
| 611 | + | return "out", nil | |||
| 612 | + | } | |||
| 613 | + | ||||
| 614 | + | func (r *envRecordingRunner) captured() [][]string { | |||
| 615 | + | r.mu.Lock() | |||
| 616 | + | defer r.mu.Unlock() | |||
| 617 | + | return append([][]string(nil), r.envs...) | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | func TestBarScriptsUseSessionEnvNotDaemonBaseEnv(t *testing.T) { | |||
| 621 | + | tests := []struct { | |||
| 622 | + | name string | |||
| 623 | + | sessEnv []string | |||
| 624 | + | baseEnv []string | |||
| 625 | + | wantPath string | |||
| 626 | + | }{ | |||
| 627 | + | { | |||
| 628 | + | name: "session env wins over stale daemon env", | |||
| 629 | + | sessEnv: []string{"PATH=/home/u/.local/bin:/usr/bin"}, | |||
| 630 | + | baseEnv: []string{"PATH=/usr/bin"}, | |||
| 631 | + | wantPath: "PATH=/home/u/.local/bin:/usr/bin", | |||
| 632 | + | }, | |||
| 633 | + | { | |||
| 634 | + | name: "falls back to daemon env when session env empty", | |||
| 635 | + | sessEnv: nil, | |||
| 636 | + | baseEnv: []string{"PATH=/usr/bin"}, | |||
| 637 | + | wantPath: "PATH=/usr/bin", | |||
| 638 | + | }, | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | for _, tc := range tests { | |||
| 642 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 643 | + | r := &envRecordingRunner{} | |||
| 644 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 645 | + | d.baseEnv = tc.baseEnv | |||
| 646 | + | sess := newBarRefreshTestSession() | |||
| 647 | + | sess.env = tc.sessEnv | |||
| 648 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 649 | + | ||||
| 650 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |||
| 651 | + | waitBarRefreshIdle(t, d) | |||
| 652 | + | ||||
| 653 | + | envs := r.captured() | |||
| 654 | + | require.Len(t, envs, 2, "one call per anchor") | |||
| 655 | + | for _, env := range envs { | |||
| 656 | + | require.Contains(t, env, tc.wantPath) | |||
| 657 | + | } | |||
| 658 | + | }) | |||
| 659 | + | } | |||
| 660 | + | } | |||
M internal/usecase/daemon/bar_script.go +39 -6
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "fmt" | |
| 7 | 8 | "strconv" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "time" | |
| @@ -14,6 +15,7 @@ import ( | |||
| 14 | 15 | ||
| 15 | 16 | const ( | |
| 16 | 17 | barScriptOutputLimit = 1024 | |
| 18 | + | barScriptStderrLimit = 512 | |
| 17 | 19 | barScriptDisplayLimit = 256 | |
| 18 | 20 | barScriptTimeout = time.Second | |
| 19 | 21 | ) | |
| @@ -55,10 +57,9 @@ func (c barScriptContext) env(base []string) []string { | |||
| 55 | 57 | type barScriptRunner struct { | |
| 56 | 58 | runner ports.ShellCommandRunner | |
| 57 | 59 | timeout time.Duration | |
| 58 | - | baseEnv []string | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | - | func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) { | |
| 62 | + | func (r barScriptRunner) run(ctx context.Context, command string, env []string, scriptCtx barScriptContext) (string, error) { | |
| 62 | 63 | if strings.TrimSpace(command) == "" { | |
| 63 | 64 | return "", nil | |
| 64 | 65 | } | |
| @@ -72,18 +73,23 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS | |||
| 72 | 73 | ctx, cancel := context.WithTimeout(ctx, timeout) | |
| 73 | 74 | defer cancel() | |
| 74 | 75 | ||
| 75 | - | stdout, err := r.runner.Run(ctx, ports.CommandSpec{ | |
| 76 | + | res, err := r.runner.Run(ctx, ports.CommandSpec{ | |
| 76 | 77 | Command: command, | |
| 77 | - | Env: scriptCtx.env(r.baseEnv), | |
| 78 | + | Env: scriptCtx.env(env), | |
| 78 | 79 | Timeout: timeout, | |
| 79 | 80 | StdoutLimit: barScriptOutputLimit, | |
| 81 | + | StderrLimit: barScriptStderrLimit, | |
| 80 | 82 | }) | |
| 81 | 83 | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | |
| 82 | 84 | return "", context.DeadlineExceeded | |
| 83 | 85 | } | |
| 84 | - | text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit) | |
| 86 | + | text := sanitizeBarScriptOutput(res.Stdout, barScriptOutputLimit) | |
| 85 | 87 | if err != nil { | |
| 86 | - | return text, err | |
| 88 | + | return text, &barScriptError{ | |
| 89 | + | exitCode: res.ExitCode, | |
| 90 | + | stderr: sanitizeBarScriptOutput(res.Stderr, barScriptStderrLimit), | |
| 91 | + | err: err, | |
| 92 | + | } | |
| 87 | 93 | } | |
| 88 | 94 | return text, nil | |
| 89 | 95 | } | |
| @@ -181,3 +187,30 @@ func trimUTF8Bytes(s string, limit int) string { | |||
| 181 | 187 | } | |
| 182 | 188 | return s[:limit] | |
| 183 | 189 | } | |
| 190 | + | ||
| 191 | + | // barScriptError carries the diagnostic detail needed to explain why a bar | |
| 192 | + | // script failed: the process exit code and whatever it wrote to stderr. | |
| 193 | + | type barScriptError struct { | |
| 194 | + | exitCode int | |
| 195 | + | stderr string | |
| 196 | + | err error | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (e *barScriptError) Error() string { | |
| 200 | + | if e.stderr != "" { | |
| 201 | + | return fmt.Sprintf("exit %d: %s", e.exitCode, e.stderr) | |
| 202 | + | } | |
| 203 | + | return fmt.Sprintf("exit %d", e.exitCode) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (e *barScriptError) Unwrap() error { return e.err } | |
| 207 | + | ||
| 208 | + | // pathFromEnv returns the PATH entry's value, or "" when unset. | |
| 209 | + | func pathFromEnv(env []string) string { | |
| 210 | + | for _, entry := range env { | |
| 211 | + | if name, value, ok := environmentEntry(entry); ok && name == "PATH" { | |
| 212 | + | return value | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | return "" | |
| 216 | + | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "fmt" | |||
| 7 | "strconv" | 8 | "strconv" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | "time" | 10 | "time" | ||
| @@ -14,6 +15,7 @@ import ( | |||||
| 14 | 15 | ||||
| 15 | const ( | 16 | const ( | ||
| 16 | barScriptOutputLimit = 1024 | 17 | barScriptOutputLimit = 1024 | ||
| 18 | + | barScriptStderrLimit = 512 | |||
| 17 | barScriptDisplayLimit = 256 | 19 | barScriptDisplayLimit = 256 | ||
| 18 | barScriptTimeout = time.Second | 20 | barScriptTimeout = time.Second | ||
| 19 | ) | 21 | ) | ||
| @@ -55,10 +57,9 @@ func (c barScriptContext) env(base []string) []string { | |||||
| 55 | type barScriptRunner struct { | 57 | type barScriptRunner struct { | ||
| 56 | runner ports.ShellCommandRunner | 58 | runner ports.ShellCommandRunner | ||
| 57 | timeout time.Duration | 59 | timeout time.Duration | ||
| 58 | - | baseEnv []string | |||
| 59 | } | 60 | } | ||
| 60 | 61 | ||||
| 61 | - | func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) { | 62 | + | func (r barScriptRunner) run(ctx context.Context, command string, env []string, scriptCtx barScriptContext) (string, error) { |
| 62 | if strings.TrimSpace(command) == "" { | 63 | if strings.TrimSpace(command) == "" { | ||
| 63 | return "", nil | 64 | return "", nil | ||
| 64 | } | 65 | } | ||
| @@ -72,18 +73,23 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS | |||||
| 72 | ctx, cancel := context.WithTimeout(ctx, timeout) | 73 | ctx, cancel := context.WithTimeout(ctx, timeout) | ||
| 73 | defer cancel() | 74 | defer cancel() | ||
| 74 | 75 | ||||
| 75 | - | stdout, err := r.runner.Run(ctx, ports.CommandSpec{ | 76 | + | res, err := r.runner.Run(ctx, ports.CommandSpec{ |
| 76 | Command: command, | 77 | Command: command, | ||
| 77 | - | Env: scriptCtx.env(r.baseEnv), | 78 | + | Env: scriptCtx.env(env), |
| 78 | Timeout: timeout, | 79 | Timeout: timeout, | ||
| 79 | StdoutLimit: barScriptOutputLimit, | 80 | StdoutLimit: barScriptOutputLimit, | ||
| 81 | + | StderrLimit: barScriptStderrLimit, | |||
| 80 | }) | 82 | }) | ||
| 81 | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | 83 | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | ||
| 82 | return "", context.DeadlineExceeded | 84 | return "", context.DeadlineExceeded | ||
| 83 | } | 85 | } | ||
| 84 | - | text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit) | 86 | + | text := sanitizeBarScriptOutput(res.Stdout, barScriptOutputLimit) |
| 85 | if err != nil { | 87 | if err != nil { | ||
| 86 | - | return text, err | 88 | + | return text, &barScriptError{ |
| 89 | + | exitCode: res.ExitCode, | |||
| 90 | + | stderr: sanitizeBarScriptOutput(res.Stderr, barScriptStderrLimit), | |||
| 91 | + | err: err, | |||
| 92 | + | } | |||
| 87 | } | 93 | } | ||
| 88 | return text, nil | 94 | return text, nil | ||
| 89 | } | 95 | } | ||
| @@ -181,3 +187,30 @@ func trimUTF8Bytes(s string, limit int) string { | |||||
| 181 | } | 187 | } | ||
| 182 | return s[:limit] | 188 | return s[:limit] | ||
| 183 | } | 189 | } | ||
| 190 | + | ||||
| 191 | + | // barScriptError carries the diagnostic detail needed to explain why a bar | |||
| 192 | + | // script failed: the process exit code and whatever it wrote to stderr. | |||
| 193 | + | type barScriptError struct { | |||
| 194 | + | exitCode int | |||
| 195 | + | stderr string | |||
| 196 | + | err error | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func (e *barScriptError) Error() string { | |||
| 200 | + | if e.stderr != "" { | |||
| 201 | + | return fmt.Sprintf("exit %d: %s", e.exitCode, e.stderr) | |||
| 202 | + | } | |||
| 203 | + | return fmt.Sprintf("exit %d", e.exitCode) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func (e *barScriptError) Unwrap() error { return e.err } | |||
| 207 | + | ||||
| 208 | + | // pathFromEnv returns the PATH entry's value, or "" when unset. | |||
| 209 | + | func pathFromEnv(env []string) string { | |||
| 210 | + | for _, entry := range env { | |||
| 211 | + | if name, value, ok := environmentEntry(entry); ok && name == "PATH" { | |||
| 212 | + | return value | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | return "" | |||
| 216 | + | } | |||
M internal/usecase/daemon/bar_script_test.go +18 -14
| @@ -85,7 +85,7 @@ func TestBarScriptContextEnv(t *testing.T) { | |||
| 85 | 85 | func TestBarScriptRunner(t *testing.T) { | |
| 86 | 86 | t.Run("nil runner returns explicit error for command", func(t *testing.T) { | |
| 87 | 87 | runner := barScriptRunner{} | |
| 88 | - | got, err := runner.run(context.Background(), "date", barScriptContext{}) | |
| 88 | + | got, err := runner.run(context.Background(), "date", nil, barScriptContext{}) | |
| 89 | 89 | if err == nil || err.Error() != "bar script command runner is nil" { | |
| 90 | 90 | t.Fatalf("run err = %v, want explicit nil runner error", err) | |
| 91 | 91 | } | |
| @@ -96,7 +96,7 @@ func TestBarScriptRunner(t *testing.T) { | |||
| 96 | 96 | ||
| 97 | 97 | t.Run("empty command skips port runner", func(t *testing.T) { | |
| 98 | 98 | runner := barScriptRunner{} | |
| 99 | - | got, err := runner.run(context.Background(), " \t\n ", barScriptContext{}) | |
| 99 | + | got, err := runner.run(context.Background(), " \t\n ", nil, barScriptContext{}) | |
| 100 | 100 | if err != nil { | |
| 101 | 101 | t.Fatalf("run err = %v, want nil", err) | |
| 102 | 102 | } | |
| @@ -119,10 +119,12 @@ func TestBarScriptRunner(t *testing.T) { | |||
| 119 | 119 | containsEnv(spec.Env, "VEV_PANE_CWD=/repo") && | |
| 120 | 120 | containsEnv(spec.Env, "VEV_COLS=120") && | |
| 121 | 121 | !containsEnv(spec.Env, "VEV_ANCHOR=old") | |
| 122 | - | })).Return([]byte("\x1b[31mok\x1b[0m\nignored"), nil) | |
| 123 | - | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}} | |
| 122 | + | })).Return(ports.CommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil) | |
| 123 | + | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond} | |
| 124 | 124 | ||
| 125 | - | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120}) | |
| 125 | + | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", | |
| 126 | + | []string{"PATH=/bin", "VEV_ANCHOR=old"}, | |
| 127 | + | barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120}) | |
| 126 | 128 | if err != nil { | |
| 127 | 129 | t.Fatalf("run err = %v, want nil", err) | |
| 128 | 130 | } | |
| @@ -134,10 +136,11 @@ func TestBarScriptRunner(t *testing.T) { | |||
| 134 | 136 | t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) { | |
| 135 | 137 | wantErr := errors.New("exit 1") | |
| 136 | 138 | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 137 | - | portRunner.EXPECT().Run(mock.Anything, mock.Anything).Return([]byte("partial\x00 output\nignored"), wantErr) | |
| 139 | + | portRunner.EXPECT().Run(mock.Anything, mock.Anything). | |
| 140 | + | Return(ports.CommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr) | |
| 138 | 141 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | |
| 139 | 142 | ||
| 140 | - | got, err := runner.run(context.Background(), "false", barScriptContext{}) | |
| 143 | + | got, err := runner.run(context.Background(), "false", nil, barScriptContext{}) | |
| 141 | 144 | if !errors.Is(err, wantErr) { | |
| 142 | 145 | t.Fatalf("run err = %v, want %v", err, wantErr) | |
| 143 | 146 | } | |
| @@ -148,13 +151,14 @@ func TestBarScriptRunner(t *testing.T) { | |||
| 148 | 151 | ||
| 149 | 152 | t.Run("timeout propagates deadline", func(t *testing.T) { | |
| 150 | 153 | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 151 | - | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) { | |
| 152 | - | <-ctx.Done() | |
| 153 | - | return nil, ctx.Err() | |
| 154 | - | }) | |
| 154 | + | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn( | |
| 155 | + | func(ctx context.Context, _ ports.CommandSpec) (ports.CommandResult, error) { | |
| 156 | + | <-ctx.Done() | |
| 157 | + | return ports.CommandResult{ExitCode: -1}, ctx.Err() | |
| 158 | + | }) | |
| 155 | 159 | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} | |
| 156 | 160 | ||
| 157 | - | _, err := runner.run(context.Background(), "sleep 1", barScriptContext{}) | |
| 161 | + | _, err := runner.run(context.Background(), "sleep 1", nil, barScriptContext{}) | |
| 158 | 162 | if !errors.Is(err, context.DeadlineExceeded) { | |
| 159 | 163 | t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded) | |
| 160 | 164 | } | |
| @@ -165,10 +169,10 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |||
| 165 | 169 | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 166 | 170 | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | |
| 167 | 171 | return spec.StdoutLimit == barScriptOutputLimit | |
| 168 | - | })).Return([]byte(strings.Repeat("a", 4096)), nil) | |
| 172 | + | })).Return(ports.CommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil) | |
| 169 | 173 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | |
| 170 | 174 | ||
| 171 | - | got, err := runner.run(context.Background(), "long-output", barScriptContext{}) | |
| 175 | + | got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{}) | |
| 172 | 176 | if err != nil { | |
| 173 | 177 | t.Fatalf("run bounded output: %v", err) | |
| 174 | 178 | } | |
| @@ -85,7 +85,7 @@ func TestBarScriptContextEnv(t *testing.T) { | |||||
| 85 | func TestBarScriptRunner(t *testing.T) { | 85 | func TestBarScriptRunner(t *testing.T) { | ||
| 86 | t.Run("nil runner returns explicit error for command", func(t *testing.T) { | 86 | t.Run("nil runner returns explicit error for command", func(t *testing.T) { | ||
| 87 | runner := barScriptRunner{} | 87 | runner := barScriptRunner{} | ||
| 88 | - | got, err := runner.run(context.Background(), "date", barScriptContext{}) | 88 | + | got, err := runner.run(context.Background(), "date", nil, barScriptContext{}) |
| 89 | if err == nil || err.Error() != "bar script command runner is nil" { | 89 | if err == nil || err.Error() != "bar script command runner is nil" { | ||
| 90 | t.Fatalf("run err = %v, want explicit nil runner error", err) | 90 | t.Fatalf("run err = %v, want explicit nil runner error", err) | ||
| 91 | } | 91 | } | ||
| @@ -96,7 +96,7 @@ func TestBarScriptRunner(t *testing.T) { | |||||
| 96 | 96 | ||||
| 97 | t.Run("empty command skips port runner", func(t *testing.T) { | 97 | t.Run("empty command skips port runner", func(t *testing.T) { | ||
| 98 | runner := barScriptRunner{} | 98 | runner := barScriptRunner{} | ||
| 99 | - | got, err := runner.run(context.Background(), " \t\n ", barScriptContext{}) | 99 | + | got, err := runner.run(context.Background(), " \t\n ", nil, barScriptContext{}) |
| 100 | if err != nil { | 100 | if err != nil { | ||
| 101 | t.Fatalf("run err = %v, want nil", err) | 101 | t.Fatalf("run err = %v, want nil", err) | ||
| 102 | } | 102 | } | ||
| @@ -119,10 +119,12 @@ func TestBarScriptRunner(t *testing.T) { | |||||
| 119 | containsEnv(spec.Env, "VEV_PANE_CWD=/repo") && | 119 | containsEnv(spec.Env, "VEV_PANE_CWD=/repo") && | ||
| 120 | containsEnv(spec.Env, "VEV_COLS=120") && | 120 | containsEnv(spec.Env, "VEV_COLS=120") && | ||
| 121 | !containsEnv(spec.Env, "VEV_ANCHOR=old") | 121 | !containsEnv(spec.Env, "VEV_ANCHOR=old") | ||
| 122 | - | })).Return([]byte("\x1b[31mok\x1b[0m\nignored"), nil) | 122 | + | })).Return(ports.CommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil) |
| 123 | - | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}} | 123 | + | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond} |
| 124 | 124 | ||||
| 125 | - | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120}) | 125 | + | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", |
| 126 | + | []string{"PATH=/bin", "VEV_ANCHOR=old"}, | |||
| 127 | + | barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120}) | |||
| 126 | if err != nil { | 128 | if err != nil { | ||
| 127 | t.Fatalf("run err = %v, want nil", err) | 129 | t.Fatalf("run err = %v, want nil", err) | ||
| 128 | } | 130 | } | ||
| @@ -134,10 +136,11 @@ func TestBarScriptRunner(t *testing.T) { | |||||
| 134 | t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) { | 136 | t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) { | ||
| 135 | wantErr := errors.New("exit 1") | 137 | wantErr := errors.New("exit 1") | ||
| 136 | portRunner := portsmocks.NewMockShellCommandRunner(t) | 138 | portRunner := portsmocks.NewMockShellCommandRunner(t) | ||
| 137 | - | portRunner.EXPECT().Run(mock.Anything, mock.Anything).Return([]byte("partial\x00 output\nignored"), wantErr) | 139 | + | portRunner.EXPECT().Run(mock.Anything, mock.Anything). |
| 140 | + | Return(ports.CommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr) | |||
| 138 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | 141 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | ||
| 139 | 142 | ||||
| 140 | - | got, err := runner.run(context.Background(), "false", barScriptContext{}) | 143 | + | got, err := runner.run(context.Background(), "false", nil, barScriptContext{}) |
| 141 | if !errors.Is(err, wantErr) { | 144 | if !errors.Is(err, wantErr) { | ||
| 142 | t.Fatalf("run err = %v, want %v", err, wantErr) | 145 | t.Fatalf("run err = %v, want %v", err, wantErr) | ||
| 143 | } | 146 | } | ||
| @@ -148,13 +151,14 @@ func TestBarScriptRunner(t *testing.T) { | |||||
| 148 | 151 | ||||
| 149 | t.Run("timeout propagates deadline", func(t *testing.T) { | 152 | t.Run("timeout propagates deadline", func(t *testing.T) { | ||
| 150 | portRunner := portsmocks.NewMockShellCommandRunner(t) | 153 | portRunner := portsmocks.NewMockShellCommandRunner(t) | ||
| 151 | - | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) { | 154 | + | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn( |
| 152 | - | <-ctx.Done() | 155 | + | func(ctx context.Context, _ ports.CommandSpec) (ports.CommandResult, error) { |
| 153 | - | return nil, ctx.Err() | 156 | + | <-ctx.Done() |
| 154 | - | }) | 157 | + | return ports.CommandResult{ExitCode: -1}, ctx.Err() |
| 158 | + | }) | |||
| 155 | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} | 159 | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} | ||
| 156 | 160 | ||||
| 157 | - | _, err := runner.run(context.Background(), "sleep 1", barScriptContext{}) | 161 | + | _, err := runner.run(context.Background(), "sleep 1", nil, barScriptContext{}) |
| 158 | if !errors.Is(err, context.DeadlineExceeded) { | 162 | if !errors.Is(err, context.DeadlineExceeded) { | ||
| 159 | t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded) | 163 | t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded) | ||
| 160 | } | 164 | } | ||
| @@ -165,10 +169,10 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |||||
| 165 | portRunner := portsmocks.NewMockShellCommandRunner(t) | 169 | portRunner := portsmocks.NewMockShellCommandRunner(t) | ||
| 166 | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | 170 | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | ||
| 167 | return spec.StdoutLimit == barScriptOutputLimit | 171 | return spec.StdoutLimit == barScriptOutputLimit | ||
| 168 | - | })).Return([]byte(strings.Repeat("a", 4096)), nil) | 172 | + | })).Return(ports.CommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil) |
| 169 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | 173 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | ||
| 170 | 174 | ||||
| 171 | - | got, err := runner.run(context.Background(), "long-output", barScriptContext{}) | 175 | + | got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{}) |
| 172 | if err != nil { | 176 | if err != nil { | ||
| 173 | t.Fatalf("run bounded output: %v", err) | 177 | t.Fatalf("run bounded output: %v", err) | ||
| 174 | } | 178 | } | ||
M internal/usecase/daemon/config.go +7 -0
| @@ -57,6 +57,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 57 | 57 | tabs := cfg.Tabs | |
| 58 | 58 | d.tabsConfig.Store(&tabs) | |
| 59 | 59 | d.storeThemeConfig(cfg) | |
| 60 | + | barChanged := false | |
| 60 | 61 | if d.barScripts != nil { | |
| 61 | 62 | d.barScripts.mu.Lock() | |
| 62 | 63 | d.barScripts.initLocked() | |
| @@ -67,10 +68,16 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 67 | 68 | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | |
| 68 | 69 | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | |
| 69 | 70 | d.barScripts.pending = make(map[domain.SessionID]bool) | |
| 71 | + | d.barScripts.lastFailure = make(map[barScriptFailureKey]string) | |
| 70 | 72 | d.barScripts.version++ | |
| 73 | + | barChanged = true | |
| 71 | 74 | } | |
| 72 | 75 | d.barScripts.mu.Unlock() | |
| 73 | 76 | } | |
| 77 | + | if barChanged { | |
| 78 | + | d.signalBarPollerReload() | |
| 79 | + | d.refreshBarScriptsAllSessions() | |
| 80 | + | } | |
| 74 | 81 | d.reapplyThemeAllSessions() | |
| 75 | 82 | d.repaintAllAttachedClients() | |
| 76 | 83 | } | |
| @@ -57,6 +57,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 57 | tabs := cfg.Tabs | 57 | tabs := cfg.Tabs | ||
| 58 | d.tabsConfig.Store(&tabs) | 58 | d.tabsConfig.Store(&tabs) | ||
| 59 | d.storeThemeConfig(cfg) | 59 | d.storeThemeConfig(cfg) | ||
| 60 | + | barChanged := false | |||
| 60 | if d.barScripts != nil { | 61 | if d.barScripts != nil { | ||
| 61 | d.barScripts.mu.Lock() | 62 | d.barScripts.mu.Lock() | ||
| 62 | d.barScripts.initLocked() | 63 | d.barScripts.initLocked() | ||
| @@ -67,10 +68,16 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 67 | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | 68 | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | ||
| 68 | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | 69 | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | ||
| 69 | d.barScripts.pending = make(map[domain.SessionID]bool) | 70 | d.barScripts.pending = make(map[domain.SessionID]bool) | ||
| 71 | + | d.barScripts.lastFailure = make(map[barScriptFailureKey]string) | |||
| 70 | d.barScripts.version++ | 72 | d.barScripts.version++ | ||
| 73 | + | barChanged = true | |||
| 71 | } | 74 | } | ||
| 72 | d.barScripts.mu.Unlock() | 75 | d.barScripts.mu.Unlock() | ||
| 73 | } | 76 | } | ||
| 77 | + | if barChanged { | |||
| 78 | + | d.signalBarPollerReload() | |||
| 79 | + | d.refreshBarScriptsAllSessions() | |||
| 80 | + | } | |||
| 74 | d.reapplyThemeAllSessions() | 81 | d.reapplyThemeAllSessions() | ||
| 75 | d.repaintAllAttachedClients() | 82 | d.repaintAllAttachedClients() | ||
| 76 | } | 83 | } | ||
M internal/usecase/daemon/daemon.go +2 -1
| @@ -283,7 +283,7 @@ func WithTempDir(dir string) Option { | |||
| 283 | 283 | func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option { | |
| 284 | 284 | return func(d *Daemon) { | |
| 285 | 285 | if runner != nil { | |
| 286 | - | d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv} | |
| 286 | + | d.barScripts.runner = barScriptRunner{runner: runner} | |
| 287 | 287 | } | |
| 288 | 288 | } | |
| 289 | 289 | } | |
| @@ -357,6 +357,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 357 | 357 | lastRefresh: make(map[domain.SessionID]time.Time), | |
| 358 | 358 | lastContext: make(map[domain.SessionID]barScriptContext), | |
| 359 | 359 | running: make(map[domain.SessionID]bool), | |
| 360 | + | reload: make(chan struct{}, 1), | |
| 360 | 361 | }, | |
| 361 | 362 | } | |
| 362 | 363 | defaults := domain.Defaults() | |
| @@ -283,7 +283,7 @@ func WithTempDir(dir string) Option { | |||||
| 283 | func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option { | 283 | func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option { | ||
| 284 | return func(d *Daemon) { | 284 | return func(d *Daemon) { | ||
| 285 | if runner != nil { | 285 | if runner != nil { | ||
| 286 | - | d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv} | 286 | + | d.barScripts.runner = barScriptRunner{runner: runner} |
| 287 | } | 287 | } | ||
| 288 | } | 288 | } | ||
| 289 | } | 289 | } | ||
| @@ -357,6 +357,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 357 | lastRefresh: make(map[domain.SessionID]time.Time), | 357 | lastRefresh: make(map[domain.SessionID]time.Time), | ||
| 358 | lastContext: make(map[domain.SessionID]barScriptContext), | 358 | lastContext: make(map[domain.SessionID]barScriptContext), | ||
| 359 | running: make(map[domain.SessionID]bool), | 359 | running: make(map[domain.SessionID]bool), | ||
| 360 | + | reload: make(chan struct{}, 1), | |||
| 360 | }, | 361 | }, | ||
| 361 | } | 362 | } | ||
| 362 | defaults := domain.Defaults() | 363 | defaults := domain.Defaults() | ||