fix: rigorous atomic user mode parser and fix router race in server
Some checks failed
check / check (push) Failing after 23s

Mode parser (internal/service/service.go):
- Reject strings without leading + or - (e.g. "xw", "w", "") with
  ERR_UMODEUNKNOWNFLAG instead of silently treating them as "-".
- Support multi-sign transitions: +w-o, -w+o, +o-w+w, -x+y, +y-x. The
  active sign flips each time + or - is seen; subsequent letters apply
  with the active sign.
- Atomic from caller's perspective: parse the whole string to a list of
  ops first, reject the whole request on any unknown mode char, and only
  then apply ops to the DB. Partial application of +w before rejecting
  +o is gone.
- HTTP and IRC still share the same ApplyUserMode entry point.

Router race (internal/server/server.go):
- The fx OnStart hook previously spawned serve() in a goroutine that
  called SetupRoutes asynchronously, while ServeHTTP delegated to
  srv.router. Test harnesses (httptest wrapping srv as Handler) raced
  against SetupRoutes writing srv.router vs ServeHTTP reading it,
  producing the race detector failures in CI on main.
- SetupRoutes is now called synchronously inside OnStart before the
  serve goroutine starts, so srv.router is fully initialized before any
  request can reach ServeHTTP.

Tests (internal/service/service_test.go):
- Replaced the per-mode tests with a single table-driven TestApplyUserMode
  that asserts both the returned mode string and the persisted DB state
  (oper/wallops) for each case, including the malformed and multi-sign
  cases above. The +wz case seeds wallops=true to prove the whole string
  is rejected and +w is not partially applied.
This commit is contained in:
clawbot
2026-04-17 10:46:24 +00:00
parent abe0cc2c30
commit 93611dad67
3 changed files with 361 additions and 146 deletions

View File

@@ -71,7 +71,17 @@ func New(
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(_ context.Context) error {
srv.startupTime = time.Now() srv.startupTime = time.Now()
go srv.Run() //nolint:contextcheck // Configure, enable Sentry, and build the router
// synchronously so that srv.router is fully initialized
// before OnStart returns. Any HTTP traffic (including
// httptest harnesses that wrap srv as a handler) is
// therefore guaranteed to see an initialized router,
// eliminating the previous race between SetupRoutes
// and ServeHTTP.
srv.configure()
srv.enableSentry()
srv.SetupRoutes()
go srv.serve() //nolint:contextcheck
return nil return nil
}, },
@@ -83,10 +93,16 @@ func New(
return srv, nil return srv, nil
} }
// Run starts the server configuration, Sentry, and begins serving. // Run configures the server and begins serving. It blocks
// until shutdown is signalled. Kept for external callers
// that embed the server outside fx. The fx lifecycle now
// performs setup synchronously in OnStart and invokes
// serve directly in a goroutine, so this is only used when
// the server is driven by hand.
func (srv *Server) Run() { func (srv *Server) Run() {
srv.configure() srv.configure()
srv.enableSentry() srv.enableSentry()
srv.SetupRoutes()
srv.serve() srv.serve()
} }
@@ -202,8 +218,6 @@ func (srv *Server) serveUntilShutdown() {
Handler: srv, Handler: srv,
} }
srv.SetupRoutes()
srv.log.Info( srv.log.Info(
"http begin listen", "listenaddr", listenAddr, "http begin listen", "listenaddr", listenAddr,
) )

View File

