Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion doc/mshell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- )`
Expand Down
157 changes: 157 additions & 0 deletions mshell/DirWatch.go
Original file line number Diff line number Diff line change
@@ -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()
}
117 changes: 117 additions & 0 deletions mshell/DirWatch_darwin.go
Original file line number Diff line number Diff line change
@@ -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:
}
}
95 changes: 95 additions & 0 deletions mshell/DirWatch_linux.go
Original file line number Diff line number Diff line change
@@ -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:
}
}
Loading