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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ This page lists the `flock` commands. The other pages show where to find each ac
| `flock install [--skip-privileged]` | Set Flock up: shims on PATH, background daemon, `.test` DNS and trusted HTTPS |
| `flock uninstall [--purge]` | Remove Flock. Keeps site settings and databases unless `--purge` is given |
| `flock status` | Show the state of the daemon and its services |
| `flock stop` | Stop the daemon and all services until `flock start`, or until it starts by itself again (at the next login; on Linux, when the computer starts) |
| `flock start` | Start the daemon and all services after `flock stop` |
| `flock restart` | Restart the daemon and all services |
| `flock logs [name] [-n 50] [-f]` | Show service logs (daemon, web, php8.5-fpm on macOS and Linux or php8.5-cgi on Windows, postgres, …). Without a name, lists the logs |
| `flock update` | Update Flock, CLI and daemon included: the app installs the new version (on Linux, apt or dnf) |
Expand Down
17 changes: 17 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,20 @@ Stop that program, then click **Restart** at the bottom of the Flock panel. On L
:::cli
Stop that program and run `flock restart`.
:::

## Stopping Flock

Flock's daemon runs in the background, so your sites keep working when the app is closed. To free its memory when you're not working, stop it: every service (PHP, databases, mail, …) stops with it, and the databases shut down cleanly.

:::app
Click **Stop** at the bottom of the Flock panel, or **Stop Daemon** in **Settings → General**. On Windows and Linux, **Stop Flock** in the menu of Flock's icon does the same. While Flock is stopped, the panel and Settings show **Start Flock**, which starts it again.
:::

:::cli
```bash
flock stop
flock start
```
:::