@@ -814,28 +814,34 @@ func (s *Service) QueryUserMode(
return modes return modes
} }
// ApplyUserMode parses a mode string character by // userModeOp is a single parsed user-mode change collected
// character (e.g. "+wo", "-w") and applies each mode // by parseUserModeString before any DB writes happen.
// change to the session. Returns the resulting mode string type userModeOp struct {
// after all changes, or an IRCError on failure. char rune
adding bool
}
// ApplyUserMode parses an IRC user-mode string and applies
// the resulting changes atomically. It supports multiple
// sign transitions (e.g. "+w-o", "-w+o", "+o-w+w") and
// rejects malformed input (empty string, no leading sign,
// bare sign with no mode letters, unknown mode letters,
// +o which must be set via OPER) with an IRCError. On
// failure, no persistent change is made. On success, the
// resulting mode string is returned.
func (s *Service) ApplyUserMode( func (s *Service) ApplyUserMode(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
modeStr string, modeStr string,
) (string, error) { ) (string, error) {
if len(modeStr) < 2 { //nolint:mnd // +/- prefix + ≥1 char ops, err := parseUserModeString(modeStr)
return "", &IRCError{ if err != nil {
Code: irc.ErrUmodeUnknownFlag, return "", err
Params: nil,
Message: "Unknown MODE flag",
}
} }
adding := modeStr[0] == '+' for _, op := range ops {
for _, ch := range modeStr[1:] {
if err := s.applySingleUserMode( if err := s.applySingleUserMode(
ctx, sessionID, ch, adding, ctx, sessionID, op.char, op.adding,
); err != nil { ); err != nil {
return "", err return "", err
} }
@@ -844,7 +850,79 @@ func (s *Service) ApplyUserMode(
return s.QueryUserMode(ctx, sessionID), nil return s.QueryUserMode(ctx, sessionID), nil
} }
// applySingleUserMode applies one user mode character. // parseUserModeString validates and parses a user-mode
// string into a list of operations. The string must begin
// with '+' or '-'; subsequent '+' / '-' characters flip the
// active sign, and letters between them are applied with
// the current sign. Every letter must be a recognized user
// mode for this server, and '+o' is never allowed via MODE
// (use OPER to become operator). If any character is
// invalid, no operations are returned and an IRCError with
// ERR_UMODEUNKNOWNFLAG (501) is returned.
func parseUserModeString(
modeStr string,
) ([]userModeOp, error) {
unknownFlag := &IRCError{
Code: irc.ErrUmodeUnknownFlag,
Params: nil,
Message: "Unknown MODE flag",
}
if modeStr == "" {
return nil, unknownFlag
}
first := modeStr[0]
if first != '+' && first != '-' {
return nil, unknownFlag
}
ops := make([]userModeOp, 0, len(modeStr)-1)
adding := true
for _, ch := range modeStr {
switch ch {
case '+':
adding = true
case '-':
adding = false
default:
if !isKnownUserModeChar(ch) {
return nil, unknownFlag
}
if ch == 'o' && adding {
return nil, unknownFlag
}
ops = append(ops, userModeOp{
char: ch, adding: adding,
})
}
}
if len(ops) == 0 {
return nil, unknownFlag
}
return ops, nil
}
// isKnownUserModeChar reports whether the character is a
// recognized user mode letter.
func isKnownUserModeChar(ch rune) bool {
switch ch {
case 'w', 'o':
return true
default:
return false
}
}
// applySingleUserMode applies one already-validated user
// mode character to the session. parseUserModeString must
// have validated the character and sign before this runs;
// the default branch here is defence-in-depth only.
func (s *Service) applySingleUserMode( func (s *Service) applySingleUserMode(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
@@ -864,7 +942,6 @@ func (s *Service) applySingleUserMode(
return fmt.Errorf("set wallops: %w", err) return fmt.Errorf("set wallops: %w", err)
} }
case 'o': case 'o':
// +o cannot be set via MODE; use OPER command.
if adding { if adding {
return &IRCError{ return &IRCError{
Code: irc.ErrUmodeUnknownFlag, Code: irc.ErrUmodeUnknownFlag,

View File

@@ -393,136 +393,260 @@ func TestQueryUserMode(t *testing.T) {
} }
} }
func TestApplyUserModeSingleChar(t *testing.T) { // TestApplyUserMode is the rigorous table-driven suite for
env := newTestEnv(t) // the shared user-mode parser. It covers every case listed
ctx := t.Context() // in sneak's review of PR #96 plus a few adjacent ones.
// Each case asserts the resulting mode string AND the
sid := createSession(ctx, t, env.db, "alice") // persisted session state, to prove that rejected input
// leaves no side effects.
// Apply +w. func TestApplyUserMode(t *testing.T) {
result, err := env.svc.ApplyUserMode(ctx, sid, "+w") type caseState struct {
if err != nil { oper bool
t.Fatalf("apply +w: %v", err) wallops bool
} }
if result != "+w" { // Each case starts from initialState, calls
t.Errorf("expected +w, got %s", result) // ApplyUserMode(modeStr), and verifies either the
// returned mode string (wantModes) or an IRCError with
// wantErrCode. After the call, the DB must match
// wantState — proving that rejected input made no
// change.
cases := []struct {
name string
initialState caseState
modeStr string
wantModes string
wantErr bool
wantErrCode irc.IRCMessageType
wantState caseState
}{
// Happy path: single-char operations.
{
name: "+w from empty",
modeStr: "+w",
wantModes: "+w",
wantState: caseState{wallops: true},
},
{
name: "-w from +w",
initialState: caseState{wallops: true},
modeStr: "-w",
wantModes: "+",
wantState: caseState{},
},
{
name: "-o from +o",
initialState: caseState{oper: true},
modeStr: "-o",
wantModes: "+",
wantState: caseState{},
},
// Multi-char without sign transitions.
{
name: "-wo from +ow",
initialState: caseState{oper: true, wallops: true},
modeStr: "-wo",
wantModes: "+",
wantState: caseState{},
},
// Multi-char with sign transitions.
{
name: "+w-o from +o",
initialState: caseState{oper: true},
modeStr: "+w-o",
wantModes: "+w",
wantState: caseState{wallops: true},
},
{
name: "-w+o always rejects +o",
initialState: caseState{wallops: true, oper: true},
modeStr: "-w+o",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
// +o is rejected before any op applies; wallops
// stays set.
wantState: caseState{wallops: true, oper: true},
},
{
name: "+o-w+w rejects because of +o",
initialState: caseState{wallops: true, oper: true},
modeStr: "+o-w+w",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
// Wallops must NOT be cleared; oper must NOT be
// cleared. Rejection is fully atomic.
wantState: caseState{wallops: true, oper: true},
},
{
name: "-x+y rejects unknown -x",
modeStr: "-x+y",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: caseState{},
},
{
name: "+y-x rejects unknown +y",
modeStr: "+y-x",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: caseState{},
},
// Malformed prefix — must not silently treat as '-'.
{
name: "w no prefix rejects",
modeStr: "w",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: caseState{},
},
{
name: "xw no prefix rejects (would have been" +
" silently -w before)",
initialState: caseState{wallops: true},
modeStr: "xw",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
// Prove wallops is NOT cleared — the whole point
// of sneak's review.
wantState: caseState{wallops: true},
},
{
name: "empty string rejects",
modeStr: "",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: caseState{},
},
// Bare signs with no mode letters reject.
{
name: "bare + rejects",
modeStr: "+",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: caseState{},
},
{
name: "bare - rejects",
modeStr: "-",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: caseState{},
},
{
name: "+-+ rejects (no mode letters)",
modeStr: "+-+",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: caseState{},
},
// Unknown mode letters reject whole string.
{
name: "+z unknown mode rejects",
modeStr: "+z",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: caseState{},
},
{
name: "+wz rejects whole thing; +w side effect" +
" must NOT persist",
modeStr: "+wz",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
// Wallops must NOT be set.
wantState: caseState{},
},
{
name: "+wo rejects whole thing; +w side effect" +
" must NOT persist",
modeStr: "+wo",
wantErr: true,
wantErrCode: irc.ErrUmodeUnknownFlag,
wantState: caseState{},
},
} }
// Apply -w. for _, tc := range cases {
result, err = env.svc.ApplyUserMode(ctx, sid, "-w") t.Run(tc.name, func(t *testing.T) {
if err != nil { env := newTestEnv(t)
t.Fatalf("apply -w: %v", err) ctx := t.Context()
} sid := createSession(ctx, t, env.db, "alice")
if result != "+" { if tc.initialState.oper {
t.Errorf("expected +, got %s", result) if err := env.db.SetSessionOper(
} ctx, sid, true,
} ); err != nil {
t.Fatalf("init oper: %v", err)
func TestApplyUserModeMultiChar(t *testing.T) { }
env := newTestEnv(t) }
ctx := t.Context()
if tc.initialState.wallops {
sid := createSession(ctx, t, env.db, "alice") if err := env.db.SetSessionWallops(
ctx, sid, true,
// Set oper first so we can test +wo (w applied, o ); err != nil {
// rejected because +o is not allowed via MODE). t.Fatalf("init wallops: %v", err)
_ = env.db.SetSessionOper(ctx, sid, true) }
}
// Apply +w alone should work.
result, err := env.svc.ApplyUserMode(ctx, sid, "+w") result, err := env.svc.ApplyUserMode(
if err != nil { ctx, sid, tc.modeStr,
t.Fatalf("apply +w: %v", err) )
}
if tc.wantErr {
if result != "+ow" { var ircErr *service.IRCError
t.Errorf("expected +ow, got %s", result) if !errors.As(err, &ircErr) {
} t.Fatalf(
"expected IRCError, got %v", err,
// Reset wallops. )
_ = env.db.SetSessionWallops(ctx, sid, false) }
// Multi-char -ow: should de-oper and remove wallops. if ircErr.Code != tc.wantErrCode {
_ = env.db.SetSessionWallops(ctx, sid, true) t.Errorf(
"code: want %d got %d",
result, err = env.svc.ApplyUserMode(ctx, sid, "-ow") tc.wantErrCode, ircErr.Code,
if err != nil { )
t.Fatalf("apply -ow: %v", err) }
} } else {
if err != nil {
if result != "+" { t.Fatalf("unexpected error: %v", err)
t.Errorf("expected +, got %s", result) }
}
if result != tc.wantModes {
// +wo should fail because +o is not allowed. t.Errorf(
_, err = env.svc.ApplyUserMode(ctx, sid, "+wo") "modes: want %q got %q",
tc.wantModes, result,
var ircErr *service.IRCError )
if !errors.As(err, &ircErr) { }
t.Fatalf("expected IRCError, got %v", err) }
}
// Whether or not the call errored, the persisted
if ircErr.Code != irc.ErrUmodeUnknownFlag { // state must match wantState — this is the
t.Errorf( // atomicity guarantee sneak demanded.
"expected ErrUmodeUnknownFlag, got %d", gotOper, err := env.db.IsSessionOper(ctx, sid)
ircErr.Code, if err != nil {
) t.Fatalf("read oper: %v", err)
} }
}
gotWallops, err := env.db.IsSessionWallops(
func TestApplyUserModeInvalidInput(t *testing.T) { ctx, sid,
env := newTestEnv(t) )
ctx := t.Context() if err != nil {
t.Fatalf("read wallops: %v", err)
sid := createSession(ctx, t, env.db, "alice") }
// Too short. if gotOper != tc.wantState.oper {
_, err := env.svc.ApplyUserMode(ctx, sid, "+") t.Errorf(
"oper: want %v got %v",
var ircErr *service.IRCError tc.wantState.oper, gotOper,
if !errors.As(err, &ircErr) { )
t.Fatalf("expected IRCError for short input, got %v", err) }
}
if gotWallops != tc.wantState.wallops {
// Unknown flag. t.Errorf(
_, err = env.svc.ApplyUserMode(ctx, sid, "+x") "wallops: want %v got %v",
if !errors.As(err, &ircErr) { tc.wantState.wallops, gotWallops,
t.Fatalf("expected IRCError for unknown flag, got %v", err) )
} }
})
if ircErr.Code != irc.ErrUmodeUnknownFlag {
t.Errorf(
"expected ErrUmodeUnknownFlag, got %d",
ircErr.Code,
)
}
}
func TestApplyUserModeDeoper(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
// Make oper via DB directly.
_ = env.db.SetSessionOper(ctx, sid, true)
// -o should work.
result, err := env.svc.ApplyUserMode(ctx, sid, "-o")
if err != nil {
t.Fatalf("apply -o: %v", err)
}
if result != "+" {
t.Errorf("expected +, got %s", result)
}
// +o should fail.
_, err = env.svc.ApplyUserMode(ctx, sid, "+o")
var ircErr *service.IRCError
if !errors.As(err, &ircErr) {
t.Fatalf("expected IRCError for +o, got %v", err)
} }
} }