carlos / platform Public

Merge remote-tracking branch 'origin/main' into design-plans-and-caps

claude agent, operated by paul@keymail.dev c6f74b9e1d4e0a08d1f19865153977694938a2d4 parent 2a1ac83parent 512baa5
6 files changed, +367 −47
  • AGENTS.md +14 −1
  • apimode_test.go +0 −5
  • auth_test.go +3 −1
  • internal/sim/litestreamfacts/facts_test.go +13 −1
  • internal/stopevidence/fdholders_alias_linux_test.go +56 −32
  • main_test.go +281 −7

CI cancelled — superseded by a newer push — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m

amadan: job cancelled — a newer push superseded it
diff --git a/AGENTS.md b/AGENTS.md
index cedb4c57c..8b81caa89 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -296,7 +296,20 @@ anything.
**and set `cmd.Dir` to a scratch dir** — the binary walks cwd ancestors for
`.carlos/config`. The fallback is the operator's real login and default
console; a logged-in laptop running `go test ./...` minted 40+ real accounts
- on the production console before this was isolated.
+ on the production console before this was isolated. A `t.TempDir()` is
+ scratch in that sense only because the root package's `TestMain`
+ (`isolateTempRoot`, main_test.go) checks both temp roots, TMPDIR and
+ GOTMPDIR (`t.TempDir()` uses GOTMPDIR when it is set), and moves whichever
+ has a `.carlos/config` above it, which is the case wherever one is under a
+ logged-in home (#226, #258). The move is to a new
+ `/var/tmp/carlos-test-*` directory, named on stderr and removed when the
+ run ends. A run that is aborted (`log.Fatal`, a panic, a timeout) leaves
+ that one directory behind: remove it by the name that was printed, never
+ by pattern. `/tmp` is never used, and with `/var/tmp` unavailable the
+ binary stops and says which variable to set. `TestMain` also drops an
+ exported `CARLOS_CONFIG` and `CARLOS_DESTINATION`, which no shared helper
+ pins. A test that leaves its cwd at the checkout is not covered by any of
+ that.
- **A sidecar is a member that nothing can dial.** `Kind == "sidecar"` rows
carry no socket and no addr, and are refused by the edge ON KIND — not
merely unmatched by it — before dispatch ever looks at shape. That refusal
diff --git a/apimode_test.go b/apimode_test.go
index d3296c017..33abb4f9d 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -1054,11 +1054,6 @@ func TestShipVerbAPIMode(t *testing.T) {
useTempCredentials(t)
clearDeploymentEnv(t)
checkNoRegistry := requireNoRegistryTouch(t)
- // The working directory useTempCredentials picks is under $TMPDIR, which
- // can sit inside the operator's real home; the .carlos/config walk goes
- // up from it and would find the real ~/.carlos/config. Name a config
- // file of the test's own (missing, so no opinion) and skip discovery.
- t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "config"))
var reqCount int32
mux := http.NewServeMux()
diff --git a/auth_test.go b/auth_test.go
index 5a51b4955..b15d338bb 100644
--- a/auth_test.go
+++ b/auth_test.go
@@ -17,7 +17,9 @@ import (
// the credentials file, $HOME, the working directory, CARLOS_CONSOLE — at
// scratch space for the length of one test. Config resolution (config.go)
// consults all four; a developer's real ~/.carlos/config must never change
-// a test's answer.
+// a test's answer. The working directory is only scratch space because
+// TestMain (main_test.go isolateTempRoot) keeps every t.TempDir() clear of
+// a .carlos/config above it: discovery walks up from here.
func useTempCredentials(t *testing.T) string {
t.Helper()
home := t.TempDir()
diff --git a/internal/sim/litestreamfacts/facts_test.go b/internal/sim/litestreamfacts/facts_test.go
index 2f76c6e5c..f66168944 100644
--- a/internal/sim/litestreamfacts/facts_test.go
+++ b/internal/sim/litestreamfacts/facts_test.go
@@ -817,7 +817,19 @@ func TestGate_AnchoredLineageThroughStore(t *testing.T) {
for _, lvl := range []int{1, 2, 3} {
waitFor(t, fmt.Sprintf("a level-%d compaction", lvl), 5*time.Second, func() bool { return len(files(t, gen, lvl)) > 0 })
}
- waitFor(t, "snapshot retention", 5*time.Second, func() bool { return len(files(t, gen, 9)) == 1 })
+ // Retention is awaited at the state that lasts: one snapshot, at the
+ // round's final position. One snapshot alone can be a moment in
+ // passing. The monitor snapshots every interval and keeps the previous
+ // snapshot while it is younger than the retention, so a tick that runs
+ // 100 ms late removes the previous one and leaves a single snapshot
+ // that is behind the position; the next tick adds the final snapshot
+ // beside it, and a round that had stopped waiting closed the store
+ // with two (#266). Once the only snapshot is at the final position a
+ // later tick can only rewrite that same file.
+ waitFor(t, "snapshot retention", 5*time.Second, func() bool {
+ l9 := files(t, gen, 9)
+ return len(l9) == 1 && int(l9[0].MaxTXID) == pos
+ })
waitFor(t, "level-0 expiry", 5*time.Second, func() bool { return len(files(t, gen, 0)) < l0Before+8 })
if err := store.Close(ctx); err != nil {
t.Fatal(err)
diff --git a/internal/stopevidence/fdholders_alias_linux_test.go b/internal/stopevidence/fdholders_alias_linux_test.go
index b0ee488ce..002a827d1 100644
--- a/internal/stopevidence/fdholders_alias_linux_test.go
+++ b/internal/stopevidence/fdholders_alias_linux_test.go
@@ -3,8 +3,10 @@
package stopevidence
import (
+ "bufio"
"context"
"errors"
+ "fmt"
"io/fs"
"os"
"os/exec"
@@ -17,21 +19,62 @@ import (
"time"
)
-// holdOpen starts a child that holds path open as descriptor 3 and sleeps.
+// heldAndQuiet ends every holder's script: the shell says it has finished
+// starting, then blocks in its own read on a pipe nothing is written to.
+//
+// A scan must not meet a holder that is still starting. A process that has
+// just been exec'd opens and closes the linker cache, libc and the locale
+// files in turn, each on the first free descriptor. A scan that reads one of
+// them between its close and the next open sees a descriptor whose stat says
+// gone and whose entry is there, which is ErrIncomplete by design
+// (TestFDHoldersStatGoneWhileTheEntryRemains). The holders here used to be
+// `sleep 60`, scanned as soon as it was started, and the hard-link test
+// failed that way once under load (#274).
+const heldAndQuiet = "echo ready && read wait"
+
+// startHolder starts cmd, a shell whose script ends in heldAndQuiet, and
+// returns its pid once it has said ready. An error means it never did.
+func startHolder(t *testing.T, cmd *exec.Cmd) (int, error) {
+ t.Helper()
+ // Wait closes this end, and until then the child's read has nothing.
+ if _, err := cmd.StdinPipe(); err != nil {
+ t.Fatal(err)
+ }
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ cmd.Stdout = w
+ err = cmd.Start()
+ w.Close()
+ if err != nil {
+ return 0, err
+ }
+ t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
+ r.SetReadDeadline(time.Now().Add(10 * time.Second))
+ if line, err := bufio.NewReader(r).ReadString('\n'); line != "ready\n" {
+ return 0, fmt.Errorf("the holder said %q before it stopped: %v", line, err)
+ }
+ return cmd.Process.Pid, nil
+}
+
+// holdOpen starts a child that holds path open as descriptor 3 and opens
+// nothing more.
func holdOpen(t *testing.T, path string) int {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
- cmd := exec.Command("sleep", "60")
+ defer f.Close()
+ cmd := exec.Command("sh", "-c", heldAndQuiet)
cmd.ExtraFiles = []*os.File{f}
- if err := cmd.Start(); err != nil {
+ pid, err := startHolder(t, cmd)
+ if err != nil {
t.Fatal(err)
}
- f.Close()
- t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
- return cmd.Process.Pid
+ return pid
}
// projectPIDs is a scan root holding only these processes' real /proc
@@ -124,35 +167,16 @@ func TestFDIdentityFindsAHolderInAnotherMountNamespace(t *testing.T) {
t.Fatal(err)
}
aliased := filepath.Join(alias, filepath.Base(db))
+ // The shell opens the database on descriptor 3 itself, after the mount,
+ // and says ready only then: until that point descriptor 3 can be some
+ // other file (the dynamic loader's libc, while unshare or sh starts).
cmd := exec.Command("unshare", "--user", "--map-root-user", "--mount", "sh", "-c",
- `mount --bind "$1" "$2" && exec sleep 60 3<"$3"`, "sh", filepath.Join(dir, "real"), alias, aliased)
- if err := cmd.Start(); err != nil {
- t.Skip("cannot start unshare:", err)
- }
- t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
- pid := cmd.Process.Pid
- fd3 := filepath.Join("/proc", strconv.Itoa(pid), "fd", "3")
- // Until sh has run the mount and exec'd sleep, fd 3 can be some other
- // file (the dynamic loader's libc, while unshare or sh starts), so wait
- // for fd 3 to be the database itself, by identity: stat through the
- // magic link reaches the file the descriptor holds in any namespace.
- want, err := os.Stat(db)
+ `mount --bind "$1" "$2" && exec 3<"$3" && `+heldAndQuiet, "sh", filepath.Join(dir, "real"), alias, aliased)
+ pid, err := startHolder(t, cmd)
if err != nil {
- t.Fatal(err)
- }
- var target string
- held := false
- for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
- if fi, err := os.Stat(fd3); err == nil && os.SameFile(fi, want) {
- target, _ = os.Readlink(fd3)
- held = true
- break
- }
- }
- if !held {
- t.Skip("the namespace holder never came up holding the database (unprivileged user namespaces refused?)")
+ t.Skip("the namespace holder never came up holding the database (unprivileged user namespaces refused?):", err)
}
- if target != aliased {
+ if target, _ := os.Readlink(filepath.Join("/proc", strconv.Itoa(pid), "fd", "3")); target != aliased {
t.Logf("descriptor text %q (expected the bind path %q)", target, aliased)
}
root := projectPIDs(t, pid)
diff --git a/main_test.go b/main_test.go
index b8711d2d9..c37400943 100644
--- a/main_test.go
+++ b/main_test.go
@@ -1,16 +1,290 @@
package main
import (
+ "errors"
+ "fmt"
"os"
+ "path/filepath"
+ "runtime"
+ "strings"
"testing"
)
-// TestMain keeps an exported deploy token out of the root package's tests.
-// resolveMode reads CARLOS_SERVICE_TOKEN, and a CI shell running `go test`
-// may well export one. In-process tests that call run() or a verb directly
-// would then inherit it. Tests that want a token set it with t.Setenv, which
-// still works after this Unsetenv.
+// TestMain keeps the operator's own state out of the root package's tests.
+//
+// Three exported variables are dropped before any test runs. No shared
+// helper pins all three, and each steers a test that calls run() or a verb
+// directly, and every child that inherits os.Environ(). A test that wants
+// one sets it with t.Setenv, which still works after an Unsetenv.
+//
+// - CARLOS_SERVICE_TOKEN: resolveMode reads it, and a CI shell running
+// `go test` may well export one.
+// - CARLOS_CONFIG: it names the one config file read and switches
+// discovery off, so with it exported every test that relies on its
+// scratch directory holding no config reads the developer's file
+// instead, and ships to the console it names.
+// - CARLOS_DESTINATION: it selects a block in whatever config a test
+// wrote, and a config that does not define it is an error.
+//
+// CARLOS_CONSOLE and CARLOS_CREDENTIALS stay: the shared helpers pin both.
+// HOME stays too, because the tests' own `go build` needs the real one.
+//
+// The operator's .carlos/config: see isolateTempRoot. The binary refuses to
+// run rather than work under one.
func TestMain(m *testing.M) {
- os.Unsetenv("CARLOS_SERVICE_TOKEN")
- os.Exit(m.Run())
+ for _, name := range []string{"CARLOS_SERVICE_TOKEN", "CARLOS_CONFIG", "CARLOS_DESTINATION"} {
+ os.Unsetenv(name)
+ }
+ // /var/tmp alone. /tmp is not offered: the tests build binaries into
+ // the temp root, and on a machine where /tmp is shared memory the move
+ // would put them there without anyone having asked.
+ moved, cleanup, err := isolateTempRoot([]string{"/var/tmp"})
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ if moved != "" {
+ fmt.Fprintln(os.Stderr, moved)
+ }
+ code := m.Run()
+ cleanup()
+ os.Exit(code)
+}
+
+// tempRootPrefix names the directory isolateTempRoot makes. Its cleanup runs
+// only when the tests return: a log.Fatal, a panic or a timeout leaves that
+// one directory behind, and the line TestMain prints says where it is.
+const tempRootPrefix = "carlos-test-"
+
+// isolateTempRoot makes sure no .carlos/config sits above the directories
+// the tests' scratch space comes from, before any test asks for one.
+//
+// The tests pin HOME, but the CLI's config discovery (config.go
+// walkConfigFiles) walks up from the working directory, and that directory
+// is a t.TempDir() for every test that uses useTempCredentials or runs the
+// binary from a scratch cmd.Dir. Where the temp root is inside a logged-in
+// operator's home (TMPDIR=~/.cache/tmp/..., the default in a sandboxed agent
+// session) the walk reached the real ~/.carlos/config: two dozen tests
+// failed with "not logged in" to the operator's console, and one offered a
+// re-login to it and waited ten minutes (#226, #258). Pinning CARLOS_CONFIG
+// in the shared helpers is not the fix, because it switches discovery off
+// for the tests that write a .carlos/config of their own.
+//
+// There are two roots. os.TempDir() is TMPDIR. t.TempDir() is GOTMPDIR
+// when that is set and os.TempDir() only when it is not (testing.go
+// makeTempDir: os.MkdirTemp(os.Getenv("GOTMPDIR"), pattern)), so a clear
+// TMPDIR says nothing about a GOTMPDIR under a home.
+//
+// Roots with nothing above them are left alone, which is every CI run.
+// Otherwise the tests get one fresh directory under the first of bases that
+// is clear, and each variable whose root was not clear is pointed at it, so
+// child processes follow. An explicit GOTMPDIR is pointed there rather than
+// cleared: cleared, t.TempDir() would fall back to TMPDIR, which may be the
+// very place the developer set GOTMPDIR to keep the tests' binaries off.
+// moved says where, and the returned cleanup removes it. When no directory
+// can be made the variables are put back, the error names the ones to set,
+// and TestMain stops.
+func isolateTempRoot(bases []string) (moved string, cleanup func(), err error) {
+ var why, names []string
+ if p := configAbove(os.TempDir()); p != "" {
+ why = append(why, fmt.Sprintf("%s is above the temp directory %s", p, os.TempDir()))
+ names = append(names, "TMPDIR")
+ }
+ if root := os.Getenv("GOTMPDIR"); root != "" {
+ if p := configAbove(root); p != "" {
+ why = append(why, fmt.Sprintf("%s is above GOTMPDIR %s", p, root))
+ names = append(names, "GOTMPDIR")
+ }
+ }
+ if len(names) == 0 {
+ return "", func() {}, nil
+ }
+ type was struct {
+ value string
+ set bool
+ }
+ before := map[string]was{}
+ for _, name := range names {
+ v, ok := os.LookupEnv(name)
+ before[name] = was{v, ok}
+ }
+ for _, base := range bases {
+ dir, err := os.MkdirTemp(base, tempRootPrefix)
+ if err != nil {
+ continue
+ }
+ for _, name := range names {
+ os.Setenv(name, dir)
+ }
+ // Asked of both roots again rather than assumed: the base may sit
+ // under a config too, and not every platform reads TMPDIR.
+ if configAbove(os.TempDir()) == "" && configAbove(os.Getenv("GOTMPDIR")) == "" {
+ moved = fmt.Sprintf("carlos tests: %s, so the tests work in %s instead (an aborted run leaves it behind)", strings.Join(why, " and "), dir)
+ return moved, func() { os.RemoveAll(dir) }, nil
+ }
+ os.Remove(dir)
+ for name, w := range before {
+ if w.set {
+ os.Setenv(name, w.value)
+ } else {
+ os.Unsetenv(name)
+ }
+ }
+ }
+ return "", nil, fmt.Errorf("carlos tests: %s, and the CLI's config walk would read it. No directory could be made under %s to work in instead: set %s to a directory with no .carlos/config above it",
+ strings.Join(why, " and "), strings.Join(bases, " or "), strings.Join(names, " and "))
+}
+
+// configAbove is the first .carlos/config the CLI's discovery would read
+// walking up from dir, "" when there is none or dir is "". The walk is over
+// the path as written, which is what discovery follows after a t.Chdir, and
+// over the path with its symlinks resolved, which is what it follows when
+// PWD is unset. Anything but "does not exist" counts as there: discovery
+// fails on a config it cannot read.
+func configAbove(dir string) string {
+ if dir == "" {
+ return ""
+ }
+ if abs, err := filepath.Abs(dir); err == nil {
+ dir = abs
+ }
+ starts := []string{dir}
+ if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != dir {
+ starts = append(starts, resolved)
+ }
+ for _, d := range starts {
+ for {
+ path := filepath.Join(d, ".carlos", "config")
+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
+ return path
+ }
+ parent := filepath.Dir(d)
+ if parent == d {
+ break
+ }
+ d = parent
+ }
+ }
+ return ""
+}
+
+// TestMain's own promises, read back from inside a test: neither root has a
+// config above it, and none of the variables it drops came through.
+func TestMainLeavesNoOperatorState(t *testing.T) {
+ for _, root := range []string{os.TempDir(), os.Getenv("GOTMPDIR"), t.TempDir()} {
+ if p := configAbove(root); p != "" {
+ t.Errorf("TestMain let the tests run with %s above %s", p, root)
+ }
+ }
+ for _, name := range []string{"CARLOS_SERVICE_TOKEN", "CARLOS_CONFIG", "CARLOS_DESTINATION"} {
+ if v, ok := os.LookupEnv(name); ok {
+ t.Errorf("%s=%q reached the tests", name, v)
+ }
+ }
+}
+
+// The guard, against a tree of its own. A root under a config moves to the
+// first base that is clear: TMPDIR, GOTMPDIR or both, whichever was under
+// it, and only those. The cleanup removes the directory and nothing else.
+// With no clear base the binary is told to stop and what to set.
+func TestIsolateTempRoot(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("os.TempDir does not read TMPDIR on windows, so there is no moving it")
+ }
+ home := t.TempDir()
+ planted := writeConfig(t, home, `{"console": "https://operator.example"}`)
+ under := filepath.Join(home, "cache", "tmp")
+ alsoUnder := filepath.Join(home, "var")
+ clear, otherClear := t.TempDir(), t.TempDir()
+ for _, d := range []string{under, alsoUnder} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got := configAbove(under); got != planted {
+ t.Fatalf("configAbove(%s) = %q, want %s", under, got, planted)
+ }
+ bases := []string{filepath.Join(home, "missing"), alsoUnder, clear}
+
+ for _, c := range []struct {
+ name string
+ tmpdir, gotmp string // as the run starts; "" is unset
+ moveTmp bool
+ moveGo bool
+ }{
+ {"TMPDIR under a config, no GOTMPDIR", under, "", true, false},
+ {"TMPDIR under a config, GOTMPDIR clear", under, otherClear, true, false},
+ {"GOTMPDIR under a config, TMPDIR clear", otherClear, under, false, true},
+ {"both under a config", under, alsoUnder, true, true},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ t.Setenv("TMPDIR", c.tmpdir)
+ t.Setenv("GOTMPDIR", c.gotmp)
+ said, cleanup, err := isolateTempRoot(bases)
+ if err != nil {
+ t.Fatal(err)
+ }
+ made, _ := os.ReadDir(clear)
+ if len(made) != 1 || !strings.HasPrefix(made[0].Name(), tempRootPrefix) {
+ t.Fatalf("the clear base holds %v, want one %s directory", made, tempRootPrefix)
+ }
+ moved := filepath.Join(clear, made[0].Name())
+ wantTmp, wantGo := c.tmpdir, c.gotmp
+ if c.moveTmp {
+ wantTmp = moved
+ }
+ if c.moveGo {
+ wantGo = moved
+ }
+ if os.TempDir() != wantTmp || os.Getenv("GOTMPDIR") != wantGo {
+ t.Fatalf("TMPDIR=%s GOTMPDIR=%s, want %s and %s", os.TempDir(), os.Getenv("GOTMPDIR"), wantTmp, wantGo)
+ }
+ if !strings.Contains(said, planted) || !strings.Contains(said, moved) ||
+ strings.Contains(said, "above GOTMPDIR") != c.moveGo || strings.Contains(said, "above the temp directory") != c.moveTmp {
+ t.Fatalf("said %q, want %s, %s and the roots that moved", said, planted, moved)
+ }
+ // What the guard is for: a test's own temp directory is clear now.
+ t.Run("a test's temp directory", func(t *testing.T) {
+ if p := configAbove(t.TempDir()); p != "" {
+ t.Fatalf("t.TempDir() is still under %s", p)
+ }
+ })
+ if left, _ := os.ReadDir(alsoUnder); len(left) != 0 {
+ t.Fatalf("the refused base kept %v", left)
+ }
+ cleanup()
+ if _, err := os.Stat(moved); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the cleanup left %s (%v)", moved, err)
+ }
+ if _, err := os.Stat(clear); err != nil {
+ t.Fatalf("the cleanup took the base with it: %v", err)
+ }
+ })
+ }
+
+ // No clear base: an error naming the config and each variable to set,
+ // with both variables as they were.
+ t.Setenv("TMPDIR", otherClear)
+ t.Setenv("GOTMPDIR", under)
+ _, _, err := isolateTempRoot([]string{alsoUnder})
+ if err == nil || !strings.Contains(err.Error(), planted) || !strings.Contains(err.Error(), "set GOTMPDIR to") {
+ t.Fatalf("no clear base = %v, want an error naming %s and GOTMPDIR", err, planted)
+ }
+ if os.TempDir() != otherClear || os.Getenv("GOTMPDIR") != under {
+ t.Fatalf("a refusal left TMPDIR=%s GOTMPDIR=%s", os.TempDir(), os.Getenv("GOTMPDIR"))
+ }
+ t.Setenv("TMPDIR", under)
+ if _, _, err := isolateTempRoot([]string{alsoUnder}); err == nil || !strings.Contains(err.Error(), "set TMPDIR and GOTMPDIR to") {
+ t.Fatalf("no clear base, both roots = %v, want both variables named", err)
+ }
+ if left, _ := os.ReadDir(alsoUnder); len(left) != 0 {
+ t.Fatalf("the refused base kept %v", left)
+ }
+
+ // Clear roots are not moved at all.
+ t.Setenv("TMPDIR", clear)
+ t.Setenv("GOTMPDIR", otherClear)
+ if said, _, err := isolateTempRoot([]string{filepath.Join(home, "missing")}); err != nil || said != "" || os.TempDir() != clear || os.Getenv("GOTMPDIR") != otherClear {
+ t.Fatalf("clear roots = %s, %s, %q, %v; want them left alone", os.TempDir(), os.Getenv("GOTMPDIR"), said, err)
+ }
}