-
Notifications
You must be signed in to change notification settings - Fork 18k
x/term: support pluggable history #68780
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
Implementation golang/term#15 demo of use/benefit fortio/terminal@77eee7b |
Change https://go.dev/cl/603957 mentions this issue: |
I don't know if this is a good proposal or not, but if you add |
Thanks for looking. If you look at the code you'll see it creates a copy while holding the lock. An iterator wouldn't be helpful as the copy (snapshot) is needed. |
Hey Laurent! I was about to push for review my patches for almost the same thing, hence would rather not waste everyone's time again and join you here. I also did a And what would you think is the best approach to achieve that? A modification to // Pop the last element from the history.
func (s *stRingBuffer) Pop() (string, bool) {
if s.size == 0 {
return "", false
}
value := s.entries[s.head]
s.head--
if s.head < 0 {
s.head += s.max
}
if s.size > 0 {
s.size--
}
return value, true
}
func (t *Terminal) PopHistory() (string, bool) {
t.lock.Lock()
defer t.lock.Unlock()
return t.history.Pop()
} Or I guess also using Would you care to include this too, or you'd rather prefer that I'll push a PR after the current code is merged? Cheers, |
Thanks, yes I ended up mostly turning auto history off for real cases and adding depending on conditions, the on behavior is for backward compatibility with current behavior. I was using replace until I hit writing a "history" (and !nn repeat) which shifts the history by one making auto not useable, maybe for minimalism I should remove replace? Edit: I wouldn't mind replacing Replace by Pop btw if that works better |
I was buying though your explanation for what So the question probably is: would IMHO the usage pattern of these APIs is:
tl;dr - I'm thinking neither And remember that once an API is added, there will always be some using it forever, along with all it's undocumented or unintended side-effects 😛. |
So I use replace in the example but it's true I just set auto to false instead in my real code. I can drop it from the proposal. I just want to say though that I wanted to avoid people clearing the whole history and re-adding to change last entry as even with a relatively small 99 entry slice it'd be wasteful lastly there is one other reason for replace which is to let users use up arrow and find the last bad entry and correct it |
ping, anyone? |
It's unclear why we bothered with a limited, circular buffer. What if we made the state an unbounded slice that is directly exposed in the struct, like I don't understand the sync.Mutex in the struct, though, so I'm not sure how it would work exactly. Another option would be to let the user pass in a History interface with some set of methods and implement it however it wants. I still am not sure what the locking rules need to be. |
Having a limited history is somewhat a reasonable thing for a cli, so as to not grow unbounded (or be manageable) - also changing that is a breaking change for callers (vs it being 100 currently automatically)
You mean Terminal.lock? (like in https://github.com/golang/term/pull/15/files#diff-76dcb3ff0bf98acc001558927365f8a3b7f0d2253580a49dc5973dfa398372c7R825) It's not a new thing and it makes the terminal thread safe, per the comment // lock protects the terminal and the state in this object from
// concurrent processing of a key press and a Write() call.
lock sync.Mutex ie terminal handles \r wrapping and refreshing the prompt concurrently with output |
This proposal has been added to the active column of the proposals project |
This seems like a lot of API to recreate a limited set of operations that could instead be expressed explicitly directly on a slice. My understanding (correct me if I'm wrong) is that we couldn't expose the history slice directly because of concurrency concerns, but it seems like we could have a simple get/put API like: // History returns a copy of the command history.
// (Which order are the strings in?)
func (*Terminal) History() []string
// SetHistory sets the terminal command history.
// (Do we copy it or take ownership? Copying is safer, but often a waste.)
func (*Terminal) SetHistory([]string) For the history capacity, we could either add a method specifically for setting that, or make it an argument to |
A slice api would be less efficient than the current circular buffer. You could argue maybe it doesn’t matter for human scale timing/history - but adding 3 apis (get/set/size) vs the 4 proposed doesn’t seem significantly better tradeoff? |
I agree that copying back and forth to a slice is a bit unfortunate. However, I don't think comparing simply the number of methods is the right way to measure the trade-off. The 4 proposed methods are really not very general, whereas a get/set slice API lets the caller implement whatever modifications they want. Here's an exploration of what this might look like if we expose it as a real deque API. The is certainly more API surface, but it's a standard data structure with a standard API that provides a lot of generality without the cost of copying to/from a slice: func (t *Terminal) History() *History
// History is a bounded deque of strings.
//
// The most recent entry is numbered 0, and called the "head".
// The least recent entry is called the tail.
type History struct { ... }
// Push pushes a new head element.
// If Len() == Cap(), this also deletes the tail.
func (h *History) Push(s string)
// Pop pops the head element, or panics if the deque is empty.
func (h *History) Pop() string
// Len returns the number of elements.
func (h *History) Len() int
// Cap returns the maximum number of elements.
func (h *History) Cap() int
// SetCap changes the maximum number of elements that can be stored in the deque.
// If the new cap is < Len(), it deletes elements from the tail.
func (h *History) SetCap(cap int)
// All iterates over all elements in the deque, starting with the head element.
func (h *History) All() iter.Seq[string]
// Get returns the i'th entry, where 0 refers to the head element.
func (h *History) Get(i int) string
// Set sets the i'th entry. It panics if i >= Len().
func (h *History) Set(i int, s string) |
Ideally that would look more like import "container/deque"
func (t *Terminal) History() *deque.Bounded[string] |
Sure, I don't disagree. Though the fact that it's bounded is a little odd for a general-purpose deque. |
I do think if we add a deque type here, it should use method names similar to those used by container/list. For my own generic deque, I have
which mirrors container/list
|
I think x/term can just have: type Terminal struct{
// If set, terminal uses this to store history for key up/down events.
// If left unset, a default implementation is used (current ring buffer).
History History
}
type History interface{
// adds a new line into the history
Add(string)
// retrieves a line from history
// 0 should be the most recent entry
// ok = false when out of range
At(idx int) (string, ok)
} This is sufficient to support the needs of x/term (adding, key up/down). |
in short I would vote for the minimal/surgical version, 1 interface, 2 methods in the current description; 2nd choice being adding Len but not using it, and that most recent only if that's the only way to get this approved |
My preference would be that |
The concerns of a one-time internal (and fairly minor) refactoring should not outweigh the concerns of designing an exported API. Implementation is fleeting. API is forever. |
For what reason? My argument for panicking is that, while |
It's a bit like "in theory, there is no difference between theory and practice, but in practice, there is"
eg what made the need for https://go-review.googlesource.com/c/term/+/408754 I will also repeat I believe it's not possible to use (implement) that API without looking at the current code - which is probably a smell of sort - so I wouldn't be very adamant on designing the most beautiful abstract API for this. Yet... anything that lets the CL move forward... not sure what the decision process is here... |
OTOH, Here's a survey of some results for deque on pkg.go.dev:
It looks like a single value form that panics is the more common way to do it. |
@ldemailly yeesh, a PR from 3 years ago — the time frame aligns exactly with work I was doing on a readline/ANSI seq package for TinyGo. During development/test, I faintly recall swapping in packages from |
@earthboundkid thanks for having taken the time to look at dequeues impls/apis - this being said whichever is more common isn't really (imo) a deciding factor (in particular because someone replacing History wouldn't just plug the same/std dequeue as what's already there). It does show @ardnew thanks for the reply - do you have an opinion on whether NthPreviousEntry() (called At() here) should panic? |
@ldemailly My knee-jerk expectation is that it should panic — that bounds checking should occur prior to access (consistent with built-in containers, as @aclements mentioned). Assuming the alternative is to add a returned
In my humble opinion, error handling should be a concern of the consumer here, not the interface. |
Agreed in general, yet your CL/PR removed (some cases of(*)) panic and replaced them with returning *: eg calling NthPreviousEntry(-200) was panicking before your PR and not anymore after - yet ok just doing -1 was returning garbage before yet not panic unless near the end of the buffer The consumer being... the terminal... in the implementation I did add unlock and defer relock in case of panic just to be safe but... having to replace entry, ok := t.historyAt(t.historyIndex + 1)
if !ok {
return "", false
} by idx := t.historyIndex + 1
if idx >= t.historyLen() {
return "", false
}
entry = t.historyAt(idx) is odd (and calls into question whether unlocking mid way and doing it twice is safe and efficient (probably not... probably will need a single replacement for making 2 calls to the api while unlocked) EDIT: I can put the 2 calls in historyAt() with unchanged signature so it's fine. not my favorite but I am wore down on accepting this :) |
Indeed, I am 100% recommending the opposite of what I did in the PR 😄 |
Sounds like the consensus is to panic. |
by some definition of consensus sure… I would say more like beaten into submission for the sake of forward progress |
@ldemailly , we're trying to work together here. The proposal process exists to ensure changes and additions to Go are durable and consistent through the review and consensus of the community and software design experts. It's not perfect, but it does typically achieve that goal. Please leave your snark at the door. |
There is no snark just that I hear the input and feedback but I don't think my points are heard - yet I would like this to get approved so I can work with whichever proposal is deemed acceptable. (also I happen to be an expert too, including on software engineering and consequences of introducing things like panic or code maintenance and am as far I know one of the people who try to use and update and maintain this package more rather than roll my own so I do find the whole thing a bit negative) |
Being in the same position of just wanting to help and provide patches like @ldemailly, I don't think that was snarky at all. As a user willing to contribute patches, I would appreciate a bit more empathy for old change requests. I don't see either the point of a panic, especially as Len() and At() are different methods and there is no lock in-between calling them (or?). So then I must always wrap then such calls in recover, if there is a possibility that At() might panic due to concurrent changes in the history? Of course, would appreciate your thoughts if I'm wrong here... |
My understanding is that a History can only be changed by x/term during a call to ReadLine, and we propose specifying that methods on History must not be called concurrently with ReadLine. So I don't think there's any danger of concurrency here. Thus you should no more have to worry about recovering panics from At than you would when accessing a slice. Concurrent changes would also cause problems with other use patterns that expect a consistent view of the history, like calling At multiple times with different indexes. |
No change in consensus, so accepted. 🎉 The proposal is to add the following API to the // A History provides a (possibly bounded) queue of input lines read by [ReadLine].
type History interface {
// Add adds a new, most recent entry to the history.
// It is allowed to drop any entry, including
// the entry being added (e.g.,, if it's a whitespace-only entry),
// the least-recent entry (e.g., to keep the history bounded),
// or any other entry.
Add(entry string)
// Len returns the number of entries in the history.
Len() int
// At returns an entry from the history.
// Index 0 is the most-recently added entry and
// index Len()-1 is the least-recently added entry.
// If index is < 0 or >= Len(), it panics.
At(index int) string
}
type Terminal struct {
...
// History records and retrieves lines of input read by [ReadLine].
//
// It is not safe to call ReadLine concurrently with any methods on History.
//
// [NewTerminal] sets this to an implementation that records the last 100 lines of input.
History History
} |
Thanks. I will update the CL and test panic and Out use cases in History to make sure the upcoming code handles it all. |
OK, I get it now that there are documented preconditions on how to use the API safely. But I for one would've preferred to have it more robust, if some users might make mistakes in using it. You are the maintainers, I am the user and yeah, I might be "holding it wrong" 😅, but I would prefer not to have to build a "bumper" around it. Of course, a good API is part of that, so your help is greatly appreciated! Anyway, Thank You, for both the explanation and getting it through! I for one just wanted convergence, to be able to drop my (similar) fork 🎉 And also Thank You to @ldemailly - your proposal covered use-cases that I haven't initially thought about, but now find very useful! |
golang/term#20 (https://go-review.googlesource.com/c/term/+/659835) has been updated (as well as fortio/terminal#85 which uses/tests it) |
Change https://go.dev/cl/659835 mentions this issue: |
Proposal Details
--- Accepted proposal:
copied from #68780 (comment)
--- Was "Interface" Proposal:
https://github.com/golang/term/pull/20/files
And even earlier "direct" proposal for reference:
x/term is very nice, it includes a 100 slot history ring buffer, unfortunately entirely private
https://github.com/golang/term/blob/master/terminal.go#L89
it would be nice to let people
and
and resize
and allow replacement of last command in history (for instance to replace invalid by validated or canonical variant)
and control whether lines are automatically added to history (defaults remains true)
if acceptable I'd be happy to make a PRedit: made a PRThe text was updated successfully, but these errors were encountered: