Skip to content

Commit 85bcddf

Browse files
fix: paginated endpoints now behave better with pagers by default
1 parent 2fed7f7 commit 85bcddf

File tree

2 files changed

+152
-29
lines changed

2 files changed

+152
-29
lines changed

README.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,51 @@ For details about specific commands, use the `--help` flag.
123123
The CLI also provides resource-based commands for more advanced usage:
124124

125125
```sh
126-
hypeman [resource] [command] [flags]
126+
# Pull an image
127+
hypeman pull nginx:alpine
128+
129+
# Boot a new VM (auto-pulls image if needed)
130+
hypeman run --name my-app nginx:alpine
131+
132+
# List running VMs
133+
hypeman ps
134+
# show all VMs
135+
hypeman ps -a
136+
137+
# View logs of your app
138+
# All commands support using VM name, ID, or partial ID
139+
hypeman logs my-app
140+
hypeman logs -f my-app
141+
142+
# Execute a command in a running VM
143+
hypeman exec my-app whoami
144+
# Shell into the VM
145+
hypeman exec -it my-app /bin/sh
146+
147+
# VM lifecycle
148+
# Turn off the VM
149+
hypeman stop my-app
150+
# Boot the VM that was turned off
151+
hypeman start my-app
152+
# Put the VM to sleep (paused)
153+
hypeman standby my-app
154+
# Awaken the VM (resumed)
155+
hypeman restore my-app
156+
157+
# Create a reverse proxy ("ingress") from the host to your VM
158+
hypeman ingress create --name my-ingress my-app --hostname my-nginx-app --port 80 --host-port 8081
159+
160+
# List ingresses
161+
hypeman ingress list
162+
163+
# Curl nginx through your ingress
164+
curl --header "Host: my-nginx-app" http://127.0.0.1:8081
165+
166+
# Delete an ingress
167+
hypeman ingress delete my-ingress
168+
169+
# Delete all VMs
170+
hypeman rm --force --all
127171
```
128172