Stopping lasts until you start Flock again, or until it starts by itself: at your next login (on Linux, when the computer starts).
13 changes: 13 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ type Settings struct {
type Backend interface {
Status() Status
Reload() error
// Stop shuts the daemon down: it stops every service and exits, as on SIGTERM.
Stop()
Root() string
// Mutate loads config.json, applies change, saves it and reloads the daemon.
Mutate(change func(*config.Config) error) error
Expand Down Expand Up @@ -286,6 +288,10 @@ func NewHandler(b Backend, token string) http.Handler {
}
writeJSON(w, http.StatusOK, b.Status())
})
mux.HandleFunc("POST /v1/stop", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{})
b.Stop()
})
mux.HandleFunc("POST /v1/settings", func(w http.ResponseWriter, r *http.Request) {
var settings Settings
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&settings); err != nil {
Expand Down Expand Up @@ -376,6 +382,13 @@ func (c *Client) Reload(ctx context.Context) (Status, error) {
return status, err
}

// Stop asks the daemon to shut down. It returns once the daemon has accepted, which may still be
// stopping its services then.
func (c *Client) Stop(ctx context.Context) error {
var out struct{}
return c.do(ctx, http.MethodPost, "/v1/stop", &out)
}

// UpdateSettings changes the given global settings and returns the resulting status.
func (c *Client) UpdateSettings(ctx context.Context, s Settings) (Status, error) {
var status Status
Expand Down
17 changes: 17 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type fakeBackend struct {
pg *postgres.Manager
frankenphp *frankenphp.Manager
binaries *binaries.Catalog
stopped bool
}

func (f *fakeBackend) PHP() *php.Manager { return f.php }
Expand Down Expand Up @@ -64,6 +65,7 @@ func (f *fakeBackend) RemoveWorktree(context.Context, string, bool) (string, err

func (f *fakeBackend) Status() Status { return Status{Sites: f.sites} }
func (f *fakeBackend) Reload() error { return nil }
func (f *fakeBackend) Stop() { f.stopped = true }
func (f *fakeBackend) Root() string { return f.root }
func (f *fakeBackend) ReleasePHP(string) {}
func (f *fakeBackend) Services() []Service { return []Service{} }
Expand Down Expand Up @@ -123,6 +125,21 @@ func TestTokenIsRequiredWhenConfigured(t *testing.T) {
}
}

func TestStopShutsTheDaemonDown(t *testing.T) {
b := &fakeBackend{root: t.TempDir()}
srv := serve(t, b, "secret")

if resp, _ := call(t, "POST", srv.URL+"/v1/stop", "", ""); resp.StatusCode != http.StatusUnauthorized || b.stopped {
t.Fatalf("no token: %d, stopped %v", resp.StatusCode, b.stopped)
}
if resp, _ := call(t, "GET", srv.URL+"/v1/stop", "secret", ""); resp.StatusCode != http.StatusMethodNotAllowed || b.stopped {
t.Fatalf("GET: %d, stopped %v", resp.StatusCode, b.stopped)
}
if resp, body := call(t, "POST", srv.URL+"/v1/stop", "secret", ""); resp.StatusCode != http.StatusOK || !b.stopped {
t.Fatalf("POST: %d %s, stopped %v", resp.StatusCode, body, b.stopped)
}
}

func TestSettingsOnlyChangeGivenKeys(t *testing.T) {
b := &fakeBackend{root: t.TempDir()}
srv := serve(t, b, "")
Expand Down
43 changes: 41 additions & 2 deletions internal/cli/install_darwin.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"context"
"fmt"
"os"
"os/exec"
Expand All @@ -13,6 +14,7 @@ import (
"github.com/spf13/cobra"
"golang.org/x/term"

"flock/internal/api"
"flock/internal/certs"
"flock/internal/dns"
"flock/internal/node"
Expand Down Expand Up @@ -166,7 +168,11 @@ func installService(root, bin string) error {

domain := "gui/" + strconv.Itoa(os.Getuid())
stopService(domain)
return bootstrapAgent(domain, path)
}

// bootstrapAgent loads the agent at path, which starts the daemon (RunAtLoad).
func bootstrapAgent(domain, path string) error {
// bootout returns before launchd has fully released the job, so bootstrap can briefly fail with EIO.
var lastErr error
for range 20 {
Expand Down Expand Up @@ -487,11 +493,44 @@ func appDirs() []string {
}
}

// restartService makes launchd kill and relaunch the daemon.
// stopDaemon unloads the agent: launchd stops the daemon with SIGTERM, so its services shut down
// cleanly, and no longer keeps it alive. The plist stays, so the daemon starts at the next login.
func stopDaemon(context.Context, *api.Client, int) error {
if !serviceInstalled() {
return fmt.Errorf("the background daemon is not installed — run `flock install`")
}
domain := "gui/" + strconv.Itoa(os.Getuid())
stopService(domain)
// stopService gives up after ten seconds, with the agent maybe still loaded.
if exec.Command("launchctl", "print", domain+"/"+launchLabel).Run() == nil {
return fmt.Errorf("launchd did not unload %s, so the daemon may still be running — see `flock logs daemon`", launchLabel)
}
return nil
}

// startDaemon loads the agent again after `flock stop`.
func startDaemon() error {
path, err := plistPath()
if err != nil {
return err
}
if !serviceInstalled() {
return fmt.Errorf("the background daemon is not installed — run `flock install`")
}
domain := "gui/" + strconv.Itoa(os.Getuid())
if exec.Command("launchctl", "print", domain+"/"+launchLabel).Run() == nil {
// Loaded already: KeepAlive restarts the daemon by itself.
return nil
}
return bootstrapAgent(domain, path)
}

// restartService makes launchd kill and relaunch the daemon, or loads the agent again after
// `flock stop` (the apps' Start Flock runs `flock restart`).
func restartService() error {
target := "gui/" + strconv.Itoa(os.Getuid()) + "/" + launchLabel
if err := exec.Command("launchctl", "print", target).Run(); err != nil {
return fmt.Errorf("the background daemon is not installed — run `flock install`")
return startDaemon()
}
if out, err := exec.Command("launchctl", "kickstart", "-k", target).CombinedOutput(); err != nil {
return fmt.Errorf("launchctl kickstart: %v: %s", err, out)
Expand Down
26 changes: 26 additions & 0 deletions internal/cli/install_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
Expand All @@ -17,6 +18,7 @@ import (
"github.com/spf13/cobra"
"golang.org/x/term"

"flock/internal/api"
"flock/internal/certs"
"flock/internal/dns"
"flock/internal/node"
Expand Down Expand Up @@ -339,6 +341,30 @@ func uninstallService() error {
return systemctl("stop", unitName(name))
}

// stopDaemon makes systemd stop the daemon (SIGTERM, so its services shut down cleanly).
func stopDaemon(context.Context, *api.Client, int) error {
name, err := serviceUser()
if err != nil {
return err
}
if !serviceInstalled() {
return fmt.Errorf("the background daemon is not installed — run `flock install`")
}
return systemctl("stop", unitName(name))
Comment thread
onairmarc marked this conversation as resolved.
Resolved
}

// startDaemon makes systemd start the daemon after `flock stop`.
func startDaemon() error {
name, err := serviceUser()
if err != nil {
return err
}
if !serviceInstalled() {
return fmt.Errorf("the background daemon is not installed — run `flock install`")
}
return systemctl("start", unitName(name))
}

// restartService makes systemd stop and start the daemon.
func restartService() error {
name, err := serviceUser()
Expand Down
80 changes: 80 additions & 0 deletions internal/cli/install_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"bytes"
"context"
"encoding/csv"
"errors"
"fmt"
Expand All @@ -22,6 +23,7 @@ import (
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"

"flock/internal/api"
"flock/internal/certs"
"flock/internal/dns"
"flock/internal/hosts"
Expand Down Expand Up @@ -514,6 +516,74 @@ func restartService() error {
return fmt.Errorf("the background daemon is not installed — run `flock install`")
}

// stopTimeout is how long `flock stop` waits for the daemon to stop its services: each gets up to
// 10 s to shut down cleanly before it is killed.
const stopTimeout = time.Minute

// stopDaemon asks the daemon (pid, 0 when it doesn't answer) to shut down, which stops its services
// cleanly (PostgreSQL, MySQL and Valkey save their data) where ending the scheduled task kills them.
// The daemon then exits with code 0, so the watchdog ends instead of restarting it, and the task
// with it. The task stays as it is and starts the daemon again at the next sign-in. A daemon that
// doesn't stop in time, or one from before `flock stop`, is ended the way `flock restart` does.
func stopDaemon(ctx context.Context, client *api.Client, pid int) error {
root, err := paths.Root()
if err != nil {
return err
}
if pid != 0 && client.Stop(ctx) == nil && waitProcessExit(root, pid, stopTimeout) {
return nil
}
installed := taskInstalled()
if !installed && !runKeyInstalled() {
if pid != 0 {
return fmt.Errorf("the daemon (pid %d) did not stop — see `flock logs daemon`", pid)
}
return nil
}
if pid != 0 {
fmt.Println("! the daemon didn't stop by itself in time; ending it")
}
// a watchdog waiting to start a crashed daemon again.
if installed {
_ = exec.Command("schtasks", "/End", "/TN", taskName).Run()
}
stopDaemonProcess()
waitDaemonGone(root)
if daemonAnswers(root) {
return fmt.Errorf("the daemon could not be ended and is still running — see `flock logs daemon`")
}
return nil
Comment thread
N7z marked this conversation as resolved.
Resolved
}

// waitProcessExit waits up to timeout for the process pid to exit.
func waitProcessExit(root string, pid int, timeout time.Duration) bool {
p, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
if err != nil {
// Gone already, or not ours to wait for: then at least wait for the API to go.
waitDaemonGone(root)
return !daemonAnswers(root)
}
defer windows.CloseHandle(p)
event, err := windows.WaitForSingleObject(p, uint32(timeout.Milliseconds()))
return err == nil && event == windows.WAIT_OBJECT_0
}

// startDaemon starts the scheduled task (or the Run key's command) and waits until the daemon answers.
func startDaemon() error {
root, err := paths.Root()
if err != nil {
return err
}
if taskInstalled() {
return runTask(root)
}
if runKeyInstalled() {
exe, args := daemonCommand(root)
return startDetached(exe, args)
}
return fmt.Errorf("the background daemon is not installed — run `flock install`")
}

// waitDaemonGone waits (up to 10 s) until no daemon answers on the socket any more. Ending the task
// or the process returns before Windows has ended it, and a daemon started meanwhile finds the old
// one still answering, exits as "already running", and its watchdog doesn't retry: nothing runs.
Expand All @@ -528,6 +598,16 @@ func waitDaemonGone(root string) {
}
}

// daemonAnswers reports whether a daemon still answers on the socket.
func daemonAnswers(root string) bool {
conn, err := net.DialTimeout("unix", paths.Socket(root), 500*time.Millisecond)
if err != nil {
return false
}
_ = conn.Close()
return true
}

// privilegedFirst: `flock install` runs ensurePrivileged before it replaces anything, so declining
// the UAC prompt leaves the installed version as it was. The installer runs it on its own
// (--privileged-only) before it replaces the app, so an update that isn't allowed doesn't happen.
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func NewRootCommand() *cobra.Command {

root.AddCommand(
newInstallCommand(), newConflictsCommand(), newUninstallCommand(), newPrivilegedSetupCommand(),
newDaemonCommand(), newRestartCommand(), newStatusCommand(), newLogsCommand(), newVersionCommand(), newUpdateCommand(),
newDaemonCommand(), newStartCommand(), newStopCommand(), newRestartCommand(), newStatusCommand(), newLogsCommand(), newVersionCommand(), newUpdateCommand(),
newSitesCommand(), newLinkCommand(), newUnlinkCommand(), newParkCommand(), newUnparkCommand(), newProxyCommand(), newUnproxyCommand(), newProxiesCommand(),
newSecureCommand(true), newSecureCommand(false), newDomainCommand(), newWorktreeCommand(), newShareCommand(), newUseCommand(),
newIsolateCommand(), newUnisolateCommand(), newIsolatedCommand(), newPinCommand(), newUnpinCommand(), newRootCommand(),
Expand Down
Loading
Loading