Fix write handling: accumulate in buffer, dispatch on clunk

This commit is contained in:
Levi Neely 2026-03-20 16:51:54 +01:00
parent bb6eeec1b3
commit e362662175
1 changed files with 50 additions and 56 deletions

View File

@ -99,10 +99,11 @@ type connState struct {
} }
type fid struct { type fid struct {
qid plan9.Qid qid plan9.Qid
path string path string
offset int64 offset int64
mode uint8 mode uint8
writeBuf []byte // accumulates Twrite chunks, dispatched on Tclunk
} }
var srv *server var srv *server
@ -525,18 +526,39 @@ func (s *server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
cs.mu.Lock() cs.mu.Lock()
defer cs.mu.Unlock()
f, ok := cs.fids[fc.Fid] f, ok := cs.fids[fc.Fid]
if !ok { if !ok {
cs.mu.Unlock()
return errorFcall(fc, "fid not found") return errorFcall(fc, "fid not found")
} }
// Check if opened for writing // Check if opened for writing
if f.mode&plan9.OWRITE == 0 && f.mode&plan9.ORDWR == 0 { if f.mode&plan9.OWRITE == 0 && f.mode&plan9.ORDWR == 0 {
cs.mu.Unlock()
return errorFcall(fc, "file not open for writing") return errorFcall(fc, "file not open for writing")
} }
// Accumulate data into the per-fid write buffer at the given offset.
// The 9P client splits writes larger than msize into multiple Twrite messages
// with increasing offsets; we reassemble here and dispatch on Tclunk.
end := int(fc.Offset) + len(fc.Data)
if end > len(f.writeBuf) {
grown := make([]byte, end)
copy(grown, f.writeBuf)
f.writeBuf = grown
}
copy(f.writeBuf[fc.Offset:], fc.Data)
cs.mu.Unlock()
return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))}
}
// dispatchWrite processes the fully-assembled write payload for a given path.
// Called from Tclunk after all Twrite chunks have been accumulated.
func (s *server) dispatchWrite(f *fid, tag uint16) *plan9.Fcall {
fc := &plan9.Fcall{Tag: tag, Data: f.writeBuf}
input := strings.TrimSpace(string(f.writeBuf))
// Handle writes to /ctl // Handle writes to /ctl
if f.path == "/ctl" { if f.path == "/ctl" {
return s.handleCtlCommand(fc) return s.handleCtlCommand(fc)
@ -544,10 +566,7 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
// Handle writes to /new // Handle writes to /new
if f.path == "/new" { if f.path == "/new" {
input := strings.TrimSpace(string(fc.Data))
// Parse: 'Title' ==signature tag1,tag2,... // Parse: 'Title' ==signature tag1,tag2,...
// Extract title (must be single-quoted)
if !strings.HasPrefix(input, "'") { if !strings.HasPrefix(input, "'") {
return errorFcall(fc, "title must be single-quoted") return errorFcall(fc, "title must be single-quoted")
} }
@ -562,28 +581,22 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return errorFcall(fc, "title cannot be empty") return errorFcall(fc, "title cannot be empty")
} }
// Extract signature and tags (everything after closing quote)
remainder := strings.TrimSpace(input[closeQuote+2:]) remainder := strings.TrimSpace(input[closeQuote+2:])
var signature string var signature string
var tags []string var tags []string
if remainder != "" { if remainder != "" {
// Check if signature is present (starts with ==)
if strings.HasPrefix(remainder, "==") { if strings.HasPrefix(remainder, "==") {
// Find end of signature (space before tags)
spaceIdx := strings.Index(remainder, " ") spaceIdx := strings.Index(remainder, " ")
if spaceIdx == -1 { if spaceIdx == -1 {
// No tags, just signature signature = remainder[2:]
signature = remainder[2:] // Skip ==
} else { } else {
signature = remainder[2:spaceIdx] // Skip == signature = remainder[2:spaceIdx]
remainder = strings.TrimSpace(remainder[spaceIdx+1:]) remainder = strings.TrimSpace(remainder[spaceIdx+1:])
} }
} }
// Extract tags if present
if remainder != "" && !strings.HasPrefix(remainder, "==") { if remainder != "" && !strings.HasPrefix(remainder, "==") {
// Validate tags
tagPattern := regexp.MustCompile(`^([\p{Ll}\p{Nd}]+,)*[\p{Ll}\p{Nd}]+$`) tagPattern := regexp.MustCompile(`^([\p{Ll}\p{Nd}]+,)*[\p{Ll}\p{Nd}]+$`)
if !tagPattern.MatchString(remainder) { if !tagPattern.MatchString(remainder) {
return errorFcall(fc, "tags must be comma-delimited lowercase unicode words (no spaces)") return errorFcall(fc, "tags must be comma-delimited lowercase unicode words (no spaces)")
@ -592,34 +605,24 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
} }
} }
// Generate timestamp identifier
identifier := time.Now().Format("20060102T150405") identifier := time.Now().Format("20060102T150405")
// Create metadata entry with temporary path
// (actual file will be created by denote package event handler)
meta := &metadata.Metadata{ meta := &metadata.Metadata{
Identifier: identifier, Identifier: identifier,
Signature: signature, Signature: signature,
Title: title, Title: title,
Tags: tags, Tags: tags,
Path: "", // Will be set when file is created Path: "",
} }
// Add to in-memory state
s.mu.Lock() s.mu.Lock()
s.notes = append(s.notes, meta) s.notes = append(s.notes, meta)
s.mu.Unlock() s.mu.Unlock()
// Call new note callback
if s.callbacks.OnNew != nil { if s.callbacks.OnNew != nil {
go s.callbacks.OnNew(identifier) // async to not block write go s.callbacks.OnNew(identifier)
} }
return &plan9.Fcall{ return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(f.writeBuf))}
Type: plan9.Rwrite,
Tag: fc.Tag,
Count: uint32(len(fc.Data)),
}
} }
// Extract note identifier and field name from path // Extract note identifier and field name from path
@ -636,16 +639,11 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return errorFcall(fc, err.Error()) return errorFcall(fc, err.Error())
} }
// Update the field in memory only
value := strings.TrimSpace(string(fc.Data))
switch fieldName { switch fieldName {
case "path": case "path":
note.Path = value note.Path = input
// Don't emit 'r' - path is metadata only, doesn't trigger renames
case "title": case "title":
note.Title = value note.Title = input
// Call update and rename callbacks synchronously
if s.callbacks.OnUpdate != nil { if s.callbacks.OnUpdate != nil {
s.callbacks.OnUpdate(noteID) s.callbacks.OnUpdate(noteID)
} }
@ -653,16 +651,15 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
s.callbacks.OnRename(noteID) s.callbacks.OnRename(noteID)
} }
case "keywords": case "keywords":
if value == "" { if input == "" {
note.Tags = []string{} note.Tags = []string{}
} else { } else {
tags := strings.Split(value, ",") tags := strings.Split(input, ",")
for i := range tags { for i := range tags {
tags[i] = strings.TrimSpace(tags[i]) tags[i] = strings.TrimSpace(tags[i])
} }
note.Tags = tags note.Tags = tags
} }
// Call update and rename callbacks synchronously
if s.callbacks.OnUpdate != nil { if s.callbacks.OnUpdate != nil {
s.callbacks.OnUpdate(noteID) s.callbacks.OnUpdate(noteID)
} }
@ -670,8 +667,7 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
s.callbacks.OnRename(noteID) s.callbacks.OnRename(noteID)
} }
case "signature": case "signature":
note.Signature = value note.Signature = input
// Call update and rename callbacks synchronously
if s.callbacks.OnUpdate != nil { if s.callbacks.OnUpdate != nil {
s.callbacks.OnUpdate(noteID) s.callbacks.OnUpdate(noteID)
} }
@ -679,15 +675,11 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
s.callbacks.OnRename(noteID) s.callbacks.OnRename(noteID)
} }
case "ctl": case "ctl":
// Handle control commands switch input {
switch value {
case "d": case "d":
// Call delete callback BEFORE removing metadata
if s.callbacks.OnDelete != nil { if s.callbacks.OnDelete != nil {
s.callbacks.OnDelete(noteID) s.callbacks.OnDelete(noteID)
} }
// Remove from in-memory state
s.mu.Lock() s.mu.Lock()
for i, n := range s.notes { for i, n := range s.notes {
if n.Identifier == noteID { if n.Identifier == noteID {
@ -695,7 +687,6 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
break break
} }
} }
// Also remove from filtered notes
for i, n := range s.filteredNotes { for i, n := range s.filteredNotes {
if n.Identifier == noteID { if n.Identifier == noteID {
s.filteredNotes = append(s.filteredNotes[:i], s.filteredNotes[i+1:]...) s.filteredNotes = append(s.filteredNotes[:i], s.filteredNotes[i+1:]...)
@ -704,7 +695,6 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
} }
s.mu.Unlock() s.mu.Unlock()
case "r": case "r":
// Manual rename request
if s.callbacks.OnRename != nil { if s.callbacks.OnRename != nil {
s.callbacks.OnRename(noteID) s.callbacks.OnRename(noteID)
} }
@ -713,11 +703,7 @@ func (s *server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return errorFcall(fc, "field is read-only") return errorFcall(fc, "field is read-only")
} }
return &plan9.Fcall{ return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(f.writeBuf))}
Type: plan9.Rwrite,
Tag: fc.Tag,
Count: uint32(len(fc.Data)),
}
} }
func (s *server) stat(cs *connState, fc *plan9.Fcall) *plan9.Fcall { func (s *server) stat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
@ -744,9 +730,17 @@ func (s *server) stat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
func (s *server) clunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { func (s *server) clunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
cs.mu.Lock() cs.mu.Lock()
defer cs.mu.Unlock() f, ok := cs.fids[fc.Fid]
if ok && len(f.writeBuf) > 0 {
// Dispatch accumulated write before clunking
cs.mu.Unlock()
if resp := s.dispatchWrite(f, fc.Tag); resp.Type == plan9.Rerror {
return resp
}
cs.mu.Lock()
}
delete(cs.fids, fc.Fid) delete(cs.fids, fc.Fid)
cs.mu.Unlock()
return &plan9.Fcall{ return &plan9.Fcall{
Type: plan9.Rclunk, Type: plan9.Rclunk,