Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)
This commit is contained in:
@@ -15,8 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
mostCopy = 1 << 20
|
||||
mostPaste = 64 << 10
|
||||
mostCopy = 1 << 20
|
||||
// majorVersion is clipmenu's store layout: <dir>/clipmenu.<major>.<user>/.
|
||||
majorVersion = 6
|
||||
)
|
||||
@@ -71,15 +70,15 @@ func cksum(data []byte) string {
|
||||
|
||||
func entryID(line string) string { return cksum([]byte(line + "\n")) }
|
||||
|
||||
// Entry is one clip in the history.
|
||||
// Entry is one clip in the history as a caller sees it: when and how long, never what. The text of a
|
||||
// clip, its first line and its id (a checksum of that line) stay in clipmenu's store, readable on the
|
||||
// operator's own screen through clipmenu (issue 750).
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Line string `json:"line"`
|
||||
At string `json:"at"`
|
||||
Bytes int `json:"bytes"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
at int64
|
||||
At string `json:"at"`
|
||||
Bytes int `json:"bytes"`
|
||||
id string
|
||||
line string
|
||||
at int64
|
||||
}
|
||||
|
||||
// HistoryResult is what node-clipboard.history answers.
|
||||
@@ -119,14 +118,15 @@ func readStore(dir string) ([]Entry, error) {
|
||||
}
|
||||
out := make([]Entry, 0, len(latest))
|
||||
for line, ns := range latest {
|
||||
out = append(out, Entry{ID: entryID(line), Line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)})
|
||||
out = append(out, Entry{id: entryID(line), line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].at > out[j].at })
|
||||
return out, scan.Err()
|
||||
}
|
||||
|
||||
// History is the clipboard's history, newest first.
|
||||
func History(limit, maxBytes int) (HistoryResult, error) {
|
||||
// History is the clipboard's history, newest first: how many entries, when each was copied and how
|
||||
// long each is, and whether the daemon is collecting. It never returns a copied text.
|
||||
func History(limit int) (HistoryResult, error) {
|
||||
dir, err := storeDir(findEnvironment())
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
@@ -140,15 +140,8 @@ func History(limit, maxBytes int) (HistoryResult, error) {
|
||||
if i == limit {
|
||||
break
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(dir, e.ID)); err == nil {
|
||||
if info, err := os.Stat(filepath.Join(dir, e.id)); err == nil {
|
||||
e.Bytes = int(info.Size())
|
||||
if maxBytes > 0 {
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, e.ID))
|
||||
if len(raw) > maxBytes {
|
||||
raw, e.Truncated = raw[:maxBytes], true
|
||||
}
|
||||
e.Text = string(raw)
|
||||
}
|
||||
}
|
||||
out.Entries = append(out.Entries, e)
|
||||
}
|
||||
@@ -159,6 +152,40 @@ func History(limit, maxBytes int) (HistoryResult, error) {
|
||||
type CopyResult struct {
|
||||
Bytes int `json:"bytes"`
|
||||
Unit string `json:"unit"`
|
||||
// Verified says the clipboard was read back and held exactly the text.
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// readBackWait is the pause between reads of the clipboard while the owner starts; read-back tries 15 times.
|
||||
var readBackWait = 200 * time.Millisecond
|
||||
|
||||
// readBack waits for the clipboard to hold text and answers why when it does not.
|
||||
func readBack(s Session, unit, text string) error {
|
||||
var last string
|
||||
for i := 0; i < 15; i++ {
|
||||
time.Sleep(readBackWait)
|
||||
r, err := s.run(3*time.Second, "", "xsel", "--output", "--clipboard")
|
||||
switch {
|
||||
case err != nil:
|
||||
last = err.Error()
|
||||
case r.Code != 0:
|
||||
last = "xsel: " + strings.TrimSpace(r.Stderr)
|
||||
case r.Stdout == text:
|
||||
return nil
|
||||
default:
|
||||
last = fmt.Sprintf("it holds %d bytes of something else", len(r.Stdout))
|
||||
}
|
||||
}
|
||||
why := ""
|
||||
if s.Display == "" {
|
||||
why = "; the session has no DISPLAY, so the owner could not reach the screen"
|
||||
}
|
||||
if j, err := s.run(5*time.Second, "", "journalctl", "--user", "-u", unit+".service", "-n", "5", "--no-pager", "-o", "cat"); err == nil {
|
||||
if tail := strings.TrimSpace(j.Stdout); tail != "" {
|
||||
why += "; the owner said: " + tail
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("the clipboard does not hold the text (%d bytes): %s%s", len(text), last, why)
|
||||
}
|
||||
|
||||
// Copy puts text on the clipboard. The clipboard is owned by a process until another copies, so the
|
||||
@@ -194,17 +221,20 @@ func Copy(text string) (CopyResult, error) {
|
||||
os.Remove(f.Name())
|
||||
return CopyResult{}, err
|
||||
}
|
||||
return CopyResult{Bytes: len(text), Unit: unit + ".service"}, nil
|
||||
if err := readBack(s, unit, text); err != nil {
|
||||
return CopyResult{}, err
|
||||
}
|
||||
return CopyResult{Bytes: len(text), Unit: unit + ".service", Verified: true}, nil
|
||||
}
|
||||
|
||||
// PasteResult is what clipmenu_paste answers.
|
||||
// PasteResult is what clipmenu_paste answers: how much text the clipboard holds, never the text. The
|
||||
// clipboard is the newest history entry, and a copied secret must not reach a caller (issue 750).
|
||||
type PasteResult struct {
|
||||
Text string `json:"text"`
|
||||
Bytes int `json:"bytes"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Bytes int `json:"bytes"`
|
||||
Text bool `json:"holds_text"`
|
||||
}
|
||||
|
||||
// Paste reads the clipboard now.
|
||||
// Paste reads the clipboard now and says how long it is.
|
||||
func Paste() (PasteResult, error) {
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
@@ -217,11 +247,7 @@ func Paste() (PasteResult, error) {
|
||||
if r.Code != 0 {
|
||||
return PasteResult{}, fmt.Errorf("xsel: %s", strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
out := PasteResult{Text: r.Stdout, Bytes: len(r.Stdout), Truncated: r.Truncated}
|
||||
if len(out.Text) > mostPaste {
|
||||
out.Text, out.Truncated = out.Text[:mostPaste], true
|
||||
}
|
||||
return out, nil
|
||||
return PasteResult{Bytes: len(r.Stdout), Text: len(r.Stdout) > 0}, nil
|
||||
}
|
||||
|
||||
// ChangeResult is what clear and delete answer.
|
||||
@@ -298,10 +324,12 @@ func Clear() (ChangeResult, error) {
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Delete forgets one entry, by id or by its first line.
|
||||
func Delete(id, line string) (ChangeResult, error) {
|
||||
if (id == "") == (line == "") {
|
||||
return ChangeResult{}, errors.New("give the entry's id or its line, one of the two")
|
||||
// Delete forgets one entry, by its place in the history as node-clipboard.history lists it (1 is the
|
||||
// newest) together with the time it reports, so a copy arriving since the listing cannot shift the
|
||||
// place onto another entry. Not by its text or id: either would let a caller confirm a guess of a copied line.
|
||||
func Delete(place int, at string) (ChangeResult, error) {
|
||||
if place < 1 {
|
||||
return ChangeResult{}, errors.New("give the entry's place in the history, 1 for the newest")
|
||||
}
|
||||
dir, err := storeOf()
|
||||
if err != nil {
|
||||
@@ -309,6 +337,17 @@ func Delete(id, line string) (ChangeResult, error) {
|
||||
}
|
||||
var out ChangeResult
|
||||
err = withStoreLock(dir, func() error {
|
||||
entries, err := readStore(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if place > len(entries) {
|
||||
return fmt.Errorf("no entry %d: the clipboard history holds %d", place, len(entries))
|
||||
}
|
||||
if entries[place-1].At != at {
|
||||
return fmt.Errorf("entry %d was copied at %s, not at %s: the history changed since it was listed; list it again", place, entries[place-1].At, at)
|
||||
}
|
||||
line := entries[place-1].line
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "line_cache"))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
@@ -319,7 +358,7 @@ func Delete(id, line string) (ChangeResult, error) {
|
||||
continue
|
||||
}
|
||||
_, text, _ := strings.Cut(l, " ")
|
||||
if text == line || (id != "" && entryID(text) == id) {
|
||||
if text == line {
|
||||
out.Removed++
|
||||
_ = os.Remove(filepath.Join(dir, entryID(text)))
|
||||
continue
|
||||
@@ -327,7 +366,7 @@ func Delete(id, line string) (ChangeResult, error) {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
if out.Removed == 0 {
|
||||
return fmt.Errorf("no entry %s%s in the clipboard history", id, line)
|
||||
return fmt.Errorf("no entry %d in the clipboard history", place)
|
||||
}
|
||||
content := strings.Join(kept, "\n")
|
||||
if content != "" {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -51,46 +52,84 @@ func store(t *testing.T, clips map[string]string, order ...string) string {
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestTheHistoryIsNewestFirstOnceEachWithItsText(t *testing.T) {
|
||||
store(t, map[string]string{"first": "first", "second (2 lines)": "second\nline two"}, "first", "second (2 lines)", "first")
|
||||
func TestTheHistoryAnswersCountTimesAndLengthsNeverContent(t *testing.T) {
|
||||
store(t, map[string]string{"first": "first", "sk-ant-api03-SECRETKEY (2 lines)": "sk-ant-api03-SECRETKEY\nline two"}, "first", "sk-ant-api03-SECRETKEY (2 lines)", "first")
|
||||
fakeProcess(t, nobody, "clipmenud")
|
||||
h, err := History(10, 4096)
|
||||
h, err := History(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !h.Collecting || h.Total != 2 || h.Entries[0].Line != "first" || h.Entries[1].Text != "second\nline two" || h.Entries[1].Bytes != 15 {
|
||||
if !h.Collecting || h.Total != 2 || len(h.Entries) != 2 || h.Entries[0].Bytes != 5 || h.Entries[1].Bytes != 31 || h.Entries[0].At == "" {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
if h, _ := History(1, 3); len(h.Entries) != 1 || h.Entries[0].Text != "fir" || !h.Entries[0].Truncated {
|
||||
t.Fatalf("limited and cut: %+v", h)
|
||||
raw, _ := json.Marshal(h)
|
||||
for _, leak := range []string{"first", "SECRETKEY", "line two"} {
|
||||
if strings.Contains(string(raw), leak) {
|
||||
t.Fatalf("the history answers %q: %s", leak, raw)
|
||||
}
|
||||
}
|
||||
if h, _ := History(10, 0); h.Entries[0].Text != "" || h.Entries[0].Bytes != 5 {
|
||||
t.Fatalf("without text: %+v", h)
|
||||
if h, _ := History(1); len(h.Entries) != 1 {
|
||||
t.Fatalf("limited: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEntryIsDeletedByIdOrLineWithItsText(t *testing.T) {
|
||||
// at is the time History reports for the entry at place.
|
||||
func at(t *testing.T, place int) string {
|
||||
t.Helper()
|
||||
h, err := History(100)
|
||||
if err != nil || place > len(h.Entries) {
|
||||
t.Fatalf("%+v, %v", h, err)
|
||||
}
|
||||
return h.Entries[place-1].At
|
||||
}
|
||||
|
||||
func TestADeleteWhoseListingIsStaleIsRefused(t *testing.T) {
|
||||
dir := store(t, map[string]string{"a": "a", "b": "b"}, "a", "b")
|
||||
listed := at(t, 1) // b
|
||||
f, _ := os.OpenFile(filepath.Join(dir, "line_cache"), os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
f.WriteString("1800000000000000000 a\n") // a copy arrives: place 1 is now a
|
||||
f.Close()
|
||||
if _, err := Delete(1, listed); err == nil || !strings.Contains(err.Error(), "list it again") {
|
||||
t.Fatalf("the wrong entry was deleted: %v", err)
|
||||
}
|
||||
if h, _ := History(10); h.Total != 2 {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEntryIsDeletedByItsPlaceWithItsTextAndNeverByItsText(t *testing.T) {
|
||||
dir := store(t, map[string]string{"a": "a", "b": "b", "c": "c"}, "a", "b", "c", "a")
|
||||
r, err := Delete(entryID("a"), "")
|
||||
// newest first: a, c, b
|
||||
r, err := Delete(1, at(t, 1))
|
||||
if err != nil || r.Removed != 2 || r.Remaining != 2 {
|
||||
t.Fatalf("by id, both copies: %+v, %v", r, err)
|
||||
t.Fatalf("by place, both copies: %+v, %v", r, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, entryID("a"))); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("the text stayed")
|
||||
}
|
||||
if r, err := Delete("", "b"); err != nil || r.Removed != 1 || r.Remaining != 1 {
|
||||
t.Fatalf("by line: %+v, %v", r, err)
|
||||
if r, err := Delete(2, at(t, 2)); err != nil || r.Removed != 1 || r.Remaining != 1 {
|
||||
t.Fatalf("second place (b): %+v, %v", r, err)
|
||||
}
|
||||
if _, err := Delete("", "zzz"); err == nil {
|
||||
t.Fatal("a missing entry was reported deleted")
|
||||
if _, err := Delete(5, "x"); err == nil {
|
||||
t.Fatal("a missing place was reported deleted")
|
||||
}
|
||||
if _, err := Delete("x", "y"); err == nil {
|
||||
t.Fatal("both an id and a line were accepted")
|
||||
if _, err := Delete(0, "x"); err == nil {
|
||||
t.Fatal("place 0 was accepted")
|
||||
}
|
||||
cache, _ := os.ReadFile(filepath.Join(dir, "line_cache"))
|
||||
if !strings.HasSuffix(string(cache), " c\n") || strings.Count(string(cache), "\n") != 1 {
|
||||
t.Fatalf("line cache: %q", cache)
|
||||
}
|
||||
for _, tool := range tools() {
|
||||
if tool.Name == "clipmenu_delete" {
|
||||
if _, ok := tool.Input["line"]; ok {
|
||||
t.Fatal("delete still takes a line")
|
||||
}
|
||||
if _, ok := tool.Input["id"]; ok {
|
||||
t.Fatal("delete still takes an id")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
|
||||
@@ -112,13 +151,21 @@ func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) {
|
||||
func copyBed(t *testing.T, xsel string) string {
|
||||
t.Helper()
|
||||
store(t, nil)
|
||||
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
|
||||
bin := fakeBinaries(t, map[string]string{"systemctl": "true", "systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`})
|
||||
bin := fakeBinaries(t, map[string]string{"systemctl": "true", "xsel": xsel,
|
||||
"systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`})
|
||||
t.Setenv("LOG", filepath.Join(bin, "log"))
|
||||
readBackWait = 0
|
||||
return bin
|
||||
}
|
||||
|
||||
func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManagerAndReadsItBack(t *testing.T) {
|
||||
bin := copyBed(t, `cat "$LOG.text"`)
|
||||
r, err := Copy("secret-free text")
|
||||
if err != nil || r.Bytes != 16 || !strings.HasPrefix(r.Unit, "clipmenu-copy-") {
|
||||
if err != nil || r.Bytes != 16 || !r.Verified || !strings.HasPrefix(r.Unit, "clipmenu-copy-") {
|
||||
t.Fatalf("%+v, %v", r, err)
|
||||
}
|
||||
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
|
||||
@@ -134,7 +181,23 @@ func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) {
|
||||
func TestCopyFailsWhenTheClipboardHoldsSomethingElse(t *testing.T) {
|
||||
copyBed(t, `printf 'old contents'`)
|
||||
_, err := Copy("new text")
|
||||
if err == nil || !strings.Contains(err.Error(), "does not hold") || strings.Contains(err.Error(), "old contents") {
|
||||
t.Fatalf("a copy that did not land was reported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFailsSayingWhyWhenTheOwnerCannotReachTheDisplay(t *testing.T) {
|
||||
copyBed(t, `echo "xsel: Can't open display: (null)" >&2; exit 1`)
|
||||
_, err := Copy("new text")
|
||||
if err == nil || !strings.Contains(err.Error(), "Can't open display") {
|
||||
t.Fatalf("the reason was not given: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteSaysHowMuchTheClipboardHoldsNeverWhat(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
if _, err := Paste(); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatal(err)
|
||||
@@ -142,8 +205,9 @@ func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) {
|
||||
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
|
||||
fakeBinaries(t, map[string]string{"xsel": `[ "$*" = "--output --clipboard" ] && printf 'on the clipboard'`})
|
||||
p, err := Paste()
|
||||
if err != nil || p.Text != "on the clipboard" || p.Bytes != 16 {
|
||||
t.Fatalf("%+v, %v", p, err)
|
||||
raw, _ := json.Marshal(p)
|
||||
if err != nil || p.Bytes != 16 || strings.Contains(string(raw), "on the clipboard") {
|
||||
t.Fatalf("%s, %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +221,26 @@ func TestWithoutAStoreTheChangesSayWhy(t *testing.T) {
|
||||
if _, err := Clear(); err == nil || !strings.Contains(err.Error(), "clipmenud has not run") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h, err := History(5, 0); err != nil || h.Total != 0 || h.Collecting {
|
||||
if h, err := History(5); err != nil || h.Total != 0 || h.Collecting {
|
||||
t.Fatalf("an empty history: %+v, %v", h, err)
|
||||
}
|
||||
}
|
||||
|
||||
// clipmenud is a bash script: its process is named "bash" and only its command line names clipmenud
|
||||
// (issue 860: collecting was false while the daemon ran).
|
||||
func TestCollectingSeesClipmenudRunAsABashScript(t *testing.T) {
|
||||
store(t, map[string]string{"first": "first"}, "first")
|
||||
fakeProcess(t, nobody, "bash")
|
||||
cmdline := filepath.Join(procRoot, strconv.Itoa(nobody), "cmdline")
|
||||
if err := os.WriteFile(cmdline, []byte("bash\x00/usr/bin/clipmenud\x00"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h, err := History(10); err != nil || !h.Collecting {
|
||||
t.Fatalf("clipmenud runs as a bash script: %+v %v", h, err)
|
||||
}
|
||||
// another bash script is not the daemon
|
||||
os.WriteFile(cmdline, []byte("bash\x00/usr/bin/other\x00"), 0o644)
|
||||
if h, _ := History(10); h.Collecting {
|
||||
t.Fatalf("a bash script that is not clipmenud counted: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,28 +22,23 @@ func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "node-clipboard.history",
|
||||
Description: "What the operator copied, newest first: each entry's id, its first line, when, its size " +
|
||||
"and its text (each cut at max_bytes). Whether the clipboard daemon is collecting.",
|
||||
Description: "What the operator copied, newest first: how many entries, when each was copied and how long " +
|
||||
"it is, never what it says. Whether the clipboard daemon is collecting. The text is read on the operator's own screen, through clipmenu.",
|
||||
Input: map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"},
|
||||
"max_bytes": map[string]any{"type": "integer", "description": "cut each entry's text at this many bytes; 0 leaves the text out (default 4096, at most 65536)"},
|
||||
"limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
limit, err := whole(args, "limit", 20, 1, 500)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
most, err := whole(args, "max_bytes", 4096, 0, 65536)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return History(limit, most)
|
||||
return History(limit)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "node-clipboard.copy",
|
||||
Description: "Put text on the operator's clipboard, as if they had copied it; it enters the history " +
|
||||
"like any copy. Answers how many bytes.",
|
||||
"like any copy. Reads the clipboard back and answers an error with the reason when it does not hold the text.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
@@ -61,7 +56,7 @@ func tools() []stdio.Tool {
|
||||
},
|
||||
{
|
||||
Name: "clipmenu_paste",
|
||||
Description: "What the operator's clipboard holds right now, as text (cut at 64 KiB, said in truncated).",
|
||||
Description: "How many bytes of text the operator's clipboard holds right now, never the text itself.",
|
||||
Run: func(map[string]any) (any, error) { return Paste() },
|
||||
},
|
||||
{
|
||||
@@ -71,19 +66,22 @@ func tools() []stdio.Tool {
|
||||
},
|
||||
{
|
||||
Name: "clipmenu_delete",
|
||||
Description: "Forget one entry of the clipboard history, by its id as the history answers it, or by " +
|
||||
"its first line exactly.",
|
||||
Description: "Forget one entry of the clipboard history, by its place in node-clipboard.history's list " +
|
||||
"(1 is the newest) and its time as the history gives it; refused when the two no longer match.",
|
||||
Input: map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "the entry's id"},
|
||||
"line": map[string]any{"type": "string", "description": "the entry's first line, exactly"},
|
||||
"place": map[string]any{"type": "integer", "description": "the entry's place in the history, 1 for the newest"},
|
||||
"at": map[string]any{"type": "string", "description": "the entry's time, as the history reports it"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
id, err := text(args, "id", false)
|
||||
place, err := whole(args, "place", 0, 0, 100000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line, _ := args["line"].(string)
|
||||
return Delete(id, line)
|
||||
at, err := text(args, "at", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Delete(place, at)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Moved bool `json:"moved"`
|
||||
Items []item `json:"items"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []claim `json:"claims"`
|
||||
@@ -23,7 +25,7 @@ type manifest struct {
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here.
|
||||
Contributions []contribution `json:"contributions"`
|
||||
Contributions []any `json:"contributions"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
@@ -46,6 +48,53 @@ type shellCode struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// An item of a collection (novox/hq ADR 0349 clause 13): its text is the template file beside the manifest.
|
||||
type item struct {
|
||||
Collection string `json:"collection"`
|
||||
Name string `json:"name"`
|
||||
Attributes map[string]string `json:"attributes"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
|
||||
// shell is the code the module adds to a shell's or the session's start, read from the templates of its items of
|
||||
// the collections shell:<file>:<slot>, in the order they are declared.
|
||||
func (m manifest) shell(t *testing.T) []shellCode {
|
||||
t.Helper()
|
||||
var out []shellCode
|
||||
for _, it := range m.Items {
|
||||
parts := strings.Split(it.Collection, ":")
|
||||
if len(parts) != 3 || parts[0] != "shell" {
|
||||
continue
|
||||
}
|
||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, shellCode{For: parts[1], Slot: parts[2], Code: string(text)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// environmentOf is the variables the module sets, read from the templates of its items of the environment.
|
||||
func (m manifest) environmentOf(t *testing.T) *environment {
|
||||
t.Helper()
|
||||
var env *environment
|
||||
for _, it := range m.Items {
|
||||
if it.Collection != "environment" || it.Attributes["kind"] != "variable" {
|
||||
continue
|
||||
}
|
||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env == nil {
|
||||
env = &environment{Variables: map[string]string{}}
|
||||
}
|
||||
env.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
@@ -58,6 +107,7 @@ func readManifest(t *testing.T) manifest {
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
m.Environment = m.environmentOf(t)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -85,26 +135,66 @@ func (m manifest) packages() (present, absent []string) {
|
||||
return present, absent
|
||||
}
|
||||
|
||||
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
|
||||
// the readable file in the repository is what the machine gets.
|
||||
func (m manifest) sameAsSource(t *testing.T, id, source string) {
|
||||
// fromTemplate checks that a file resource takes its text from its template file in the module's
|
||||
// repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the
|
||||
// template is there. The readable file in the repository is the one place the machine's text is written.
|
||||
func (m manifest) fromTemplate(t *testing.T, id, template string) {
|
||||
t.Helper()
|
||||
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := m.resource(t, id)
|
||||
if r["type"] != "file" {
|
||||
t.Fatalf("%s is a %v, not a file", id, r["type"])
|
||||
}
|
||||
if got, _ := r["content"].(string); got != string(want) {
|
||||
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
|
||||
if r["template"] != template {
|
||||
t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"])
|
||||
}
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil {
|
||||
t.Fatalf("resource %s names %s: %v", id, template, err)
|
||||
}
|
||||
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
|
||||
t.Fatalf("%s under the home is the account's", id)
|
||||
}
|
||||
}
|
||||
|
||||
// everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs
|
||||
// files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func (m manifest) everyFileIsATemplate(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
|
||||
// text is the text a file resource places on the machine: its template file's.
|
||||
func (m manifest) text(t *testing.T, id string) string {
|
||||
t.Helper()
|
||||
name, _ := m.resource(t, id)["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %s names no template", id)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
|
||||
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
|
||||
func checkTheToolsAgree(t *testing.T, m manifest) {
|
||||
@@ -176,31 +266,42 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type contribution struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
// displaySession is the collection the module's lines for the window manager are an item of.
|
||||
const displaySession = "contribution:node-display-session:config"
|
||||
|
||||
// i3Lines is what the module contributes to the window manager.
|
||||
// i3Lines is what the module contributes to the window manager: the text of its item of the collection
|
||||
// contribution:node-display-session:config (design 57 L3, issue 848).
|
||||
func (m manifest) i3Lines() string {
|
||||
var out string
|
||||
for _, c := range m.Contributions {
|
||||
if c.Seat == "node-display-session" && c.Kind == "config" {
|
||||
out += c.Content
|
||||
for _, it := range m.Items {
|
||||
if it.Collection == displaySession {
|
||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
out += string(text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
|
||||
// i3LinesAreSource checks the window-manager lines are one item whose template is the source file, and that no
|
||||
// old-form contribution to node-display-session is left.
|
||||
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
|
||||
t.Helper()
|
||||
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if len(m.Contributions) != 0 {
|
||||
t.Fatalf("the module still holds contributions: the lines are an item of %s", displaySession)
|
||||
}
|
||||
if m.i3Lines() != string(want) {
|
||||
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
|
||||
var templates []string
|
||||
for _, it := range m.Items {
|
||||
if it.Collection == displaySession {
|
||||
templates = append(templates, it.Template)
|
||||
}
|
||||
}
|
||||
if len(templates) != 1 || templates[0] != source {
|
||||
t.Fatalf("the items of %s are %v, want the one template %s", displaySession, templates, source)
|
||||
}
|
||||
if m.i3Lines() == "" {
|
||||
t.Fatalf("the template %s holds no line", source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clipmenu's shape (novox/hq ADR 0208, research 026/04): it claims node-clipboard serving history
|
||||
@@ -27,20 +34,48 @@ func TestItClaimsTheClipboardSeatServingHistoryAndCopy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDaemonStartsOnceFromTheSessionsStart(t *testing.T) {
|
||||
func TestTheDaemonIsTheOperatorsUserUnitEnabledAndRunning(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
if len(m.shell(t)) != 0 {
|
||||
t.Fatalf("a second start from the session: %+v", m.shell(t))
|
||||
}
|
||||
code := m.Shell[0].Code
|
||||
// Started once, through the module's text-only xsel (a selection holding an image is never read).
|
||||
if strings.Count(code, "clipmenud &\n") != 1 || !strings.Contains(code, `PATH="/usr/local/lib/mesh-clipmenu:$PATH" clipmenud &`) ||
|
||||
strings.Contains(code, "greenclip") {
|
||||
t.Fatalf("%q", code)
|
||||
svc := m.resource(t, "daemon")
|
||||
want := map[string]any{"id": "daemon", "type": "service", "unit": "clipmenud.service", "scope": "user",
|
||||
"user": "${machine:account}", "state": "running", "boot": "enabled", "reload-on": nil}
|
||||
for k, v := range want {
|
||||
if k != "reload-on" && svc[k] != v {
|
||||
t.Fatalf("daemon %s = %v, want %v: %v", k, svc[k], v, svc)
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "service" || r["type"] == "process" {
|
||||
t.Fatalf("a second start: %v", r)
|
||||
// ADR 0240 rule 8: a long-running resource says how it is ready. The launcher keeps the unit active while
|
||||
// it waits for the display and execs clipmenud, so the unit's state is the daemon's own liveness.
|
||||
if h, _ := svc["health"].(map[string]any); h["kind"] != "unit" {
|
||||
t.Fatalf("daemon health = %v, want kind unit", svc["health"])
|
||||
}
|
||||
dropin := m.resource(t, "daemon-dropin")
|
||||
content := m.text(t, "daemon-dropin")
|
||||
if dropin["path"] != "${machine:account-home}/.config/systemd/user/clipmenud.service.d/mesh.conf" ||
|
||||
dropin["owner"] != "${machine:account}" ||
|
||||
!strings.Contains(content, "ExecStart=/usr/local/lib/mesh-clipmenu/clipmenud-session") ||
|
||||
strings.Contains(content, "greenclip") {
|
||||
t.Fatalf("drop-in: %v", dropin)
|
||||
}
|
||||
launcher := m.text(t, "launcher")
|
||||
if !strings.Contains(launcher, "DISPLAY") || !strings.Contains(launcher, "/tmp/.X11-unix") ||
|
||||
!strings.Contains(launcher, `PATH="/usr/local/lib/mesh-clipmenu:$PATH"`) || !strings.Contains(launcher, "exec clipmenud") {
|
||||
t.Fatalf("launcher: %q", launcher)
|
||||
}
|
||||
// Waits for the display instead of exiting into the start limit; DISPLAY from the manager wins.
|
||||
if !strings.Contains(launcher, "until [ -n \"$DISPLAY\" ]") || !strings.Contains(launcher, "sleep 1") ||
|
||||
strings.Contains(launcher, "exit 1") || strings.Contains(launcher, "exit 0") {
|
||||
t.Fatalf("the launcher must wait, not exit: %q", launcher)
|
||||
}
|
||||
if !strings.Contains(content, "StartLimitIntervalSec=0") || !strings.Contains(content, "RestartSec=2s") {
|
||||
t.Fatalf("drop-in lacks the safety net: %q", content)
|
||||
}
|
||||
for k, v := range m.environmentOf(t).Variables {
|
||||
if !strings.Contains(content, "Environment="+k+"="+v) {
|
||||
t.Fatalf("the unit lacks %s=%s", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,3 +99,131 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
checkTheToolsAgree(t, m)
|
||||
checkNoSecretsOrInstallationNames(t)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
readManifest(t).everyFileIsATemplate(t)
|
||||
}
|
||||
|
||||
// Run for real: DISPLAY from the manager is used at once; several sockets and no DISPLAY wait.
|
||||
func TestTheLauncherUsesTheManagersDisplayAndWaitsOtherwise(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
script := m.text(t, "launcher")
|
||||
dir := t.TempDir()
|
||||
file := dir + "/launcher"
|
||||
// clipmenud and the socket directory are faked: the script's own paths are rewritten.
|
||||
body := strings.ReplaceAll(script, "/tmp/.X11-unix", dir+"/x11")
|
||||
body = strings.Replace(body, "exec clipmenud", `echo "DISPLAY=$DISPLAY"`, 1)
|
||||
if err := os.WriteFile(file, []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.MkdirAll(dir+"/x11", 0o755)
|
||||
fakeBin := dir + "/bin"
|
||||
os.MkdirAll(fakeBin, 0o755)
|
||||
// the manager's environment: empty until the file appears
|
||||
os.WriteFile(fakeBin+"/systemctl", []byte("#!/bin/sh\n[ -f \""+dir+"/env\" ] && cat \""+dir+"/env\"\nexit 0\n"), 0o755)
|
||||
run := func(env ...string) (string, bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2500*time.Millisecond)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "sh", file)
|
||||
cmd.Env = append([]string{"PATH=" + fakeBin + ":" + os.Getenv("PATH"), "HOME=" + dir}, env...)
|
||||
out, _ := cmd.Output()
|
||||
return strings.TrimSpace(string(out)), ctx.Err() == nil
|
||||
}
|
||||
if out, ok := run("DISPLAY=:7"); !ok || out != "DISPLAY=:7" {
|
||||
t.Fatalf("with DISPLAY: %q %v", out, ok)
|
||||
}
|
||||
if _, ok := run(); ok {
|
||||
t.Fatal("it exited with no X server")
|
||||
}
|
||||
for _, n := range []string{"X0", "X1"} {
|
||||
l, _ := net.Listen("unix", dir+"/x11/"+n)
|
||||
defer l.Close()
|
||||
}
|
||||
if _, ok := run(); ok {
|
||||
t.Fatal("it chose one of two servers")
|
||||
}
|
||||
// the session hands the manager its display while the launcher waits
|
||||
go func() {
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
os.WriteFile(dir+"/env", []byte("DISPLAY=:1\nOTHER=x\n"), 0o644)
|
||||
}()
|
||||
if out, ok := run(); !ok || out != "DISPLAY=:1" {
|
||||
t.Fatalf("the display that arrived while waiting: %q %v", out, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Run for real: while another clipmenud of the account holds the session lock the launcher waits without
|
||||
// a word, and starts clipmenud once the lock is free (issue 862).
|
||||
func TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock(t *testing.T) {
|
||||
if _, err := exec.LookPath("flock"); err != nil {
|
||||
t.Skip("flock is missing here")
|
||||
}
|
||||
m := readManifest(t)
|
||||
script := m.text(t, "launcher")
|
||||
dir := t.TempDir()
|
||||
body := strings.Replace(script, "exec clipmenud", `echo "started"`, 1)
|
||||
file := dir + "/launcher"
|
||||
if err := os.WriteFile(file, []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := dir + "/cm/clipmenu.6.op"
|
||||
os.MkdirAll(store, 0o700)
|
||||
lock := store + "/session_lock"
|
||||
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed
|
||||
holder := exec.Command("flock", "-x", "-n", lock, "sleep", "60")
|
||||
holder.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := holder.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// flock's child shares the lock, so the whole group goes
|
||||
release := func() { syscall.Kill(-holder.Process.Pid, syscall.SIGKILL); holder.Wait() }
|
||||
defer release()
|
||||
for i := 0; i < 50; i++ {
|
||||
if probe := exec.Command("flock", "-n", lock, "true"); probe.Run() != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
var out syncBuffer
|
||||
cmd := exec.Command("sh", file)
|
||||
cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "DISPLAY=:7", "USER=op", "CM_DIR=" + dir + "/cm"}
|
||||
cmd.Stdout, cmd.Stderr = &out, &out
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatalf("the launcher went on while the lock was held: %q", out.String())
|
||||
case <-time.After(2500 * time.Millisecond):
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("the launcher spoke while it waited: %q", out.String())
|
||||
}
|
||||
release()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
cmd.Process.Kill()
|
||||
t.Fatal("the launcher did not start clipmenud after the lock was freed")
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "started" {
|
||||
t.Fatalf("after the lock was freed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
b strings.Builder
|
||||
}
|
||||
|
||||
func (s *syncBuffer) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.b.Write(p)
|
||||
}
|
||||
|
||||
func (s *syncBuffer) Len() int { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Len() }
|
||||
func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
|
||||
|
||||
@@ -403,7 +403,7 @@ func processesOf(comm string) []int {
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
if readTrimmed(filepath.Join(dir, "comm")) == comm {
|
||||
if readTrimmed(filepath.Join(dir, "comm")) == comm || runsScript(dir, comm) {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
}
|
||||
@@ -411,6 +411,24 @@ func processesOf(comm string) []int {
|
||||
return pids
|
||||
}
|
||||
|
||||
// runsScript is whether the process is an interpreter running the script named name: clipmenud is a
|
||||
// bash script, so its comm is "bash" and only its command line, "bash /usr/bin/clipmenud", names it.
|
||||
func runsScript(procDir, name string) bool {
|
||||
raw, err := os.ReadFile(filepath.Join(procDir, "cmdline"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00")
|
||||
if len(args) < 2 {
|
||||
return false
|
||||
}
|
||||
switch filepath.Base(args[0]) {
|
||||
case "bash", "sh", "dash", "zsh", "env":
|
||||
return filepath.Base(args[1]) == name
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
|
||||
func signalAll(comm string, sig syscall.Signal) []int {
|
||||
var reached []int
|
||||
|
||||
Reference in New Issue
Block a user