Skip to content

fix: cleanup temporary directories #566

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 14, 2025
Merged
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
5 changes: 5 additions & 0 deletions commands/local_server_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,11 @@ var localServerStartCmd = &console.Command{
return err
}
terminal.Eprintln("")
// wait for the PHP Server to be done cleaning up
if p.PHPServer != nil {
<-p.PHPServer.StoppedChan
}
pidFile.CleanupDirectories()
ui.Success("Stopped all processes successfully")
}
return nil
Expand Down
5 changes: 3 additions & 2 deletions local/php/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func Composer(dir string, args, env []string, stdout, stderr, logger io.Writer,
fmt.Fprintln(logger, " WARNING: Unable to find Composer, downloading one. It is recommended to install Composer yourself at https://getcomposer.org/download/")
// we don't store it under bin/ to avoid it being found by findComposer as we want to only use it as a fallback
binDir := filepath.Join(util.GetHomeDir(), "composer")
if path, err = downloadComposer(binDir); err != nil {
if path, err = downloadComposer(binDir, debugLogger); err != nil {
return ComposerResult{
code: 1,
error: errors.Wrap(err, "unable to find composer, get it at https://getcomposer.org/download/"),
Expand Down Expand Up @@ -157,7 +157,7 @@ func findComposer(extraBin string, logger zerolog.Logger) (string, error) {
return "", os.ErrNotExist
}

func downloadComposer(dir string) (string, error) {
func downloadComposer(dir string, debugLogger zerolog.Logger) (string, error) {
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
Expand Down Expand Up @@ -193,6 +193,7 @@ func downloadComposer(dir string) (string, error) {
SkipNbArgs: 1,
Stdout: &stdout,
Stderr: &stdout,
Logger: debugLogger,
}
ret := e.Execute(false)
if ret == 1 {
Expand Down
108 changes: 97 additions & 11 deletions local/php/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"runtime"
"strings"
"syscall"
"time"

"github.com/pkg/errors"
"github.com/rs/xid"
Expand Down Expand Up @@ -163,7 +164,11 @@ func (e *Executor) DetectScriptDir() (string, error) {
return e.scriptDir, nil
}

// Config determines the right version of PHP depending on the configuration (+ its configuration)
// Config determines the right version of PHP depending on the configuration
// (+ its configuration). It also creates some symlinks to ease the integration
// with underlying tools that could try to run PHP. This is the responsibility
// of the caller to clean those temporary files. One can call
// CleanupTemporaryDirectories to do so.
func (e *Executor) Config(loadDotEnv bool) error {
// reset environment
e.environ = make([]string, 0)
Expand Down Expand Up @@ -220,8 +225,10 @@ func (e *Executor) Config(loadDotEnv bool) error {
// prepending the PHP directory in the PATH does not work well if the PHP binary is not named "php" (like php7.3 for instance)
// in that case, we create a temp directory with a symlink
// we also link php-config for pecl to pick up the right one (it is always looks for something called php-config)
phpDir := filepath.Join(cliDir, "tmp", xid.New().String(), "bin")
e.tempDir = phpDir
if e.tempDir == "" {
e.tempDir = filepath.Join(cliDir, "tmp", xid.New().String())
}
phpDir := filepath.Join(e.tempDir, "bin")
if err := os.MkdirAll(phpDir, 0755); err != nil {
return err
}
Expand Down Expand Up @@ -284,6 +291,92 @@ func (e *Executor) Config(loadDotEnv bool) error {
return err
}

func (e *Executor) CleanupTemporaryDirectories() {
go cleanupStaleTemporaryDirectories(e.Logger)
if e.iniDir != "" {
os.RemoveAll(e.iniDir)
}
if e.tempDir != "" {
os.RemoveAll(e.tempDir)
}
}

// The Symfony CLI used to leak temporary directories until v5.10.8. The bug is
// fixed but because directories names are random they are not going to be
// reused and thus are not going to be cleaned up. And because they might be
// in-use by running servers we can't simply delete the parent directory. This
// is why we make our best to find the oldest directories and remove then,
// cleaning the directory little by little.
func cleanupStaleTemporaryDirectories(mainLogger zerolog.Logger) {
parentDirectory := filepath.Join(util.GetHomeDir(), "tmp")
mainLogger = mainLogger.With().Str("dir", parentDirectory).Logger()

if len(parentDirectory) < 6 {
mainLogger.Warn().Msg("temporary dir path looks too short")
return
}

mainLogger.Debug().Msg("Starting temporary directory cleanup...")
dir, err := os.Open(parentDirectory)
if err != nil {
mainLogger.Warn().Err(err).Msg("Failed to open temporary directory")
return
}
defer dir.Close()

// the duration after which we consider temporary directories as
// stale and can be removed
cutoff := time.Now().Add(-7 * 24 * time.Hour)

for {
// we might have a lof of entries so we need to work in batches
entries, err := dir.Readdirnames(30)
if err == io.EOF {
mainLogger.Debug().Msg("Cleaning is done...")
return
}
if err != nil {
mainLogger.Warn().Err(err).Msg("Failed to read entries")
return
}

for _, entry := range entries {
logger := mainLogger.With().Str("entry", entry).Logger()

// we generate temporary directory names with
// `xid.New().String()` which is always 20 char long
if len(entry) != 20 {
logger.Debug().Msg("found an entry that is not from us")
continue
} else if _, err := xid.FromString(entry); err != nil {
logger.Debug().Err(err).Msg("found an entry that is not from us")
continue
}

entryPath := filepath.Join(parentDirectory, entry)
file, err := os.Open(entryPath)
if err != nil {
logger.Warn().Err(err).Msg("failed to read entry")
continue
} else if fi, err := file.Stat(); err != nil {
logger.Warn().Err(err).Msg("failed to read entry")
continue
} else if !fi.IsDir() {
logger.Warn().Err(err).Msg("entry is not a directory")
continue
} else if fi.ModTime().After(cutoff) {
logger.Debug().Any("cutoff", cutoff).Msg("entry is more recent than cutoff, keeping it for now")
continue
}

logger.Debug().Str("entry", entry).Msg("entry matches the criterias, removing it")
if err := os.RemoveAll(entryPath); err != nil {
logger.Warn().Err(err).Msg("failed to remove entry")
}
}
}
}

// Find composer depending on the configuration
func (e *Executor) findComposer(extraBin string) (string, error) {
if scriptDir, err := e.DetectScriptDir(); err == nil {
Expand Down Expand Up @@ -312,14 +405,7 @@ func (e *Executor) Execute(loadDotEnv bool) int {
fmt.Fprintln(os.Stderr, err)
return 1
}
defer func() {
if e.iniDir != "" {
os.RemoveAll(e.iniDir)
}
if e.tempDir != "" {
os.RemoveAll(e.tempDir)
}
}()
defer e.CleanupTemporaryDirectories()
cmd := execCommand(e.Args[0], e.Args[1:]...)
environ := append(os.Environ(), e.environ...)
gpathname := "PATH"
Expand Down
6 changes: 1 addition & 5 deletions local/php/fpm.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,5 @@ env['LC_ALL'] = C
}

func (p *Server) fpmConfigFile() string {
path := filepath.Join(p.homeDir, fmt.Sprintf("php/%s/fpm-%s.ini", name(p.projectDir), p.Version.Version))
if _, err := os.Stat(filepath.Dir(path)); os.IsNotExist(err) {
_ = os.MkdirAll(filepath.Dir(path), 0755)
}
return path
return filepath.Join(p.tempDir, fmt.Sprintf("fpm-%s.ini", p.Version.Version))
}
7 changes: 1 addition & 6 deletions local/php/php_builtin_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ package php

import (
"fmt"
"os"
"path/filepath"
)

Expand Down Expand Up @@ -61,9 +60,5 @@ require $script;
`)

func (p *Server) phpRouterFile() string {
path := filepath.Join(p.homeDir, fmt.Sprintf("php/%s-router.php", name(p.projectDir)))
if _, err := os.Stat(filepath.Dir(path)); os.IsNotExist(err) {
_ = os.MkdirAll(filepath.Dir(path), 0755)
}
return path
return filepath.Join(p.tempDir, fmt.Sprintf("%s-router.php", p.Version.Version))
Copy link
Member Author

@tucksaun tucksaun Feb 8, 2025

Choose a reason for hiding this comment

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

Note for the reviewers: I moved the routing file to the same location as the FPM config.
This way I can always create and clean the directory no matter the SAPI

}
21 changes: 13 additions & 8 deletions local/php/php_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ import (
type Server struct {
Version *phpstore.Version
logger zerolog.Logger
StoppedChan chan bool
appVersion string
homeDir string
tempDir string
projectDir string
documentRoot string
passthru string
Expand All @@ -75,16 +76,22 @@ func NewServer(homeDir, projectDir, documentRoot, passthru, appVersion string, l
Version: version,
logger: logger.With().Str("source", "PHP").Str("php", version.Version).Str("path", version.ServerPath()).Logger(),
appVersion: appVersion,
homeDir: homeDir,
projectDir: projectDir,
documentRoot: documentRoot,
passthru: passthru,
StoppedChan: make(chan bool, 1),
}, nil
}

// Start starts a PHP server
func (p *Server) Start(ctx context.Context, pidFile *pid.PidFile) (*pid.PidFile, func() error, error) {
var pathsToRemove []string
p.tempDir = pidFile.TempDirectory()
if _, err := os.Stat(p.tempDir); os.IsNotExist(err) {
if err = os.MkdirAll(p.tempDir, 0755); err != nil {
return nil, nil, err
}
}

port, err := process.FindAvailablePort()
if err != nil {
p.logger.Debug().Err(err).Msg("unable to find an available port")
Expand Down Expand Up @@ -123,7 +130,6 @@ func (p *Server) Start(ctx context.Context, pidFile *pid.PidFile) (*pid.PidFile,
return nil, nil, errors.WithStack(err)
}
p.proxy.Transport = &cgiTransport{}
pathsToRemove = append(pathsToRemove, fpmConfigFile)
binName = "php-fpm"
workerName = "PHP-FPM"
args = []string{p.Version.ServerPath(), "--nodaemonize", "--fpm-config", fpmConfigFile}
Expand All @@ -149,7 +155,6 @@ func (p *Server) Start(ctx context.Context, pidFile *pid.PidFile) (*pid.PidFile,
if err := os.WriteFile(routerPath, phprouter, 0644); err != nil {
return nil, nil, errors.WithStack(err)
}
pathsToRemove = append(pathsToRemove, routerPath)
binName = "php"
workerName = "PHP"
args = []string{p.Version.ServerPath(), "-S", "127.0.0.1:" + strconv.Itoa(port), "-d", "variables_order=EGPCS", routerPath}
Expand All @@ -161,6 +166,7 @@ func (p *Server) Start(ctx context.Context, pidFile *pid.PidFile) (*pid.PidFile,
BinName: binName,
Args: args,
scriptDir: p.projectDir,
Logger: p.logger,
}
p.logger.Info().Int("port", port).Msg("listening")

Expand Down Expand Up @@ -192,9 +198,8 @@ func (p *Server) Start(ctx context.Context, pidFile *pid.PidFile) (*pid.PidFile,

return phpPidFile, func() error {
defer func() {
for _, path := range pathsToRemove {
os.RemoveAll(path)
}
e.CleanupTemporaryDirectories()
p.StoppedChan <- true
}()

return errors.Wrap(errors.WithStack(runner.Run()), "PHP server exited unexpectedly")
Expand Down
13 changes: 13 additions & 0 deletions local/pid/pidfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,19 @@ func (p *PidFile) WorkerPidDir() string {
return filepath.Join(util.GetHomeDir(), "var", name(p.Dir))
}

func (p *PidFile) TempDirectory() string {
return filepath.Join(util.GetHomeDir(), "php", name(p.Dir))
}

func (p *PidFile) CleanupDirectories() {
os.RemoveAll(p.TempDirectory())
// We don't want to force removal of log and pid files, we only want to
// clean up empty directories. To do so we use `os.Remove` instead of
// `os.RemoveAll`
os.Remove(p.WorkerLogDir())
os.Remove(p.WorkerPidDir())
}

func (p *PidFile) LogReader() (io.ReadCloser, error) {
logFile := p.LogFile()
if err := os.MkdirAll(filepath.Dir(logFile), 0755); err != nil {
Expand Down
12 changes: 4 additions & 8 deletions local/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,9 @@ import (

// Project represents a PHP project
type Project struct {
HTTP *lhttp.Server
PHPServer *php.Server
Logger zerolog.Logger
homeDir string
projectDir string
HTTP *lhttp.Server
PHPServer *php.Server
Logger zerolog.Logger
}

// New creates a new PHP project
Expand All @@ -49,9 +47,7 @@ func New(c *Config) (*Project, error) {
}
passthru, err := realPassthru(documentRoot, c.Passthru)
p := &Project{
Logger: c.Logger.With().Str("source", "HTTP").Logger(),
homeDir: c.HomeDir,
projectDir: c.ProjectDir,
Logger: c.Logger.With().Str("source", "HTTP").Logger(),
HTTP: &lhttp.Server{
DocumentRoot: documentRoot,
Port: c.Port,
Expand Down
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,14 @@ func main() {
BinName: args[1],
Args: args[1:],
ExtraEnv: getCliExtraEnv(),
Logger: terminal.Logger,
}
os.Exit(e.Execute(true))
}
// called via "symfony console"?
if len(args) >= 2 && args[1] == "console" {
if executor, err := php.SymonyConsoleExecutor(args[2:]); err == nil {
executor.Logger = terminal.Logger
executor.ExtraEnv = getCliExtraEnv()
os.Exit(executor.Execute(false))
}
Expand Down
Loading