fix: ended page scopes start no timers; SSH transfers keep their connection from the idle sweep; linked files download whole; only the sent item unpacks on an SSH host, replace and skip as on a machine

This commit is contained in:
redtea committed 2026-10-15 22:05:04 +03:00
1 parent e05ff05015
commit 64ce8f2b72
7 files changed
+172 -49

No files matched your search

+6 -2
View File
@@ -366,7 +366,7 @@ func (a *Agent) doShipIn(ctx context.Context, env proto.TaskEnvelope) proto.Task
} }
}() }()
if conn != nil { if conn != nil {
err := a.unpackRemote(conn, pr, dir, args.Name, args.Conflict) skipped, err := a.unpackRemote(conn, pr, dir, args.Name, args.Conflict)
if err == nil { if err == nil {
_, _ = io.Copy(io.Discard, pr) _, _ = io.Copy(io.Discard, pr)
} }
@@ -374,7 +374,11 @@ func (a *Agent) doShipIn(ctx context.Context, env proto.TaskEnvelope) proto.Task
if err != nil { if err != nil {
return proto.TaskResult{Error: err.Error()} return proto.TaskResult{Error: err.Error()}
} }
data, _ := json.Marshal(map[string]int{}) res := map[string]int{}
if skipped {
res["skipped"] = 1
}
data, _ := json.Marshal(res)
return proto.TaskResult{OK: true, Data: data} return proto.TaskResult{OK: true, Data: data}
} }
u := &unpacker{dir: dir, name: args.Name, conflict: args.Conflict} u := &unpacker{dir: dir, name: args.Name, conflict: args.Conflict}
+66 -28
View File
@@ -39,6 +39,28 @@ type sshfsConn struct {
statFmt string // "gnu", "bsd" or "ls" statFmt string // "gnu", "bsd" or "ls"
home string home string
last time.Time last time.Time
busy int // transfers under way: the sweep leaves the connection be
}
// hold marks the connection busy for a long transfer; the returned func ends
// it and starts the idle clock again.
func (c *sshfsConn) hold() func() {
c.mu.Lock()
c.busy++
c.mu.Unlock()
return func() {
c.mu.Lock()
c.busy--
c.last = time.Now()
c.mu.Unlock()
}
}
// idle: nothing under way and unused for sshfsIdle by now.
func (c *sshfsConn) idle(now time.Time) bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.busy == 0 && now.Sub(c.last) > sshfsIdle
} }
var ( var (
@@ -151,11 +173,7 @@ func hasLine(lines []string, w string) bool {
func sshfsSweep() { func sshfsSweep() {
for range time.Tick(time.Minute) { for range time.Tick(time.Minute) {
sshfsConns.Range(func(k, v any) bool { sshfsConns.Range(func(k, v any) bool {
c := v.(*sshfsConn) if v.(*sshfsConn).idle(time.Now()) {
c.mu.Lock()
idle := time.Since(c.last) > sshfsIdle
c.mu.Unlock()
if idle {
sshfsClose(k.(string)) sshfsClose(k.(string))
} }
return true return true
@@ -410,14 +428,20 @@ func writeCommand(p string) string {
return "cat > " + shq(tmp) + " && mv -f -- " + shq(tmp) + " " + shq(p) + " || { rm -f -- " + shq(tmp) + "; exit 1; }" return "cat > " + shq(tmp) + " && mv -f -- " + shq(tmp) + " " + shq(p) + " || { rm -f -- " + shq(tmp) + "; exit 1; }"
} }
func (a *Agent) sshfsSize(c *sshfsConn, p string) (int64, error) { // sizeCommand tells the size of p - of its target, if a link: what cat
cmd := "stat -c %s -- " + shq(p) // reads.
if c.statFmt == "bsd" { func sizeCommand(statFmt, p string) string {
cmd = "stat -f %z -- " + shq(p) switch statFmt {
} else if c.statFmt == "ls" { case "bsd":
cmd = "wc -c < " + shq(p) return "stat -L -f %z -- " + shq(p)
case "ls":
return "wc -c < " + shq(p)
} }
out, err := a.sshfsRun(c, cmd, nil) return "stat -L -c %s -- " + shq(p)
}
func (a *Agent) sshfsSize(c *sshfsConn, p string) (int64, error) {
out, err := a.sshfsRun(c, sizeCommand(c.statFmt, p), nil)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -483,6 +507,7 @@ func (a *Agent) doSSHFSSend(ctx context.Context, env proto.TaskEnvelope) proto.T
if err != nil { if err != nil {
return proto.TaskResult{Error: err.Error()} return proto.TaskResult{Error: err.Error()}
} }
defer c.hold()()
size, err := a.sshfsSize(c, args.Path) size, err := a.sshfsSize(c, args.Path)
if err != nil { if err != nil {
return proto.TaskResult{Error: err.Error()} return proto.TaskResult{Error: err.Error()}
@@ -518,7 +543,8 @@ func (a *Agent) doSSHFSSend(ctx context.Context, env proto.TaskEnvelope) proto.T
return proto.TaskResult{Error: "передача прервана: " + err.Error()} return proto.TaskResult{Error: "передача прервана: " + err.Error()}
} }
} }
_ = s.Wait() // All of it is read; the session closes (deferred) without waiting on a
// cat that may have more to say.
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": size}) data, _ := json.Marshal(map[string]any{"path": args.Path, "size": size})
return proto.TaskResult{OK: true, Data: data} return proto.TaskResult{OK: true, Data: data}
} }
@@ -534,6 +560,7 @@ func (a *Agent) doSSHFSPut(ctx context.Context, env proto.TaskEnvelope) proto.Ta
if err != nil { if err != nil {
return proto.TaskResult{Error: err.Error()} return proto.TaskResult{Error: err.Error()}
} }
defer c.hold()()
s, err := a.sshfsSession(c) s, err := a.sshfsSession(c)
if err != nil { if err != nil {
return proto.TaskResult{Error: err.Error()} return proto.TaskResult{Error: err.Error()}
@@ -598,7 +625,8 @@ func renameTar(w io.Writer, r io.Reader, base, name string) error {
if i := strings.IndexByte(n, '/'); i >= 0 { if i := strings.IndexByte(n, '/'); i >= 0 {
top, rest = n[:i], n[i:] top, rest = n[:i], n[i:]
} }
if top != base { // Only base and what is under it - nothing that climbs out by "..".
if top != base || strings.Contains("/"+rest+"/", "/../") {
continue continue
} }
h.Name = name + rest h.Name = name + rest
@@ -615,6 +643,7 @@ func renameTar(w io.Writer, r io.Reader, base, name string) error {
// packRemote writes src on the connection's host as a tar stream named name, // packRemote writes src on the connection's host as a tar stream named name,
// counting what goes for the progress. // counting what goes for the progress.
func (a *Agent) packRemote(ctx context.Context, w io.Writer, c *sshfsConn, src, name string, m *shipMeter) error { func (a *Agent) packRemote(ctx context.Context, w io.Writer, c *sshfsConn, src, name string, m *shipMeter) error {
defer c.hold()()
s, err := a.sshfsSession(c) s, err := a.sshfsSession(c)
if err != nil { if err != nil {
return err return err
@@ -657,25 +686,34 @@ func (a *Agent) remoteSize(c *sshfsConn, src string) int64 {
// unpackRemote puts a tar stream into dir on the connection's host, by the // unpackRemote puts a tar stream into dir on the connection's host, by the
// conflict rule: tar keeps or replaces what is there, an "error" rule // conflict rule: tar keeps or replaces what is there, an "error" rule
// refuses when name is there already. // refuses when name is there already.
func (a *Agent) unpackRemote(c *sshfsConn, r io.Reader, dir, name, conflict string) error { func (a *Agent) unpackRemote(c *sshfsConn, r io.Reader, dir, name, conflict string) (skipped bool, err error) {
defer c.hold()()
target := strings.TrimSuffix(dir, "/") + "/" + name target := strings.TrimSuffix(dir, "/") + "/" + name
out, _ := a.sshfsRun(c, "test -e "+shq(target)+" -o -L "+shq(target)+" && echo LPRA_EXISTS; tar --version 2>/dev/null | head -n 1", nil) out, _ := a.sshfsRun(c, "test -e "+shq(target)+" -o -L "+shq(target)+" && echo LPRA_EXISTS; tar --version 2>/dev/null | head -n 1", nil)
if strings.Contains(string(out), "LPRA_EXISTS") && conflict != "replace" && conflict != "skip" { if strings.Contains(string(out), "LPRA_EXISTS") {
return errors.New(target + " уже существует")
}
gnu := strings.Contains(string(out), "GNU tar")
flags := ""
switch conflict { switch conflict {
case "skip": case "skip":
flags = " -k" // Left as it was, as on a machine.
if gnu { return true, nil
flags = " --skip-old-files"
}
case "replace": case "replace":
if gnu { default:
flags = " --overwrite" return false, errors.New(target + " уже существует")
} }
} }
_, err := a.sshfsRun(c, "mkdir -p -- "+shq(dir)+" && cd -- "+shq(dir)+" && tar -xf -"+flags, r) // Only name and what is under it reaches the host's tar.
return err pr, pw := io.Pipe()
go func() { pw.CloseWithError(renameTar(pw, r, name, name)) }()
defer pr.Close()
_, err = a.sshfsRun(c, unpackCommand(dir, name, conflict, strings.Contains(string(out), "GNU tar")), pr)
return false, err
}
// unpackCommand unpacks a tar stream of name into dir; "replace" removes
// what is there first, so the thing is replaced whole as on a machine.
func unpackCommand(dir, name, conflict string, gnu bool) string {
cmd := "mkdir -p -- " + shq(dir) + " && cd -- " + shq(dir) + " && tar -xf -"
if conflict == "replace" {
cmd = "rm -rf -- " + shq(strings.TrimSuffix(dir, "/")+"/"+name) + " && " + cmd
}
return cmd
} }
+65
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestParseStatLine(t *testing.T) { func TestParseStatLine(t *testing.T) {
@@ -127,3 +128,67 @@ func TestZipCommand(t *testing.T) {
t.Fatalf("unzip: %s", got) t.Fatalf("unzip: %s", got)
} }
} }
// A connection busy with a long transfer is not idle, however long ago it
// was opened; once the transfer ends the idle clock starts again.
func TestSweepSparesBusyConn(t *testing.T) {
c := &sshfsConn{last: time.Now().Add(-2 * sshfsIdle)}
release := c.hold()
if c.idle(time.Now()) {
t.Fatal("a busy connection was taken for idle")
}
release()
if c.idle(time.Now()) {
t.Fatal("just released: not idle yet")
}
if !c.idle(time.Now().Add(2 * sshfsIdle)) {
t.Fatal("unused for long: idle")
}
}
// A file's size is its target's when it is a link: what cat will read.
func TestSizeCommandFollowsLinks(t *testing.T) {
for _, f := range []string{"gnu", "bsd"} {
if cmd := sizeCommand(f, "/x"); !strings.Contains(cmd, "stat -L ") {
t.Fatalf("%s: %s", f, cmd)
}
}
}
// Nothing of a tar stream lands outside name: other entries and ".." paths
// are dropped.
func TestRenameTarKeepsInsideName(t *testing.T) {
var src bytes.Buffer
tw := tar.NewWriter(&src)
for _, n := range []string{"d/a", "d/../../etc/x", ".ssh/authorized_keys", "d/b"} {
_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Typeflag: tar.TypeReg})
}
_ = tw.Close()
var out bytes.Buffer
if err := renameTar(&out, &src, "d", "d"); err != nil {
t.Fatal(err)
}
tr := tar.NewReader(&out)
var got []string
for {
h, err := tr.Next()
if err != nil {
break
}
got = append(got, h.Name)
}
if strings.Join(got, ",") != "d/a,d/b" {
t.Fatalf("entries: %v", got)
}
}
// Into an SSH host, "replace" replaces the thing whole, as on a machine.
func TestUnpackCommandReplaces(t *testing.T) {
cmd := unpackCommand("/srv", "d", "replace", true)
if !strings.HasPrefix(cmd, "rm -rf -- '/srv/d' && ") || strings.Contains(cmd, "--overwrite") {
t.Fatalf("replace: %s", cmd)
}
if cmd := unpackCommand("/srv", "d", "error", false); cmd != "mkdir -p -- '/srv' && cd -- '/srv' && tar -xf -" {
t.Fatalf("error: %s", cmd)
}
}
+1
View File
@@ -780,6 +780,7 @@ p.mv-a { margin:0; font-size:13px; }
.fpane.narrow .fm-bar { gap:5px; } .fpane.narrow .fm-bar { gap:5px; }
.fpane.narrow .fm-bar .btn.ico { padding-left:8px; padding-right:8px; gap:4px; font-size:12.5px; } .fpane.narrow .fm-bar .btn.ico { padding-left:8px; padding-right:8px; gap:4px; font-size:12.5px; }
.fpane.narrow .fp-sel { gap:4px; } .fpane.narrow .fp-sel { gap:4px; }
.fpane.narrow .fp-selinfo { margin-right:6px; }
.fpane.narrow .fp-sel .btn.sq { width:32px; height:32px; } .fpane.narrow .fp-sel .btn.sq { width:32px; height:32px; }
/* The pane: its bar and list scroll inside it. */ /* The pane: its bar and list scroll inside it. */
.fpane-body { flex:1; min-height:0; overflow:auto; padding:10px 12px 12px; } .fpane-body { flex:1; min-height:0; overflow:auto; padding:10px 12px 12px; }
+1 -18
View File
@@ -15,24 +15,7 @@
function register(name, mod) { modules[name] = mod; } function register(name, mod) { modules[name] = mod; }
// scope gives a module timers, requests and listeners that end with it. const scope = WSUtil.scope;
function scope() {
const timers = new Set(), offs = [], ac = new AbortController();
return {
timeout(fn, ms) { const id = setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); return id; },
interval(fn, ms) { const id = setInterval(fn, ms); timers.add(id); return id; },
clear(id) { clearTimeout(id); clearInterval(id); timers.delete(id); },
fetch(url, opt) {
const o = Object.assign({credentials: 'same-origin'}, opt || {});
// A request with its own signal ends on either.
o.signal = o.signal && AbortSignal.any ? AbortSignal.any([o.signal, ac.signal]) : (o.signal || ac.signal);
return fetch(url, o);
},
on(target, ev, fn, opt) { target.addEventListener(ev, fn, opt); offs.push(() => target.removeEventListener(ev, fn, opt)); },
signal: ac.signal,
end() { timers.forEach(id => { clearTimeout(id); clearInterval(id); }); timers.clear(); offs.forEach(f => f()); ac.abort(); }
};
}
function unmount(area) { function unmount(area) {
const m = mounted.get(area); const m = mounted.get(area);
+23 -1
View File
@@ -22,6 +22,28 @@
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' M'; if (n < 1073741824) return (n / 1048576).toFixed(1) + ' M';
return (n / 1073741824).toFixed(1) + ' G'; return (n / 1073741824).toFixed(1) + ' G';
} }
const api = {shellQuote, shellQuoteAll, dropAction, fmtSize}; // scope gives a module timers, requests and listeners that end with it;
// once ended it starts nothing new (a request's late answer cannot set a
// timer going again).
function scope() {
let ended = false;
const timers = new Set(), offs = [], ac = new AbortController();
return {
timeout(fn, ms) { if (ended) return 0; const id = setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); return id; },
interval(fn, ms) { if (ended) return 0; const id = setInterval(fn, ms); timers.add(id); return id; },
clear(id) { clearTimeout(id); clearInterval(id); timers.delete(id); },
fetch(url, opt) {
const o = Object.assign({credentials: 'same-origin'}, opt || {});
// A request with its own signal ends on either.
o.signal = o.signal && AbortSignal.any ? AbortSignal.any([o.signal, ac.signal]) : (o.signal || ac.signal);
return fetch(url, o);
},
on(target, ev, fn, opt) { target.addEventListener(ev, fn, opt); offs.push(() => target.removeEventListener(ev, fn, opt)); },
signal: ac.signal,
end() { ended = true; timers.forEach(id => { clearTimeout(id); clearInterval(id); }); timers.clear(); offs.forEach(f => f()); ac.abort(); }
};
}
const api = {shellQuote, shellQuoteAll, dropAction, fmtSize, scope};
if (typeof module !== 'undefined') module.exports = api; else root.WSUtil = api; if (typeof module !== 'undefined') module.exports = api; else root.WSUtil = api;
})(this); })(this);
+10
View File
@@ -39,3 +39,13 @@ test('dropAction: other SSH user on the same host is another place', () => {
const b = {kind: 'ssh', id: 'm1|bob@h:22', label: 'bob@h'}; const b = {kind: 'ssh', id: 'm1|bob@h:22', label: 'bob@h'};
assert.strictEqual(U.dropAction({source: a, paths: ['/x'], dir: '/'}, {kind: 'term', source: b}).kind, 'deny'); assert.strictEqual(U.dropAction({source: a, paths: ['/x'], dir: '/'}, {kind: 'term', source: b}).kind, 'deny');
}); });
test('scope: nothing new runs after it ended', async () => {
const s = U.scope();
let n = 0;
s.end();
s.timeout(() => n++, 1);
s.interval(() => n++, 1);
await new Promise(r => setTimeout(r, 20));
assert.strictEqual(n, 0);
});