├── .github └── workflows │ └── build.yml ├── .gitignore ├── .whitesource ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── README.md ├── cli ├── display.go ├── input.go ├── input_darwin.go ├── input_linux.go └── pm-cli.go ├── client └── client.go ├── go.mod ├── go.sum ├── http.go ├── pm.go ├── pm_test.go └── types.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - go: 1.11.9 17 | build-with: false 18 | - go: 1.13.14 19 | build-with: true 20 | - go: 1.14.6 21 | build-with: false 22 | continue-on-error: ${{ matrix.build-with == false }} 23 | name: Build with ${{ matrix.go }} 24 | env: 25 | GO111MODULE: on 26 | 27 | steps: 28 | - name: Set up Go 29 | uses: actions/setup-go@v1 30 | with: 31 | go-version: ${{ matrix.go }} 32 | 33 | - name: Checkout code 34 | uses: actions/checkout@v2 35 | 36 | - name: Go mod cache 37 | id: cache-go 38 | uses: actions/cache@v1 39 | with: 40 | path: ~/go/pkg/mod 41 | key: ${{ runner.os }}-go-${{ matrix.go }}-${{ hashFiles('**/go.sum') }} 42 | 43 | - name: Go mod download 44 | run: go mod download 45 | 46 | - name: Vet 47 | run: | 48 | go vet ./... 49 | 50 | - name: Test 51 | run: | 52 | go test -vet=off -race -coverprofile=coverage.txt -covermode=atomic ./... 53 | 54 | - name: Upload code coverage report 55 | if: matrix.build-with == true 56 | env: 57 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 58 | run: bash <(curl -s https://raw.githubusercontent.com/VividCortex/codecov-bash/master/codecov) 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | /coverage.txt 3 | -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | { 2 | "settingsInheritedFrom": "VividCortex/whitesource-config@master" 3 | } -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | digest = "1:c48b5479b9e79dac5d1c4cedfca3be3b92fc3360aa8f1f796ba13d9a68ffe126" 7 | name = "github.com/VividCortex/multitick" 8 | packages = ["."] 9 | pruneopts = "UT" 10 | revision = "282a3ac778f5ec4e211d33d7b8eacbaed4f861e7" 11 | 12 | [solve-meta] 13 | analyzer-name = "dep" 14 | analyzer-version = 1 15 | input-imports = ["github.com/VividCortex/multitick"] 16 | solver-name = "gps-cdcl" 17 | solver-version = 1 18 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [[constraint]] 29 | branch = "master" 30 | name = "github.com/VividCortex/multitick" 31 | 32 | [prune] 33 | go-tests = true 34 | unused-packages = true 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 VividCortex 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pm 2 | 3 | ![build](https://github.com/VividCortex/pm/workflows/build/badge.svg) 4 | 5 | `pm` is a process manager with an HTTP interface. We use it at 6 | [VividCortex](https://vividcortex.com/) to inspect and manage API server 7 | programs. It replaces an internal-only project that was similar. 8 | 9 | `pm` is in beta and will change rapidly. Please see the issues list for what's 10 | planned, or to suggest changes. 11 | 12 | A Processlist is useful for inspecting and managing what's running in a 13 | program, such as an HTTP server or other server program. Processes within this 14 | program are user-defined tasks, such as HTTP requests. 15 | 16 | ## Documentation 17 | 18 | Please read the generated package documentation for both 19 | [server](http://godoc.org/github.com/VividCortex/pm) and 20 | [client](http://godoc.org/github.com/VividCortex/pm/client). 21 | 22 | ## Getting Started 23 | 24 | Package pm is a process manager with an HTTP monitoring/control interface. 25 | 26 | Processes or tasks are user-defined routines within a running Go program. Think 27 | of the routines handling client requests in a web server, for instance. This 28 | package is designed to keep track of them, making information available through 29 | an HTTP interface. Client tools connecting to the later can thus monitor active 30 | tasks, having access to the full status history with timing data. Also, 31 | application-specific attributes may be attached to tasks (method/URI for the web 32 | server case, for example), that will be integrated with status/timing 33 | information. 34 | 35 | 36 | Using `pm` starts by opening a server port to handle requests for task information 37 | through HTTP. That goes like this (although you probably want to add error 38 | checking/handling code): 39 | 40 | ```go 41 | go pm.ListenAndServe(":8081") 42 | ``` 43 | 44 | Processes to be tracked must call `Start()` with a process identifier and, 45 | optionally, a set of attributes. Even though the id is arbitrary, it's up to the 46 | application to choose one not in use by any other running task. A deferred call 47 | to `Done()` with the same id should follow: 48 | 49 | ```go 50 | pm.Start(requestID, nil, map[string]interface{}{ 51 | "host": req.RemoteAddr, 52 | "method": req.Method, 53 | "uri": req.RequestURI, 54 | }) 55 | defer pm.Done(requestID) 56 | ``` 57 | 58 | Finally, each task can change its status as often as required with a `Status()` 59 | call. Status strings are completely arbitrary and never inspected by the 60 | package. Now you're all set to try something like this: 61 | 62 | ``` 63 | curl http://localhost:8081/procs/ 64 | curl http://localhost:8081/procs//history 65 | ``` 66 | 67 | where `` stands for an actual process id in your application. You'll get 68 | JSON responses including, respectively, the set of processes currently running 69 | and the full history for your chosen id. 70 | 71 | Tasks can also be cancelled from the HTTP interface. In order to do that, you 72 | should call the DELETE method on `/procs/`. Given the lack of support in Go 73 | to cancel a running routine, cancellation requests are implemented in this 74 | package as panics. Please refer to the full package documentation to learn how 75 | to properly deal with this. If you're **not** interested in this feature, you 76 | can disable cancellation completely by running the following *before* you 77 | `Start()` any task: 78 | 79 | ```go 80 | pm.SetOptions(ProclistOptions{ 81 | ForbidCancel: true 82 | }) 83 | ``` 84 | 85 | See package `pm/client` for an HTTP client implementation you can readily use 86 | from Go applications. 87 | 88 | ## Contributing 89 | 90 | We only accept pull requests for minor fixes or improvements. This includes: 91 | 92 | * Small bug fixes 93 | * Typos 94 | * Documentation or comments 95 | 96 | Please open issues to discuss new features. Pull requests for new features will be rejected, 97 | so we recommend forking the repository and making changes in your fork for your use case. 98 | 99 | ## License 100 | 101 | Copyright (c) 2013 VividCortex, licensed under the MIT license. 102 | Please see the LICENSE file for details. 103 | 104 | ## Cat Picture 105 | 106 | ![mechanic cat](http://heidicullinan.files.wordpress.com/2012/03/funny-cat-pictures-lolcats-mechanic-cat-is-on-the-job.jpg) 107 | -------------------------------------------------------------------------------- /cli/display.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | func checkTermSize() { 12 | ScreenHeight, ScreenWidth = getTermSize() 13 | } 14 | 15 | func getTermSize() (int, int) { 16 | cmd := exec.Command("stty", "size") 17 | cmd.Stdin = os.Stdin 18 | out, err := cmd.Output() 19 | if err != nil { 20 | return 0, 0 21 | } 22 | dims := strings.Split(strings.Trim(string(out), "\n"), " ") 23 | height, _ := strconv.Atoi(dims[0]) 24 | width, _ := strconv.Atoi(dims[1]) 25 | return height, width 26 | } 27 | 28 | func clearScreen(keepHist bool) { 29 | if keepHist { 30 | fmt.Print("\033[2J\033[;H") 31 | } else { 32 | fmt.Print("\033[0;0H") 33 | height, width := ScreenHeight, ScreenWidth 34 | blank := "" 35 | for width > 0 { 36 | blank += " " 37 | width-- 38 | } 39 | for height > 0 { 40 | fmt.Print(blank) 41 | height-- 42 | } 43 | fmt.Print("\033[0;0H") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /cli/input.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | func inputLoop() { 10 | for { 11 | switch readKey() { 12 | case "k": 13 | paused = true 14 | 15 | host := "" 16 | id := "" 17 | message := "" 18 | fmt.Println() 19 | host = readString("Host: ") 20 | id = readString("ID: ") 21 | message = readString("Message: ") 22 | fmt.Printf("Killing ID %s on %s with message %s.\n", id, host, message) 23 | if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { 24 | host = "http://" + host 25 | } 26 | 27 | client, exists := clients[host] 28 | if exists { 29 | client.Kill(id, message) 30 | } 31 | 32 | paused = false 33 | case "p": 34 | paused = !paused 35 | case "q": 36 | os.Exit(0) 37 | } 38 | } 39 | } 40 | 41 | func readKey() string { 42 | var b []byte = make([]byte, 1) 43 | os.Stdin.Read(b) 44 | return string(b) 45 | } 46 | 47 | func readString(prompt string) string { 48 | // enable input buffering (cooked mode) temporarily 49 | enableInputBuffering() 50 | defer disableInputBuffering() 51 | 52 | read := "" 53 | fmt.Print(prompt + " ") 54 | 55 | fmt.Scanf("%s", &read) 56 | return read 57 | } 58 | -------------------------------------------------------------------------------- /cli/input_darwin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "os/exec" 4 | 5 | func disableInputBuffering() { 6 | exec.Command("stty", "-f", "/dev/tty", "cbreak").Run() 7 | } 8 | 9 | func enableInputBuffering() { 10 | exec.Command("stty", "-f", "/dev/tty", "cooked").Run() 11 | } 12 | -------------------------------------------------------------------------------- /cli/input_linux.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "os/exec" 4 | 5 | func disableInputBuffering() { 6 | exec.Command("stty", "-F", "/dev/tty", "cbreak").Run() 7 | } 8 | 9 | func enableInputBuffering() { 10 | exec.Command("stty", "-F", "/dev/tty", "cooked").Run() 11 | } 12 | -------------------------------------------------------------------------------- /cli/pm-cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | "time" 9 | 10 | "github.com/VividCortex/multitick" 11 | "github.com/VividCortex/pm" 12 | "github.com/VividCortex/pm/client" 13 | ) 14 | 15 | var ( 16 | Endpoints = "" // e.g. "api1:9085,api2:9085,api1:9086,api2:9086" 17 | KeepHist = true 18 | RefreshInterval = time.Second 19 | clients = map[string]*client.Client{} 20 | 21 | ScreenHeight = 40 22 | ScreenWidth = 160 23 | 24 | Display = make(chan []Line) 25 | Trickle = make(chan Line) 26 | 27 | // These might need to be protected by mutexes 28 | Columns = []string{"Host", "Id", "Time", "Status"} 29 | LengthFor = map[string]int{ 30 | "Host": len("longhostname:1234"), 31 | "Id": len("ae14f5cac98273e8"), 32 | "Time": len("300.123s"), 33 | "Status": len("this one is a long enough status!"), 34 | } 35 | 36 | paused = false 37 | ) 38 | 39 | type Line struct { 40 | Host, Id, Status string 41 | ProcAge, StatusAge time.Duration 42 | Cols map[string]string 43 | } 44 | 45 | func init() { 46 | disableInputBuffering() 47 | checkTermSize() 48 | } 49 | 50 | func main() { 51 | flag.StringVar(&Endpoints, "endpoints", Endpoints, "Comma-separated host:port list of APIs to poll") 52 | flag.BoolVar(&KeepHist, "keep-hist", KeepHist, "Keep output history on refreshes") 53 | flag.DurationVar(&RefreshInterval, "refresh", RefreshInterval, "Time interval between refreshes") 54 | flag.Parse() 55 | 56 | ticker := multitick.NewTicker(RefreshInterval, RefreshInterval) 57 | 58 | endpoints := strings.Split(Endpoints, ",") 59 | for _, e := range endpoints { 60 | if !strings.HasPrefix(e, "http://") && !strings.HasPrefix(e, "https://") { 61 | e = "http://" + e 62 | } 63 | clients[e] = client.NewClient(e) 64 | 65 | go poll(e, ticker.Subscribe()) 66 | } 67 | 68 | go top(ticker.Subscribe()) 69 | go inputLoop() 70 | 71 | clearScreen(true) 72 | for lines := range Display { 73 | if paused { 74 | continue 75 | } 76 | 77 | checkTermSize() 78 | clearScreen(KeepHist) 79 | 80 | // Compute and print column headers 81 | lineFormat := "" 82 | for _, c := range Columns { 83 | l := LengthFor[c] 84 | colFormat := fmt.Sprintf(" %%-%ds", l) 85 | fmt.Printf(colFormat, c) 86 | lineFormat += colFormat 87 | } 88 | fmt.Println() 89 | printed := 1 90 | 91 | // Print as many lines as we have room for 92 | for _, l := range lines { 93 | printed++ 94 | args := []interface{}{l.Host, l.Id, fmt.Sprintf("%.4g", l.ProcAge.Seconds()), l.Status} 95 | for _, c := range Columns[4:] { 96 | args = append(args, l.Cols[c]) 97 | } 98 | output := fmt.Sprintf(lineFormat, args...) 99 | if len(output) > ScreenWidth { 100 | output = output[:ScreenWidth] 101 | } 102 | fmt.Println(output) 103 | if printed == ScreenHeight-1 { 104 | break 105 | } 106 | } 107 | } 108 | } 109 | 110 | // poll one of the endpoints for its /procs/ data. 111 | func poll(hostPort string, ticker <-chan time.Time) { 112 | for _ = range ticker { 113 | msg, err := clients[hostPort].Processes() 114 | if err == nil { 115 | msgToLines(hostPort, msg) 116 | } 117 | } 118 | } 119 | 120 | func msgToLines(hostPort string, msg *pm.ProcResponse) { 121 | for _, p := range msg.Procs { 122 | l := Line{ 123 | Host: strings.Replace(hostPort, "http://", "", -1), 124 | Id: p.Id, 125 | Status: p.Status, 126 | ProcAge: msg.ServerTime.Sub(p.ProcTime), 127 | StatusAge: msg.ServerTime.Sub(p.StatusTime), 128 | Cols: map[string]string{}, 129 | } 130 | for name, value := range p.Attrs { 131 | colLen, ok := LengthFor[name] 132 | if !ok { 133 | Columns = append(Columns, name) 134 | } 135 | if len(name) > colLen { 136 | LengthFor[name] = len(name) 137 | } 138 | l.Cols[name] = value.(string) 139 | } 140 | Trickle <- l 141 | } 142 | } 143 | 144 | // aggregate, sort, and batch up the data coming from the pm APIs. 145 | func top(ticker <-chan time.Time) { 146 | var Lines []Line 147 | for { 148 | select { 149 | case l := <-Trickle: 150 | Lines = append(Lines, l) 151 | case <-ticker: 152 | sort.Sort(ByAge(Lines)) 153 | Display <- Lines 154 | Lines = Lines[0:0] 155 | } 156 | } 157 | } 158 | 159 | // ByAge implements sort.Interface for []line based on 160 | // the ProcAge field. 161 | type ByAge []Line 162 | 163 | func (a ByAge) Len() int { return len(a) } 164 | func (a ByAge) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 165 | func (a ByAge) Less(i, j int) bool { return a[i].ProcAge > a[j].ProcAge } // Reversed! 166 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013 VividCortex. Please see the LICENSE file for license terms. 2 | 3 | /* 4 | This package provides an HTTP client to use with pm-enabled processes. 5 | */ 6 | package client 7 | 8 | import ( 9 | "bytes" 10 | "encoding/json" 11 | "errors" 12 | "fmt" 13 | "net/http" 14 | 15 | "github.com/VividCortex/pm" 16 | ) 17 | 18 | type Client struct { 19 | *http.Client 20 | BaseURI string 21 | Headers map[string]string 22 | } 23 | 24 | // NewClient returns a new client set to connect to the given URI. 25 | func NewClient(uri string) *Client { 26 | return &Client{ 27 | Client: &http.Client{}, 28 | BaseURI: uri, 29 | Headers: map[string]string{ 30 | "Accept": "application/json", 31 | "Content-Type": "application/json", 32 | "User-Agent": "go-pm", 33 | }, 34 | } 35 | } 36 | 37 | func (c *Client) makeRequest(verb, endpoint string, body, result interface{}) error { 38 | buf := new(bytes.Buffer) 39 | if body != nil { 40 | if err := json.NewEncoder(buf).Encode(body); err != nil { 41 | return err 42 | } 43 | } 44 | req, err := http.NewRequest(verb, c.BaseURI+endpoint, buf) 45 | if err != nil { 46 | return err 47 | } 48 | for header, value := range c.Headers { 49 | req.Header.Add(header, value) 50 | } 51 | 52 | resp, err := c.Do(req) 53 | if err == nil && resp.StatusCode > 299 { 54 | msg := fmt.Sprintf("HTTP Status Code %d from %s %s\n", resp.StatusCode, verb, c.BaseURI+endpoint) 55 | err = errors.New(msg) 56 | } 57 | 58 | if resp != nil { 59 | defer resp.Body.Close() 60 | if err == nil && result != nil { 61 | json.NewDecoder(resp.Body).Decode(result) 62 | } 63 | } 64 | return err 65 | } 66 | 67 | // Processes issues a GET to /proc, thus retrieving the full process list from 68 | // the server. The result is provided as a ProcResponse. 69 | func (c *Client) Processes() (*pm.ProcResponse, error) { 70 | var result pm.ProcResponse 71 | if err := c.makeRequest("GET", "/procs/", nil, &result); err != nil { 72 | return nil, err 73 | } 74 | return &result, nil 75 | } 76 | 77 | // History issues a GET to /proc//history for a given id, thus returning the 78 | // complete history for the task at the server. 79 | func (c *Client) History(id string) (*pm.HistoryResponse, error) { 80 | var result pm.HistoryResponse 81 | endpoint := fmt.Sprintf("/procs/%s/history", id) 82 | 83 | if err := c.makeRequest("GET", endpoint, nil, &result); err != nil { 84 | return nil, err 85 | } 86 | return &result, nil 87 | } 88 | 89 | // Kill requests the cancellation of a given task. Note that it will effectively 90 | // be cancelled as soon as the task reaches its next cancellation point. 91 | func (c *Client) Kill(id, message string) error { 92 | body := pm.CancelRequest{Message: message} 93 | endpoint := fmt.Sprintf("/procs/%s", id) 94 | 95 | if err := c.makeRequest("DELETE", endpoint, body, nil); err != nil { 96 | return err 97 | } 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/VividCortex/pm 2 | 3 | go 1.11 4 | 5 | require github.com/VividCortex/multitick v0.0.0-20200801004505-282a3ac778f5 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/VividCortex/multitick v0.0.0-20200801004505-282a3ac778f5 h1:GWCC4DiLU7ZKx3VIhnE9btGM1iErTMu3NM97FJTetLU= 2 | github.com/VividCortex/multitick v0.0.0-20200801004505-282a3ac778f5/go.mod h1:My+PwOJ0H3kuCiwQrymprV3oQMd/KvV6vaTcXQRPCLc= 3 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package pm 2 | 3 | // Copyright (c) 2013 VividCortex, Inc. All rights reserved. 4 | // Please see the LICENSE file for applicable license terms. 5 | 6 | import ( 7 | "encoding/json" 8 | "net/http" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | const ( 14 | HeaderContentType = "Content-Type" 15 | MediaJSON = "application/json" 16 | ) 17 | 18 | func (pl *Proclist) getProcs() []ProcDetail { 19 | pl.mu.RLock() 20 | defer pl.mu.RUnlock() 21 | procs := make([]ProcDetail, 0, len(pl.procs)) 22 | 23 | for id, p := range pl.procs { 24 | p.mu.RLock() 25 | attrs := make(map[string]interface{}) 26 | for name, value := range p.attrs { 27 | attrs[name] = value 28 | } 29 | firstHEntry := p.history.Front().Value.(*historyEntry) 30 | lastHEntry := p.history.Back().Value.(*historyEntry) 31 | 32 | procs = append(procs, ProcDetail{ 33 | Id: id, 34 | Attrs: attrs, 35 | ProcTime: firstHEntry.ts, 36 | StatusTime: lastHEntry.ts, 37 | Status: lastHEntry.status, 38 | Cancelling: p.cancel.isPending, 39 | }) 40 | p.mu.RUnlock() 41 | } 42 | 43 | return procs 44 | } 45 | 46 | func httpError(w http.ResponseWriter, httpCode int) { 47 | http.Error(w, http.StatusText(httpCode), httpCode) 48 | } 49 | 50 | func (pl *Proclist) handleProclistReq(w http.ResponseWriter, r *http.Request) { 51 | b, err := json.Marshal(ProcResponse{ 52 | Procs: pl.getProcs(), 53 | ServerTime: time.Now(), 54 | }) 55 | if err != nil { 56 | httpError(w, http.StatusInternalServerError) 57 | return 58 | } 59 | w.Header().Set(HeaderContentType, MediaJSON) 60 | w.Write(b) 61 | } 62 | 63 | func (pl *Proclist) getHistory(id string) ([]HistoryDetail, error) { 64 | pl.mu.RLock() 65 | p, present := pl.procs[id] 66 | pl.mu.RUnlock() 67 | 68 | if !present { 69 | return []HistoryDetail{}, ErrNoSuchProcess 70 | } 71 | 72 | p.mu.RLock() 73 | defer p.mu.RUnlock() 74 | history := make([]HistoryDetail, 0, p.history.Len()) 75 | 76 | entry := p.history.Front() 77 | for entry != nil { 78 | v := entry.Value.(*historyEntry) 79 | history = append(history, HistoryDetail{ 80 | Ts: v.ts, 81 | Status: v.status, 82 | }) 83 | entry = entry.Next() 84 | } 85 | 86 | return history, nil 87 | } 88 | 89 | func (pl *Proclist) handleHistoryReq(w http.ResponseWriter, r *http.Request, id string) { 90 | history, err := pl.getHistory(id) 91 | if err != nil { 92 | httpError(w, http.StatusNotFound) 93 | } 94 | b, err := json.Marshal(HistoryResponse{ 95 | History: history, 96 | ServerTime: time.Now(), 97 | }) 98 | if err != nil { 99 | httpError(w, http.StatusInternalServerError) 100 | return 101 | } 102 | w.Header().Set(HeaderContentType, MediaJSON) 103 | w.Write(b) 104 | } 105 | 106 | func (pl *Proclist) handleCancelReq(w http.ResponseWriter, r *http.Request, id string) { 107 | var message string 108 | var cancel CancelRequest 109 | if err := json.NewDecoder(r.Body).Decode(&cancel); err == nil { 110 | message = cancel.Message 111 | } 112 | if err := pl.Kill(id, message); err != nil { 113 | httpCode := http.StatusNotFound 114 | if err == ErrForbidden { 115 | httpCode = http.StatusForbidden 116 | } 117 | httpError(w, httpCode) 118 | } 119 | } 120 | 121 | func (pl *Proclist) handleProcsReq(w http.ResponseWriter, r *http.Request) { 122 | w.Header().Set("Access-Control-Allow-Origin", "*") 123 | 124 | path := r.URL.Path 125 | if path == "/procs/" { 126 | if r.Method == "GET" { 127 | pl.handleProclistReq(w, r) 128 | } else { 129 | httpError(w, http.StatusMethodNotAllowed) 130 | } 131 | return 132 | } 133 | 134 | // Path should start with "/procs/" 135 | subdir := path[len("/procs/"):] 136 | sep := strings.Index(subdir, "/") 137 | if sep < 0 { 138 | sep = len(subdir) 139 | } 140 | if sep == 0 { 141 | httpError(w, http.StatusNotFound) 142 | return 143 | } 144 | id := subdir[:sep] 145 | subdir = subdir[sep:] 146 | 147 | switch { 148 | case subdir == "" || subdir == "/": 149 | if r.Method == "DELETE" { 150 | pl.handleCancelReq(w, r, id) 151 | } else if r.Method == "OPTIONS" { 152 | w.Header().Set("Access-Control-Allow-Methods", "DELETE") 153 | } else { 154 | httpError(w, http.StatusMethodNotAllowed) 155 | } 156 | case subdir == "/history": 157 | if r.Method == "GET" { 158 | pl.handleHistoryReq(w, r, id) 159 | } else { 160 | httpError(w, http.StatusMethodNotAllowed) 161 | } 162 | default: 163 | httpError(w, http.StatusNotFound) 164 | } 165 | } 166 | 167 | // ListenAndServe starts an HTTP server at the given address (localhost:80 168 | // by default, as results from the underlying net/http implementation). 169 | func (pl *Proclist) ListenAndServe(addr string) error { 170 | serveMux := http.NewServeMux() 171 | serveMux.HandleFunc("/procs/", pl.handleProcsReq) 172 | return http.ListenAndServe(addr, serveMux) 173 | } 174 | 175 | // ListenAndServe starts an HTTP server at the given address (localhost:80 176 | // by default, as results from the underlying net/http implementation). 177 | func ListenAndServe(addr string) error { 178 | return DefaultProclist.ListenAndServe(addr) 179 | } 180 | -------------------------------------------------------------------------------- /pm.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013 VividCortex. Please see the LICENSE file for license terms. 2 | 3 | /* 4 | Package pm is a process manager with an HTTP monitoring/control interface. 5 | 6 | To this package, processes or tasks are user-defined routines within a running 7 | program. This model is particularly suitable to server programs, with 8 | independent client-generated requests for action. The library would keep track 9 | of all such tasks and make the information available through HTTP, thus 10 | providing valuable insight into the running application. The client can also ask 11 | for a particular task to be canceled. 12 | 13 | Using pm starts by calling ListenAndServe() in a separate routine, like so: 14 | 15 | go pm.ListenAndServe(":8081") 16 | 17 | Note that pm's ListenAndServe() will not return by itself. Nevertheless, it will 18 | if the underlying net/http's ListenAndServe() does. So it's probably a good idea 19 | to wrap that call with some error checking and retrying for production code. 20 | 21 | Tasks to be tracked must be declared with Start(); that's when the identifier 22 | gets linked to them. An optional set of (arbitrary) attributes may be provided, 23 | that will get attached to the running task and be reported to the HTTP client 24 | tool as well. (Think of host, method and URI for a web server, for instance.) 25 | This could go like this: 26 | 27 | pm.Start(requestID, nil, &map[string]interface{}{ 28 | "host": req.RemoteAddr, 29 | "method": req.Method, 30 | "uri": req.RequestURI, 31 | }) 32 | defer pm.Done(requestID) 33 | 34 | Note the deferred pm.Done() call. That's essential to mark the task as finished 35 | and release resources, and has to be called no matter if the task succeeded or 36 | not. That's why it's a good idea to use Go's defer mechanism. In fact, the 37 | protection from cancel-related panics (see StopCancelPanic below) does NOT work 38 | if Done() is called in-line (i.e., not deferred) due to properties of recover(). 39 | 40 | An HTTP client issuing a GET call to /procs/ would receive a JSON response with a 41 | snapshot of all currently running tasks. Each one will include the time when it 42 | was started (as per the server clock) as well as the complete set of attributes 43 | provided to Start(). Note that to avoid issues with clock skews among servers, 44 | the current time for the server is returned as well. 45 | 46 | The reply to the /procs/ GET also includes a status. When tasks start they are 47 | set to "init", but they may change their status using pm's Status() function. 48 | Each call will record the change and the HTTP client will receive the last 49 | status available, together with the time it was set. Furthermore, the client may 50 | GET /procs//history instead (where is the task identifier) to have a 51 | complete history for the given task, including all status changes with their 52 | time information. However, note that task information is completely recycled as 53 | soon as a task is Done(). Hence, client applications should be prepared to 54 | receive a not found reply even if they've just seen the task in a /procs/ GET 55 | result. 56 | 57 | Given the lack of a statement in Go to kill a routine, cancellations are 58 | implemented as panics. A DELETE call to /procs/ will mark the task with the 59 | given identifier as cancel-pending. Nevertheless, the task will never notice 60 | until it reaches another Status() call, which is by definition a cancellation 61 | point. Calls to Status() either return having set the new status, or panic with 62 | an error of type CancelErr. Needless to say, the application should handle this 63 | gracefully or will otherwise crash. Programs serving multiple requests will 64 | probably be already protected, with a recover() call at the level where the 65 | routine was started. But if that's not the case, or if you simply want the panic 66 | to be handled transparently, you may use this: 67 | 68 | pm.SetOptions(ProclistOptions{ 69 | StopCancelPanic: true 70 | }) 71 | 72 | When the StopCancelPanic option is set (which is NOT the default) Done() will 73 | recover a panic due to a cancel operation. In such a case, the routine running 74 | that code will jump to the next statement after the invocation to the function 75 | that called Start(). (Read it again.) In other words, stack unfolding stops at 76 | the level where Done() was deferred. Notice, though, that this behavior is 77 | specifically tailored to panics raising from pending cancellation requests. 78 | Should any other panic arise for any reason, it will continue past Done() as 79 | usual. So will panics due to cancel requests if StopCancelPanic is not set. 80 | 81 | Options set with pm.SetOptions() work on a global scope for the process list. 82 | Alternatively, you may provide a specific set for some task by using the options 83 | argument in the Start() call. If none is given, pm will grab the current global 84 | values at the time Start() was invoked. Task-specific options may come handy 85 | when, for instance, some tasks should be resilient to cancellation. Setting the 86 | ForbidCancel option makes the task effectively immune to cancel requests. 87 | Attempts by clients to cancel one of such tasks will inevitably yield an error 88 | message with no other effects. Set that with the global SetOptions() function 89 | and none of your tasks will cancel, ever. 90 | 91 | HTTP clients can learn about pending cancellation requests. Furthermore, if a 92 | client request happens to be handled between the task is done/canceled and 93 | resource recycling (a VERY tiny time window), then the result would include one 94 | of these as the status: "killed", "aborted" or "ended", if it was respectively 95 | canceled, died out of another panic (not pm-related) or finished successfully. 96 | The "killed" status may optionally add a user-defined message, provided through 97 | the HTTP /procs/ DELETE method. 98 | 99 | For the cancellation feature to be useful, applications should collaborate. Go 100 | lacks a mechanism to cancel arbitrary routines (it even lacks identifiers for 101 | them), so programs willing to provide the feature must be willing to help. It's 102 | a good practice to add cancellation points every once in a while, particularly 103 | when lengthy operations are run. However, applications are not expected to 104 | change status that frequently. This package provides the function CheckCancel() 105 | for that. It works as a cancellation point by definition, without messing with 106 | the task status, nor leaving a trace in history. 107 | 108 | Finally, please note that cancellation requests yield panics in the same routine 109 | that called Start() with that given identifier. However, it's not unusual for 110 | servers to spawn additional Go routines to handle the same request. The 111 | application is responsible of cleaning up, if there are additional resources 112 | that should be recycled. The proper way to do this is by catching CancelErr type 113 | panics, cleaning-up and then re-panic, i.e.: 114 | 115 | func handleRequest(requestId string) { 116 | pm.Start(requestId, map[string]interface{}{}) 117 | defer pm.Done(requestId) 118 | defer func() { 119 | if e := recover(); e != nil { 120 | if _, canceled := e.(CancelErr); canceled { 121 | // do your cleanup here 122 | } 123 | panic(e) // re-panic with same error (cancel or not) 124 | } 125 | }() 126 | // your application code goes here 127 | } 128 | */ 129 | package pm 130 | 131 | import ( 132 | "container/list" 133 | "errors" 134 | "sync" 135 | "time" 136 | ) 137 | 138 | // Type Proclist is the main type for the process-list. You may have as many as 139 | // you wish, each with it's own HTTP server, but that's probably useful only in 140 | // a handful of cases. The typical use of this package is through the default 141 | // Proclist object (DefaultProclist) and package-level functions. The zero value 142 | // for the type is a Proclist ready to be used. 143 | type Proclist struct { 144 | mu sync.RWMutex 145 | procs map[string]*proc 146 | opts ProclistOpts 147 | } 148 | 149 | // Type ProclistOpts provides all options to be set for a Proclist. Options 150 | // shared with ProcOpts act as defaults in case no options are provided in a 151 | // task's call to Start(). 152 | type ProclistOpts struct { 153 | StopCancelPanic bool // Stop cancel-related panics at Done() 154 | ForbidCancel bool // Forbid cancellation requests 155 | } 156 | 157 | // Type ProcOpts provides options for the process. 158 | type ProcOpts struct { 159 | StopCancelPanic bool // Stop cancel-related panics at Done() 160 | ForbidCancel bool // Forbid cancellation requests 161 | } 162 | 163 | type proc struct { 164 | mu sync.RWMutex 165 | id string 166 | attrs map[string]interface{} 167 | history list.List 168 | cancel struct { 169 | isPending bool 170 | message string 171 | } 172 | opts ProcOpts 173 | } 174 | 175 | type historyEntry struct { 176 | ts time.Time 177 | status string 178 | } 179 | 180 | var ( 181 | ErrForbidden = errors.New("forbidden") 182 | ErrNoSuchProcess = errors.New("no such process") 183 | ) 184 | 185 | // Options returns the options set for this Proclist. 186 | func (pl *Proclist) Options() ProclistOpts { 187 | pl.mu.RLock() 188 | defer pl.mu.RUnlock() 189 | return pl.opts 190 | } 191 | 192 | // SetOptions sets the options to be used for this Proclist. 193 | func (pl *Proclist) SetOptions(opts ProclistOpts) { 194 | pl.mu.Lock() 195 | defer pl.mu.Unlock() 196 | pl.opts = opts 197 | } 198 | 199 | // Start marks the beginning of a task at this Proclist. All attributes are 200 | // recorded but not handled internally by the package. They will be mapped to 201 | // JSON and provided to HTTP clients for this task. It's the caller's 202 | // responsibility to provide different identifiers for separate tasks. An id can 203 | // only be reused after the task previously using it is over. If process options 204 | // are not provided (nil), Start() will snapshot the global options for the 205 | // process list set by SetOptions(). 206 | func (pl *Proclist) Start(id string, opts *ProcOpts, attrs *map[string]interface{}) { 207 | if opts == nil { 208 | opts = &ProcOpts{ 209 | StopCancelPanic: pl.opts.StopCancelPanic, 210 | ForbidCancel: pl.opts.ForbidCancel, 211 | } 212 | } 213 | p := &proc{ 214 | id: id, 215 | opts: *opts, 216 | } 217 | if attrs != nil { 218 | p.attrs = *attrs 219 | } else { 220 | p.attrs = make(map[string]interface{}) 221 | } 222 | p.addHistoryEntry(time.Now(), "init") 223 | 224 | pl.mu.Lock() 225 | if pl.procs == nil { 226 | pl.procs = make(map[string]*proc) 227 | } 228 | pl.procs[id] = p 229 | pl.mu.Unlock() 230 | } 231 | 232 | // SetAttribute sets an application-specific attribute for the task given by id. 233 | // Unrecognized identifiers are silently skipped. Duplicate attribute names for 234 | // the task overwrite the previously set value. 235 | func (pl *Proclist) SetAttribute(id, name string, value interface{}) { 236 | pl.mu.RLock() 237 | p, present := pl.procs[id] 238 | pl.mu.RUnlock() 239 | 240 | if present { 241 | p.mu.Lock() 242 | defer p.mu.Unlock() 243 | p.attrs[name] = value 244 | } 245 | } 246 | 247 | // DelAttribute deletes an attribute for the task given by id. Unrecognized 248 | // task identifiers are silently skipped. 249 | func (pl *Proclist) DelAttribute(id, name string) { 250 | pl.mu.RLock() 251 | p, present := pl.procs[id] 252 | pl.mu.RUnlock() 253 | 254 | if present { 255 | p.mu.Lock() 256 | defer p.mu.Unlock() 257 | delete(p.attrs, name) 258 | } 259 | } 260 | 261 | // Type CancelErr is the type used for cancellation-induced panics. 262 | type CancelErr string 263 | 264 | // Error returns the error message for a CancelErr. 265 | func (e CancelErr) Error() string { 266 | return string(e) 267 | } 268 | 269 | func (p *proc) doCancel() { 270 | message := "killed" 271 | if len(p.cancel.message) > 0 { 272 | message += ": " + p.cancel.message 273 | } 274 | 275 | panic(CancelErr(message)) 276 | } 277 | 278 | // addHistoryEntry pushes a new entry to the processes' history, assuming the 279 | // lock is already held. 280 | func (p *proc) addHistoryEntry(ts time.Time, status string) { 281 | p.history.PushBack(&historyEntry{ 282 | ts: ts, 283 | status: status, 284 | }) 285 | } 286 | 287 | // Status changes the status for a task in a Proclist, adding an item to the 288 | // task's history. Note that Status() is a cancellation point, thus the routine 289 | // calling it is subject to a panic due to a pending Kill(). 290 | func (pl *Proclist) Status(id, status string) { 291 | ts := time.Now() 292 | pl.mu.RLock() 293 | p, present := pl.procs[id] 294 | pl.mu.RUnlock() 295 | 296 | if present { 297 | p.mu.Lock() 298 | defer p.mu.Unlock() 299 | p.addHistoryEntry(ts, status) 300 | 301 | if p.cancel.isPending { 302 | p.doCancel() 303 | } 304 | } 305 | } 306 | 307 | // CheckCancel introduces a cancellation point just like Status() does, but 308 | // without changing the task status, nor adding an entry to history. 309 | func (pl *Proclist) CheckCancel(id string) { 310 | pl.mu.RLock() 311 | p, present := pl.procs[id] 312 | pl.mu.RUnlock() 313 | 314 | if present { 315 | p.mu.Lock() 316 | defer p.mu.Unlock() 317 | 318 | if p.cancel.isPending { 319 | p.doCancel() 320 | } 321 | } 322 | } 323 | 324 | // Kill sets a cancellation request to the task with the given identifier, that 325 | // will be effective as soon as the routine running that task hits a 326 | // cancellation point. The (optional) message will be included in the CancelErr 327 | // object used for panic. 328 | func (pl *Proclist) Kill(id, message string) error { 329 | ts := time.Now() 330 | pl.mu.RLock() 331 | p, present := pl.procs[id] 332 | pl.mu.RUnlock() 333 | 334 | if !present { 335 | return ErrNoSuchProcess 336 | } 337 | p.mu.Lock() 338 | defer p.mu.Unlock() 339 | 340 | if p.opts.ForbidCancel { 341 | return ErrForbidden 342 | } 343 | if !p.cancel.isPending { 344 | p.cancel.isPending = true 345 | p.cancel.message = message 346 | 347 | var hentry string 348 | if len(message) > 0 { 349 | hentry = "[cancel request: " + message + "]" 350 | } else { 351 | hentry = "[cancel request]" 352 | } 353 | p.addHistoryEntry(ts, hentry) 354 | } 355 | return nil 356 | } 357 | 358 | // done marks the end of a process, registering it depending on the outcome. 359 | // Parameter e is supposed to be the result of recover(), so that we know 360 | // whether processing ended normally, was canceled or aborted due to any other 361 | // panic. 362 | func (pl *Proclist) done(id string, e interface{}) { 363 | pl.mu.Lock() 364 | p, present := pl.procs[id] 365 | if present { 366 | delete(pl.procs, id) 367 | } 368 | stopPanic := pl.opts.StopCancelPanic 369 | pl.mu.Unlock() 370 | 371 | if present { 372 | ts := time.Now() 373 | p.mu.Lock() 374 | defer p.mu.Unlock() 375 | 376 | if e != nil { 377 | if msg, canceled := e.(CancelErr); canceled { 378 | p.addHistoryEntry(ts, string(msg)) 379 | if !p.opts.StopCancelPanic { 380 | panic(e) 381 | } 382 | } else { 383 | p.addHistoryEntry(ts, "aborted") 384 | panic(e) 385 | } 386 | } else { 387 | p.addHistoryEntry(ts, "ended") 388 | } 389 | } else if e != nil { 390 | _, canceled := e.(CancelErr) 391 | if !canceled || !stopPanic { 392 | panic(e) 393 | } 394 | } 395 | } 396 | 397 | // Done marks the end of a task, writing in history depending on the outcome 398 | // (i.e., aborted, killed or finished successfully). This function releases 399 | // resources associated with the process, thus making the id available for use 400 | // by another task. It also stops panics raising from cancellation requests, but 401 | // only when the StopCancelPanic option is set AND Done is called with a defer 402 | // statement. 403 | func (pl *Proclist) Done(id string) { 404 | pl.done(id, recover()) 405 | } 406 | 407 | // This is the default Proclist set for the package. Package-level operations 408 | // use this list. 409 | var DefaultProclist Proclist 410 | 411 | // Options returns the options set for the default Proclist. 412 | func Options() ProclistOpts { 413 | return DefaultProclist.Options() 414 | } 415 | 416 | // SetOptions sets the options to be used for this Proclist. 417 | func SetOptions(opts ProclistOpts) { 418 | DefaultProclist.SetOptions(opts) 419 | } 420 | 421 | // Start marks the beginning of a task at the default Proclist. All attributes 422 | // are recorded but not handled internally by the package. They will be mapped 423 | // to JSON and provided to HTTP clients for this task. It's the caller's 424 | // responsibility to provide different identifiers for separate tasks. An id can 425 | // only be reused after the task previously using it is over. If process options 426 | // are not provided (nil), Start() will snapshot the global options for the 427 | // process list set by SetOptions(). 428 | func Start(id string, opts *ProcOpts, attrs *map[string]interface{}) { 429 | DefaultProclist.Start(id, opts, attrs) 430 | } 431 | 432 | // SetAttribute sets an application-specific attribute for the task given by id. 433 | // Unrecognized identifiers are silently skipped. Duplicate attribute names for 434 | // the task overwrite the previously set value. 435 | func SetAttribute(id, name string, value interface{}) { 436 | DefaultProclist.SetAttribute(id, name, value) 437 | } 438 | 439 | // DelAttribute deletes an attribute for the task given by id. Unrecognized 440 | // task identifiers are silently skipped. 441 | func DelAttribute(id, name string) { 442 | DefaultProclist.DelAttribute(id, name) 443 | } 444 | 445 | // Status changes the status for a task in the default Proclist, adding an item 446 | // to the task's history. Note that Status() is a cancellation point, thus the 447 | // routine calling it is subject to a panic due to a pending Kill(). 448 | func Status(id, status string) { 449 | DefaultProclist.Status(id, status) 450 | } 451 | 452 | // CheckCancel introduces a cancellation point just like Status() does, but 453 | // without changing the task status, nor adding an entry to history. 454 | func CheckCancel(id string) { 455 | DefaultProclist.CheckCancel(id) 456 | } 457 | 458 | // Kill sets a cancellation request to the task with the given identifier, that 459 | // will be effective as soon as the routine running that task hits a 460 | // cancellation point. The (optional) message will be included in the CancelErr 461 | // object used for panic. 462 | func Kill(id, message string) error { 463 | return DefaultProclist.Kill(id, message) 464 | } 465 | 466 | // Done marks the end of a task, writing to history depending on the outcome 467 | // (i.e., aborted, killed or finished successfully). This function releases 468 | // resources associated with the process, thus making the id available for use 469 | // by another task. It also stops panics raising from cancellation requests, but 470 | // only when the StopCancelPanic option is set AND Done is called with a defer 471 | // statement. 472 | func Done(id string) { 473 | DefaultProclist.done(id, recover()) 474 | } 475 | -------------------------------------------------------------------------------- /pm_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013 VividCortex. Please see the LICENSE file for license terms. 2 | 3 | package pm 4 | 5 | import ( 6 | "bytes" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "net/http" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func attrMapEquals(m1, m2 map[string]interface{}) bool { 16 | if len(m1) != len(m2) { 17 | return false 18 | } 19 | for k, v1 := range m1 { 20 | if v2, present := m2[k]; !present || v1 != v2 { 21 | return false 22 | } 23 | } 24 | return true 25 | } 26 | 27 | func procMapEquals(t *testing.T, m1, m2 map[string]ProcDetail) bool { 28 | if len(m1) != len(m2) { 29 | return false 30 | } 31 | for k, v1 := range m1 { 32 | if v2, present := m2[k]; !present { 33 | return false 34 | } else if v1.Id != v2.Id || v1.Status != v2.Status || v1.Cancelling != v2.Cancelling { 35 | return false 36 | } else if !attrMapEquals(v1.Attrs, v2.Attrs) { 37 | return false 38 | } 39 | } 40 | return true 41 | } 42 | 43 | func procMap(t *testing.T, pr *ProcResponse) map[string]ProcDetail { 44 | procs := make(map[string]ProcDetail) 45 | for _, proc := range pr.Procs { 46 | if _, present := procs[proc.Id]; present { 47 | t.Error("process doubly defined:", proc.Id) 48 | } 49 | procs[proc.Id] = proc 50 | } 51 | return procs 52 | } 53 | 54 | func checkProcResponse(t *testing.T, reply, expected *ProcResponse) { 55 | if !procMapEquals(t, procMap(t, reply), procMap(t, expected)) { 56 | t.Fatal("bad proclist result") 57 | } 58 | } 59 | 60 | func checkHistoryResponse(t *testing.T, reply, expected *HistoryResponse) { 61 | if len(reply.History) != len(expected.History) { 62 | t.Fatalf("bad history length; expected %d, received %d", 63 | len(expected.History), len(reply.History)) 64 | } 65 | 66 | for i := range reply.History { 67 | if reply.History[i].Status != expected.History[i].Status { 68 | t.Fatal("bad history received") 69 | } 70 | } 71 | } 72 | 73 | func TestProclist(t *testing.T) { 74 | attrs1 := map[string]interface{}{ 75 | "method": "GET", 76 | "uri": "/hosts/1", 77 | "host": "localhost:15233", 78 | "tmp": "/tmp/sock", 79 | } 80 | attrs2 := map[string]interface{}{ 81 | "method": "PUT", 82 | "uri": "/hosts/2/config", 83 | "host": "localhost:12538", 84 | } 85 | Start("req1", &ProcOpts{ForbidCancel: true}, &attrs1) 86 | defer Done("req1") 87 | Start("req2", &ProcOpts{StopCancelPanic: true}, &attrs2) 88 | 89 | req1Status := []string{ 90 | "init", 91 | "searching", 92 | "reading", 93 | "sending", 94 | } 95 | for _, s := range req1Status[1:] { 96 | Status("req1", s) 97 | } 98 | 99 | delete(attrs1, "tmp") 100 | DelAttribute("req1", "tmp") 101 | attrs2["database"] = "customers" 102 | SetAttribute("req2", "database", "customers") 103 | 104 | procs := DefaultProclist.getProcs() 105 | if len(procs) != 2 { 106 | t.Fatalf("len(procs) = %d; expecting 2", len(procs)) 107 | } 108 | 109 | var p1, p2 ProcDetail 110 | if procs[0].Id == "req1" && procs[1].Id == "req2" { 111 | p1, p2 = procs[0], procs[1] 112 | } else if procs[0].Id == "req2" && procs[1].Id == "req1" { 113 | p1, p2 = procs[1], procs[0] 114 | } else { 115 | t.Fatalf("unexpected procs found: %s, %s", procs[0].Id, procs[1].Id) 116 | } 117 | 118 | if p1.Cancelling || p2.Cancelling { 119 | t.Error("cancellation pending but never enabled") 120 | } 121 | 122 | if p1.Status != req1Status[len(req1Status)-1] { 123 | t.Error("bad status for req1; expecting '", 124 | req1Status[len(req1Status)-1], "', got ", p1.Status) 125 | } 126 | if p2.Status != "init" { 127 | t.Error("bad status for req2; expecting 'init', got ", p2.Status) 128 | } 129 | 130 | if !attrMapEquals(p1.Attrs, attrs1) { 131 | t.Error("bad attribute set for req1") 132 | } 133 | if !attrMapEquals(p2.Attrs, attrs2) { 134 | t.Error("bad attribute set for req2") 135 | } 136 | 137 | func() { 138 | defer Done("req2") 139 | Kill("req2", "") 140 | Status("req2", "searching") 141 | t.Error("req2 was not cancelled when it had to") 142 | }() 143 | 144 | func() { 145 | defer func() { 146 | if e := recover(); e != nil { 147 | if _, ok := e.(CancelErr); ok { 148 | t.Fatal("req2 was incorrectly cancelled") 149 | } else { 150 | panic(e) 151 | } 152 | } 153 | }() 154 | 155 | Kill("req1", "") 156 | CheckCancel("req1") 157 | }() 158 | 159 | history, err := DefaultProclist.getHistory("req1") 160 | if err != nil { 161 | t.Fatal("unable to retrieve history") 162 | } 163 | for i, item := range history { 164 | if item.Status != req1Status[i] { 165 | msg := fmt.Sprintf("bad status at position %d; got %s, expected %s", 166 | i, item.Status, req1Status[i]) 167 | t.Error(msg) 168 | } 169 | } 170 | } 171 | 172 | type Client struct { 173 | *http.Client 174 | BaseURI string 175 | Headers map[string]string 176 | } 177 | 178 | func newClient(uri string) *Client { 179 | return &Client{ 180 | Client: &http.Client{}, 181 | BaseURI: uri, 182 | Headers: map[string]string{ 183 | "Accept": "application/json", 184 | "User-Agent": "go-pm", 185 | "Content-Type": "application/json", 186 | }, 187 | } 188 | } 189 | 190 | func (c *Client) makeRequest(verb, endpoint string, body, result interface{}) error { 191 | buf := new(bytes.Buffer) 192 | if body != nil { 193 | if err := json.NewEncoder(buf).Encode(body); err != nil { 194 | return err 195 | } 196 | } 197 | req, err := http.NewRequest(verb, c.BaseURI+endpoint, buf) 198 | if err != nil { 199 | return err 200 | } 201 | for header, value := range c.Headers { 202 | req.Header.Add(header, value) 203 | } 204 | 205 | resp, err := c.Do(req) 206 | if err == nil && resp.StatusCode != 200 { 207 | err = errors.New("HTTP Status Code: " + resp.Status) 208 | } 209 | 210 | if resp != nil { 211 | defer resp.Body.Close() 212 | if err == nil && result != nil { 213 | json.NewDecoder(resp.Body).Decode(result) 214 | } 215 | } 216 | return err 217 | } 218 | 219 | func (c *Client) getProcs(t *testing.T) *ProcResponse { 220 | var result ProcResponse 221 | if err := c.makeRequest("GET", "/procs/", nil, &result); err != nil { 222 | t.Fatal(err) 223 | } 224 | return &result 225 | } 226 | 227 | func (c *Client) getHistory(t *testing.T, id string) *HistoryResponse { 228 | var result HistoryResponse 229 | endpoint := fmt.Sprintf("/procs/%s/history", id) 230 | 231 | if err := c.makeRequest("GET", endpoint, nil, &result); err != nil { 232 | t.Fatal(err) 233 | } 234 | return &result 235 | } 236 | 237 | func (c *Client) kill(t *testing.T, id, message string) { 238 | body := CancelRequest{Message: message} 239 | endpoint := fmt.Sprintf("/procs/%s", id) 240 | 241 | if err := c.makeRequest("DELETE", endpoint, body, nil); err != nil { 242 | t.Fatal(err) 243 | } 244 | } 245 | 246 | func TestHttpServer(t *testing.T) { 247 | procs := []struct { 248 | id string 249 | status []string 250 | readyCh chan struct{} 251 | exitCh chan struct{} 252 | }{ 253 | {id: "req1", status: []string{"S11", "S12", "S13"}}, 254 | {id: "req2", status: []string{"S21", "S22"}}, 255 | } 256 | 257 | SetOptions(ProclistOpts{StopCancelPanic: true}) 258 | portError := make(chan error, 1) 259 | 260 | go func() { 261 | if err := ListenAndServe(":6680"); err != nil { 262 | // t.Fatal() has to be called in the main routine 263 | portError <- err 264 | } 265 | }() 266 | 267 | select { 268 | case err := <-portError: 269 | t.Fatal(err) 270 | case <-time.After(time.Duration(100) * time.Millisecond): 271 | } 272 | 273 | for i := range procs { 274 | procs[i].readyCh = make(chan struct{}, 1) 275 | procs[i].exitCh = make(chan struct{}, 1) 276 | 277 | go func(i int) { 278 | Start(procs[i].id, nil, nil) 279 | defer Done(procs[i].id) 280 | for _, s := range procs[i].status { 281 | Status(procs[i].id, s) 282 | } 283 | 284 | procs[i].readyCh <- struct{}{} 285 | <-procs[i].exitCh 286 | }(i) 287 | } 288 | 289 | for _, p := range procs { 290 | <-p.readyCh 291 | } 292 | 293 | c := newClient("http://localhost:6680") 294 | checkProcResponse(t, c.getProcs(t), &ProcResponse{ 295 | Procs: []ProcDetail{ 296 | ProcDetail{ 297 | Id: "req1", 298 | Status: "S13", 299 | Cancelling: false, 300 | }, 301 | ProcDetail{ 302 | Id: "req2", 303 | Status: "S22", 304 | Cancelling: false, 305 | }, 306 | }, 307 | }) 308 | 309 | procs[1].exitCh <- struct{}{} // req2 310 | time.Sleep(time.Duration(100) * time.Millisecond) 311 | 312 | checkProcResponse(t, c.getProcs(t), &ProcResponse{ 313 | Procs: []ProcDetail{ 314 | ProcDetail{ 315 | Id: "req1", 316 | Status: "S13", 317 | Cancelling: false, 318 | }, 319 | }, 320 | }) 321 | 322 | checkHistoryResponse(t, c.getHistory(t, "req1"), &HistoryResponse{ 323 | History: []HistoryDetail{ 324 | HistoryDetail{Status: "init"}, 325 | HistoryDetail{Status: "S11"}, 326 | HistoryDetail{Status: "S12"}, 327 | HistoryDetail{Status: "S13"}, 328 | }, 329 | }) 330 | 331 | c.kill(t, "req1", "my message") 332 | checkHistoryResponse(t, c.getHistory(t, "req1"), &HistoryResponse{ 333 | History: []HistoryDetail{ 334 | HistoryDetail{Status: "init"}, 335 | HistoryDetail{Status: "S11"}, 336 | HistoryDetail{Status: "S12"}, 337 | HistoryDetail{Status: "S13"}, 338 | HistoryDetail{Status: "[cancel request: my message]"}, 339 | }, 340 | }) 341 | 342 | procs[0].exitCh <- struct{}{} // req1 343 | time.Sleep(time.Duration(100) * time.Millisecond) 344 | checkProcResponse(t, c.getProcs(t), &ProcResponse{}) 345 | 346 | for _, p := range procs { 347 | p.exitCh <- struct{}{} 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package pm 2 | 3 | // Copyright (c) 2013 VividCortex, Inc. All rights reserved. 4 | // Please see the LICENSE file for applicable license terms. 5 | 6 | import ( 7 | "time" 8 | ) 9 | 10 | // Type ProcDetail encodes a full process list from the server, including an 11 | // attributes array with application-defined names/values. 12 | type ProcDetail struct { 13 | Id string `json:"id"` 14 | Attrs map[string]interface{} `json:"attrs,omitempty"` 15 | ProcTime time.Time `json:"procTime"` 16 | StatusTime time.Time `json:"statusTime"` 17 | Status string `json:"status"` 18 | Cancelling bool `json:"cancelling,omitempty"` 19 | } 20 | 21 | // ProcResponse is the response for a GET to /proc. 22 | type ProcResponse struct { 23 | Procs []ProcDetail `json:"procs"` 24 | ServerTime time.Time `json:"serverTime"` 25 | } 26 | 27 | // HistoryDetail encodes one entry from the process' history. 28 | type HistoryDetail struct { 29 | Ts time.Time `json:"ts"` 30 | Status string `json:"status"` 31 | } 32 | 33 | // HistoryResponse is the response for a GET to /proc//history. 34 | type HistoryResponse struct { 35 | History []HistoryDetail `json:"history"` 36 | ServerTime time.Time `json:"serverTime"` 37 | } 38 | 39 | // CancelRequest is the request body resulting from Kill(). 40 | type CancelRequest struct { 41 | Message string `json:"message"` 42 | } 43 | --------------------------------------------------------------------------------