diff --git a/CHANGELOG.md b/CHANGELOG.md index 3538e5d..4be2606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- File manager: cut/copy clipboard changes made in one mshell instance now + appear in all other running instances immediately, via a native directory + watch (inotify on Linux, kqueue on macOS, `ReadDirectoryChangesW` on + Windows) with a bounded stat-poll fallback. Clipboard writes are now atomic + and serialized with an OS file lock, so concurrent instances can no longer + lose or corrupt clipboard entries. Lock waits time out and clipboard reads + are capped in size and time, so a stuck or hostile process sharing the + clipboard file cannot hang or bloat other instances. + ### Added - Match arms may list several literals in a row, matching if the subject equals diff --git a/doc/mshell.md b/doc/mshell.md index d652eeb..0d9f37a 100644 --- a/doc/mshell.md +++ b/doc/mshell.md @@ -1095,7 +1095,8 @@ end wl # Output: 11 On exit, changes the working directory to the directory the user navigated to. On Windows, pressing `h` at the root of a drive shows the mounted drive letters so you can switch volumes. The preview pane short-circuits common binary extensions and shows first-level contents for `.zip` and `.tar.gz` archives. - Yank bindings copy text about the selected entry to the system clipboard: `yf` (file name), `yp` (full path), `yg` (path relative to the enclosing `.git` directory). `(str -- )` + Yank bindings copy text about the selected entry to the system clipboard: `yf` (file name), `yp` (full path), `yg` (path relative to the enclosing `.git` directory). + The cut/copy clipboard (`d` / `yy` / `p`) is shared across all running mshell instances and propagates between them immediately. `(str -- )` - `clip`: Copy a string to the system clipboard. Cross-platform: uses `pbcopy` on macOS, `clip` on Windows, and the first available of `wl-copy`, `xclip`, or `xsel` on Linux. `(str -- )` - `writeFile`: Write a string (UTF-8) or raw binary data to file. Overwrites file if it exists. `(str|bytes content str|path file -- )` - `appendFile`: Append a string (UTF-8) or raw binary data to file. `(str|bytes content str|path file -- )` diff --git a/mshell/DirWatch.go b/mshell/DirWatch.go new file mode 100644 index 0000000..3398ab1 --- /dev/null +++ b/mshell/DirWatch.go @@ -0,0 +1,157 @@ +package main + +import ( + "time" +) + +// Clipboard change propagation. +// +// Every FileManager instance watches the history directory (where the shared +// fm_clipboard file lives) so cut/copy state from other mshell instances is +// reflected immediately, even while this instance is idle. The platform +// dirWatcher implementations (DirWatch_linux.go, DirWatch_darwin.go, +// DirWatch_windows.go) deliver hint events only; the actual clipboard state +// is always re-read from disk (see refreshClipboard in FileManager.go). +// +// Degradation ladder: +// 1. Platform directory watch (instant updates). +// 2. If the watch cannot be established or dies: stat poll every 3 seconds, +// bounded to 1 minute, then the goroutine goes dormant. +// 3. While dormant, the next keystroke restarts the ladder from step 1 +// (restartClipboardWatchIfDormant), and the stat check at the top of +// every render still keeps the display fresh per keystroke regardless. + +const clipboardWatchDebounce = 50 * time.Millisecond +const clipboardPollInterval = 3 * time.Second +const clipboardPollMaxDuration = time.Minute + +func (fm *FileManager) startClipboardWatch() { + dir, err := GetHistoryDir() + if err != nil { + return + } + fm.watchDir = dir + fm.watchDone = make(chan struct{}) + fm.watchWG.Add(1) + go func() { + defer fm.watchWG.Done() + fm.clipboardWatchLoop() + }() +} + +func (fm *FileManager) stopClipboardWatch() { + if fm.watchDone == nil { + return + } + close(fm.watchDone) + fm.watchWG.Wait() +} + +// restartClipboardWatchIfDormant restarts the watch goroutine after a bounded +// poll window expired. Called on every keystroke; the atomic check makes the +// common case (watch alive) free. Only ever called from the input loop, so it +// cannot race with stopClipboardWatch. +func (fm *FileManager) restartClipboardWatchIfDormant() { + if fm.watchDone == nil || !fm.watchDormant.CompareAndSwap(true, false) { + return + } + fm.watchWG.Add(1) + go func() { + defer fm.watchWG.Done() + fm.clipboardWatchLoop() + }() +} + +func (fm *FileManager) clipboardWatchLoop() { + watcher, err := watchDirectory(fm.watchDir) + if err != nil { + fm.clipboardPollLoop() + return + } + defer watcher.Close() + + // The watch only now exists: anything written before this point (startup, + // or the dormant gap before a restart) produced no event, so reconcile + // with the actual on-disk state once. Every later write is covered by an + // event because the watch was established before this refresh. + fm.applyClipboardChange(false) + + for { + select { + case <-fm.watchDone: + return + case name, ok := <-watcher.Events(): + if !ok { + // The watcher died (e.g. the directory was removed); + // degrade to bounded polling. + fm.clipboardPollLoop() + return + } + // An empty name means the backend does not know which entry + // changed (kqueue, or an event-queue overflow); treat it as + // potentially the clipboard file. + if name != "" && name != clipboardFileName { + continue + } + // Debounce: writers create a temp file and rename it into + // place, which produces short bursts of events. + timer := time.NewTimer(clipboardWatchDebounce) + select { + case <-fm.watchDone: + timer.Stop() + return + case <-timer.C: + } + drainEvents(watcher.Events()) + fm.applyClipboardChange(true) + } + } +} + +// drainEvents empties any queued events without blocking so a burst results +// in a single reload. +func drainEvents(events <-chan string) { + for { + select { + case _, ok := <-events: + if !ok { + return + } + default: + return + } + } +} + +// clipboardPollLoop stat-polls the clipboard for up to a minute, then marks +// the watch dormant and exits. The next keystroke restarts the watch. +func (fm *FileManager) clipboardPollLoop() { + ticker := time.NewTicker(clipboardPollInterval) + defer ticker.Stop() + deadline := time.NewTimer(clipboardPollMaxDuration) + defer deadline.Stop() + for { + select { + case <-fm.watchDone: + return + case <-deadline.C: + fm.watchDormant.Store(true) + return + case <-ticker.C: + fm.applyClipboardChange(false) + } + } +} + +// applyClipboardChange re-reads the shared clipboard and re-renders if it +// changed. force skips the stat comparison (used when the watcher reported an +// event for the clipboard file itself). renderMu keeps this from painting +// over modal prompts or an editor session: handleInput holds the mutex for +// its full duration, so this blocks until input handling finishes. +func (fm *FileManager) applyClipboardChange(force bool) { + fm.renderMu.Lock() + if fm.refreshClipboard(force) { + fm.render() + } + fm.renderMu.Unlock() +} diff --git a/mshell/DirWatch_darwin.go b/mshell/DirWatch_darwin.go new file mode 100644 index 0000000..93c734c --- /dev/null +++ b/mshell/DirWatch_darwin.go @@ -0,0 +1,117 @@ +//go:build darwin + +package main + +import ( + "sync" + + "golang.org/x/sys/unix" +) + +// shutdownIdent identifies the EVFILT_USER event used to wake the read loop +// during Close. +const shutdownIdent = 1 + +// dirWatcher delivers coarse change notifications for a single directory via +// kqueue. Directory-level vnode events fire on entry create/delete/rename but +// carry no entry names, so every event is sent as an empty name and consumers +// must re-check the actual state on disk. +type dirWatcher struct { + events chan string + kq int + dirFd int + done chan struct{} // closed when readLoop exits + closeOnce sync.Once +} + +func watchDirectory(path string) (*dirWatcher, error) { + // O_EVTONLY watches without holding a real open reference, so the watch + // does not prevent the volume from unmounting. + dirFd, err := unix.Open(path, unix.O_EVTONLY|unix.O_CLOEXEC|unix.O_DIRECTORY, 0) + if err != nil { + return nil, err + } + kq, err := unix.Kqueue() + if err != nil { + unix.Close(dirFd) + return nil, err + } + + // Register the directory vnode events plus a user event used only to + // wake the read loop for shutdown. + var dirEvent, userEvent unix.Kevent_t + unix.SetKevent(&dirEvent, dirFd, unix.EVFILT_VNODE, unix.EV_ADD|unix.EV_CLEAR) + dirEvent.Fflags = unix.NOTE_WRITE | unix.NOTE_EXTEND | unix.NOTE_ATTRIB | unix.NOTE_LINK | unix.NOTE_DELETE | unix.NOTE_RENAME + unix.SetKevent(&userEvent, shutdownIdent, unix.EVFILT_USER, unix.EV_ADD|unix.EV_CLEAR) + if _, err := unix.Kevent(kq, []unix.Kevent_t{dirEvent, userEvent}, nil, nil); err != nil { + unix.Close(kq) + unix.Close(dirFd) + return nil, err + } + + w := &dirWatcher{ + events: make(chan string, 16), + kq: kq, + dirFd: dirFd, + done: make(chan struct{}), + } + go w.readLoop() + return w, nil +} + +func (w *dirWatcher) Events() <-chan string { + return w.events +} + +// Close wakes the read loop via the user event, waits for it to exit, and +// only then closes the descriptors, so a blocked Kevent call can never race +// with descriptor reuse. +func (w *dirWatcher) Close() error { + w.closeOnce.Do(func() { + var trigger unix.Kevent_t + unix.SetKevent(&trigger, shutdownIdent, unix.EVFILT_USER, 0) + trigger.Fflags = unix.NOTE_TRIGGER + unix.Kevent(w.kq, []unix.Kevent_t{trigger}, nil, nil) // EBADF etc. only if readLoop already exited + <-w.done + unix.Close(w.kq) + unix.Close(w.dirFd) + }) + return nil +} + +func (w *dirWatcher) readLoop() { + defer close(w.done) + defer close(w.events) + received := make([]unix.Kevent_t, 4) + for { + n, err := unix.Kevent(w.kq, nil, received, nil) + if err == unix.EINTR { + continue + } + if err != nil { + return + } + for i := 0; i < n; i++ { + if received[i].Filter == unix.EVFILT_USER { + return + } + if received[i].Fflags&(unix.NOTE_DELETE|unix.NOTE_RENAME) != 0 { + // The watched directory itself was deleted or renamed; the + // watch is dead. Send a final hint and exit so the consumer + // can fall back to polling. + w.send("") + return + } + w.send("") + } + } +} + +// send never blocks: events are hints and the consumer re-reads the real +// state, so dropping an event behind an already-pending one loses nothing. +func (w *dirWatcher) send(name string) { + select { + case w.events <- name: + default: + } +} diff --git a/mshell/DirWatch_linux.go b/mshell/DirWatch_linux.go new file mode 100644 index 0000000..97da763 --- /dev/null +++ b/mshell/DirWatch_linux.go @@ -0,0 +1,95 @@ +//go:build linux + +package main + +import ( + "bytes" + "os" + "unsafe" + + "golang.org/x/sys/unix" +) + +// dirWatcher delivers coarse change notifications for a single directory via +// inotify. Each event carries the name of the changed entry. Events are hints +// only: the channel drops events when full and a kernel queue overflow is +// reported as an empty name, so consumers must re-read the actual state from +// disk rather than trust the event stream to be complete. +type dirWatcher struct { + events chan string + file *os.File // wraps the inotify descriptor; Close unblocks Read +} + +func watchDirectory(path string) (*dirWatcher, error) { + fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) + if err != nil { + return nil, err + } + mask := uint32(unix.IN_CREATE | unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_MOVED_FROM | unix.IN_DELETE | unix.IN_ONLYDIR) + if _, err := unix.InotifyAddWatch(fd, path, mask); err != nil { + unix.Close(fd) + return nil, err + } + // os.NewFile registers the non-blocking descriptor with the runtime + // poller, so Read blocks cooperatively and Close unblocks a pending Read. + w := &dirWatcher{ + events: make(chan string, 16), + file: os.NewFile(uintptr(fd), "inotify"), + } + go w.readLoop() + return w, nil +} + +func (w *dirWatcher) Events() <-chan string { + return w.events +} + +func (w *dirWatcher) Close() error { + return w.file.Close() +} + +func (w *dirWatcher) readLoop() { + defer close(w.events) + // Room for many events; a single event is at most + // unix.SizeofInotifyEvent + NAME_MAX + 1 bytes. + buf := make([]byte, 4096) + for { + n, err := w.file.Read(buf) + if err != nil { + return // closed or unrecoverable + } + for offset := 0; offset+unix.SizeofInotifyEvent <= n; { + event := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset])) + if event.Mask&unix.IN_IGNORED != 0 { + // The watch itself is gone (directory deleted or moved). + // Exit so the consumer can fall back to polling. + return + } + nameLen := int(event.Len) + if offset+unix.SizeofInotifyEvent+nameLen > n { + // A record claiming to extend past the read is malformed; + // the kernel never splits events, so stop parsing rather + // than slice past the data (which would panic the process). + break + } + name := "" + if nameLen > 0 { + nameBytes := buf[offset+unix.SizeofInotifyEvent : offset+unix.SizeofInotifyEvent+nameLen] + name = string(bytes.TrimRight(nameBytes, "\x00")) + } + // IN_Q_OVERFLOW arrives with no name: the empty name tells the + // consumer events were lost and it must re-check state. + w.send(name) + offset += unix.SizeofInotifyEvent + nameLen + } + } +} + +// send never blocks: events are hints and the consumer re-reads the real +// state, so dropping an event behind an already-pending one loses nothing. +func (w *dirWatcher) send(name string) { + select { + case w.events <- name: + default: + } +} diff --git a/mshell/DirWatch_linux_test.go b/mshell/DirWatch_linux_test.go new file mode 100644 index 0000000..62d8762 --- /dev/null +++ b/mshell/DirWatch_linux_test.go @@ -0,0 +1,84 @@ +//go:build linux + +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// waitForEvent reads events until one matches want ("" accepts any) or the +// timeout expires. +func waitForEvent(t *testing.T, w *dirWatcher, want string) bool { + t.Helper() + deadline := time.After(2 * time.Second) + for { + select { + case name, ok := <-w.Events(): + if !ok { + t.Fatal("event channel closed unexpectedly") + } + if want == "" || name == want { + return true + } + case <-deadline: + return false + } + } +} + +func TestWatchDirectoryRename(t *testing.T) { + dir := t.TempDir() + w, err := watchDirectory(dir) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + // Mimic the atomic clipboard write: temp file, then rename into place. + tmp := filepath.Join(dir, "fm_clipboard.tmp-1") + if err := os.WriteFile(tmp, []byte("cut\t/a\n"), 0644); err != nil { + t.Fatal(err) + } + final := filepath.Join(dir, "fm_clipboard") + if err := os.Rename(tmp, final); err != nil { + t.Fatal(err) + } + + if !waitForEvent(t, w, "fm_clipboard") { + t.Fatal("no event for renamed clipboard file") + } + + // Deletion is also observed. + if err := os.Remove(final); err != nil { + t.Fatal(err) + } + if !waitForEvent(t, w, "fm_clipboard") { + t.Fatal("no event for deleted clipboard file") + } +} + +func TestWatchDirectoryClose(t *testing.T) { + dir := t.TempDir() + w, err := watchDirectory(dir) + if err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + select { + case _, ok := <-w.Events(): + if ok { + // A buffered event from setup is fine; the channel must still + // close afterwards. + if _, ok := <-w.Events(); ok { + t.Fatal("event channel should close after Close") + } + } + case <-time.After(2 * time.Second): + t.Fatal("event channel did not close after Close") + } +} diff --git a/mshell/DirWatch_windows.go b/mshell/DirWatch_windows.go new file mode 100644 index 0000000..ade4cd0 --- /dev/null +++ b/mshell/DirWatch_windows.go @@ -0,0 +1,176 @@ +//go:build windows + +package main + +import ( + "encoding/binary" + "sync" + "unicode/utf16" + + "golang.org/x/sys/windows" +) + +// dirWatcher delivers change notifications for a single directory via +// overlapped ReadDirectoryChangesW. Each event carries the name of the +// changed entry; a kernel buffer overflow (lost events) is reported as an +// empty name, so consumers must re-read the actual state rather than trust +// the event stream to be complete. +type dirWatcher struct { + events chan string + handle windows.Handle + olEvent windows.Handle // auto-reset, signaled when an overlapped read completes + stopEvent windows.Handle // auto-reset, signaled by Close to stop the read loop + done chan struct{} // closed when readLoop exits + closeOnce sync.Once +} + +func watchDirectory(path string) (*dirWatcher, error) { + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile( + pathPtr, + windows.FILE_LIST_DIRECTORY, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OVERLAPPED, + 0, + ) + if err != nil { + return nil, err + } + olEvent, err := windows.CreateEvent(nil, 0, 0, nil) + if err != nil { + windows.CloseHandle(handle) + return nil, err + } + stopEvent, err := windows.CreateEvent(nil, 0, 0, nil) + if err != nil { + windows.CloseHandle(olEvent) + windows.CloseHandle(handle) + return nil, err + } + + w := &dirWatcher{ + events: make(chan string, 16), + handle: handle, + olEvent: olEvent, + stopEvent: stopEvent, + done: make(chan struct{}), + } + go w.readLoop() + return w, nil +} + +func (w *dirWatcher) Events() <-chan string { + return w.events +} + +// Close signals the read loop, waits for it to exit, and only then closes the +// handles, so an outstanding overlapped read can never race with handle reuse. +func (w *dirWatcher) Close() error { + w.closeOnce.Do(func() { + windows.SetEvent(w.stopEvent) + <-w.done + windows.CloseHandle(w.handle) + windows.CloseHandle(w.olEvent) + windows.CloseHandle(w.stopEvent) + }) + return nil +} + +func (w *dirWatcher) readLoop() { + defer close(w.done) + defer close(w.events) + // The kernel writes FILE_NOTIFY_INFORMATION records into this buffer + // while a read is outstanding, so it must stay allocated for the life of + // the loop. + buf := make([]byte, 8192) + filter := uint32(windows.FILE_NOTIFY_CHANGE_FILE_NAME | windows.FILE_NOTIFY_CHANGE_LAST_WRITE | windows.FILE_NOTIFY_CHANGE_SIZE) + for { + var ol windows.Overlapped + ol.HEvent = w.olEvent + err := windows.ReadDirectoryChanges(w.handle, &buf[0], uint32(len(buf)), false, filter, nil, &ol, 0) + if err != nil && err != windows.ERROR_IO_PENDING { + return + } + + which, err := windows.WaitForMultipleObjects([]windows.Handle{w.stopEvent, w.olEvent}, false, windows.INFINITE) + if err != nil { + windows.CancelIoEx(w.handle, &ol) + return + } + if which == windows.WAIT_OBJECT_0 { + // Stop requested. Cancel the outstanding read and wait for it to + // complete so the kernel is no longer writing into buf. + windows.CancelIoEx(w.handle, &ol) + var n uint32 + windows.GetOverlappedResult(w.handle, &ol, &n, true) + return + } + + var n uint32 + if err := windows.GetOverlappedResult(w.handle, &ol, &n, false); err != nil { + if err == windows.ERROR_NOTIFY_ENUM_DIR { + // Kernel buffer overflowed: events were lost; tell the + // consumer to re-check state. + w.send("") + continue + } + return + } + if n == 0 { + // Overflow can also surface as a successful zero-length read. + w.send("") + continue + } + w.parseAndSend(buf[:n]) + } +} + +// parseAndSend walks the packed FILE_NOTIFY_INFORMATION records: +// NextEntryOffset uint32, Action uint32, FileNameLength uint32 (bytes), +// FileName []uint16 (not NUL-terminated). +func (w *dirWatcher) parseAndSend(buf []byte) { + // All arithmetic is in int (64-bit) so a malformed length or chain + // offset can never wrap and loop forever or slice out of range. + offset := 0 + for { + if offset+12 > len(buf) { + return + } + next := int(binary.LittleEndian.Uint32(buf[offset:])) + nameLen := int(binary.LittleEndian.Uint32(buf[offset+8:])) + nameStart := offset + 12 + nameEnd := nameStart + nameLen + if nameEnd > len(buf) { + return + } + nameBytes := buf[nameStart:nameEnd] + codeUnits := make([]uint16, nameLen/2) + for i := range codeUnits { + codeUnits[i] = binary.LittleEndian.Uint16(nameBytes[i*2:]) + } + w.send(string(utf16.Decode(codeUnits))) + if next == 0 { + return + } + if next < 12 || offset+next > len(buf) { + // A chain offset that is too small or points past the buffer is + // malformed; stop parsing rather than spin on it. + return + } + offset += next + } +} + +// send never blocks: events are hints and the consumer re-reads the real +// state, so dropping an event behind an already-pending one loses nothing. +func (w *dirWatcher) send(name string) { + select { + case w.events <- name: + default: + } +} diff --git a/mshell/FileLock_unix.go b/mshell/FileLock_unix.go new file mode 100644 index 0000000..c85968e --- /dev/null +++ b/mshell/FileLock_unix.go @@ -0,0 +1,58 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/sys/unix" +) + +const fileLockRetryInterval = 50 * time.Millisecond + +// openNonBlock is ORed into opens of the shared clipboard file so that a FIFO +// planted at that path cannot block open(2) forever waiting for a writer. It +// has no effect on regular files. +const openNonBlock = unix.O_NONBLOCK + +// fileLock is an exclusive advisory lock backed by flock(2). The lock is tied +// to the open file description, so the OS releases it automatically if the +// process dies while holding it; no stale-lock cleanup is ever needed. +type fileLock struct { + f *os.File +} + +// acquireFileLock acquires an exclusive lock on path, creating the lock file +// if needed. The wait is bounded: it retries a non-blocking lock until +// timeout, then fails, so a stuck or hostile lock holder can never hang the +// caller indefinitely. +func acquireFileLock(path string, timeout time.Duration) (*fileLock, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return nil, err + } + deadline := time.Now().Add(timeout) + for { + err = unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if err == nil { + return &fileLock{f: f}, nil + } + if err != unix.EWOULDBLOCK && err != unix.EAGAIN && err != unix.EINTR { + f.Close() + return nil, err + } + if time.Now().After(deadline) { + f.Close() + return nil, fmt.Errorf("timed out waiting for lock on %s", filepath.Base(path)) + } + time.Sleep(fileLockRetryInterval) + } +} + +func (l *fileLock) release() { + // Closing the descriptor releases the flock. + l.f.Close() +} diff --git a/mshell/FileLock_windows.go b/mshell/FileLock_windows.go new file mode 100644 index 0000000..1474b53 --- /dev/null +++ b/mshell/FileLock_windows.go @@ -0,0 +1,63 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/sys/windows" +) + +const fileLockRetryInterval = 50 * time.Millisecond + +// openNonBlock mirrors the unix constant ORed into clipboard opens. Windows +// has no FIFO-in-the-filesystem equivalent whose open blocks, so it is a +// no-op here. +const openNonBlock = 0 + +// fileLock is an exclusive lock backed by LockFileEx. The lock is tied to the +// open handle, so the OS releases it automatically if the process dies while +// holding it; no stale-lock cleanup is ever needed. +type fileLock struct { + f *os.File +} + +// acquireFileLock acquires an exclusive lock on path, creating the lock file +// if needed. The wait is bounded: it retries a non-blocking lock until +// timeout, then fails, so a stuck or hostile lock holder can never hang the +// caller indefinitely. +func acquireFileLock(path string, timeout time.Duration) (*fileLock, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return nil, err + } + deadline := time.Now().Add(timeout) + for { + // Lock one byte at offset 0 (the offset lives in the Overlapped + // struct). LOCKFILE_FAIL_IMMEDIATELY keeps each attempt non-blocking. + ol := new(windows.Overlapped) + err = windows.LockFileEx(windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, ol) + if err == nil { + return &fileLock{f: f}, nil + } + if err != windows.ERROR_LOCK_VIOLATION { + f.Close() + return nil, err + } + if time.Now().After(deadline) { + f.Close() + return nil, fmt.Errorf("timed out waiting for lock on %s", filepath.Base(path)) + } + time.Sleep(fileLockRetryInterval) + } +} + +func (l *fileLock) release() { + ol := new(windows.Overlapped) + windows.UnlockFileEx(windows.Handle(l.f.Fd()), 0, 1, 0, ol) + l.f.Close() +} diff --git a/mshell/FileManager.go b/mshell/FileManager.go index 06b2866..e145fbd 100644 --- a/mshell/FileManager.go +++ b/mshell/FileManager.go @@ -18,6 +18,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "unicode/utf8" @@ -111,8 +112,25 @@ type FileManager struct { // Status message (shown once at bottom, cleared on first keypress) statusMsg string + // Clipboard cache. The shared on-disk clipboard file is re-read only + // when its mtime/size change (see refreshClipboard). + clipCut []string + clipCopy []string + clipMod time.Time + clipSize int64 + clipLoaded bool + + // Clipboard watch state (see DirWatch.go). + watchDir string + watchDone chan struct{} + watchWG sync.WaitGroup + watchDormant atomic.Bool + // renderMu serializes render() calls between the main loop and the - // preview receiver goroutine. + // goroutines that render asynchronously (preview receiver, clipboard + // watcher). handleInput holds it for its full duration — including + // modal prompts and editor sessions — which is what keeps those + // goroutines from painting over an overlay or an external editor. renderMu sync.Mutex } @@ -394,6 +412,8 @@ func (fm *FileManager) schedulePreview() { func (fm *FileManager) mainLoop() { fm.startPreviewLoop() defer fm.stopPreviewLoop() + fm.startClipboardWatch() + defer fm.stopClipboardWatch() for { fm.renderMu.Lock() @@ -409,6 +429,7 @@ func (fm *FileManager) mainLoop() { if fm.handleInput(buf, n) { return } + fm.restartClipboardWatchIfDormant() } } @@ -536,12 +557,11 @@ func (fm *FileManager) adjustScroll() { } func (fm *FileManager) leftPaneWidth() int { - cutPaths, copyPaths := loadClipboard() clipSet := make(map[string]bool) - for _, p := range cutPaths { + for _, p := range fm.clipCut { clipSet[p] = true } - for _, p := range copyPaths { + for _, p := range fm.clipCopy { clipSet[p] = true } @@ -643,14 +663,16 @@ func (fm *FileManager) render() { // Move cursor to top-left, clear screen buf.WriteString("\033[H\033[2J") + // Pick up shared clipboard changes (cheap stat unless the file changed). + fm.refreshClipboard(false) + cutPaths, copyPaths := fm.clipCut, fm.clipCopy + leftW := fm.leftPaneWidth() rightW := fm.cols - leftW - 3 // 3 for " │ " separator if rightW < 0 { rightW = 0 } - // Load clipboard for display - cutPaths, copyPaths := loadClipboard() cutSet := make(map[string]bool) copySet := make(map[string]bool) for _, p := range cutPaths { @@ -1511,7 +1533,9 @@ func (fm *FileManager) handleInputEvent(buf []byte) (int, bool) { fm.clipboardPaste() } case 'c': - clearClipboard() + if err := clearClipboard(); err != nil { + fm.statusMsg = fmt.Sprintf("Clipboard: %s", err.Error()) + } case 'x': if fm.requireRealDirectory() { fm.deleteEntry() @@ -2040,7 +2064,9 @@ func (fm *FileManager) clipboardCut() { return } absPath := filepath.Join(fm.currentDir, fm.entries[fm.cursor].Name()) - _ = addClipboardPath("cut", absPath) + if err := addClipboardPath("cut", absPath); err != nil { + fm.statusMsg = fmt.Sprintf("Clipboard: %s", err.Error()) + } } func (fm *FileManager) clipboardCopy() { @@ -2048,7 +2074,9 @@ func (fm *FileManager) clipboardCopy() { return } absPath := filepath.Join(fm.currentDir, fm.entries[fm.cursor].Name()) - _ = addClipboardPath("copy", absPath) + if err := addClipboardPath("copy", absPath); err != nil { + fm.statusMsg = fmt.Sprintf("Clipboard: %s", err.Error()) + } } type yankKind int @@ -2113,32 +2141,54 @@ func gitRelativePath(absPath string) (string, error) { } func (fm *FileManager) clipboardPaste() { + // Snapshot without the lock: reads are safe against atomic writers, and + // the paste itself may prompt the user or copy large files, which must + // never happen while holding the clipboard lock. cutPaths, copyPaths := loadClipboard() if len(cutPaths) == 0 && len(copyPaths) == 0 { return } - if !fm.pasteClipboardPaths("cut", cutPaths) { - return - } - if !fm.pasteClipboardPaths("copy", copyPaths) { - return + handled, ok := fm.pasteClipboardPaths("cut", cutPaths) + if ok { + fm.pasteClipboardPaths("copy", copyPaths) } - if len(cutPaths) > 0 { - _ = saveClipboard(nil, copyPaths) + // Remove the cut entries that were actually handled, merging against the + // current clipboard state: another instance may have added entries while + // this paste was running, and those must survive. + if len(handled) > 0 { + err := withClipboardLock(func() error { + curCut, curCopy, ok := readClipboard() + if !ok { + return fmt.Errorf("clipboard read failed or timed out; cut entries left in place") + } + for _, p := range handled { + curCut = removePath(curCut, p) + } + return saveClipboard(curCut, curCopy) + }) + if err != nil { + fm.statusMsg = fmt.Sprintf("Clipboard: %s", err.Error()) + } } + fm.loadDirectory() fm.clampCursor() fm.adjustScroll() } -func (fm *FileManager) pasteClipboardPaths(op string, paths []string) bool { +// pasteClipboardPaths pastes paths into the current directory. For "cut" it +// returns the source paths that no longer need a clipboard entry (moved, or +// already in place); the bool reports whether the whole batch completed. +func (fm *FileManager) pasteClipboardPaths(op string, paths []string) ([]string, bool) { + var handled []string for _, src := range paths { base := filepath.Base(src) dest := filepath.Join(fm.currentDir, base) if src == dest { if op == "cut" { + handled = append(handled, src) continue } // Copy to same directory: go straight to versioned name @@ -2148,7 +2198,7 @@ func (fm *FileManager) pasteClipboardPaths(op string, paths []string) bool { choice := fm.promptConflict(base) if choice == 0 { // Cancel - return false + return handled, false } if choice == 2 { // Version number @@ -2159,15 +2209,16 @@ func (fm *FileManager) pasteClipboardPaths(op string, paths []string) bool { if op == "cut" { if err := os.Rename(src, dest); err != nil { - return false + return handled, false } + handled = append(handled, src) } else { if err := CopyFile(src, dest); err != nil { - return false + return handled, false } } } - return true + return handled, true } // promptConflict shows an overlay asking the user how to handle a name conflict. @@ -2386,8 +2437,28 @@ func isBookmarkChar(c byte) bool { } // Clipboard +// +// The clipboard file is shared state between concurrently running mshell +// instances, so it is handled with three rules: +// - Loads are read-only and tolerant: malformed lines are skipped, never +// "repaired" back to disk. +// - Writes are atomic: content goes to a temp file in the same directory, +// which is then renamed over the real file, so a reader can never observe +// a torn write. +// - Read-modify-write sequences run under an exclusive OS file lock +// (withClipboardLock), so concurrent mutations from two instances cannot +// lose entries. +// - Everything is bounded: the lock wait times out, reads time out (and +// refuse non-regular files, so a planted FIFO cannot block forever), and +// loads cap both bytes read and entries parsed. A hostile or runaway +// process sharing the file can never hang an instance or force unbounded +// work. const clipboardFileName = "fm_clipboard" +const clipboardLockFileName = "fm_clipboard.lock" +const clipboardLockTimeout = 2 * time.Second +const maxClipboardBytes = 1 << 20 // 1 MiB +const maxClipboardEntries = 1024 func clipboardFilePath() (string, error) { dir, err := GetHistoryDir() @@ -2397,62 +2468,177 @@ func clipboardFilePath() (string, error) { return filepath.Join(dir, clipboardFileName), nil } +// withClipboardLock runs fn while holding the exclusive clipboard lock. +// Callers must not hold the lock across user prompts or slow file copies — +// only across the load/modify/save itself — or they block every other +// instance's cut/copy keystrokes. +func withClipboardLock(fn func() error) error { + dir, err := GetHistoryDir() + if err != nil { + return err + } + lock, err := acquireFileLock(filepath.Join(dir, clipboardLockFileName), clipboardLockTimeout) + if err != nil { + return err + } + defer lock.release() + return fn() +} + +// loadClipboard reads the shared clipboard for display purposes. Any failure +// — unreadable file, timed-out read — degrades to an empty clipboard. Callers +// that read-modify-write must use readClipboard and honor ok instead. func loadClipboard() ([]string, []string) { + cutPaths, copyPaths, _ := readClipboard() + return cutPaths, copyPaths +} + +// clipboardReadGate makes clipboard reads single-flight. A read hung on a +// pathological filesystem (dead network mount, wedged FUSE daemon) cannot be +// cancelled, only abandoned — the gate keeps each render from stacking +// another blocked goroutine on top, bounding hung readers to one per process. +var clipboardReadGate = make(chan struct{}, 1) + +const clipboardReadTimeout = 2 * time.Second + +// readClipboard reads the shared clipboard with a bounded wait: the file read +// runs in a goroutine and is abandoned after clipboardReadTimeout. ok is +// false when the read failed or timed out. Read-modify-write callers MUST +// abort on !ok rather than proceed — treating a transient read failure as an +// empty clipboard would save emptiness over other instances' entries. +func readClipboard() (cutPaths []string, copyPaths []string, ok bool) { + select { + case clipboardReadGate <- struct{}{}: + default: + // A previous read is still hung; fail fast instead of joining it. + return nil, nil, false + } + + type clipResult struct { + cut, copy []string + ok bool + } + resCh := make(chan clipResult, 1) + go func() { + defer func() { <-clipboardReadGate }() + cut, copy, ok := readClipboardFile() + resCh <- clipResult{cut, copy, ok} + }() + + timer := time.NewTimer(clipboardReadTimeout) + defer timer.Stop() + select { + case r := <-resCh: + return r.cut, r.copy, r.ok + case <-timer.C: + return nil, nil, false + } +} + +// readClipboardFile reads and parses the clipboard file. It never writes: +// with atomic saves in place, an unparseable line can only be an old format +// or a hand-edit, and destroying other instances' state over that is worse +// than ignoring it. ok is false only when the file exists but could not be +// read; a missing file is a normal empty clipboard. +func readClipboardFile() ([]string, []string, bool) { path, err := clipboardFilePath() if err != nil { - return nil, nil + return nil, nil, false } - data, err := os.ReadFile(path) + // Non-blocking open: a FIFO planted at this path would otherwise block + // open(2) forever waiting for a writer. + f, err := os.OpenFile(path, os.O_RDONLY|openNonBlock, 0) if err != nil { - return nil, nil + if os.IsNotExist(err) { + return nil, nil, true + } + return nil, nil, false } - trimmed := strings.TrimRight(string(data), "\n") - if trimmed == "" { - return nil, nil + defer f.Close() + info, err := f.Stat() + if err != nil { + return nil, nil, false + } + if !info.Mode().IsRegular() { + // Reading a FIFO or device node could block forever. Treat it as an + // empty clipboard: the next save atomically renames a regular file + // over it, healing the path. + return nil, nil, true + } + // Bounded read: any process can write this file, so a huge one must + // degrade to a partial load, never an unbounded allocation. Reading one + // byte past the cap detects truncation, and the possibly mid-line tail is + // dropped so a partial path can never be mistaken for a real entry. + data, err := io.ReadAll(io.LimitReader(f, maxClipboardBytes+1)) + if err != nil { + return nil, nil, false } - lines := strings.Split(trimmed, "\n") - if len(lines) == 0 { - return nil, nil + if len(data) > maxClipboardBytes { + if i := bytes.LastIndexByte(data[:maxClipboardBytes], '\n'); i >= 0 { + data = data[:i] + } else { + data = nil + } } var cutPaths []string var copyPaths []string - dirty := false - for _, line := range lines { - if line == "" { - dirty = true - continue + for _, line := range strings.Split(string(data), "\n") { + if len(cutPaths)+len(copyPaths) >= maxClipboardEntries { + break } parts := strings.SplitN(line, "\t", 2) - if len(parts) != 2 { - dirty = true + if len(parts) != 2 || parts[1] == "" { continue } op := parts[0] path := parts[1] - if path == "" { - dirty = true - continue - } if op == "cut" { copyPaths = removePath(copyPaths, path) cutPaths = appendUniquePath(cutPaths, path) } else if op == "copy" { cutPaths = removePath(cutPaths, path) copyPaths = appendUniquePath(copyPaths, path) - } else { - dirty = true } } - if dirty { - _ = saveClipboard(cutPaths, copyPaths) + return cutPaths, copyPaths, true +} + +// refreshClipboard reloads the clipboard cache if the file changed on disk +// since the last load; force skips the stat comparison. Returns true if the +// cache was reloaded. Callers must hold renderMu. +func (fm *FileManager) refreshClipboard(force bool) bool { + path, err := clipboardFilePath() + if err != nil { + return false } - return cutPaths, copyPaths + var mod time.Time + var size int64 + if info, err := os.Stat(path); err == nil { + mod = info.ModTime() + size = info.Size() + } + if !force && fm.clipLoaded && mod.Equal(fm.clipMod) && size == fm.clipSize { + return false + } + cutPaths, copyPaths, ok := readClipboard() + if !ok { + // Keep the stale cache. clipMod/clipSize are left untouched, so the + // next call re-detects the mismatch and retries the read. + return false + } + fm.clipCut, fm.clipCopy = cutPaths, copyPaths + fm.clipMod = mod + fm.clipSize = size + fm.clipLoaded = true + return true } +// saveClipboard atomically replaces the clipboard file. Callers must hold the +// clipboard lock (withClipboardLock). func saveClipboard(cutPaths []string, copyPaths []string) error { if len(cutPaths) == 0 && len(copyPaths) == 0 { - return clearClipboard() + return removeClipboardFile() } path, err := clipboardFilePath() if err != nil { @@ -2469,15 +2655,73 @@ func saveClipboard(cutPaths []string, copyPaths []string) error { sb.WriteString(p) sb.WriteByte('\n') } - return os.WriteFile(path, []byte(sb.String()), 0644) + + sweepStaleClipboardTemps(filepath.Dir(path)) + + tmp, err := os.CreateTemp(filepath.Dir(path), clipboardFileName+".tmp-*") + if err != nil { + return err + } + if _, err := tmp.WriteString(sb.String()); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return err + } + // Chmod through the descriptor, not the name, so the mode can only ever + // land on the file this process created. + if err := tmp.Chmod(0644); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return err + } + if err := os.Rename(tmp.Name(), path); err != nil { + os.Remove(tmp.Name()) + return err + } + return nil } -func clearClipboard() error { +// sweepStaleClipboardTemps removes fm_clipboard.tmp-* files orphaned by a +// process that died between CreateTemp and Rename, so they cannot accumulate +// forever. Only files older than a minute are touched: a live writer holds +// its temp file for milliseconds, and the caller holds the clipboard lock, so +// anything that old is guaranteed abandoned. Best-effort: errors are ignored. +func sweepStaleClipboardTemps(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + cutoff := time.Now().Add(-time.Minute) + for _, e := range entries { + if !strings.HasPrefix(e.Name(), clipboardFileName+".tmp-") { + continue + } + info, err := e.Info() + if err != nil || info.ModTime().After(cutoff) { + continue + } + os.Remove(filepath.Join(dir, e.Name())) + } +} + +func removeClipboardFile() error { path, err := clipboardFilePath() if err != nil { return err } - return os.Remove(path) + err = os.Remove(path) + if err != nil && os.IsNotExist(err) { + return nil + } + return err +} + +func clearClipboard() error { + return withClipboardLock(removeClipboardFile) } func appendUniquePath(paths []string, path string) []string { @@ -2497,15 +2741,20 @@ func removePath(paths []string, path string) []string { } func addClipboardPath(op string, path string) error { - cutPaths, copyPaths := loadClipboard() - if op == "cut" { - copyPaths = removePath(copyPaths, path) - cutPaths = appendUniquePath(cutPaths, path) - } else if op == "copy" { - cutPaths = removePath(cutPaths, path) - copyPaths = appendUniquePath(copyPaths, path) - } - return saveClipboard(cutPaths, copyPaths) + return withClipboardLock(func() error { + cutPaths, copyPaths, ok := readClipboard() + if !ok { + return fmt.Errorf("clipboard read failed or timed out; not saving") + } + if op == "cut" { + copyPaths = removePath(copyPaths, path) + cutPaths = appendUniquePath(cutPaths, path) + } else if op == "copy" { + cutPaths = removePath(cutPaths, path) + copyPaths = appendUniquePath(copyPaths, path) + } + return saveClipboard(cutPaths, copyPaths) + }) } func saveBookmarks(bookmarks map[byte]string) error { diff --git a/mshell/FileManagerClipboard_test.go b/mshell/FileManagerClipboard_test.go new file mode 100644 index 0000000..fe5dfba --- /dev/null +++ b/mshell/FileManagerClipboard_test.go @@ -0,0 +1,393 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +// useTempHistoryDir points GetHistoryDir at a per-test temp directory so +// clipboard tests never touch the user's real shared clipboard. +func useTempHistoryDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if runtime.GOOS == "windows" { + t.Setenv("LOCALAPPDATA", dir) + } else { + t.Setenv("XDG_DATA_HOME", dir) + } + got, err := GetHistoryDir() + if err != nil { + t.Fatalf("GetHistoryDir: %v", err) + } + if !strings.HasPrefix(got, dir) { + t.Fatalf("GetHistoryDir %q not under temp dir %q; aborting to protect real data", got, dir) + } + return got +} + +func TestClipboardConcurrentAdds(t *testing.T) { + useTempHistoryDir(t) + + const n = 30 + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + op := "copy" + if i%2 == 0 { + op = "cut" + } + if err := addClipboardPath(op, fmt.Sprintf("/some/path/file%d", i)); err != nil { + t.Errorf("addClipboardPath %d: %v", i, err) + } + }(i) + } + wg.Wait() + + cutPaths, copyPaths := loadClipboard() + if len(cutPaths)+len(copyPaths) != n { + t.Fatalf("expected %d clipboard entries after concurrent adds, got %d cut + %d copy", + n, len(cutPaths), len(copyPaths)) + } +} + +func TestClipboardLoadIsReadOnly(t *testing.T) { + useTempHistoryDir(t) + path, err := clipboardFilePath() + if err != nil { + t.Fatal(err) + } + + // Valid entries mixed with malformed lines (no tab, empty path, unknown + // op, blank line). + content := "cut\t/a/b\nbogus line\ncopy\t\nteleport\t/x\n\ncopy\t/c/d\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cutPaths, copyPaths := loadClipboard() + if len(cutPaths) != 1 || cutPaths[0] != "/a/b" { + t.Errorf("cut paths = %v, want [/a/b]", cutPaths) + } + if len(copyPaths) != 1 || copyPaths[0] != "/c/d" { + t.Errorf("copy paths = %v, want [/c/d]", copyPaths) + } + + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != content { + t.Errorf("loadClipboard modified the file: %q -> %q", content, string(after)) + } +} + +func TestClipboardSaveRoundtripAndClear(t *testing.T) { + useTempHistoryDir(t) + + err := withClipboardLock(func() error { + return saveClipboard([]string{"/one", "/two"}, []string{"/three"}) + }) + if err != nil { + t.Fatal(err) + } + cutPaths, copyPaths := loadClipboard() + if len(cutPaths) != 2 || len(copyPaths) != 1 { + t.Fatalf("roundtrip mismatch: cut %v copy %v", cutPaths, copyPaths) + } + + // No stray temp files left behind. + path, _ := clipboardFilePath() + entries, err := os.ReadDir(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.Contains(e.Name(), ".tmp-") { + t.Errorf("leftover temp file %q", e.Name()) + } + } + + // Saving empty removes the file; clearing an already-clear clipboard + // is not an error. + if err := withClipboardLock(func() error { return saveClipboard(nil, nil) }); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("clipboard file should be removed after empty save") + } + if err := clearClipboard(); err != nil { + t.Errorf("clearClipboard on missing file: %v", err) + } +} + +func TestRefreshClipboard(t *testing.T) { + useTempHistoryDir(t) + fm := &FileManager{} + + if !fm.refreshClipboard(false) { + t.Fatal("first refresh should load") + } + if len(fm.clipCut) != 0 || len(fm.clipCopy) != 0 { + t.Fatalf("expected empty clipboard, got cut %v copy %v", fm.clipCut, fm.clipCopy) + } + if fm.refreshClipboard(false) { + t.Error("refresh with no change should not reload") + } + + if err := addClipboardPath("cut", "/from/elsewhere"); err != nil { + t.Fatal(err) + } + if !fm.refreshClipboard(false) { + t.Fatal("refresh should detect the new clipboard file") + } + if len(fm.clipCut) != 1 || fm.clipCut[0] != "/from/elsewhere" { + t.Fatalf("cut cache = %v, want [/from/elsewhere]", fm.clipCut) + } + + if !fm.refreshClipboard(true) { + t.Error("forced refresh should always reload") + } +} + +// TestClipboardWatchPropagates checks the full push pipeline: a mutation by +// another instance reaches an idle FileManager (no keystrokes) through the +// directory watch — via an event, or via the one-time reconcile that runs +// right after the watch is established (which covers writes racing startup). +func TestClipboardWatchPropagates(t *testing.T) { + useTempHistoryDir(t) + + devnull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer devnull.Close() + + fm := &FileManager{rows: 24, cols: 80, ttyOut: devnull} + fm.startClipboardWatch() + defer fm.stopClipboardWatch() + + // Initial load, as the first render would do. + fm.renderMu.Lock() + fm.refreshClipboard(false) + fm.renderMu.Unlock() + + // Another instance cuts a file. + if err := addClipboardPath("cut", "/other/instance/file"); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + fm.renderMu.Lock() + got := len(fm.clipCut) + fm.renderMu.Unlock() + if got == 1 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("clipboard change never propagated to the idle instance") +} + +func TestClipboardPasteMergesConcurrentEntries(t *testing.T) { + useTempHistoryDir(t) + + srcDir := t.TempDir() + destDir := t.TempDir() + srcFile := filepath.Join(srcDir, "moveme.txt") + if err := os.WriteFile(srcFile, []byte("payload"), 0644); err != nil { + t.Fatal(err) + } + + if err := addClipboardPath("cut", srcFile); err != nil { + t.Fatal(err) + } + // Simulate another instance adding an entry before this one pastes. + if err := addClipboardPath("copy", "/other/instance/entry"); err != nil { + t.Fatal(err) + } + + fm := &FileManager{currentDir: destDir} + fm.clipboardPaste() + + if _, err := os.Stat(filepath.Join(destDir, "moveme.txt")); err != nil { + t.Errorf("pasted file missing: %v", err) + } + if _, err := os.Stat(srcFile); !os.IsNotExist(err) { + t.Errorf("cut source should be gone after paste") + } + + cutPaths, copyPaths := loadClipboard() + if len(cutPaths) != 0 { + t.Errorf("cut entries should be consumed by paste, got %v", cutPaths) + } + if len(copyPaths) != 1 || copyPaths[0] != "/other/instance/entry" { + t.Errorf("concurrent copy entry lost: %v", copyPaths) + } +} + +// TestClipboardLockWaitIsBounded holds the clipboard lock and checks that a +// second acquire fails after its timeout instead of hanging: a stuck or +// hostile lock holder must never freeze another instance's keystroke. +func TestClipboardLockWaitIsBounded(t *testing.T) { + useTempHistoryDir(t) + dir, err := GetHistoryDir() + if err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(dir, clipboardLockFileName) + + held, err := acquireFileLock(lockPath, time.Second) + if err != nil { + t.Fatal(err) + } + defer held.release() + + start := time.Now() + if _, err := acquireFileLock(lockPath, 150*time.Millisecond); err == nil { + t.Fatal("second acquire should time out while the lock is held") + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("timed-out acquire took %v; the wait is not bounded", elapsed) + } +} + +// TestClipboardLoadEntryCap writes more entries than the cap and checks the +// load degrades to exactly the cap instead of doing unbounded work. +func TestClipboardLoadEntryCap(t *testing.T) { + useTempHistoryDir(t) + path, err := clipboardFilePath() + if err != nil { + t.Fatal(err) + } + + var sb strings.Builder + for i := 0; i < maxClipboardEntries+100; i++ { + fmt.Fprintf(&sb, "copy\t/bulk/file%05d\n", i) + } + if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil { + t.Fatal(err) + } + + cutPaths, copyPaths := loadClipboard() + if got := len(cutPaths) + len(copyPaths); got != maxClipboardEntries { + t.Fatalf("loaded %d entries, want cap of %d", got, maxClipboardEntries) + } +} + +// TestClipboardLoadByteCap writes a file past the byte cap (using paths long +// enough that the entry cap is not hit first) and checks that the load is +// partial rather than empty, and that the line straddling the cap is dropped +// whole — a truncated fragment must never surface as a real path. +func TestClipboardLoadByteCap(t *testing.T) { + useTempHistoryDir(t) + path, err := clipboardFilePath() + if err != nil { + t.Fatal(err) + } + + longSeg := strings.Repeat("x", 4096) + written := make(map[string]bool) + var sb strings.Builder + for i := 0; sb.Len() <= maxClipboardBytes+8192; i++ { + p := fmt.Sprintf("/bulk/%s/%05d", longSeg, i) + written[p] = true + sb.WriteString("copy\t" + p + "\n") + } + if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil { + t.Fatal(err) + } + + _, copyPaths := loadClipboard() + if len(copyPaths) == 0 { + t.Fatal("byte cap should degrade to a partial load, not an empty one") + } + if len(copyPaths) >= len(written) { + t.Fatalf("loaded all %d entries; the byte cap did not bound the read", len(written)) + } + for _, p := range copyPaths { + if !written[p] { + t.Fatalf("loaded a path that was never written (truncated fragment?): %.80q", p) + } + } +} + +// TestClipboardSaveSweepsStaleTemps checks that a save removes temp files +// orphaned by a crashed writer (old mtime) while leaving recent ones alone, +// so orphans cannot accumulate forever. +func TestClipboardSaveSweepsStaleTemps(t *testing.T) { + useTempHistoryDir(t) + path, err := clipboardFilePath() + if err != nil { + t.Fatal(err) + } + dir := filepath.Dir(path) + + stale := filepath.Join(dir, clipboardFileName+".tmp-stale") + fresh := filepath.Join(dir, clipboardFileName+".tmp-fresh") + for _, p := range []string{stale, fresh} { + if err := os.WriteFile(p, []byte("orphan"), 0644); err != nil { + t.Fatal(err) + } + } + old := time.Now().Add(-2 * time.Minute) + if err := os.Chtimes(stale, old, old); err != nil { + t.Fatal(err) + } + + err = withClipboardLock(func() error { + return saveClipboard([]string{"/a"}, nil) + }) + if err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("stale temp file should have been swept") + } + if _, err := os.Stat(fresh); err != nil { + t.Errorf("recent temp file should have been left alone: %v", err) + } +} + +// TestClipboardReadSingleFlight simulates a hung read by occupying the read +// gate and checks that further reads fail fast — and, critically, that a +// mutation aborts instead of saving an empty clipboard over real entries. +func TestClipboardReadSingleFlight(t *testing.T) { + useTempHistoryDir(t) + + if err := addClipboardPath("cut", "/existing/entry"); err != nil { + t.Fatal(err) + } + + clipboardReadGate <- struct{}{} + defer func() { <-clipboardReadGate }() + + start := time.Now() + if _, _, ok := readClipboard(); ok { + t.Fatal("read should fail while another read is in flight") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("gated read took %v, want fail-fast", elapsed) + } + + if err := addClipboardPath("copy", "/new/entry"); err == nil { + t.Fatal("mutation must abort when the clipboard cannot be read") + } + + // The existing entry survived the aborted mutation. + <-clipboardReadGate + cutPaths, _ := loadClipboard() + clipboardReadGate <- struct{}{} + if len(cutPaths) != 1 || cutPaths[0] != "/existing/entry" { + t.Fatalf("existing entry lost after aborted mutation: %v", cutPaths) + } +} diff --git a/mshell/FileManagerClipboard_unix_test.go b/mshell/FileManagerClipboard_unix_test.go new file mode 100644 index 0000000..040f030 --- /dev/null +++ b/mshell/FileManagerClipboard_unix_test.go @@ -0,0 +1,52 @@ +//go:build !windows + +package main + +import ( + "os" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +// TestClipboardFifoDoesNotHang plants a FIFO at the clipboard path — which +// would block a naive open(2) forever waiting for a writer — and checks that +// a load returns promptly and empty, and that the next save heals the path by +// renaming a regular file over it. +func TestClipboardFifoDoesNotHang(t *testing.T) { + useTempHistoryDir(t) + path, err := clipboardFilePath() + if err != nil { + t.Fatal(err) + } + if err := unix.Mkfifo(path, 0644); err != nil { + t.Fatal(err) + } + + done := make(chan struct{}) + go func() { + defer close(done) + cutPaths, copyPaths := loadClipboard() + if len(cutPaths)+len(copyPaths) != 0 { + t.Errorf("FIFO should read as empty clipboard, got cut %v copy %v", cutPaths, copyPaths) + } + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("loadClipboard hung on a FIFO at the clipboard path") + } + + // The next mutation atomically renames a regular file over the FIFO. + if err := addClipboardPath("cut", "/heal"); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !info.Mode().IsRegular() { + t.Fatalf("clipboard path not healed to a regular file, mode %v", info.Mode()) + } +}