129173
## Resource Management
@@ -213,6 +257,12 @@ hypeman run --hypervisor qemu --name qemu-vm myimage:latest
213257
hypeman run --hypervisor cloud-hypervisor --name ch-vm myimage:latest
214258
```
215259

260+
The CLI also provides resource-based commands for more advanced usage:
261+
262+
```sh
263+
hypeman [resource] [command] [flags]
264+
```
265+
216266
## Global Flags
217267

218268
- `--debug` - Enable debug logging (includes HTTP request/response details)

pkg/cmd/cmdutil.go

Lines changed: 101 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -82,28 +82,99 @@ func streamOutput(label string, generateOutput func(w *os.File) error) error {
8282
return streamToStdout(generateOutput)
8383
}
8484

85-
pagerInput, outputFile, isSocketPair, err := createPagerFiles()
86-
if err != nil {
87-
return err
85+
// Windows lacks UNIX socket APIs, so we fall back to pipes there or if
86+
// socket creation fails. We prefer sockets when available because they
87+
// allow for smaller buffer sizes, preventing unnecessary data streaming
88+
// from the backend. Pipes typically have large buffers but serve as a
89+
// decent alternative when sockets aren't available.
90+
if runtime.GOOS == "windows" {
91+
return streamToPagerWithPipe(label, generateOutput)
92+
}
93+
94+
// Try to use socket pair for better buffer control
95+
pagerInput, pid, err := openSocketPairPager(label)
96+
if err != nil || pagerInput == nil {
97+
// Fall back to pipe if socket setup fails
98+
return streamToPagerWithPipe(label, generateOutput)
8899
}
89100
defer pagerInput.Close()
90-
defer outputFile.Close()
91101

92-
cmd, err := startPagerCommand(pagerInput, label, isSocketPair)
102+
// If we would be streaming to a terminal and aren't forcing color one way
103+
// or the other, we should configure things to use color so the pager gets
104+
// colorized input.
105+
if isTerminal(os.Stdout) && os.Getenv("FORCE_COLOR") == "" {
106+
os.Setenv("FORCE_COLOR", "1")
107+
}
108+
109+
// If the pager exits before reading all input, then generateOutput() will
110+
// produce a broken pipe error, which is fine and we don't want to propagate it.
111+
if err := generateOutput(pagerInput); err != nil &&
112+
!strings.Contains(err.Error(), "broken pipe") {
113+
return err
114+
}
115+
116+
// Close the file NOW before we wait for the child process to terminate.
117+
// This way, the child will receive the end-of-file signal and know that
118+
// there is no more input. Otherwise the child process may block
119+
// indefinitely waiting for another line (this can happen when streaming
120+
// less than a screenful of data to a pager).
121+
pagerInput.Close()
122+
123+
// Wait for child process to exit
124+
var wstatus syscall.WaitStatus
125+
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
126+
if wstatus.ExitStatus() != 0 {
127+
return fmt.Errorf("Pager exited with non-zero exit status: %d", wstatus.ExitStatus())
128+
}
129+
return err
130+
}
131+
132+
func streamToPagerWithPipe(label string, generateOutput func(w *os.File) error) error {
133+
r, w, err := os.Pipe()
93134
if err != nil {
94135
return err
95136
}
137+
defer r.Close()
138+
defer w.Close()
139+
140+
pagerProgram := os.Getenv("PAGER")
141+
if pagerProgram == "" {
142+
pagerProgram = "less"
143+
}
96144

97-
if err := pagerInput.Close(); err != nil {
145+
if _, err := exec.LookPath(pagerProgram); err != nil {
98146
return err
99147
}
100148

101-
// If the pager exits before reading all input, then generateOutput() will
102-
// produce a broken pipe error, which is fine and we don't want to propagate it.
103-
if err := generateOutput(outputFile); err != nil && !strings.Contains(err.Error(), "broken pipe") {
149+
cmd := exec.Command(pagerProgram)
150+
cmd.Stdin = r
151+
cmd.Stdout = os.Stdout
152+
cmd.Stderr = os.Stderr
153+
cmd.Env = append(os.Environ(),
154+
"LESS=-r -P "+label,
155+
"MORE=-r -P "+label,
156+
)
157+
158+
if err := cmd.Start(); err != nil {
104159
return err
105160
}
106161

162+
if err := r.Close(); err != nil {
163+
return err
164+
}
165+
166+
// If we would be streaming to a terminal and aren't forcing color one way
167+
// or the other, we should configure things to use color so the pager gets
168+
// colorized input.
169+
if isTerminal(os.Stdout) && os.Getenv("FORCE_COLOR") == "" {
170+
os.Setenv("FORCE_COLOR", "1")
171+
}
172+
173+
if err := generateOutput(w); err != nil && !strings.Contains(err.Error(), "broken pipe") {
174+
return err
175+
}
176+
177+
w.Close()
107178
return cmd.Wait()
108179
}
109180

@@ -137,31 +208,33 @@ func startPagerCommand(pagerInput *os.File, label string, useSocketpair bool) (*
137208
pagerProgram = "less"
138209
}
139210

140-
if shouldUseColors(os.Stdout) {
141-
os.Setenv("FORCE_COLOR", "1")
211+
pagerPath, err := exec.LookPath(pagerProgram)
212+
if err != nil {
213+
unix.Close(parentFd)
214+
return nil, 0, err
142215
}
143216

144-
var cmd *exec.Cmd
145-
if useSocketpair {
146-
cmd = exec.Command(pagerProgram, fmt.Sprintf("/dev/fd/%d", pagerInput.Fd()))
147-
cmd.ExtraFiles = []*os.File{pagerInput}
148-
} else {
149-
cmd = exec.Command(pagerProgram)
150-
cmd.Stdin = pagerInput
151-
}
217+
env := os.Environ()
218+
env = append(env, "LESS=-r -P "+label)
219+
env = append(env, "MORE=-r -P "+label)
152220

153-
cmd.Stdout = os.Stdout
154-
cmd.Stderr = os.Stderr
155-
cmd.Env = append(os.Environ(),
156-
"LESS=-r -f -P "+label,
157-
"MORE=-r -f -P "+label,
158-
)
221+
procAttr := &syscall.ProcAttr{
222+
Dir: "",
223+
Env: env,
224+
Files: []uintptr{
225+
uintptr(childFd), // stdin (fd 0)
226+
uintptr(syscall.Stdout), // stdout (fd 1)
227+
uintptr(syscall.Stderr), // stderr (fd 2)
228+
},
229+
}
159230

160-
if err := cmd.Start(); err != nil {
161-
return nil, err
231+
pid, err := syscall.ForkExec(pagerPath, []string{pagerProgram}, procAttr)
232+
if err != nil {
233+
unix.Close(parentFd)
234+
return nil, 0, err
162235
}
163236

164-
return cmd, nil
237+
return parentConn, pid, nil
165238
}
166239

167240
func shouldUseColors(w io.Writer) bool {

0 commit comments

Comments
 (0)