detached 2be8535

Merge pull request #312 from bnema/fix/high-priority-backlog-208-285

fix(backlog): resolve high priority session and filter issues
commit
2be85352cc3bdf4104eb928c90382d5ce0c26f0d
author
bnema · · 2026-06-19 06:55 UTC
committer
GitHub · · 2026-06-19 06:55 UTC
merge of
26e3f49 + 0970f6b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
20 files · +1032 -69
M .mockery.yaml +1 -0
@@ -14,6 +14,7 @@ packages:
1414 DatabaseProvider: {}
1515 DiffFormatter: {}
1616 SessionLogger: {}
17+ SessionProcessProbe: {}
1718 SessionSpawner: {}
1819 WindowStateProvider: {}
1920 WebView: {}
@@ -14,6 +14,7 @@ packages:
14 DatabaseProvider: {}14 DatabaseProvider: {}
15 DiffFormatter: {}15 DiffFormatter: {}
16 SessionLogger: {}16 SessionLogger: {}
17+ SessionProcessProbe: {}
17 SessionSpawner: {}18 SessionSpawner: {}
18 WindowStateProvider: {}19 WindowStateProvider: {}
19 WebView: {}20 WebView: {}
M internal/application/port/mocks/mocks.go +319 -0
@@ -6469,6 +6469,232 @@ func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Co
64696469 return _c
64706470 }
64716471
6472+// NewMockHistorySidebarHistory creates a new instance of MockHistorySidebarHistory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
6473+// The first argument is typically a *testing.T value.
6474+func NewMockHistorySidebarHistory(t interface {
6475+ mock.TestingT
6476+ Cleanup(func())
6477+}) *MockHistorySidebarHistory {
6478+ mock := &MockHistorySidebarHistory{}
6479+ mock.Mock.Test(t)
6480+
6481+ t.Cleanup(func() { mock.AssertExpectations(t) })
6482+
6483+ return mock
6484+}
6485+
6486+// MockHistorySidebarHistory is an autogenerated mock type for the HistorySidebarHistory type
6487+type MockHistorySidebarHistory struct {
6488+ mock.Mock
6489+}
6490+
6491+type MockHistorySidebarHistory_Expecter struct {
6492+ mock *mock.Mock
6493+}
6494+
6495+func (_m *MockHistorySidebarHistory) EXPECT() *MockHistorySidebarHistory_Expecter {
6496+ return &MockHistorySidebarHistory_Expecter{mock: &_m.Mock}
6497+}
6498+
6499+// Delete provides a mock function for the type MockHistorySidebarHistory
6500+func (_mock *MockHistorySidebarHistory) Delete(ctx context.Context, id int64) error {
6501+ ret := _mock.Called(ctx, id)
6502+
6503+ if len(ret) == 0 {
6504+ panic("no return value specified for Delete")
6505+ }
6506+
6507+ var r0 error
6508+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64) error); ok {
6509+ r0 = returnFunc(ctx, id)
6510+ } else {
6511+ r0 = ret.Error(0)
6512+ }
6513+ return r0
6514+}
6515+
6516+// MockHistorySidebarHistory_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
6517+type MockHistorySidebarHistory_Delete_Call struct {
6518+ *mock.Call
6519+}
6520+
6521+// Delete is a helper method to define mock.On call
6522+// - ctx context.Context
6523+// - id int64
6524+func (_e *MockHistorySidebarHistory_Expecter) Delete(ctx interface{}, id interface{}) *MockHistorySidebarHistory_Delete_Call {
6525+ return &MockHistorySidebarHistory_Delete_Call{Call: _e.mock.On("Delete", ctx, id)}
6526+}
6527+
6528+func (_c *MockHistorySidebarHistory_Delete_Call) Run(run func(ctx context.Context, id int64)) *MockHistorySidebarHistory_Delete_Call {
6529+ _c.Call.Run(func(args mock.Arguments) {
6530+ var arg0 context.Context
6531+ if args[0] != nil {
6532+ arg0 = args[0].(context.Context)
6533+ }
6534+ var arg1 int64
6535+ if args[1] != nil {
6536+ arg1 = args[1].(int64)
6537+ }
6538+ run(
6539+ arg0,
6540+ arg1,
6541+ )
6542+ })
6543+ return _c
6544+}
6545+
6546+func (_c *MockHistorySidebarHistory_Delete_Call) Return(err error) *MockHistorySidebarHistory_Delete_Call {
6547+ _c.Call.Return(err)
6548+ return _c
6549+}
6550+
6551+func (_c *MockHistorySidebarHistory_Delete_Call) RunAndReturn(run func(ctx context.Context, id int64) error) *MockHistorySidebarHistory_Delete_Call {
6552+ _c.Call.Return(run)
6553+ return _c
6554+}
6555+
6556+// GetRecent provides a mock function for the type MockHistorySidebarHistory
6557+func (_mock *MockHistorySidebarHistory) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {
6558+ ret := _mock.Called(ctx, limit, offset)
6559+
6560+ if len(ret) == 0 {
6561+ panic("no return value specified for GetRecent")
6562+ }
6563+
6564+ var r0 []*entity.HistoryEntry
6565+ var r1 error
6566+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) ([]*entity.HistoryEntry, error)); ok {
6567+ return returnFunc(ctx, limit, offset)
6568+ }
6569+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) []*entity.HistoryEntry); ok {
6570+ r0 = returnFunc(ctx, limit, offset)
6571+ } else {
6572+ if ret.Get(0) != nil {
6573+ r0 = ret.Get(0).([]*entity.HistoryEntry)
6574+ }
6575+ }
6576+ if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok {
6577+ r1 = returnFunc(ctx, limit, offset)
6578+ } else {
6579+ r1 = ret.Error(1)
6580+ }
6581+ return r0, r1
6582+}
6583+
6584+// MockHistorySidebarHistory_GetRecent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecent'
6585+type MockHistorySidebarHistory_GetRecent_Call struct {
6586+ *mock.Call
6587+}
6588+
6589+// GetRecent is a helper method to define mock.On call
6590+// - ctx context.Context
6591+// - limit int
6592+// - offset int
6593+func (_e *MockHistorySidebarHistory_Expecter) GetRecent(ctx interface{}, limit interface{}, offset interface{}) *MockHistorySidebarHistory_GetRecent_Call {
6594+ return &MockHistorySidebarHistory_GetRecent_Call{Call: _e.mock.On("GetRecent", ctx, limit, offset)}
6595+}
6596+
6597+func (_c *MockHistorySidebarHistory_GetRecent_Call) Run(run func(ctx context.Context, limit int, offset int)) *MockHistorySidebarHistory_GetRecent_Call {
6598+ _c.Call.Run(func(args mock.Arguments) {
6599+ var arg0 context.Context
6600+ if args[0] != nil {
6601+ arg0 = args[0].(context.Context)
6602+ }
6603+ var arg1 int
6604+ if args[1] != nil {
6605+ arg1 = args[1].(int)
6606+ }
6607+ var arg2 int
6608+ if args[2] != nil {
6609+ arg2 = args[2].(int)
6610+ }
6611+ run(
6612+ arg0,
6613+ arg1,
6614+ arg2,
6615+ )
6616+ })
6617+ return _c
6618+}
6619+
6620+func (_c *MockHistorySidebarHistory_GetRecent_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistorySidebarHistory_GetRecent_Call {
6621+ _c.Call.Return(historyEntrys, err)
6622+ return _c
6623+}
6624+
6625+func (_c *MockHistorySidebarHistory_GetRecent_Call) RunAndReturn(run func(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHistorySidebarHistory_GetRecent_Call {
6626+ _c.Call.Return(run)
6627+ return _c
6628+}
6629+
6630+// Search provides a mock function for the type MockHistorySidebarHistory
6631+func (_mock *MockHistorySidebarHistory) Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
6632+ ret := _mock.Called(ctx, input)
6633+
6634+ if len(ret) == 0 {
6635+ panic("no return value specified for Search")
6636+ }
6637+
6638+ var r0 *dto.HistorySearchOutput
6639+ var r1 error
6640+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error)); ok {
6641+ return returnFunc(ctx, input)
6642+ }
6643+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) *dto.HistorySearchOutput); ok {
6644+ r0 = returnFunc(ctx, input)
6645+ } else {
6646+ if ret.Get(0) != nil {
6647+ r0 = ret.Get(0).(*dto.HistorySearchOutput)
6648+ }
6649+ }
6650+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.HistorySearchInput) error); ok {
6651+ r1 = returnFunc(ctx, input)
6652+ } else {
6653+ r1 = ret.Error(1)
6654+ }
6655+ return r0, r1
6656+}
6657+
6658+// MockHistorySidebarHistory_Search_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Search'
6659+type MockHistorySidebarHistory_Search_Call struct {
6660+ *mock.Call
6661+}
6662+
6663+// Search is a helper method to define mock.On call
6664+// - ctx context.Context
6665+// - input dto.HistorySearchInput
6666+func (_e *MockHistorySidebarHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHistorySidebarHistory_Search_Call {
6667+ return &MockHistorySidebarHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)}
6668+}
6669+
6670+func (_c *MockHistorySidebarHistory_Search_Call) Run(run func(ctx context.Context, input dto.HistorySearchInput)) *MockHistorySidebarHistory_Search_Call {
6671+ _c.Call.Run(func(args mock.Arguments) {
6672+ var arg0 context.Context
6673+ if args[0] != nil {
6674+ arg0 = args[0].(context.Context)
6675+ }
6676+ var arg1 dto.HistorySearchInput
6677+ if args[1] != nil {
6678+ arg1 = args[1].(dto.HistorySearchInput)
6679+ }
6680+ run(
6681+ arg0,
6682+ arg1,
6683+ )
6684+ })
6685+ return _c
6686+}
6687+
6688+func (_c *MockHistorySidebarHistory_Search_Call) Return(historySearchOutput *dto.HistorySearchOutput, err error) *MockHistorySidebarHistory_Search_Call {
6689+ _c.Call.Return(historySearchOutput, err)
6690+ return _c
6691+}
6692+
6693+func (_c *MockHistorySidebarHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)) *MockHistorySidebarHistory_Search_Call {
6694+ _c.Call.Return(run)
6695+ return _c
6696+}
6697+
64726698 // NewMockHomepageHistory creates a new instance of MockHomepageHistory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
64736699 // The first argument is typically a *testing.T value.
64746700 func NewMockHomepageHistory(t interface {
@@ -10675,6 +10901,99 @@ func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context
1067510901 return _c
1067610902 }
1067710903
10904+// NewMockSessionProcessProbe creates a new instance of MockSessionProcessProbe. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
10905+// The first argument is typically a *testing.T value.
10906+func NewMockSessionProcessProbe(t interface {
10907+ mock.TestingT
10908+ Cleanup(func())
10909+}) *MockSessionProcessProbe {
10910+ mock := &MockSessionProcessProbe{}
10911+ mock.Mock.Test(t)
10912+
10913+ t.Cleanup(func() { mock.AssertExpectations(t) })
10914+
10915+ return mock
10916+}
10917+
10918+// MockSessionProcessProbe is an autogenerated mock type for the SessionProcessProbe type
10919+type MockSessionProcessProbe struct {
10920+ mock.Mock
10921+}
10922+
10923+type MockSessionProcessProbe_Expecter struct {
10924+ mock *mock.Mock
10925+}
10926+
10927+func (_m *MockSessionProcessProbe) EXPECT() *MockSessionProcessProbe_Expecter {
10928+ return &MockSessionProcessProbe_Expecter{mock: &_m.Mock}
10929+}
10930+
10931+// IsProcessAlive provides a mock function for the type MockSessionProcessProbe
10932+func (_mock *MockSessionProcessProbe) IsProcessAlive(ctx context.Context, pid int) (bool, error) {
10933+ ret := _mock.Called(ctx, pid)
10934+
10935+ if len(ret) == 0 {
10936+ panic("no return value specified for IsProcessAlive")
10937+ }
10938+
10939+ var r0 bool
10940+ var r1 error
10941+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) (bool, error)); ok {
10942+ return returnFunc(ctx, pid)
10943+ }
10944+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) bool); ok {
10945+ r0 = returnFunc(ctx, pid)
10946+ } else {
10947+ r0 = ret.Get(0).(bool)
10948+ }
10949+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
10950+ r1 = returnFunc(ctx, pid)
10951+ } else {
10952+ r1 = ret.Error(1)
10953+ }
10954+ return r0, r1
10955+}
10956+
10957+// MockSessionProcessProbe_IsProcessAlive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsProcessAlive'
10958+type MockSessionProcessProbe_IsProcessAlive_Call struct {
10959+ *mock.Call
10960+}
10961+
10962+// IsProcessAlive is a helper method to define mock.On call
10963+// - ctx context.Context
10964+// - pid int
10965+func (_e *MockSessionProcessProbe_Expecter) IsProcessAlive(ctx interface{}, pid interface{}) *MockSessionProcessProbe_IsProcessAlive_Call {
10966+ return &MockSessionProcessProbe_IsProcessAlive_Call{Call: _e.mock.On("IsProcessAlive", ctx, pid)}
10967+}
10968+
10969+func (_c *MockSessionProcessProbe_IsProcessAlive_Call) Run(run func(ctx context.Context, pid int)) *MockSessionProcessProbe_IsProcessAlive_Call {
10970+ _c.Call.Run(func(args mock.Arguments) {
10971+ var arg0 context.Context
10972+ if args[0] != nil {
10973+ arg0 = args[0].(context.Context)
10974+ }
10975+ var arg1 int
10976+ if args[1] != nil {
10977+ arg1 = args[1].(int)
10978+ }
10979+ run(
10980+ arg0,
10981+ arg1,
10982+ )
10983+ })
10984+ return _c
10985+}
10986+
10987+func (_c *MockSessionProcessProbe_IsProcessAlive_Call) Return(b bool, err error) *MockSessionProcessProbe_IsProcessAlive_Call {
10988+ _c.Call.Return(b, err)
10989+ return _c
10990+}
10991+
10992+func (_c *MockSessionProcessProbe_IsProcessAlive_Call) RunAndReturn(run func(ctx context.Context, pid int) (bool, error)) *MockSessionProcessProbe_IsProcessAlive_Call {
10993+ _c.Call.Return(run)
10994+ return _c
10995+}
10996+
1067810997 // NewMockWindowStateProvider creates a new instance of MockWindowStateProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1067910998 // The first argument is typically a *testing.T value.
1068010999 func NewMockWindowStateProvider(t interface {
@@ -6469,6 +6469,232 @@ func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Co
6469 return _c6469 return _c
6470 }6470 }
6471 6471
6472+// NewMockHistorySidebarHistory creates a new instance of MockHistorySidebarHistory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
6473+// The first argument is typically a *testing.T value.
6474+func NewMockHistorySidebarHistory(t interface {
6475+ mock.TestingT
6476+ Cleanup(func())
6477+}) *MockHistorySidebarHistory {
6478+ mock := &MockHistorySidebarHistory{}
6479+ mock.Mock.Test(t)
6480+
6481+ t.Cleanup(func() { mock.AssertExpectations(t) })
6482+
6483+ return mock
6484+}
6485+
6486+// MockHistorySidebarHistory is an autogenerated mock type for the HistorySidebarHistory type
6487+type MockHistorySidebarHistory struct {
6488+ mock.Mock
6489+}
6490+
6491+type MockHistorySidebarHistory_Expecter struct {
6492+ mock *mock.Mock
6493+}
6494+
6495+func (_m *MockHistorySidebarHistory) EXPECT() *MockHistorySidebarHistory_Expecter {
6496+ return &MockHistorySidebarHistory_Expecter{mock: &_m.Mock}
6497+}
6498+
6499+// Delete provides a mock function for the type MockHistorySidebarHistory
6500+func (_mock *MockHistorySidebarHistory) Delete(ctx context.Context, id int64) error {
6501+ ret := _mock.Called(ctx, id)
6502+
6503+ if len(ret) == 0 {
6504+ panic("no return value specified for Delete")
6505+ }
6506+
6507+ var r0 error
6508+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64) error); ok {
6509+ r0 = returnFunc(ctx, id)
6510+ } else {
6511+ r0 = ret.Error(0)
6512+ }
6513+ return r0
6514+}
6515+
6516+// MockHistorySidebarHistory_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
6517+type MockHistorySidebarHistory_Delete_Call struct {
6518+ *mock.Call
6519+}
6520+
6521+// Delete is a helper method to define mock.On call
6522+// - ctx context.Context
6523+// - id int64
6524+func (_e *MockHistorySidebarHistory_Expecter) Delete(ctx interface{}, id interface{}) *MockHistorySidebarHistory_Delete_Call {
6525+ return &MockHistorySidebarHistory_Delete_Call{Call: _e.mock.On("Delete", ctx, id)}
6526+}
6527+
6528+func (_c *MockHistorySidebarHistory_Delete_Call) Run(run func(ctx context.Context, id int64)) *MockHistorySidebarHistory_Delete_Call {
6529+ _c.Call.Run(func(args mock.Arguments) {
6530+ var arg0 context.Context
6531+ if args[0] != nil {
6532+ arg0 = args[0].(context.Context)
6533+ }
6534+ var arg1 int64
6535+ if args[1] != nil {
6536+ arg1 = args[1].(int64)
6537+ }
6538+ run(
6539+ arg0,
6540+ arg1,
6541+ )
6542+ })
6543+ return _c
6544+}
6545+
6546+func (_c *MockHistorySidebarHistory_Delete_Call) Return(err error) *MockHistorySidebarHistory_Delete_Call {
6547+ _c.Call.Return(err)
6548+ return _c
6549+}
6550+
6551+func (_c *MockHistorySidebarHistory_Delete_Call) RunAndReturn(run func(ctx context.Context, id int64) error) *MockHistorySidebarHistory_Delete_Call {
6552+ _c.Call.Return(run)
6553+ return _c
6554+}
6555+
6556+// GetRecent provides a mock function for the type MockHistorySidebarHistory
6557+func (_mock *MockHistorySidebarHistory) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {
6558+ ret := _mock.Called(ctx, limit, offset)
6559+
6560+ if len(ret) == 0 {
6561+ panic("no return value specified for GetRecent")
6562+ }
6563+
6564+ var r0 []*entity.HistoryEntry
6565+ var r1 error
6566+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) ([]*entity.HistoryEntry, error)); ok {
6567+ return returnFunc(ctx, limit, offset)
6568+ }
6569+ if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) []*entity.HistoryEntry); ok {
6570+ r0 = returnFunc(ctx, limit, offset)
6571+ } else {
6572+ if ret.Get(0) != nil {
6573+ r0 = ret.Get(0).([]*entity.HistoryEntry)
6574+ }
6575+ }
6576+ if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok {
6577+ r1 = returnFunc(ctx, limit, offset)
6578+ } else {
6579+ r1 = ret.Error(1)
6580+ }
6581+ return r0, r1
6582+}
6583+
6584+// MockHistorySidebarHistory_GetRecent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecent'
6585+type MockHistorySidebarHistory_GetRecent_Call struct {
6586+ *mock.Call
6587+}
6588+
6589+// GetRecent is a helper method to define mock.On call
6590+// - ctx context.Context
6591+// - limit int
6592+// - offset int
6593+func (_e *MockHistorySidebarHistory_Expecter) GetRecent(ctx interface{}, limit interface{}, offset interface{}) *MockHistorySidebarHistory_GetRecent_Call {
6594+ return &MockHistorySidebarHistory_GetRecent_Call{Call: _e.mock.On("GetRecent", ctx, limit, offset)}
6595+}
6596+
6597+func (_c *MockHistorySidebarHistory_GetRecent_Call) Run(run func(ctx context.Context, limit int, offset int)) *MockHistorySidebarHistory_GetRecent_Call {
6598+ _c.Call.Run(func(args mock.Arguments) {
6599+ var arg0 context.Context
6600+ if args[0] != nil {
6601+ arg0 = args[0].(context.Context)
6602+ }
6603+ var arg1 int
6604+ if args[1] != nil {
6605+ arg1 = args[1].(int)
6606+ }
6607+ var arg2 int
6608+ if args[2] != nil {
6609+ arg2 = args[2].(int)
6610+ }
6611+ run(
6612+ arg0,
6613+ arg1,
6614+ arg2,
6615+ )
6616+ })
6617+ return _c
6618+}
6619+
6620+func (_c *MockHistorySidebarHistory_GetRecent_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistorySidebarHistory_GetRecent_Call {
6621+ _c.Call.Return(historyEntrys, err)
6622+ return _c
6623+}
6624+
6625+func (_c *MockHistorySidebarHistory_GetRecent_Call) RunAndReturn(run func(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHistorySidebarHistory_GetRecent_Call {
6626+ _c.Call.Return(run)
6627+ return _c
6628+}
6629+
6630+// Search provides a mock function for the type MockHistorySidebarHistory
6631+func (_mock *MockHistorySidebarHistory) Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
6632+ ret := _mock.Called(ctx, input)
6633+
6634+ if len(ret) == 0 {
6635+ panic("no return value specified for Search")
6636+ }
6637+
6638+ var r0 *dto.HistorySearchOutput
6639+ var r1 error
6640+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error)); ok {
6641+ return returnFunc(ctx, input)
6642+ }
6643+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) *dto.HistorySearchOutput); ok {
6644+ r0 = returnFunc(ctx, input)
6645+ } else {
6646+ if ret.Get(0) != nil {
6647+ r0 = ret.Get(0).(*dto.HistorySearchOutput)
6648+ }
6649+ }
6650+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.HistorySearchInput) error); ok {
6651+ r1 = returnFunc(ctx, input)
6652+ } else {
6653+ r1 = ret.Error(1)
6654+ }
6655+ return r0, r1
6656+}
6657+
6658+// MockHistorySidebarHistory_Search_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Search'
6659+type MockHistorySidebarHistory_Search_Call struct {
6660+ *mock.Call
6661+}
6662+
6663+// Search is a helper method to define mock.On call
6664+// - ctx context.Context
6665+// - input dto.HistorySearchInput
6666+func (_e *MockHistorySidebarHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHistorySidebarHistory_Search_Call {
6667+ return &MockHistorySidebarHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)}
6668+}
6669+
6670+func (_c *MockHistorySidebarHistory_Search_Call) Run(run func(ctx context.Context, input dto.HistorySearchInput)) *MockHistorySidebarHistory_Search_Call {
6671+ _c.Call.Run(func(args mock.Arguments) {
6672+ var arg0 context.Context
6673+ if args[0] != nil {
6674+ arg0 = args[0].(context.Context)
6675+ }
6676+ var arg1 dto.HistorySearchInput
6677+ if args[1] != nil {
6678+ arg1 = args[1].(dto.HistorySearchInput)
6679+ }
6680+ run(
6681+ arg0,
6682+ arg1,
6683+ )
6684+ })
6685+ return _c
6686+}
6687+
6688+func (_c *MockHistorySidebarHistory_Search_Call) Return(historySearchOutput *dto.HistorySearchOutput, err error) *MockHistorySidebarHistory_Search_Call {
6689+ _c.Call.Return(historySearchOutput, err)
6690+ return _c
6691+}
6692+
6693+func (_c *MockHistorySidebarHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)) *MockHistorySidebarHistory_Search_Call {
6694+ _c.Call.Return(run)
6695+ return _c
6696+}
6697+
6472 // NewMockHomepageHistory creates a new instance of MockHomepageHistory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.6698 // NewMockHomepageHistory creates a new instance of MockHomepageHistory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
6473 // The first argument is typically a *testing.T value.6699 // The first argument is typically a *testing.T value.
6474 func NewMockHomepageHistory(t interface {6700 func NewMockHomepageHistory(t interface {
@@ -10675,6 +10901,99 @@ func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context
10675 return _c10901 return _c
10676 }10902 }
10677 10903
10904+// NewMockSessionProcessProbe creates a new instance of MockSessionProcessProbe. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
10905+// The first argument is typically a *testing.T value.
10906+func NewMockSessionProcessProbe(t interface {
10907+ mock.TestingT
10908+ Cleanup(func())
10909+}) *MockSessionProcessProbe {
10910+ mock := &MockSessionProcessProbe{}
10911+ mock.Mock.Test(t)
10912+
10913+ t.Cleanup(func() { mock.AssertExpectations(t) })
10914+
10915+ return mock
10916+}
10917+
10918+// MockSessionProcessProbe is an autogenerated mock type for the SessionProcessProbe type
10919+type MockSessionProcessProbe struct {
10920+ mock.Mock
10921+}
10922+
10923+type MockSessionProcessProbe_Expecter struct {
10924+ mock *mock.Mock
10925+}
10926+
10927+func (_m *MockSessionProcessProbe) EXPECT() *MockSessionProcessProbe_Expecter {
10928+ return &MockSessionProcessProbe_Expecter{mock: &_m.Mock}
10929+}
10930+
10931+// IsProcessAlive provides a mock function for the type MockSessionProcessProbe
10932+func (_mock *MockSessionProcessProbe) IsProcessAlive(ctx context.Context, pid int) (bool, error) {
10933+ ret := _mock.Called(ctx, pid)
10934+
10935+ if len(ret) == 0 {
10936+ panic("no return value specified for IsProcessAlive")
10937+ }
10938+
10939+ var r0 bool
10940+ var r1 error
10941+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) (bool, error)); ok {
10942+ return returnFunc(ctx, pid)
10943+ }
10944+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) bool); ok {
10945+ r0 = returnFunc(ctx, pid)
10946+ } else {
10947+ r0 = ret.Get(0).(bool)
10948+ }
10949+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
10950+ r1 = returnFunc(ctx, pid)
10951+ } else {
10952+ r1 = ret.Error(1)
10953+ }
10954+ return r0, r1
10955+}
10956+
10957+// MockSessionProcessProbe_IsProcessAlive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsProcessAlive'
10958+type MockSessionProcessProbe_IsProcessAlive_Call struct {
10959+ *mock.Call
10960+}
10961+
10962+// IsProcessAlive is a helper method to define mock.On call
10963+// - ctx context.Context
10964+// - pid int
10965+func (_e *MockSessionProcessProbe_Expecter) IsProcessAlive(ctx interface{}, pid interface{}) *MockSessionProcessProbe_IsProcessAlive_Call {
10966+ return &MockSessionProcessProbe_IsProcessAlive_Call{Call: _e.mock.On("IsProcessAlive", ctx, pid)}
10967+}
10968+
10969+func (_c *MockSessionProcessProbe_IsProcessAlive_Call) Run(run func(ctx context.Context, pid int)) *MockSessionProcessProbe_IsProcessAlive_Call {
10970+ _c.Call.Run(func(args mock.Arguments) {
10971+ var arg0 context.Context
10972+ if args[0] != nil {
10973+ arg0 = args[0].(context.Context)
10974+ }
10975+ var arg1 int
10976+ if args[1] != nil {
10977+ arg1 = args[1].(int)
10978+ }
10979+ run(
10980+ arg0,
10981+ arg1,
10982+ )
10983+ })
10984+ return _c
10985+}
10986+
10987+func (_c *MockSessionProcessProbe_IsProcessAlive_Call) Return(b bool, err error) *MockSessionProcessProbe_IsProcessAlive_Call {
10988+ _c.Call.Return(b, err)
10989+ return _c
10990+}
10991+
10992+func (_c *MockSessionProcessProbe_IsProcessAlive_Call) RunAndReturn(run func(ctx context.Context, pid int) (bool, error)) *MockSessionProcessProbe_IsProcessAlive_Call {
10993+ _c.Call.Return(run)
10994+ return _c
10995+}
10996+
10678 // NewMockWindowStateProvider creates a new instance of MockWindowStateProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.10997 // NewMockWindowStateProvider creates a new instance of MockWindowStateProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
10679 // The first argument is typically a *testing.T value.10998 // The first argument is typically a *testing.T value.
10680 func NewMockWindowStateProvider(t interface {10999 func NewMockWindowStateProvider(t interface {
A internal/application/port/session_process.go +8 -0
@@ -0,0 +1,8 @@
1+package port
2+
3+import "context"
4+
5+// SessionProcessProbe checks whether a process recorded for a browser session is still alive.
6+type SessionProcessProbe interface {
7+ IsProcessAlive(ctx context.Context, pid int) (bool, error)
8+}
@@ -0,0 +1,8 @@
1+package port
2+
3+import "context"
4+
5+// SessionProcessProbe checks whether a process recorded for a browser session is still alive.
6+type SessionProcessProbe interface {
7+ IsProcessAlive(ctx context.Context, pid int) (bool, error)
8+}
M internal/application/usecase/cleanup_sessions.go +67 -3
@@ -4,19 +4,26 @@ import (
44 "context"
55 "time"
66
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
79 "github.com/bnema/dumber/internal/domain/repository"
810 "github.com/bnema/dumber/internal/logging"
911 )
1012
1113 // CleanupSessionsUseCase handles automatic cleanup of old exited sessions.
1214 type CleanupSessionsUseCase struct {
13- sessionRepo repository.SessionRepository
15+ sessionRepo repository.SessionRepository
16+ processProbe port.SessionProcessProbe
1417 }
1518
1619 // NewCleanupSessionsUseCase creates a new CleanupSessionsUseCase.
17-func NewCleanupSessionsUseCase(sessionRepo repository.SessionRepository) *CleanupSessionsUseCase {
20+func NewCleanupSessionsUseCase(
21+ sessionRepo repository.SessionRepository,
22+ processProbe port.SessionProcessProbe,
23+) *CleanupSessionsUseCase {
1824 return &CleanupSessionsUseCase{
19- sessionRepo: sessionRepo,
25+ sessionRepo: sessionRepo,
26+ processProbe: processProbe,
2027 }
2128 }
2229
@@ -44,6 +51,63 @@ type CleanupSessionsOutput struct {
4451 TotalDeleted int64
4552 }
4653
54+type ReconcileActiveBrowserSessionsInput struct {
55+ CurrentSessionID entity.SessionID
56+ RecentLimit int
57+ EndedAt time.Time
58+}
59+
60+type ReconcileActiveBrowserSessionsOutput struct {
61+ EndedDeadSessions int64
62+}
63+
64+// ReconcileActiveBrowserSessions ends only active browser sessions whose recorded process is proven dead.
65+func (uc *CleanupSessionsUseCase) ReconcileActiveBrowserSessions(
66+ ctx context.Context,
67+ input ReconcileActiveBrowserSessionsInput,
68+) (ReconcileActiveBrowserSessionsOutput, error) {
69+ log := logging.FromContext(ctx)
70+ output := ReconcileActiveBrowserSessionsOutput{}
71+ if uc.processProbe == nil {
72+ return output, nil
73+ }
74+ if input.RecentLimit <= 0 {
75+ input.RecentLimit = 20
76+ }
77+ endedAt := input.EndedAt
78+ if endedAt.IsZero() {
79+ endedAt = time.Now()
80+ }
81+
82+ recent, err := uc.sessionRepo.GetRecent(ctx, input.RecentLimit)
83+ if err != nil {
84+ return output, err
85+ }
86+ for _, s := range recent {
87+ if s == nil || s.ID == input.CurrentSessionID || s.Type != entity.SessionTypeBrowser || !s.IsActive() {
88+ continue
89+ }
90+ if s.ProcessID == nil {
91+ log.Debug().Str("session_id", string(s.ID)).Msg("skipping active session without process id")
92+ continue
93+ }
94+ alive, probeErr := uc.processProbe.IsProcessAlive(ctx, *s.ProcessID)
95+ if probeErr != nil {
96+ log.Warn().Err(probeErr).Str("session_id", string(s.ID)).Int("pid", *s.ProcessID).Msg("failed to probe session process")
97+ continue
98+ }
99+ if alive {
100+ continue
101+ }
102+ if err := uc.sessionRepo.MarkEnded(ctx, s.ID, endedAt); err != nil {
103+ return output, err
104+ }
105+ log.Info().Str("session_id", string(s.ID)).Int("pid", *s.ProcessID).Msg("ended dead browser session")
106+ output.EndedDeadSessions++
107+ }
108+ return output, nil
109+}
110+
47111 // Execute cleans up old exited sessions based on the provided configuration.
48112 // It first deletes sessions older than MaxExitedSessionAgeDays,
49113 // then enforces the MaxExitedSessions count limit.
@@ -4,19 +4,26 @@ import (
4 "context"4 "context"
5 "time"5 "time"
6 6
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
7 "github.com/bnema/dumber/internal/domain/repository"9 "github.com/bnema/dumber/internal/domain/repository"
8 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
9 )11 )
10 12
11 // CleanupSessionsUseCase handles automatic cleanup of old exited sessions.13 // CleanupSessionsUseCase handles automatic cleanup of old exited sessions.
12 type CleanupSessionsUseCase struct {14 type CleanupSessionsUseCase struct {
13- sessionRepo repository.SessionRepository15+ sessionRepo repository.SessionRepository
16+ processProbe port.SessionProcessProbe
14 }17 }
15 18
16 // NewCleanupSessionsUseCase creates a new CleanupSessionsUseCase.19 // NewCleanupSessionsUseCase creates a new CleanupSessionsUseCase.
17-func NewCleanupSessionsUseCase(sessionRepo repository.SessionRepository) *CleanupSessionsUseCase {20+func NewCleanupSessionsUseCase(
21+ sessionRepo repository.SessionRepository,
22+ processProbe port.SessionProcessProbe,
23+) *CleanupSessionsUseCase {
18 return &CleanupSessionsUseCase{24 return &CleanupSessionsUseCase{
19- sessionRepo: sessionRepo,25+ sessionRepo: sessionRepo,
26+ processProbe: processProbe,
20 }27 }
21 }28 }
22 29
@@ -44,6 +51,63 @@ type CleanupSessionsOutput struct {
44 TotalDeleted int6451 TotalDeleted int64
45 }52 }
46 53
54+type ReconcileActiveBrowserSessionsInput struct {
55+ CurrentSessionID entity.SessionID
56+ RecentLimit int
57+ EndedAt time.Time
58+}
59+
60+type ReconcileActiveBrowserSessionsOutput struct {
61+ EndedDeadSessions int64
62+}
63+
64+// ReconcileActiveBrowserSessions ends only active browser sessions whose recorded process is proven dead.
65+func (uc *CleanupSessionsUseCase) ReconcileActiveBrowserSessions(
66+ ctx context.Context,
67+ input ReconcileActiveBrowserSessionsInput,
68+) (ReconcileActiveBrowserSessionsOutput, error) {
69+ log := logging.FromContext(ctx)
70+ output := ReconcileActiveBrowserSessionsOutput{}
71+ if uc.processProbe == nil {
72+ return output, nil
73+ }
74+ if input.RecentLimit <= 0 {
75+ input.RecentLimit = 20
76+ }
77+ endedAt := input.EndedAt
78+ if endedAt.IsZero() {
79+ endedAt = time.Now()
80+ }
81+
82+ recent, err := uc.sessionRepo.GetRecent(ctx, input.RecentLimit)
83+ if err != nil {
84+ return output, err
85+ }
86+ for _, s := range recent {
87+ if s == nil || s.ID == input.CurrentSessionID || s.Type != entity.SessionTypeBrowser || !s.IsActive() {
88+ continue
89+ }
90+ if s.ProcessID == nil {
91+ log.Debug().Str("session_id", string(s.ID)).Msg("skipping active session without process id")
92+ continue
93+ }
94+ alive, probeErr := uc.processProbe.IsProcessAlive(ctx, *s.ProcessID)
95+ if probeErr != nil {
96+ log.Warn().Err(probeErr).Str("session_id", string(s.ID)).Int("pid", *s.ProcessID).Msg("failed to probe session process")
97+ continue
98+ }
99+ if alive {
100+ continue
101+ }
102+ if err := uc.sessionRepo.MarkEnded(ctx, s.ID, endedAt); err != nil {
103+ return output, err
104+ }
105+ log.Info().Str("session_id", string(s.ID)).Int("pid", *s.ProcessID).Msg("ended dead browser session")
106+ output.EndedDeadSessions++
107+ }
108+ return output, nil
109+}
110+
47 // Execute cleans up old exited sessions based on the provided configuration.111 // Execute cleans up old exited sessions based on the provided configuration.
48 // It first deletes sessions older than MaxExitedSessionAgeDays,112 // It first deletes sessions older than MaxExitedSessionAgeDays,
49 // then enforces the MaxExitedSessions count limit.113 // then enforces the MaxExitedSessions count limit.
M internal/application/usecase/cleanup_sessions_test.go +77 -8
@@ -5,13 +5,82 @@ import (
55 "testing"
66 "time"
77
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
89 "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
911 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
1012 "github.com/stretchr/testify/assert"
1113 "github.com/stretchr/testify/mock"
1214 "github.com/stretchr/testify/require"
1315 )
1416
17+func TestCleanupSessionsUseCase_ReconcileActiveBrowserSessions_EndsOnlyProvenDeadSessions(t *testing.T) {
18+ ctx := testContext()
19+
20+ sessionRepo := repomocks.NewMockSessionRepository(t)
21+ processProbe := portmocks.NewMockSessionProcessProbe(t)
22+
23+ now := time.Date(2026, 6, 18, 20, 0, 0, 0, time.UTC)
24+ livePID := 111
25+ deadPID := 222
26+ current := &entity.Session{ID: "current", Type: entity.SessionTypeBrowser, StartedAt: now}
27+ live := &entity.Session{ID: "live", Type: entity.SessionTypeBrowser, StartedAt: now.Add(-time.Minute), ProcessID: &livePID}
28+ dead := &entity.Session{ID: "dead", Type: entity.SessionTypeBrowser, StartedAt: now.Add(-2 * time.Minute), ProcessID: &deadPID}
29+ unknown := &entity.Session{ID: "unknown", Type: entity.SessionTypeBrowser, StartedAt: now.Add(-3 * time.Minute)}
30+
31+ sessionRepo.EXPECT().
32+ GetRecent(ctx, 25).
33+ Return([]*entity.Session{current, live, dead, unknown}, nil)
34+ processProbe.EXPECT().
35+ IsProcessAlive(ctx, livePID).
36+ Return(true, nil)
37+ processProbe.EXPECT().
38+ IsProcessAlive(ctx, deadPID).
39+ Return(false, nil)
40+ sessionRepo.EXPECT().
41+ MarkEnded(ctx, dead.ID, now).
42+ Return(nil)
43+
44+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, processProbe)
45+
46+ output, err := uc.ReconcileActiveBrowserSessions(ctx, usecase.ReconcileActiveBrowserSessionsInput{
47+ CurrentSessionID: current.ID,
48+ RecentLimit: 25,
49+ EndedAt: now,
50+ })
51+
52+ require.NoError(t, err)
53+ assert.Equal(t, int64(1), output.EndedDeadSessions)
54+}
55+
56+func TestCleanupSessionsUseCase_ReconcileActiveBrowserSessions_SkipsWhenProbeErrors(t *testing.T) {
57+ ctx := testContext()
58+
59+ sessionRepo := repomocks.NewMockSessionRepository(t)
60+ processProbe := portmocks.NewMockSessionProcessProbe(t)
61+
62+ now := time.Date(2026, 6, 18, 20, 30, 0, 0, time.UTC)
63+ pid := 333
64+ active := &entity.Session{ID: "active", Type: entity.SessionTypeBrowser, StartedAt: now.Add(-time.Minute), ProcessID: &pid}
65+
66+ sessionRepo.EXPECT().
67+ GetRecent(ctx, 20).
68+ Return([]*entity.Session{active}, nil)
69+ processProbe.EXPECT().
70+ IsProcessAlive(ctx, pid).
71+ Return(false, errors.New("probe failed"))
72+
73+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, processProbe)
74+
75+ output, err := uc.ReconcileActiveBrowserSessions(ctx, usecase.ReconcileActiveBrowserSessionsInput{
76+ RecentLimit: 20,
77+ EndedAt: now,
78+ })
79+
80+ require.NoError(t, err)
81+ assert.Equal(t, int64(0), output.EndedDeadSessions)
82+}
83+
1584 func TestCleanupSessionsUseCase_Execute_AgeAndCountCleanup(t *testing.T) {
1685 ctx := testContext()
1786
@@ -32,7 +101,7 @@ func TestCleanupSessionsUseCase_Execute_AgeAndCountCleanup(t *testing.T) {
32101 DeleteOldestExited(ctx, 50).
33102 Return(int64(2), nil)
34103
35- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
104+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
36105
37106 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
38107 MaxExitedSessions: 50,
@@ -57,7 +126,7 @@ func TestCleanupSessionsUseCase_Execute_AgeOnlyCleanup(t *testing.T) {
57126
58127 // Count-based with -1 should be skipped (disabled)
59128 // MaxExitedSessions >= 0 triggers count cleanup, so use -1 to disable
60- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
129+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
61130
62131 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
63132 MaxExitedSessions: -1, // Disabled
@@ -80,7 +149,7 @@ func TestCleanupSessionsUseCase_Execute_CountOnlyCleanup(t *testing.T) {
80149 DeleteOldestExited(ctx, 10).
81150 Return(int64(4), nil)
82151
83- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
152+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
84153
85154 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
86155 MaxExitedSessions: 10,
@@ -107,7 +176,7 @@ func TestCleanupSessionsUseCase_Execute_NoCleanupNeeded(t *testing.T) {
107176 DeleteOldestExited(ctx, 50).
108177 Return(int64(0), nil)
109178
110- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
179+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
111180
112181 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
113182 MaxExitedSessions: 50,
@@ -135,7 +204,7 @@ func TestCleanupSessionsUseCase_Execute_AgeDeleteError_ContinuesToCount(t *testi
135204 DeleteOldestExited(ctx, 50).
136205 Return(int64(3), nil)
137206
138- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
207+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
139208
140209 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
141210 MaxExitedSessions: 50,
@@ -164,7 +233,7 @@ func TestCleanupSessionsUseCase_Execute_CountDeleteError(t *testing.T) {
164233 DeleteOldestExited(ctx, 50).
165234 Return(int64(0), errors.New("db error"))
166235
167- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
236+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
168237
169238 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
170239 MaxExitedSessions: 50,
@@ -187,7 +256,7 @@ func TestCleanupSessionsUseCase_Execute_BothDisabled(t *testing.T) {
187256 // Actually, count with 0 means "keep 0" which will trigger cleanup
188257 // So we need -1 for count and 0 for age to fully disable
189258
190- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
259+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
191260
192261 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
193262 MaxExitedSessions: -1, // Disabled
@@ -210,7 +279,7 @@ func TestCleanupSessionsUseCase_Execute_ZeroMaxSessions(t *testing.T) {
210279 DeleteOldestExited(ctx, 0).
211280 Return(int64(10), nil)
212281
213- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
282+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
214283
215284 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
216285 MaxExitedSessions: 0, // Keep 0 = delete all
@@ -5,13 +5,82 @@ import (
5 "testing"5 "testing"
6 "time"6 "time"
7 7
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
8 "github.com/bnema/dumber/internal/application/usecase"9 "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
9 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"11 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
13 )15 )
14 16
17+func TestCleanupSessionsUseCase_ReconcileActiveBrowserSessions_EndsOnlyProvenDeadSessions(t *testing.T) {
18+ ctx := testContext()
19+
20+ sessionRepo := repomocks.NewMockSessionRepository(t)
21+ processProbe := portmocks.NewMockSessionProcessProbe(t)
22+
23+ now := time.Date(2026, 6, 18, 20, 0, 0, 0, time.UTC)
24+ livePID := 111
25+ deadPID := 222
26+ current := &entity.Session{ID: "current", Type: entity.SessionTypeBrowser, StartedAt: now}
27+ live := &entity.Session{ID: "live", Type: entity.SessionTypeBrowser, StartedAt: now.Add(-time.Minute), ProcessID: &livePID}
28+ dead := &entity.Session{ID: "dead", Type: entity.SessionTypeBrowser, StartedAt: now.Add(-2 * time.Minute), ProcessID: &deadPID}
29+ unknown := &entity.Session{ID: "unknown", Type: entity.SessionTypeBrowser, StartedAt: now.Add(-3 * time.Minute)}
30+
31+ sessionRepo.EXPECT().
32+ GetRecent(ctx, 25).
33+ Return([]*entity.Session{current, live, dead, unknown}, nil)
34+ processProbe.EXPECT().
35+ IsProcessAlive(ctx, livePID).
36+ Return(true, nil)
37+ processProbe.EXPECT().
38+ IsProcessAlive(ctx, deadPID).
39+ Return(false, nil)
40+ sessionRepo.EXPECT().
41+ MarkEnded(ctx, dead.ID, now).
42+ Return(nil)
43+
44+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, processProbe)
45+
46+ output, err := uc.ReconcileActiveBrowserSessions(ctx, usecase.ReconcileActiveBrowserSessionsInput{
47+ CurrentSessionID: current.ID,
48+ RecentLimit: 25,
49+ EndedAt: now,
50+ })
51+
52+ require.NoError(t, err)
53+ assert.Equal(t, int64(1), output.EndedDeadSessions)
54+}
55+
56+func TestCleanupSessionsUseCase_ReconcileActiveBrowserSessions_SkipsWhenProbeErrors(t *testing.T) {
57+ ctx := testContext()
58+
59+ sessionRepo := repomocks.NewMockSessionRepository(t)
60+ processProbe := portmocks.NewMockSessionProcessProbe(t)
61+
62+ now := time.Date(2026, 6, 18, 20, 30, 0, 0, time.UTC)
63+ pid := 333
64+ active := &entity.Session{ID: "active", Type: entity.SessionTypeBrowser, StartedAt: now.Add(-time.Minute), ProcessID: &pid}
65+
66+ sessionRepo.EXPECT().
67+ GetRecent(ctx, 20).
68+ Return([]*entity.Session{active}, nil)
69+ processProbe.EXPECT().
70+ IsProcessAlive(ctx, pid).
71+ Return(false, errors.New("probe failed"))
72+
73+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, processProbe)
74+
75+ output, err := uc.ReconcileActiveBrowserSessions(ctx, usecase.ReconcileActiveBrowserSessionsInput{
76+ RecentLimit: 20,
77+ EndedAt: now,
78+ })
79+
80+ require.NoError(t, err)
81+ assert.Equal(t, int64(0), output.EndedDeadSessions)
82+}
83+
15 func TestCleanupSessionsUseCase_Execute_AgeAndCountCleanup(t *testing.T) {84 func TestCleanupSessionsUseCase_Execute_AgeAndCountCleanup(t *testing.T) {
16 ctx := testContext()85 ctx := testContext()
17 86
@@ -32,7 +101,7 @@ func TestCleanupSessionsUseCase_Execute_AgeAndCountCleanup(t *testing.T) {
32 DeleteOldestExited(ctx, 50).101 DeleteOldestExited(ctx, 50).
33 Return(int64(2), nil)102 Return(int64(2), nil)
34 103
35- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)104+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
36 105
37 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{106 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
38 MaxExitedSessions: 50,107 MaxExitedSessions: 50,
@@ -57,7 +126,7 @@ func TestCleanupSessionsUseCase_Execute_AgeOnlyCleanup(t *testing.T) {
57 126
58 // Count-based with -1 should be skipped (disabled)127 // Count-based with -1 should be skipped (disabled)
59 // MaxExitedSessions >= 0 triggers count cleanup, so use -1 to disable128 // MaxExitedSessions >= 0 triggers count cleanup, so use -1 to disable
60- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)129+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
61 130
62 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{131 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
63 MaxExitedSessions: -1, // Disabled132 MaxExitedSessions: -1, // Disabled
@@ -80,7 +149,7 @@ func TestCleanupSessionsUseCase_Execute_CountOnlyCleanup(t *testing.T) {
80 DeleteOldestExited(ctx, 10).149 DeleteOldestExited(ctx, 10).
81 Return(int64(4), nil)150 Return(int64(4), nil)
82 151
83- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)152+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
84 153
85 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{154 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
86 MaxExitedSessions: 10,155 MaxExitedSessions: 10,
@@ -107,7 +176,7 @@ func TestCleanupSessionsUseCase_Execute_NoCleanupNeeded(t *testing.T) {
107 DeleteOldestExited(ctx, 50).176 DeleteOldestExited(ctx, 50).
108 Return(int64(0), nil)177 Return(int64(0), nil)
109 178
110- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)179+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
111 180
112 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{181 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
113 MaxExitedSessions: 50,182 MaxExitedSessions: 50,
@@ -135,7 +204,7 @@ func TestCleanupSessionsUseCase_Execute_AgeDeleteError_ContinuesToCount(t *testi
135 DeleteOldestExited(ctx, 50).204 DeleteOldestExited(ctx, 50).
136 Return(int64(3), nil)205 Return(int64(3), nil)
137 206
138- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)207+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
139 208
140 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{209 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
141 MaxExitedSessions: 50,210 MaxExitedSessions: 50,
@@ -164,7 +233,7 @@ func TestCleanupSessionsUseCase_Execute_CountDeleteError(t *testing.T) {
164 DeleteOldestExited(ctx, 50).233 DeleteOldestExited(ctx, 50).
165 Return(int64(0), errors.New("db error"))234 Return(int64(0), errors.New("db error"))
166 235
167- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)236+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
168 237
169 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{238 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
170 MaxExitedSessions: 50,239 MaxExitedSessions: 50,
@@ -187,7 +256,7 @@ func TestCleanupSessionsUseCase_Execute_BothDisabled(t *testing.T) {
187 // Actually, count with 0 means "keep 0" which will trigger cleanup256 // Actually, count with 0 means "keep 0" which will trigger cleanup
188 // So we need -1 for count and 0 for age to fully disable257 // So we need -1 for count and 0 for age to fully disable
189 258
190- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)259+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
191 260
192 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{261 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
193 MaxExitedSessions: -1, // Disabled262 MaxExitedSessions: -1, // Disabled
@@ -210,7 +279,7 @@ func TestCleanupSessionsUseCase_Execute_ZeroMaxSessions(t *testing.T) {
210 DeleteOldestExited(ctx, 0).279 DeleteOldestExited(ctx, 0).
211 Return(int64(10), nil)280 Return(int64(10), nil)
212 281
213- uc := usecase.NewCleanupSessionsUseCase(sessionRepo)282+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil)
214 283
215 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{284 output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
216 MaxExitedSessions: 0, // Keep 0 = delete all285 MaxExitedSessions: 0, // Keep 0 = delete all
M internal/bootstrap/session.go +14 -16
@@ -18,6 +18,7 @@ import (
1818 "github.com/bnema/dumber/internal/domain/repository"
1919 "github.com/bnema/dumber/internal/infrastructure/config"
2020 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
21+ "github.com/bnema/dumber/internal/infrastructure/process"
2122 corelogging "github.com/bnema/dumber/internal/logging"
2223 "github.com/rs/zerolog"
2324 )
@@ -86,13 +87,15 @@ func StartBrowserSession(
8687
8788 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
8889 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
89- cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
90+ cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo, process.NewLivenessProbe())
9091
9192 now := time.Now()
93+ pid := os.Getpid()
9294 session := &entity.Session{
9395 ID: entity.SessionID(corelogging.GenerateSessionID()),
9496 Type: entity.SessionTypeBrowser,
9597 StartedAt: now.UTC(),
98+ ProcessID: &pid,
9699 }
97100 if err := session.Validate(); err != nil {
98101 return nil, ctx, err
@@ -160,7 +163,7 @@ func StartBrowserSession(
160163 }
161164
162165 // Background cleanup of old sessions.
163- go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, logDir, session.ID, log)
166+ go runSessionCleanup(persistCtx, cleanupUC, cfg, logDir, session.ID, log)
164167 })
165168 return persistErr
166169 }
@@ -311,7 +314,6 @@ func sessionIDFromLogPath(logPath string) string {
311314
312315 func runSessionCleanup(
313316 startupCtx context.Context,
314- sessionUC *usecase.ManageSessionUseCase,
315317 cleanupUC *usecase.CleanupSessionsUseCase,
316318 cfg *config.Config,
317319 logDir string,
@@ -320,22 +322,18 @@ func runSessionCleanup(
320322 ) {
321323 bgCtx := context.WithoutCancel(startupCtx)
322324
323- // End stale active sessions that have no running process.
324- recent, err := sessionUC.GetRecentSessions(bgCtx, recentSessionsLimit)
325+ // End only active browser sessions whose recorded process is proven dead.
326+ staleOutput, err := cleanupUC.ReconcileActiveBrowserSessions(bgCtx, usecase.ReconcileActiveBrowserSessionsInput{
327+ CurrentSessionID: currentSessionID,
328+ RecentLimit: recentSessionsLimit,
329+ EndedAt: time.Now(),
330+ })
325331 if err != nil {
326332 if log != nil {
327- log.Warn().Err(err).Msg("background: failed to list recent sessions")
328- }
329- } else {
330- now := time.Now()
331- for _, s := range recent {
332- if s != nil &&
333- s.ID != currentSessionID &&
334- s.Type == entity.SessionTypeBrowser &&
335- s.IsActive() {
336- _ = sessionUC.EndSession(bgCtx, s.ID, now)
337- }
333+ log.Warn().Err(err).Msg("background: failed to reconcile active sessions")
338334 }
335+ } else if staleOutput.EndedDeadSessions > 0 && log != nil {
336+ log.Info().Int64("ended", staleOutput.EndedDeadSessions).Msg("background: ended dead browser sessions")
339337 }
340338
341339 // Clean up old exited sessions.
@@ -18,6 +18,7 @@ import (
18 "github.com/bnema/dumber/internal/domain/repository"18 "github.com/bnema/dumber/internal/domain/repository"
19 "github.com/bnema/dumber/internal/infrastructure/config"19 "github.com/bnema/dumber/internal/infrastructure/config"
20 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"20 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
21+ "github.com/bnema/dumber/internal/infrastructure/process"
21 corelogging "github.com/bnema/dumber/internal/logging"22 corelogging "github.com/bnema/dumber/internal/logging"
22 "github.com/rs/zerolog"23 "github.com/rs/zerolog"
23 )24 )
@@ -86,13 +87,15 @@ func StartBrowserSession(
86 87
87 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()88 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
88 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)89 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
89- cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)90+ cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo, process.NewLivenessProbe())
90 91
91 now := time.Now()92 now := time.Now()
93+ pid := os.Getpid()
92 session := &entity.Session{94 session := &entity.Session{
93 ID: entity.SessionID(corelogging.GenerateSessionID()),95 ID: entity.SessionID(corelogging.GenerateSessionID()),
94 Type: entity.SessionTypeBrowser,96 Type: entity.SessionTypeBrowser,
95 StartedAt: now.UTC(),97 StartedAt: now.UTC(),
98+ ProcessID: &pid,
96 }99 }
97 if err := session.Validate(); err != nil {100 if err := session.Validate(); err != nil {
98 return nil, ctx, err101 return nil, ctx, err
@@ -160,7 +163,7 @@ func StartBrowserSession(
160 }163 }
161 164
162 // Background cleanup of old sessions.165 // Background cleanup of old sessions.
163- go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, logDir, session.ID, log)166+ go runSessionCleanup(persistCtx, cleanupUC, cfg, logDir, session.ID, log)
164 })167 })
165 return persistErr168 return persistErr
166 }169 }
@@ -311,7 +314,6 @@ func sessionIDFromLogPath(logPath string) string {
311 314
312 func runSessionCleanup(315 func runSessionCleanup(
313 startupCtx context.Context,316 startupCtx context.Context,
314- sessionUC *usecase.ManageSessionUseCase,
315 cleanupUC *usecase.CleanupSessionsUseCase,317 cleanupUC *usecase.CleanupSessionsUseCase,
316 cfg *config.Config,318 cfg *config.Config,
317 logDir string,319 logDir string,
@@ -320,22 +322,18 @@ func runSessionCleanup(
320 ) {322 ) {
321 bgCtx := context.WithoutCancel(startupCtx)323 bgCtx := context.WithoutCancel(startupCtx)
322 324
323- // End stale active sessions that have no running process.325+ // End only active browser sessions whose recorded process is proven dead.
324- recent, err := sessionUC.GetRecentSessions(bgCtx, recentSessionsLimit)326+ staleOutput, err := cleanupUC.ReconcileActiveBrowserSessions(bgCtx, usecase.ReconcileActiveBrowserSessionsInput{
327+ CurrentSessionID: currentSessionID,
328+ RecentLimit: recentSessionsLimit,
329+ EndedAt: time.Now(),
330+ })
325 if err != nil {331 if err != nil {
326 if log != nil {332 if log != nil {
327- log.Warn().Err(err).Msg("background: failed to list recent sessions")333+ log.Warn().Err(err).Msg("background: failed to reconcile active sessions")
328- }
329- } else {
330- now := time.Now()
331- for _, s := range recent {
332- if s != nil &&
333- s.ID != currentSessionID &&
334- s.Type == entity.SessionTypeBrowser &&
335- s.IsActive() {
336- _ = sessionUC.EndSession(bgCtx, s.ID, now)
337- }
338 }334 }
335+ } else if staleOutput.EndedDeadSessions > 0 && log != nil {
336+ log.Info().Int64("ended", staleOutput.EndedDeadSessions).Msg("background: ended dead browser sessions")
339 }337 }
340 338
341 // Clean up old exited sessions.339 // Clean up old exited sessions.
M internal/bootstrap/session_test.go +18 -17
@@ -13,13 +13,20 @@ import (
1313 "github.com/stretchr/testify/require"
1414 )
1515
16-func TestRunSessionCleanup_SkipsCurrentActiveSession(t *testing.T) {
16+type fakeProcessProbe struct {
17+ alive map[int]bool
18+}
19+
20+func (p fakeProcessProbe) IsProcessAlive(_ context.Context, pid int) (bool, error) {
21+ return p.alive[pid], nil
22+}
23+
24+func TestRunSessionCleanup_DoesNotEndOtherActiveBrowserSessionsWithoutLivenessProof(t *testing.T) {
1725 t.Parallel()
1826
1927 ctx := context.Background()
2028 repo := repomocks.NewMockSessionRepository(t)
21- sessionUC := usecase.NewManageSessionUseCase(repo, nil)
22- cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
29+ cleanupUC := usecase.NewCleanupSessionsUseCase(repo, fakeProcessProbe{alive: map[int]bool{1234: true}})
2330 cfg := config.DefaultConfig()
2431 logDir := t.TempDir()
2532
@@ -29,24 +36,19 @@ func TestRunSessionCleanup_SkipsCurrentActiveSession(t *testing.T) {
2936 Type: entity.SessionTypeBrowser,
3037 StartedAt: now,
3138 }
32- stale := &entity.Session{
33- ID: entity.SessionID("stale"),
39+ livePID := 1234
40+ live := &entity.Session{
41+ ID: entity.SessionID("live-other-process"),
3442 Type: entity.SessionTypeBrowser,
3543 StartedAt: now.Add(-time.Hour),
44+ ProcessID: &livePID,
3645 }
3746
38- repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{current, stale}, nil).Once()
39- repo.EXPECT().MarkEnded(mock.Anything, stale.ID, mock.MatchedBy(func(t time.Time) bool {
40- diff := t.Sub(now)
41- if diff < 0 {
42- diff = -diff
43- }
44- return diff < 2*time.Second
45- })).Return(nil).Once()
47+ repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{current, live}, nil).Once()
4648 repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()
4749 repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
4850
49- runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, logDir, current.ID, nil)
51+ runSessionCleanup(ctx, cleanupUC, cfg, logDir, current.ID, nil)
5052 }
5153
5254 func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
@@ -54,8 +56,7 @@ func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
5456
5557 ctx := context.Background()
5658 repo := repomocks.NewMockSessionRepository(t)
57- sessionUC := usecase.NewManageSessionUseCase(repo, nil)
58- cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
59+ cleanupUC := usecase.NewCleanupSessionsUseCase(repo, fakeProcessProbe{})
5960 cfg := config.DefaultConfig()
6061 logDir := t.TempDir()
6162
@@ -64,6 +65,6 @@ func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
6465 repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
6566
6667 require.NotPanics(t, func() {
67- runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, logDir, entity.SessionID("current"), nil)
68+ runSessionCleanup(ctx, cleanupUC, cfg, logDir, entity.SessionID("current"), nil)
6869 })
6970 }
@@ -13,13 +13,20 @@ import (
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 )14 )
15 15
16-func TestRunSessionCleanup_SkipsCurrentActiveSession(t *testing.T) {16+type fakeProcessProbe struct {
17+ alive map[int]bool
18+}
19+
20+func (p fakeProcessProbe) IsProcessAlive(_ context.Context, pid int) (bool, error) {
21+ return p.alive[pid], nil
22+}
23+
24+func TestRunSessionCleanup_DoesNotEndOtherActiveBrowserSessionsWithoutLivenessProof(t *testing.T) {
17 t.Parallel()25 t.Parallel()
18 26
19 ctx := context.Background()27 ctx := context.Background()
20 repo := repomocks.NewMockSessionRepository(t)28 repo := repomocks.NewMockSessionRepository(t)
21- sessionUC := usecase.NewManageSessionUseCase(repo, nil)29+ cleanupUC := usecase.NewCleanupSessionsUseCase(repo, fakeProcessProbe{alive: map[int]bool{1234: true}})
22- cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
23 cfg := config.DefaultConfig()30 cfg := config.DefaultConfig()
24 logDir := t.TempDir()31 logDir := t.TempDir()
25 32
@@ -29,24 +36,19 @@ func TestRunSessionCleanup_SkipsCurrentActiveSession(t *testing.T) {
29 Type: entity.SessionTypeBrowser,36 Type: entity.SessionTypeBrowser,
30 StartedAt: now,37 StartedAt: now,
31 }38 }
32- stale := &entity.Session{39+ livePID := 1234
33- ID: entity.SessionID("stale"),40+ live := &entity.Session{
41+ ID: entity.SessionID("live-other-process"),
34 Type: entity.SessionTypeBrowser,42 Type: entity.SessionTypeBrowser,
35 StartedAt: now.Add(-time.Hour),43 StartedAt: now.Add(-time.Hour),
44+ ProcessID: &livePID,
36 }45 }
37 46
38- repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{current, stale}, nil).Once()47+ repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{current, live}, nil).Once()
39- repo.EXPECT().MarkEnded(mock.Anything, stale.ID, mock.MatchedBy(func(t time.Time) bool {
40- diff := t.Sub(now)
41- if diff < 0 {
42- diff = -diff
43- }
44- return diff < 2*time.Second
45- })).Return(nil).Once()
46 repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()48 repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()
47 repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()49 repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
48 50
49- runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, logDir, current.ID, nil)51+ runSessionCleanup(ctx, cleanupUC, cfg, logDir, current.ID, nil)
50 }52 }
51 53
52 func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {54 func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
@@ -54,8 +56,7 @@ func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
54 56
55 ctx := context.Background()57 ctx := context.Background()
56 repo := repomocks.NewMockSessionRepository(t)58 repo := repomocks.NewMockSessionRepository(t)
57- sessionUC := usecase.NewManageSessionUseCase(repo, nil)59+ cleanupUC := usecase.NewCleanupSessionsUseCase(repo, fakeProcessProbe{})
58- cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
59 cfg := config.DefaultConfig()60 cfg := config.DefaultConfig()
60 logDir := t.TempDir()61 logDir := t.TempDir()
61 62
@@ -64,6 +65,6 @@ func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
64 repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()65 repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
65 66
66 require.NotPanics(t, func() {67 require.NotPanics(t, func() {
67- runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, logDir, entity.SessionID("current"), nil)68+ runSessionCleanup(ctx, cleanupUC, cfg, logDir, entity.SessionID("current"), nil)
68 })69 })
69 }70 }
M internal/domain/entity/session.go +1 -0
@@ -24,6 +24,7 @@ type Session struct {
2424 Type SessionType
2525 StartedAt time.Time
2626 EndedAt *time.Time
27+ ProcessID *int
2728 }
2829
2930 func (s *Session) ShortID() string {
@@ -24,6 +24,7 @@ type Session struct {
24 Type SessionType24 Type SessionType
25 StartedAt time.Time25 StartedAt time.Time
26 EndedAt *time.Time26 EndedAt *time.Time
27+ ProcessID *int
27 }28 }
28 29
29 func (s *Session) ShortID() string {30 func (s *Session) ShortID() string {
M internal/infrastructure/filtering/downloader.go +46 -1
@@ -9,6 +9,7 @@ import (
99 "os"
1010 pathpkg "path"
1111 "path/filepath"
12+ "slices"
1213 "strings"
1314 "time"
1415
@@ -202,12 +203,46 @@ func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(Downlo
202203 // Cache the manifest we already fetched
203204 if err := d.cacheManifest(manifest); err != nil {
204205 log.Warn().Err(err).Msg("failed to cache manifest")
206+ } else if err := d.pruneObsoleteCachedFilterFiles(files); err != nil {
207+ log.Warn().Err(err).Msg("failed to prune obsolete cached filter files")
205208 }
206209
207210 log.Info().Int("files", len(paths)).Int64("total_bytes", totalBytes).Msg("filter download complete")
208211 return paths, nil
209212 }
210213
214+func (d *Downloader) pruneObsoleteCachedFilterFiles(currentFiles []string) error {
215+ current := make(map[string]struct{}, len(currentFiles))
216+ for _, filename := range currentFiles {
217+ _, safeFilename, err := d.cachePathForFilterFile(filename)
218+ if err != nil {
219+ return err
220+ }
221+ current[safeFilename] = struct{}{}
222+ }
223+
224+ entries, err := os.ReadDir(d.cacheDir)
225+ if err != nil {
226+ return err
227+ }
228+ for _, entry := range entries {
229+ if entry.IsDir() {
230+ continue
231+ }
232+ name := entry.Name()
233+ if !strings.HasPrefix(name, "combined-part") || !strings.HasSuffix(name, ".json") {
234+ continue
235+ }
236+ if _, ok := current[name]; ok {
237+ continue
238+ }
239+ if err := os.Remove(filepath.Join(d.cacheDir, name)); err != nil {
240+ return fmt.Errorf("remove obsolete cached filter %s: %w", name, err)
241+ }
242+ }
243+ return nil
244+}
245+
211246 // downloadFile downloads a single file and returns its local path.
212247 func (d *Downloader) downloadFile(ctx context.Context, filename string, maxBytes int64) (string, int64, error) {
213248 localPath, safeFilename, err := d.cachePathForFilterFile(filename)
@@ -420,7 +455,7 @@ func (d *Downloader) NeedsUpdate(ctx context.Context) (bool, error) {
420455 return false, err // Can't check, return error
421456 }
422457
423- needsUpdate := cached.Version != latest.Version
458+ needsUpdate := !sameManifestRelease(cached, latest)
424459 if needsUpdate {
425460 log.Info().
426461 Str("cached_version", cached.Version).
@@ -433,6 +468,16 @@ func (d *Downloader) NeedsUpdate(ctx context.Context) (bool, error) {
433468 return needsUpdate, nil
434469 }
435470
471+func sameManifestRelease(cached, latest *Manifest) bool {
472+ if cached == nil || latest == nil {
473+ return false
474+ }
475+ return cached.Version == latest.Version &&
476+ cached.GeneratedAt.Equal(latest.GeneratedAt) &&
477+ cached.Combined.TotalRules == latest.Combined.TotalRules &&
478+ slices.Equal(cached.Combined.Files, latest.Combined.Files)
479+}
480+
436481 // ClearCache removes all cached filter files.
437482 func (d *Downloader) ClearCache() error {
438483 return os.RemoveAll(d.cacheDir)
@@ -9,6 +9,7 @@ import (
9 "os"9 "os"
10 pathpkg "path"10 pathpkg "path"
11 "path/filepath"11 "path/filepath"
12+ "slices"
12 "strings"13 "strings"
13 "time"14 "time"
14 15
@@ -202,12 +203,46 @@ func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(Downlo
202 // Cache the manifest we already fetched203 // Cache the manifest we already fetched
203 if err := d.cacheManifest(manifest); err != nil {204 if err := d.cacheManifest(manifest); err != nil {
204 log.Warn().Err(err).Msg("failed to cache manifest")205 log.Warn().Err(err).Msg("failed to cache manifest")
206+ } else if err := d.pruneObsoleteCachedFilterFiles(files); err != nil {
207+ log.Warn().Err(err).Msg("failed to prune obsolete cached filter files")
205 }208 }
206 209
207 log.Info().Int("files", len(paths)).Int64("total_bytes", totalBytes).Msg("filter download complete")210 log.Info().Int("files", len(paths)).Int64("total_bytes", totalBytes).Msg("filter download complete")
208 return paths, nil211 return paths, nil
209 }212 }
210 213
214+func (d *Downloader) pruneObsoleteCachedFilterFiles(currentFiles []string) error {
215+ current := make(map[string]struct{}, len(currentFiles))
216+ for _, filename := range currentFiles {
217+ _, safeFilename, err := d.cachePathForFilterFile(filename)
218+ if err != nil {
219+ return err
220+ }
221+ current[safeFilename] = struct{}{}
222+ }
223+
224+ entries, err := os.ReadDir(d.cacheDir)
225+ if err != nil {
226+ return err
227+ }
228+ for _, entry := range entries {
229+ if entry.IsDir() {
230+ continue
231+ }
232+ name := entry.Name()
233+ if !strings.HasPrefix(name, "combined-part") || !strings.HasSuffix(name, ".json") {
234+ continue
235+ }
236+ if _, ok := current[name]; ok {
237+ continue
238+ }
239+ if err := os.Remove(filepath.Join(d.cacheDir, name)); err != nil {
240+ return fmt.Errorf("remove obsolete cached filter %s: %w", name, err)
241+ }
242+ }
243+ return nil
244+}
245+
211 // downloadFile downloads a single file and returns its local path.246 // downloadFile downloads a single file and returns its local path.
212 func (d *Downloader) downloadFile(ctx context.Context, filename string, maxBytes int64) (string, int64, error) {247 func (d *Downloader) downloadFile(ctx context.Context, filename string, maxBytes int64) (string, int64, error) {
213 localPath, safeFilename, err := d.cachePathForFilterFile(filename)248 localPath, safeFilename, err := d.cachePathForFilterFile(filename)
@@ -420,7 +455,7 @@ func (d *Downloader) NeedsUpdate(ctx context.Context) (bool, error) {
420 return false, err // Can't check, return error455 return false, err // Can't check, return error
421 }456 }
422 457
423- needsUpdate := cached.Version != latest.Version458+ needsUpdate := !sameManifestRelease(cached, latest)
424 if needsUpdate {459 if needsUpdate {
425 log.Info().460 log.Info().
426 Str("cached_version", cached.Version).461 Str("cached_version", cached.Version).
@@ -433,6 +468,16 @@ func (d *Downloader) NeedsUpdate(ctx context.Context) (bool, error) {
433 return needsUpdate, nil468 return needsUpdate, nil
434 }469 }
435 470
471+func sameManifestRelease(cached, latest *Manifest) bool {
472+ if cached == nil || latest == nil {
473+ return false
474+ }
475+ return cached.Version == latest.Version &&
476+ cached.GeneratedAt.Equal(latest.GeneratedAt) &&
477+ cached.Combined.TotalRules == latest.Combined.TotalRules &&
478+ slices.Equal(cached.Combined.Files, latest.Combined.Files)
479+}
480+
436 // ClearCache removes all cached filter files.481 // ClearCache removes all cached filter files.
437 func (d *Downloader) ClearCache() error {482 func (d *Downloader) ClearCache() error {
438 return os.RemoveAll(d.cacheDir)483 return os.RemoveAll(d.cacheDir)
M internal/infrastructure/filtering/downloader_test.go +78 -0
@@ -10,6 +10,7 @@ import (
1010 "path/filepath"
1111 "strings"
1212 "testing"
13+ "time"
1314
1415 "github.com/stretchr/testify/require"
1516 )
@@ -120,6 +121,37 @@ func TestDownloader_DownloadFilters_AllowsSafeNestedManifestFilenames(t *testing
120121 require.Equal(t, body, string(content))
121122 }
122123
124+func TestDownloader_DownloadFilters_PrunesObsoleteCachedCombinedParts(t *testing.T) {
125+ cacheDir := filepath.Join(t.TempDir(), "cache")
126+ require.NoError(t, os.MkdirAll(cacheDir, cacheDirPerm))
127+ require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "combined-part3.json"), []byte(`[]`), manifestFilePerm))
128+ require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "other.json"), []byte(`[]`), manifestFilePerm))
129+
130+ body := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
131+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
132+ switch r.URL.Path {
133+ case "/" + FilterFiles.Manifest:
134+ _, _ = io.WriteString(w, `{"version":"test","combined":{"total_rules":2,"files":["combined-part1.json","combined-part2.json"]}}`)
135+ case "/combined-part1.json", "/combined-part2.json":
136+ _, _ = io.WriteString(w, body)
137+ default:
138+ http.NotFound(w, r)
139+ }
140+ }))
141+ defer server.Close()
142+
143+ d := NewDownloader(cacheDir)
144+ d.baseURL = server.URL
145+
146+ paths, err := d.DownloadFilters(context.Background(), nil)
147+ require.NoError(t, err)
148+ require.Len(t, paths, 2)
149+ require.FileExists(t, filepath.Join(cacheDir, "combined-part1.json"))
150+ require.FileExists(t, filepath.Join(cacheDir, "combined-part2.json"))
151+ require.NoFileExists(t, filepath.Join(cacheDir, "combined-part3.json"))
152+ require.FileExists(t, filepath.Join(cacheDir, "other.json"))
153+}
154+
123155 func TestDownloader_FetchManifest_RejectsOversizedManifest(t *testing.T) {
124156 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125157 if r.URL.Path != "/"+FilterFiles.Manifest {
@@ -169,6 +201,52 @@ func TestDownloader_FetchManifest_RejectsTooManyFiles(t *testing.T) {
169201 require.Contains(t, err.Error(), "limit is 2")
170202 }
171203
204+func TestDownloader_NeedsUpdate_DetectsSameVersionManifestMetadataChange(t *testing.T) {
205+ cacheDir := filepath.Join(t.TempDir(), "cache")
206+ require.NoError(t, os.MkdirAll(cacheDir, cacheDirPerm))
207+
208+ cachedGeneratedAt := time.Date(2026, 3, 28, 4, 0, 0, 0, time.UTC)
209+ latestGeneratedAt := time.Date(2026, 3, 28, 5, 0, 0, 0, time.UTC)
210+ cachedManifest := Manifest{
211+ Version: "2026.03.28",
212+ GeneratedAt: cachedGeneratedAt,
213+ Combined: CombinedInfo{
214+ TotalRules: 3,
215+ Files: []string{"combined-part1.json", "combined-part2.json", "combined-part3.json"},
216+ },
217+ }
218+ cachedPayload, err := json.Marshal(cachedManifest)
219+ require.NoError(t, err)
220+ require.NoError(t, os.WriteFile(filepath.Join(cacheDir, FilterFiles.Manifest), cachedPayload, manifestFilePerm))
221+
222+ latestManifest := Manifest{
223+ Version: "2026.03.28",
224+ GeneratedAt: latestGeneratedAt,
225+ Combined: CombinedInfo{
226+ TotalRules: 2,
227+ Files: []string{"combined-part1.json", "combined-part2.json"},
228+ },
229+ }
230+ latestPayload, err := json.Marshal(latestManifest)
231+ require.NoError(t, err)
232+
233+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
234+ if r.URL.Path != "/"+FilterFiles.Manifest {
235+ http.NotFound(w, r)
236+ return
237+ }
238+ _, _ = w.Write(latestPayload)
239+ }))
240+ defer server.Close()
241+
242+ d := NewDownloader(cacheDir)
243+ d.baseURL = server.URL
244+
245+ needsUpdate, err := d.NeedsUpdate(context.Background())
246+ require.NoError(t, err)
247+ require.True(t, needsUpdate)
248+}
249+
172250 func TestDownloader_DownloadFilters_RejectsOversizedFilterFile(t *testing.T) {
173251 cacheDir := filepath.Join(t.TempDir(), "cache")
174252 filename := "combined.json"
@@ -10,6 +10,7 @@ import (
10 "path/filepath"10 "path/filepath"
11 "strings"11 "strings"
12 "testing"12 "testing"
13+ "time"
13 14
14 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
15 )16 )
@@ -120,6 +121,37 @@ func TestDownloader_DownloadFilters_AllowsSafeNestedManifestFilenames(t *testing
120 require.Equal(t, body, string(content))121 require.Equal(t, body, string(content))
121 }122 }
122 123
124+func TestDownloader_DownloadFilters_PrunesObsoleteCachedCombinedParts(t *testing.T) {
125+ cacheDir := filepath.Join(t.TempDir(), "cache")
126+ require.NoError(t, os.MkdirAll(cacheDir, cacheDirPerm))
127+ require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "combined-part3.json"), []byte(`[]`), manifestFilePerm))
128+ require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "other.json"), []byte(`[]`), manifestFilePerm))
129+
130+ body := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
131+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
132+ switch r.URL.Path {
133+ case "/" + FilterFiles.Manifest:
134+ _, _ = io.WriteString(w, `{"version":"test","combined":{"total_rules":2,"files":["combined-part1.json","combined-part2.json"]}}`)
135+ case "/combined-part1.json", "/combined-part2.json":
136+ _, _ = io.WriteString(w, body)
137+ default:
138+ http.NotFound(w, r)
139+ }
140+ }))
141+ defer server.Close()
142+
143+ d := NewDownloader(cacheDir)
144+ d.baseURL = server.URL
145+
146+ paths, err := d.DownloadFilters(context.Background(), nil)
147+ require.NoError(t, err)
148+ require.Len(t, paths, 2)
149+ require.FileExists(t, filepath.Join(cacheDir, "combined-part1.json"))
150+ require.FileExists(t, filepath.Join(cacheDir, "combined-part2.json"))
151+ require.NoFileExists(t, filepath.Join(cacheDir, "combined-part3.json"))
152+ require.FileExists(t, filepath.Join(cacheDir, "other.json"))
153+}
154+
123 func TestDownloader_FetchManifest_RejectsOversizedManifest(t *testing.T) {155 func TestDownloader_FetchManifest_RejectsOversizedManifest(t *testing.T) {
124 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {156 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125 if r.URL.Path != "/"+FilterFiles.Manifest {157 if r.URL.Path != "/"+FilterFiles.Manifest {
@@ -169,6 +201,52 @@ func TestDownloader_FetchManifest_RejectsTooManyFiles(t *testing.T) {
169 require.Contains(t, err.Error(), "limit is 2")201 require.Contains(t, err.Error(), "limit is 2")
170 }202 }
171 203
204+func TestDownloader_NeedsUpdate_DetectsSameVersionManifestMetadataChange(t *testing.T) {
205+ cacheDir := filepath.Join(t.TempDir(), "cache")
206+ require.NoError(t, os.MkdirAll(cacheDir, cacheDirPerm))
207+
208+ cachedGeneratedAt := time.Date(2026, 3, 28, 4, 0, 0, 0, time.UTC)
209+ latestGeneratedAt := time.Date(2026, 3, 28, 5, 0, 0, 0, time.UTC)
210+ cachedManifest := Manifest{
211+ Version: "2026.03.28",
212+ GeneratedAt: cachedGeneratedAt,
213+ Combined: CombinedInfo{
214+ TotalRules: 3,
215+ Files: []string{"combined-part1.json", "combined-part2.json", "combined-part3.json"},
216+ },
217+ }
218+ cachedPayload, err := json.Marshal(cachedManifest)
219+ require.NoError(t, err)
220+ require.NoError(t, os.WriteFile(filepath.Join(cacheDir, FilterFiles.Manifest), cachedPayload, manifestFilePerm))
221+
222+ latestManifest := Manifest{
223+ Version: "2026.03.28",
224+ GeneratedAt: latestGeneratedAt,
225+ Combined: CombinedInfo{
226+ TotalRules: 2,
227+ Files: []string{"combined-part1.json", "combined-part2.json"},
228+ },
229+ }
230+ latestPayload, err := json.Marshal(latestManifest)
231+ require.NoError(t, err)
232+
233+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
234+ if r.URL.Path != "/"+FilterFiles.Manifest {
235+ http.NotFound(w, r)
236+ return
237+ }
238+ _, _ = w.Write(latestPayload)
239+ }))
240+ defer server.Close()
241+
242+ d := NewDownloader(cacheDir)
243+ d.baseURL = server.URL
244+
245+ needsUpdate, err := d.NeedsUpdate(context.Background())
246+ require.NoError(t, err)
247+ require.True(t, needsUpdate)
248+}
249+
172 func TestDownloader_DownloadFilters_RejectsOversizedFilterFile(t *testing.T) {250 func TestDownloader_DownloadFilters_RejectsOversizedFilterFile(t *testing.T) {
173 cacheDir := filepath.Join(t.TempDir(), "cache")251 cacheDir := filepath.Join(t.TempDir(), "cache")
174 filename := "combined.json"252 filename := "combined.json"
M internal/infrastructure/filtering/manager.go +68 -5
@@ -208,6 +208,11 @@ func (m *Manager) loadFromCache(ctx context.Context) bool {
208208 return false
209209 }
210210
211+ partIDs = m.compiledIDsForCachedManifest(partIDs)
212+ if len(partIDs) == 0 {
213+ return false
214+ }
215+
211216 log.Debug().Int("parts", len(partIDs)).Msg("found compiled filters, loading from cache")
212217 var filters []*webkit.UserContentFilter
213218 for _, id := range partIDs {
@@ -316,13 +321,32 @@ func (m *Manager) setActiveFilters(filters []*webkit.UserContentFilter, message
316321 return version
317322 }
318323
319-// checkStaleCacheAndUpdate checks if the cache is stale and triggers a background update.
320-func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
321- if !m.downloader.IsCacheStale(CacheMaxAge) {
322- return
324+func (m *Manager) compiledIDsForCachedManifest(storedIDs []string) []string {
325+ manifest, err := m.downloader.GetCachedManifest()
326+ if err != nil || manifest == nil || len(manifest.Combined.Files) == 0 {
327+ return storedIDs
323328 }
329+
330+ available := make(map[string]struct{}, len(storedIDs))
331+ for _, id := range storedIDs {
332+ available[id] = struct{}{}
333+ }
334+
335+ expectedIDs := expectedCompiledFilterIDs(len(manifest.Combined.Files))
336+ ids := make([]string, 0, len(expectedIDs))
337+ for _, id := range expectedIDs {
338+ if _, ok := available[id]; !ok {
339+ return nil
340+ }
341+ ids = append(ids, id)
342+ }
343+ return ids
344+}
345+
346+// checkStaleCacheAndUpdate checks for updates after loading cached filters.
347+func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
324348 log := logging.FromContext(ctx)
325- log.Info().Msg("filter cache is stale, checking for updates in background")
349+ log.Info().Msg("checking for filter updates in background")
326350 go func() {
327351 if err := m.CheckForUpdates(ctx); err != nil {
328352 log.Warn().Err(err).Msg("background filter update check failed")
@@ -330,6 +354,43 @@ func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
330354 }()
331355 }
332356
357+func expectedCompiledFilterIDs(count int) []string {
358+ ids := make([]string, 0, count)
359+ for i := range count {
360+ ids = append(ids, fmt.Sprintf("%s-%d", FilterIdentifierPrefix, i))
361+ }
362+ return ids
363+}
364+
365+func (m *Manager) pruneObsoleteCompiledFilters(ctx context.Context, expectedCount int) {
366+ log := logging.FromContext(ctx).With().
367+ Str("component", "filter-manager").
368+ Logger()
369+
370+ identifiers, err := m.store.FetchIdentifiers(ctx)
371+ if err != nil {
372+ log.Warn().Err(err).Msg("failed to fetch compiled filter identifiers for pruning")
373+ return
374+ }
375+
376+ expected := make(map[string]struct{}, expectedCount)
377+ for _, id := range expectedCompiledFilterIDs(expectedCount) {
378+ expected[id] = struct{}{}
379+ }
380+
381+ for _, id := range identifiers {
382+ if !strings.HasPrefix(id, FilterIdentifierPrefix) {
383+ continue
384+ }
385+ if _, ok := expected[id]; ok {
386+ continue
387+ }
388+ if err := m.store.Remove(ctx, id); err != nil {
389+ log.Warn().Err(err).Str("id", id).Msg("failed to remove obsolete compiled filter")
390+ }
391+ }
392+}
393+
333394 // getCachedVersion returns the version from the cached manifest.
334395 func (m *Manager) getCachedVersion() string {
335396 manifest, err := m.downloader.GetCachedManifest()
@@ -421,6 +482,8 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error {
421482 return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err)
422483 }
423484
485+ m.pruneObsoleteCompiledFilters(ctx, len(paths))
486+
424487 version := m.setActiveFilters(filters, "Filters updated")
425488 log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters updated successfully")
426489
@@ -208,6 +208,11 @@ func (m *Manager) loadFromCache(ctx context.Context) bool {
208 return false208 return false
209 }209 }
210 210
211+ partIDs = m.compiledIDsForCachedManifest(partIDs)
212+ if len(partIDs) == 0 {
213+ return false
214+ }
215+
211 log.Debug().Int("parts", len(partIDs)).Msg("found compiled filters, loading from cache")216 log.Debug().Int("parts", len(partIDs)).Msg("found compiled filters, loading from cache")
212 var filters []*webkit.UserContentFilter217 var filters []*webkit.UserContentFilter
213 for _, id := range partIDs {218 for _, id := range partIDs {
@@ -316,13 +321,32 @@ func (m *Manager) setActiveFilters(filters []*webkit.UserContentFilter, message
316 return version321 return version
317 }322 }
318 323
319-// checkStaleCacheAndUpdate checks if the cache is stale and triggers a background update.324+func (m *Manager) compiledIDsForCachedManifest(storedIDs []string) []string {
320-func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {325+ manifest, err := m.downloader.GetCachedManifest()
321- if !m.downloader.IsCacheStale(CacheMaxAge) {326+ if err != nil || manifest == nil || len(manifest.Combined.Files) == 0 {
322- return327+ return storedIDs
323 }328 }
329+
330+ available := make(map[string]struct{}, len(storedIDs))
331+ for _, id := range storedIDs {
332+ available[id] = struct{}{}
333+ }
334+
335+ expectedIDs := expectedCompiledFilterIDs(len(manifest.Combined.Files))
336+ ids := make([]string, 0, len(expectedIDs))
337+ for _, id := range expectedIDs {
338+ if _, ok := available[id]; !ok {
339+ return nil
340+ }
341+ ids = append(ids, id)
342+ }
343+ return ids
344+}
345+
346+// checkStaleCacheAndUpdate checks for updates after loading cached filters.
347+func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
324 log := logging.FromContext(ctx)348 log := logging.FromContext(ctx)
325- log.Info().Msg("filter cache is stale, checking for updates in background")349+ log.Info().Msg("checking for filter updates in background")
326 go func() {350 go func() {
327 if err := m.CheckForUpdates(ctx); err != nil {351 if err := m.CheckForUpdates(ctx); err != nil {
328 log.Warn().Err(err).Msg("background filter update check failed")352 log.Warn().Err(err).Msg("background filter update check failed")
@@ -330,6 +354,43 @@ func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
330 }()354 }()
331 }355 }
332 356
357+func expectedCompiledFilterIDs(count int) []string {
358+ ids := make([]string, 0, count)
359+ for i := range count {
360+ ids = append(ids, fmt.Sprintf("%s-%d", FilterIdentifierPrefix, i))
361+ }
362+ return ids
363+}
364+
365+func (m *Manager) pruneObsoleteCompiledFilters(ctx context.Context, expectedCount int) {
366+ log := logging.FromContext(ctx).With().
367+ Str("component", "filter-manager").
368+ Logger()
369+
370+ identifiers, err := m.store.FetchIdentifiers(ctx)
371+ if err != nil {
372+ log.Warn().Err(err).Msg("failed to fetch compiled filter identifiers for pruning")
373+ return
374+ }
375+
376+ expected := make(map[string]struct{}, expectedCount)
377+ for _, id := range expectedCompiledFilterIDs(expectedCount) {
378+ expected[id] = struct{}{}
379+ }
380+
381+ for _, id := range identifiers {
382+ if !strings.HasPrefix(id, FilterIdentifierPrefix) {
383+ continue
384+ }
385+ if _, ok := expected[id]; ok {
386+ continue
387+ }
388+ if err := m.store.Remove(ctx, id); err != nil {
389+ log.Warn().Err(err).Str("id", id).Msg("failed to remove obsolete compiled filter")
390+ }
391+ }
392+}
393+
333 // getCachedVersion returns the version from the cached manifest.394 // getCachedVersion returns the version from the cached manifest.
334 func (m *Manager) getCachedVersion() string {395 func (m *Manager) getCachedVersion() string {
335 manifest, err := m.downloader.GetCachedManifest()396 manifest, err := m.downloader.GetCachedManifest()
@@ -421,6 +482,8 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error {
421 return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err)482 return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err)
422 }483 }
423 484
485+ m.pruneObsoleteCompiledFilters(ctx, len(paths))
486+
424 version := m.setActiveFilters(filters, "Filters updated")487 version := m.setActiveFilters(filters, "Filters updated")
425 log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters updated successfully")488 log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters updated successfully")
426 489
M internal/infrastructure/filtering/manager_test.go +125 -3
@@ -223,6 +223,60 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) {
223223 assert.True(t, foundActive, "Expected active state")
224224 }
225225
226+func TestManager_LoadAsync_ChecksForUpdatesAfterCacheLoadEvenWhenCacheIsFresh(t *testing.T) {
227+ // Setup temp directories
228+ tmpDir := t.TempDir()
229+ storeDir := filepath.Join(tmpDir, "store")
230+ jsonDir := filepath.Join(tmpDir, "json")
231+
232+ mockStore := mocks.NewMockFilterStore(t)
233+ mockDownloader := mocks.NewMockFilterDownloader(t)
234+ updateChecked := make(chan struct{})
235+
236+ mockStore.EXPECT().
237+ FetchIdentifiers(mock.Anything).
238+ Return([]string{"ublock-combined-0"}, nil)
239+ mockStore.EXPECT().
240+ Load(mock.Anything, "ublock-combined-0").
241+ Return(&webkit.UserContentFilter{}, nil)
242+ mockDownloader.EXPECT().
243+ GetCachedManifest().
244+ Return(&filtering.Manifest{
245+ Version: "2026.03.27",
246+ Combined: filtering.CombinedInfo{
247+ Files: []string{"combined-part1.json"},
248+ },
249+ }, nil)
250+ mockDownloader.EXPECT().
251+ NeedsUpdate(mock.Anything).
252+ Run(func(_ context.Context) { close(updateChecked) }).
253+ Return(false, nil).
254+ Once()
255+
256+ mgr, err := filtering.NewManager(filtering.ManagerConfig{
257+ StoreDir: storeDir,
258+ JSONDir: jsonDir,
259+ Enabled: true,
260+ AutoUpdate: true,
261+ Store: mockStore,
262+ Downloader: mockDownloader,
263+ })
264+ require.NoError(t, err)
265+
266+ ctx := testContext()
267+ require.NoError(t, mgr.Initialize(ctx))
268+ mgr.LoadAsync(ctx)
269+
270+ require.Eventually(t, func() bool {
271+ select {
272+ case <-updateChecked:
273+ return true
274+ default:
275+ return false
276+ }
277+ }, 3*time.Second, 50*time.Millisecond)
278+}
279+
226280 func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T) {
227281 // Setup temp directories
228282 tmpDir := t.TempDir()
@@ -254,6 +308,10 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T)
254308 Compile(mock.Anything, "ublock-combined-0", mock.Anything).
255309 Return(&webkit.UserContentFilter{}, nil)
256310
311+ mockStore.EXPECT().
312+ FetchIdentifiers(mock.Anything).
313+ Return([]string{"ublock-combined-0"}, nil)
314+
257315 // GetCachedManifest for version
258316 mockDownloader.EXPECT().
259317 GetCachedManifest().
@@ -295,6 +353,68 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T)
295353 }
296354 }
297355
356+func TestManager_CheckForUpdates_PrunesObsoleteCompiledFiltersAfterSuccessfulUpdate(t *testing.T) {
357+ tmpDir := t.TempDir()
358+ storeDir := filepath.Join(tmpDir, "store")
359+ jsonDir := filepath.Join(tmpDir, "json")
360+
361+ validJSON := `[{
362+ "trigger":{"url-filter":"test"},
363+ "action":{"type":"block"}
364+ }]`
365+ jsonFile1 := filepath.Join(jsonDir, "combined-part1.json")
366+ jsonFile2 := filepath.Join(jsonDir, "combined-part2.json")
367+ require.NoError(t, os.MkdirAll(jsonDir, 0o755))
368+ require.NoError(t, os.WriteFile(jsonFile1, []byte(validJSON), 0o644))
369+ require.NoError(t, os.WriteFile(jsonFile2, []byte(validJSON), 0o644))
370+
371+ mockStore := mocks.NewMockFilterStore(t)
372+ mockDownloader := mocks.NewMockFilterDownloader(t)
373+
374+ mockDownloader.EXPECT().
375+ NeedsUpdate(mock.Anything).
376+ Return(true, nil)
377+ mockDownloader.EXPECT().
378+ DownloadFilters(mock.Anything, mock.Anything).
379+ Return([]string{jsonFile1, jsonFile2}, nil)
380+ mockStore.EXPECT().
381+ Compile(mock.Anything, "ublock-combined-0", jsonFile1).
382+ Return(&webkit.UserContentFilter{}, nil)
383+ mockStore.EXPECT().
384+ Compile(mock.Anything, "ublock-combined-1", jsonFile2).
385+ Return(&webkit.UserContentFilter{}, nil)
386+ mockDownloader.EXPECT().
387+ GetCachedManifest().
388+ Return(&filtering.Manifest{
389+ Version: "2026.03.28",
390+ Combined: filtering.CombinedInfo{
391+ Files: []string{"combined-part1.json", "combined-part2.json"},
392+ },
393+ }, nil)
394+ mockStore.EXPECT().
395+ FetchIdentifiers(mock.Anything).
396+ Return([]string{"ublock-combined", "ublock-combined-0", "ublock-combined-1", "ublock-combined-2", "other-filter"}, nil)
397+ mockStore.EXPECT().
398+ Remove(mock.Anything, "ublock-combined").
399+ Return(nil)
400+ mockStore.EXPECT().
401+ Remove(mock.Anything, "ublock-combined-2").
402+ Return(nil)
403+
404+ mgr, err := filtering.NewManager(filtering.ManagerConfig{
405+ StoreDir: storeDir,
406+ JSONDir: jsonDir,
407+ Enabled: true,
408+ AutoUpdate: true,
409+ Store: mockStore,
410+ Downloader: mockDownloader,
411+ })
412+ require.NoError(t, err)
413+
414+ ctx := testContext()
415+ require.NoError(t, mgr.CheckForUpdates(ctx))
416+}
417+
298418 func TestManager_CheckForUpdates_SkipsWhenUpToDate(t *testing.T) {
299419 // Setup temp directories
300420 tmpDir := t.TempDir()
@@ -592,11 +712,13 @@ func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) {
592712 mockDownloader.EXPECT().
593713 GetCachedManifest().
594714 Return(&filtering.Manifest{Version: "2025.12.19"}, nil)
715+
716+ // Background CheckForUpdates after cache load should observe no update.
595717 mockDownloader.EXPECT().
596- IsCacheStale(filtering.CacheMaxAge).
597- Return(false).Once()
718+ NeedsUpdate(mock.Anything).
719+ Return(false, nil).Once()
598720
599- // CheckForUpdates: compile fails, should keep active filter
721+ // Explicit CheckForUpdates: compile fails, should keep active filter
600722 mockDownloader.EXPECT().
601723 NeedsUpdate(mock.Anything).
602724 Return(true, nil).Once()
@@ -223,6 +223,60 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) {
223 assert.True(t, foundActive, "Expected active state")223 assert.True(t, foundActive, "Expected active state")
224 }224 }
225 225
226+func TestManager_LoadAsync_ChecksForUpdatesAfterCacheLoadEvenWhenCacheIsFresh(t *testing.T) {
227+ // Setup temp directories
228+ tmpDir := t.TempDir()
229+ storeDir := filepath.Join(tmpDir, "store")
230+ jsonDir := filepath.Join(tmpDir, "json")
231+
232+ mockStore := mocks.NewMockFilterStore(t)
233+ mockDownloader := mocks.NewMockFilterDownloader(t)
234+ updateChecked := make(chan struct{})
235+
236+ mockStore.EXPECT().
237+ FetchIdentifiers(mock.Anything).
238+ Return([]string{"ublock-combined-0"}, nil)
239+ mockStore.EXPECT().
240+ Load(mock.Anything, "ublock-combined-0").
241+ Return(&webkit.UserContentFilter{}, nil)
242+ mockDownloader.EXPECT().
243+ GetCachedManifest().
244+ Return(&filtering.Manifest{
245+ Version: "2026.03.27",
246+ Combined: filtering.CombinedInfo{
247+ Files: []string{"combined-part1.json"},
248+ },
249+ }, nil)
250+ mockDownloader.EXPECT().
251+ NeedsUpdate(mock.Anything).
252+ Run(func(_ context.Context) { close(updateChecked) }).
253+ Return(false, nil).
254+ Once()
255+
256+ mgr, err := filtering.NewManager(filtering.ManagerConfig{
257+ StoreDir: storeDir,
258+ JSONDir: jsonDir,
259+ Enabled: true,
260+ AutoUpdate: true,
261+ Store: mockStore,
262+ Downloader: mockDownloader,
263+ })
264+ require.NoError(t, err)
265+
266+ ctx := testContext()
267+ require.NoError(t, mgr.Initialize(ctx))
268+ mgr.LoadAsync(ctx)
269+
270+ require.Eventually(t, func() bool {
271+ select {
272+ case <-updateChecked:
273+ return true
274+ default:
275+ return false
276+ }
277+ }, 3*time.Second, 50*time.Millisecond)
278+}
279+
226 func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T) {280 func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T) {
227 // Setup temp directories281 // Setup temp directories
228 tmpDir := t.TempDir()282 tmpDir := t.TempDir()
@@ -254,6 +308,10 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T)
254 Compile(mock.Anything, "ublock-combined-0", mock.Anything).308 Compile(mock.Anything, "ublock-combined-0", mock.Anything).
255 Return(&webkit.UserContentFilter{}, nil)309 Return(&webkit.UserContentFilter{}, nil)
256 310
311+ mockStore.EXPECT().
312+ FetchIdentifiers(mock.Anything).
313+ Return([]string{"ublock-combined-0"}, nil)
314+
257 // GetCachedManifest for version315 // GetCachedManifest for version
258 mockDownloader.EXPECT().316 mockDownloader.EXPECT().
259 GetCachedManifest().317 GetCachedManifest().
@@ -295,6 +353,68 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T)
295 }353 }
296 }354 }
297 355
356+func TestManager_CheckForUpdates_PrunesObsoleteCompiledFiltersAfterSuccessfulUpdate(t *testing.T) {
357+ tmpDir := t.TempDir()
358+ storeDir := filepath.Join(tmpDir, "store")
359+ jsonDir := filepath.Join(tmpDir, "json")
360+
361+ validJSON := `[{
362+ "trigger":{"url-filter":"test"},
363+ "action":{"type":"block"}
364+ }]`
365+ jsonFile1 := filepath.Join(jsonDir, "combined-part1.json")
366+ jsonFile2 := filepath.Join(jsonDir, "combined-part2.json")
367+ require.NoError(t, os.MkdirAll(jsonDir, 0o755))
368+ require.NoError(t, os.WriteFile(jsonFile1, []byte(validJSON), 0o644))
369+ require.NoError(t, os.WriteFile(jsonFile2, []byte(validJSON), 0o644))
370+
371+ mockStore := mocks.NewMockFilterStore(t)
372+ mockDownloader := mocks.NewMockFilterDownloader(t)
373+
374+ mockDownloader.EXPECT().
375+ NeedsUpdate(mock.Anything).
376+ Return(true, nil)
377+ mockDownloader.EXPECT().
378+ DownloadFilters(mock.Anything, mock.Anything).
379+ Return([]string{jsonFile1, jsonFile2}, nil)
380+ mockStore.EXPECT().
381+ Compile(mock.Anything, "ublock-combined-0", jsonFile1).
382+ Return(&webkit.UserContentFilter{}, nil)
383+ mockStore.EXPECT().
384+ Compile(mock.Anything, "ublock-combined-1", jsonFile2).
385+ Return(&webkit.UserContentFilter{}, nil)
386+ mockDownloader.EXPECT().
387+ GetCachedManifest().
388+ Return(&filtering.Manifest{
389+ Version: "2026.03.28",
390+ Combined: filtering.CombinedInfo{
391+ Files: []string{"combined-part1.json", "combined-part2.json"},
392+ },
393+ }, nil)
394+ mockStore.EXPECT().
395+ FetchIdentifiers(mock.Anything).
396+ Return([]string{"ublock-combined", "ublock-combined-0", "ublock-combined-1", "ublock-combined-2", "other-filter"}, nil)
397+ mockStore.EXPECT().
398+ Remove(mock.Anything, "ublock-combined").
399+ Return(nil)
400+ mockStore.EXPECT().
401+ Remove(mock.Anything, "ublock-combined-2").
402+ Return(nil)
403+
404+ mgr, err := filtering.NewManager(filtering.ManagerConfig{
405+ StoreDir: storeDir,
406+ JSONDir: jsonDir,
407+ Enabled: true,
408+ AutoUpdate: true,
409+ Store: mockStore,
410+ Downloader: mockDownloader,
411+ })
412+ require.NoError(t, err)
413+
414+ ctx := testContext()
415+ require.NoError(t, mgr.CheckForUpdates(ctx))
416+}
417+
298 func TestManager_CheckForUpdates_SkipsWhenUpToDate(t *testing.T) {418 func TestManager_CheckForUpdates_SkipsWhenUpToDate(t *testing.T) {
299 // Setup temp directories419 // Setup temp directories
300 tmpDir := t.TempDir()420 tmpDir := t.TempDir()
@@ -592,11 +712,13 @@ func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) {
592 mockDownloader.EXPECT().712 mockDownloader.EXPECT().
593 GetCachedManifest().713 GetCachedManifest().
594 Return(&filtering.Manifest{Version: "2025.12.19"}, nil)714 Return(&filtering.Manifest{Version: "2025.12.19"}, nil)
715+
716+ // Background CheckForUpdates after cache load should observe no update.
595 mockDownloader.EXPECT().717 mockDownloader.EXPECT().
596- IsCacheStale(filtering.CacheMaxAge).718+ NeedsUpdate(mock.Anything).
597- Return(false).Once()719+ Return(false, nil).Once()
598 720
599- // CheckForUpdates: compile fails, should keep active filter721+ // Explicit CheckForUpdates: compile fails, should keep active filter
600 mockDownloader.EXPECT().722 mockDownloader.EXPECT().
601 NeedsUpdate(mock.Anything).723 NeedsUpdate(mock.Anything).
602 Return(true, nil).Once()724 Return(true, nil).Once()
A internal/infrastructure/persistence/sqlite/migrations/010_session_process_id.sql +5 -0
@@ -0,0 +1,5 @@
1+-- +goose Up
2+ALTER TABLE sessions ADD COLUMN process_id INTEGER;
3+
4+-- +goose Down
5+-- SQLite cannot drop columns portably in older versions; keep the nullable metadata column.
@@ -0,0 +1,5 @@
1+-- +goose Up
2+ALTER TABLE sessions ADD COLUMN process_id INTEGER;
3+
4+-- +goose Down
5+-- SQLite cannot drop columns portably in older versions; keep the nullable metadata column.
M internal/infrastructure/persistence/sqlite/queries/sessions.sql +2 -2
@@ -1,6 +1,6 @@
11 -- name: InsertSession :exec
2-INSERT INTO sessions (id, type, started_at, ended_at)
3-VALUES (?, ?, ?, ?);
2+INSERT INTO sessions (id, type, started_at, ended_at, process_id)
3+VALUES (?, ?, ?, ?, ?);
44
55 -- name: GetSessionByID :one
66 SELECT * FROM sessions WHERE id = ? LIMIT 1;
@@ -1,6 +1,6 @@
1 -- name: InsertSession :exec1 -- name: InsertSession :exec
2-INSERT INTO sessions (id, type, started_at, ended_at)2+INSERT INTO sessions (id, type, started_at, ended_at, process_id)
3-VALUES (?, ?, ?, ?);3+VALUES (?, ?, ?, ?, ?);
4 4
5 -- name: GetSessionByID :one5 -- name: GetSessionByID :one
6 SELECT * FROM sessions WHERE id = ? LIMIT 1;6 SELECT * FROM sessions WHERE id = ? LIMIT 1;
M internal/infrastructure/persistence/sqlite/session_repo.go +12 -0
@@ -32,12 +32,17 @@ func (r *sessionRepo) Save(ctx context.Context, session *entity.Session) error {
3232 if session.EndedAt != nil {
3333 endedAt = sql.NullTime{Time: session.EndedAt.UTC(), Valid: true}
3434 }
35+ var processID sql.NullInt64
36+ if session.ProcessID != nil {
37+ processID = sql.NullInt64{Int64: int64(*session.ProcessID), Valid: true}
38+ }
3539
3640 return r.queries.InsertSession(ctx, sqlc.InsertSessionParams{
3741 ID: string(session.ID),
3842 Type: string(session.Type),
3943 StartedAt: session.StartedAt.UTC(),
4044 EndedAt: endedAt,
45+ ProcessID: processID,
4146 })
4247 }
4348
@@ -127,10 +132,17 @@ func sessionFromRow(row sqlc.Session) *entity.Session {
127132 endedAt = &t
128133 }
129134
135+ var processID *int
136+ if row.ProcessID.Valid {
137+ pid := int(row.ProcessID.Int64)
138+ processID = &pid
139+ }
140+
130141 return &entity.Session{
131142 ID: entity.SessionID(row.ID),
132143 Type: entity.SessionType(row.Type),
133144 StartedAt: row.StartedAt.UTC(),
134145 EndedAt: endedAt,
146+ ProcessID: processID,
135147 }
136148 }
@@ -32,12 +32,17 @@ func (r *sessionRepo) Save(ctx context.Context, session *entity.Session) error {
32 if session.EndedAt != nil {32 if session.EndedAt != nil {
33 endedAt = sql.NullTime{Time: session.EndedAt.UTC(), Valid: true}33 endedAt = sql.NullTime{Time: session.EndedAt.UTC(), Valid: true}
34 }34 }
35+ var processID sql.NullInt64
36+ if session.ProcessID != nil {
37+ processID = sql.NullInt64{Int64: int64(*session.ProcessID), Valid: true}
38+ }
35 39
36 return r.queries.InsertSession(ctx, sqlc.InsertSessionParams{40 return r.queries.InsertSession(ctx, sqlc.InsertSessionParams{
37 ID: string(session.ID),41 ID: string(session.ID),
38 Type: string(session.Type),42 Type: string(session.Type),
39 StartedAt: session.StartedAt.UTC(),43 StartedAt: session.StartedAt.UTC(),
40 EndedAt: endedAt,44 EndedAt: endedAt,
45+ ProcessID: processID,
41 })46 })
42 }47 }
43 48
@@ -127,10 +132,17 @@ func sessionFromRow(row sqlc.Session) *entity.Session {
127 endedAt = &t132 endedAt = &t
128 }133 }
129 134
135+ var processID *int
136+ if row.ProcessID.Valid {
137+ pid := int(row.ProcessID.Int64)
138+ processID = &pid
139+ }
140+
130 return &entity.Session{141 return &entity.Session{
131 ID: entity.SessionID(row.ID),142 ID: entity.SessionID(row.ID),
132 Type: entity.SessionType(row.Type),143 Type: entity.SessionType(row.Type),
133 StartedAt: row.StartedAt.UTC(),144 StartedAt: row.StartedAt.UTC(),
134 EndedAt: endedAt,145 EndedAt: endedAt,
146+ ProcessID: processID,
135 }147 }
136 }148 }
M internal/infrastructure/persistence/sqlite/session_repo_test.go +34 -1
@@ -18,6 +18,32 @@ func testCtx() context.Context {
1818 return logging.WithContext(context.Background(), logger)
1919 }
2020
21+func TestSessionRepository_CRUD_NilProcessID(t *testing.T) {
22+ ctx := testCtx()
23+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
24+
25+ db, err := sqlite.NewConnection(ctx, dbPath)
26+ require.NoError(t, err)
27+ t.Cleanup(func() { _ = db.Close() })
28+
29+ repo := sqlite.NewSessionRepository(db)
30+
31+ startedAt := time.Date(2025, 12, 22, 7, 0, 0, 0, time.UTC)
32+ s := &entity.Session{ID: "20251222_070000_nilp", Type: entity.SessionTypeBrowser, StartedAt: startedAt}
33+ require.NoError(t, repo.Save(ctx, s))
34+
35+ got, err := repo.FindByID(ctx, s.ID)
36+ require.NoError(t, err)
37+ require.NotNil(t, got)
38+ assert.Nil(t, got.ProcessID)
39+
40+ active, err := repo.GetActive(ctx)
41+ require.NoError(t, err)
42+ require.NotNil(t, active)
43+ assert.Equal(t, s.ID, active.ID)
44+ assert.Nil(t, active.ProcessID)
45+}
46+
2147 func TestSessionRepository_CRUD(t *testing.T) {
2248 ctx := testCtx()
2349 dbPath := filepath.Join(t.TempDir(), "dumber.db")
@@ -29,7 +55,8 @@ func TestSessionRepository_CRUD(t *testing.T) {
2955 repo := sqlite.NewSessionRepository(db)
3056
3157 startedAt := time.Date(2025, 12, 22, 8, 0, 0, 0, time.UTC)
32- s := &entity.Session{ID: "20251222_080000_abcd", Type: entity.SessionTypeBrowser, StartedAt: startedAt}
58+ pid := 4321
59+ s := &entity.Session{ID: "20251222_080000_abcd", Type: entity.SessionTypeBrowser, StartedAt: startedAt, ProcessID: &pid}
3360 require.NoError(t, repo.Save(ctx, s))
3461
3562 got, err := repo.FindByID(ctx, s.ID)
@@ -38,12 +65,16 @@ func TestSessionRepository_CRUD(t *testing.T) {
3865 assert.Equal(t, s.ID, got.ID)
3966 assert.Equal(t, s.Type, got.Type)
4067 assert.True(t, got.StartedAt.Equal(startedAt))
68+ require.NotNil(t, got.ProcessID)
69+ assert.Equal(t, pid, *got.ProcessID)
4170 assert.Nil(t, got.EndedAt)
4271
4372 active, err := repo.GetActive(ctx)
4473 require.NoError(t, err)
4574 require.NotNil(t, active)
4675 assert.Equal(t, s.ID, active.ID)
76+ require.NotNil(t, active.ProcessID)
77+ assert.Equal(t, pid, *active.ProcessID)
4778
4879 endedAt := time.Date(2025, 12, 22, 9, 0, 0, 0, time.UTC)
4980 require.NoError(t, repo.MarkEnded(ctx, s.ID, endedAt))
@@ -61,4 +92,6 @@ func TestSessionRepository_CRUD(t *testing.T) {
6192 require.NoError(t, err)
6293 require.Len(t, recent, 1)
6394 assert.Equal(t, s.ID, recent[0].ID)
95+ require.NotNil(t, recent[0].ProcessID)
96+ assert.Equal(t, pid, *recent[0].ProcessID)
6497 }
@@ -18,6 +18,32 @@ func testCtx() context.Context {
18 return logging.WithContext(context.Background(), logger)18 return logging.WithContext(context.Background(), logger)
19 }19 }
20 20
21+func TestSessionRepository_CRUD_NilProcessID(t *testing.T) {
22+ ctx := testCtx()
23+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
24+
25+ db, err := sqlite.NewConnection(ctx, dbPath)
26+ require.NoError(t, err)
27+ t.Cleanup(func() { _ = db.Close() })
28+
29+ repo := sqlite.NewSessionRepository(db)
30+
31+ startedAt := time.Date(2025, 12, 22, 7, 0, 0, 0, time.UTC)
32+ s := &entity.Session{ID: "20251222_070000_nilp", Type: entity.SessionTypeBrowser, StartedAt: startedAt}
33+ require.NoError(t, repo.Save(ctx, s))
34+
35+ got, err := repo.FindByID(ctx, s.ID)
36+ require.NoError(t, err)
37+ require.NotNil(t, got)
38+ assert.Nil(t, got.ProcessID)
39+
40+ active, err := repo.GetActive(ctx)
41+ require.NoError(t, err)
42+ require.NotNil(t, active)
43+ assert.Equal(t, s.ID, active.ID)
44+ assert.Nil(t, active.ProcessID)
45+}
46+
21 func TestSessionRepository_CRUD(t *testing.T) {47 func TestSessionRepository_CRUD(t *testing.T) {
22 ctx := testCtx()48 ctx := testCtx()
23 dbPath := filepath.Join(t.TempDir(), "dumber.db")49 dbPath := filepath.Join(t.TempDir(), "dumber.db")
@@ -29,7 +55,8 @@ func TestSessionRepository_CRUD(t *testing.T) {
29 repo := sqlite.NewSessionRepository(db)55 repo := sqlite.NewSessionRepository(db)
30 56
31 startedAt := time.Date(2025, 12, 22, 8, 0, 0, 0, time.UTC)57 startedAt := time.Date(2025, 12, 22, 8, 0, 0, 0, time.UTC)
32- s := &entity.Session{ID: "20251222_080000_abcd", Type: entity.SessionTypeBrowser, StartedAt: startedAt}58+ pid := 4321
59+ s := &entity.Session{ID: "20251222_080000_abcd", Type: entity.SessionTypeBrowser, StartedAt: startedAt, ProcessID: &pid}
33 require.NoError(t, repo.Save(ctx, s))60 require.NoError(t, repo.Save(ctx, s))
34 61
35 got, err := repo.FindByID(ctx, s.ID)62 got, err := repo.FindByID(ctx, s.ID)
@@ -38,12 +65,16 @@ func TestSessionRepository_CRUD(t *testing.T) {
38 assert.Equal(t, s.ID, got.ID)65 assert.Equal(t, s.ID, got.ID)
39 assert.Equal(t, s.Type, got.Type)66 assert.Equal(t, s.Type, got.Type)
40 assert.True(t, got.StartedAt.Equal(startedAt))67 assert.True(t, got.StartedAt.Equal(startedAt))
68+ require.NotNil(t, got.ProcessID)
69+ assert.Equal(t, pid, *got.ProcessID)
41 assert.Nil(t, got.EndedAt)70 assert.Nil(t, got.EndedAt)
42 71
43 active, err := repo.GetActive(ctx)72 active, err := repo.GetActive(ctx)
44 require.NoError(t, err)73 require.NoError(t, err)
45 require.NotNil(t, active)74 require.NotNil(t, active)
46 assert.Equal(t, s.ID, active.ID)75 assert.Equal(t, s.ID, active.ID)
76+ require.NotNil(t, active.ProcessID)
77+ assert.Equal(t, pid, *active.ProcessID)
47 78
48 endedAt := time.Date(2025, 12, 22, 9, 0, 0, 0, time.UTC)79 endedAt := time.Date(2025, 12, 22, 9, 0, 0, 0, time.UTC)
49 require.NoError(t, repo.MarkEnded(ctx, s.ID, endedAt))80 require.NoError(t, repo.MarkEnded(ctx, s.ID, endedAt))
@@ -61,4 +92,6 @@ func TestSessionRepository_CRUD(t *testing.T) {
61 require.NoError(t, err)92 require.NoError(t, err)
62 require.Len(t, recent, 1)93 require.Len(t, recent, 1)
63 assert.Equal(t, s.ID, recent[0].ID)94 assert.Equal(t, s.ID, recent[0].ID)
95+ require.NotNil(t, recent[0].ProcessID)
96+ assert.Equal(t, pid, *recent[0].ProcessID)
64 }97 }
M internal/infrastructure/persistence/sqlite/sqlc/models.go +5 -4
@@ -83,10 +83,11 @@ type Permission struct {
8383 }
8484
8585 type Session struct {
86- ID string `json:"id"`
87- Type string `json:"type"`
88- StartedAt time.Time `json:"started_at"`
89- EndedAt sql.NullTime `json:"ended_at"`
86+ ID string `json:"id"`
87+ Type string `json:"type"`
88+ StartedAt time.Time `json:"started_at"`
89+ EndedAt sql.NullTime `json:"ended_at"`
90+ ProcessID sql.NullInt64 `json:"process_id"`
9091 }
9192
9293 type SessionState struct {
@@ -83,10 +83,11 @@ type Permission struct {
83 }83 }
84 84
85 type Session struct {85 type Session struct {
86- ID string `json:"id"`86+ ID string `json:"id"`
87- Type string `json:"type"`87+ Type string `json:"type"`
88- StartedAt time.Time `json:"started_at"`88+ StartedAt time.Time `json:"started_at"`
89- EndedAt sql.NullTime `json:"ended_at"`89+ EndedAt sql.NullTime `json:"ended_at"`
90+ ProcessID sql.NullInt64 `json:"process_id"`
90 }91 }
91 92
92 type SessionState struct {93 type SessionState struct {
M internal/infrastructure/persistence/sqlite/sqlc/sessions.sql.go +14 -9
@@ -56,7 +56,7 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
5656 }
5757
5858 const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one
59-SELECT id, type, started_at, ended_at FROM sessions
59+SELECT id, type, started_at, ended_at, process_id FROM sessions
6060 WHERE type = 'browser' AND ended_at IS NULL
6161 ORDER BY started_at DESC
6262 LIMIT 1
@@ -70,12 +70,13 @@ func (q *Queries) GetActiveBrowserSession(ctx context.Context) (Session, error)
7070 &i.Type,
7171 &i.StartedAt,
7272 &i.EndedAt,
73+ &i.ProcessID,
7374 )
7475 return i, err
7576 }
7677
7778 const GetRecentSessions = `-- name: GetRecentSessions :many
78-SELECT id, type, started_at, ended_at FROM sessions
79+SELECT id, type, started_at, ended_at, process_id FROM sessions
7980 ORDER BY started_at DESC
8081 LIMIT ?
8182 `
@@ -94,6 +95,7 @@ func (q *Queries) GetRecentSessions(ctx context.Context, limit int64) ([]Session
9495 &i.Type,
9596 &i.StartedAt,
9697 &i.EndedAt,
98+ &i.ProcessID,
9799 ); err != nil {
98100 return nil, err
99101 }
@@ -109,7 +111,7 @@ func (q *Queries) GetRecentSessions(ctx context.Context, limit int64) ([]Session
109111 }
110112
111113 const GetSessionByID = `-- name: GetSessionByID :one
112-SELECT id, type, started_at, ended_at FROM sessions WHERE id = ? LIMIT 1
114+SELECT id, type, started_at, ended_at, process_id FROM sessions WHERE id = ? LIMIT 1
113115 `
114116
115117 func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error) {
@@ -120,20 +122,22 @@ func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error
120122 &i.Type,
121123 &i.StartedAt,
122124 &i.EndedAt,
125+ &i.ProcessID,
123126 )
124127 return i, err
125128 }
126129
127130 const InsertSession = `-- name: InsertSession :exec
128-INSERT INTO sessions (id, type, started_at, ended_at)
129-VALUES (?, ?, ?, ?)
131+INSERT INTO sessions (id, type, started_at, ended_at, process_id)
132+VALUES (?, ?, ?, ?, ?)
130133 `
131134
132135 type InsertSessionParams struct {
133- ID string `json:"id"`
134- Type string `json:"type"`
135- StartedAt time.Time `json:"started_at"`
136- EndedAt sql.NullTime `json:"ended_at"`
136+ ID string `json:"id"`
137+ Type string `json:"type"`
138+ StartedAt time.Time `json:"started_at"`
139+ EndedAt sql.NullTime `json:"ended_at"`
140+ ProcessID sql.NullInt64 `json:"process_id"`
137141 }
138142
139143 func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) error {
@@ -142,6 +146,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er
142146 arg.Type,
143147 arg.StartedAt,
144148 arg.EndedAt,
149+ arg.ProcessID,
145150 )
146151 return err
147152 }
@@ -56,7 +56,7 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error {
56 }56 }
57 57
58 const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one58 const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one
59-SELECT id, type, started_at, ended_at FROM sessions59+SELECT id, type, started_at, ended_at, process_id FROM sessions
60 WHERE type = 'browser' AND ended_at IS NULL60 WHERE type = 'browser' AND ended_at IS NULL
61 ORDER BY started_at DESC61 ORDER BY started_at DESC
62 LIMIT 162 LIMIT 1
@@ -70,12 +70,13 @@ func (q *Queries) GetActiveBrowserSession(ctx context.Context) (Session, error)
70 &i.Type,70 &i.Type,
71 &i.StartedAt,71 &i.StartedAt,
72 &i.EndedAt,72 &i.EndedAt,
73+ &i.ProcessID,
73 )74 )
74 return i, err75 return i, err
75 }76 }
76 77
77 const GetRecentSessions = `-- name: GetRecentSessions :many78 const GetRecentSessions = `-- name: GetRecentSessions :many
78-SELECT id, type, started_at, ended_at FROM sessions79+SELECT id, type, started_at, ended_at, process_id FROM sessions
79 ORDER BY started_at DESC80 ORDER BY started_at DESC
80 LIMIT ?81 LIMIT ?
81 `82 `
@@ -94,6 +95,7 @@ func (q *Queries) GetRecentSessions(ctx context.Context, limit int64) ([]Session
94 &i.Type,95 &i.Type,
95 &i.StartedAt,96 &i.StartedAt,
96 &i.EndedAt,97 &i.EndedAt,
98+ &i.ProcessID,
97 ); err != nil {99 ); err != nil {
98 return nil, err100 return nil, err
99 }101 }
@@ -109,7 +111,7 @@ func (q *Queries) GetRecentSessions(ctx context.Context, limit int64) ([]Session
109 }111 }
110 112
111 const GetSessionByID = `-- name: GetSessionByID :one113 const GetSessionByID = `-- name: GetSessionByID :one
112-SELECT id, type, started_at, ended_at FROM sessions WHERE id = ? LIMIT 1114+SELECT id, type, started_at, ended_at, process_id FROM sessions WHERE id = ? LIMIT 1
113 `115 `
114 116
115 func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error) {117 func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error) {
@@ -120,20 +122,22 @@ func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error
120 &i.Type,122 &i.Type,
121 &i.StartedAt,123 &i.StartedAt,
122 &i.EndedAt,124 &i.EndedAt,
125+ &i.ProcessID,
123 )126 )
124 return i, err127 return i, err
125 }128 }
126 129
127 const InsertSession = `-- name: InsertSession :exec130 const InsertSession = `-- name: InsertSession :exec
128-INSERT INTO sessions (id, type, started_at, ended_at)131+INSERT INTO sessions (id, type, started_at, ended_at, process_id)
129-VALUES (?, ?, ?, ?)132+VALUES (?, ?, ?, ?, ?)
130 `133 `
131 134
132 type InsertSessionParams struct {135 type InsertSessionParams struct {
133- ID string `json:"id"`136+ ID string `json:"id"`
134- Type string `json:"type"`137+ Type string `json:"type"`
135- StartedAt time.Time `json:"started_at"`138+ StartedAt time.Time `json:"started_at"`
136- EndedAt sql.NullTime `json:"ended_at"`139+ EndedAt sql.NullTime `json:"ended_at"`
140+ ProcessID sql.NullInt64 `json:"process_id"`
137 }141 }
138 142
139 func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) error {143 func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) error {
@@ -142,6 +146,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er
142 arg.Type,146 arg.Type,
143 arg.StartedAt,147 arg.StartedAt,
144 arg.EndedAt,148 arg.EndedAt,
149+ arg.ProcessID,
145 )150 )
146 return err151 return err
147 }152 }
A internal/infrastructure/process/liveness.go +57 -0
@@ -0,0 +1,57 @@
1+package process
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "syscall"
9+)
10+
11+type processSignalFunc func(pid int, signal os.Signal) error
12+
13+// LivenessProbe checks whether a process is alive by sending signal 0.
14+type LivenessProbe struct {
15+ signal processSignalFunc
16+}
17+
18+// NewLivenessProbe creates a LivenessProbe that uses os.FindProcess and Signal.
19+func NewLivenessProbe() *LivenessProbe {
20+ return &LivenessProbe{signal: signalProcess}
21+}
22+
23+// IsProcessAlive checks whether the process with the given PID is alive.
24+// It returns immediately with the context error when ctx is done, and returns
25+// an error for invalid PIDs. Signal errors are mapped for cleanup safety:
26+// ESRCH means the process is not alive, EPERM means the process exists but is
27+// not signalable by this user, and unexpected signal errors are propagated.
28+func (p *LivenessProbe) IsProcessAlive(ctx context.Context, pid int) (bool, error) {
29+ if err := ctx.Err(); err != nil {
30+ return false, err
31+ }
32+ if pid <= 0 {
33+ return false, fmt.Errorf("invalid pid %d", pid)
34+ }
35+ signal := p.signal
36+ if signal == nil {
37+ signal = signalProcess
38+ }
39+ if err := signal(pid, syscall.Signal(0)); err != nil {
40+ if errors.Is(err, syscall.ESRCH) {
41+ return false, nil
42+ }
43+ if errors.Is(err, syscall.EPERM) {
44+ return true, nil
45+ }
46+ return false, err
47+ }
48+ return true, nil
49+}
50+
51+func signalProcess(pid int, signal os.Signal) error {
52+ proc, err := os.FindProcess(pid)
53+ if err != nil {
54+ return err
55+ }
56+ return proc.Signal(signal)
57+}
@@ -0,0 +1,57 @@
1+package process
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "syscall"
9+)
10+
11+type processSignalFunc func(pid int, signal os.Signal) error
12+
13+// LivenessProbe checks whether a process is alive by sending signal 0.
14+type LivenessProbe struct {
15+ signal processSignalFunc
16+}
17+
18+// NewLivenessProbe creates a LivenessProbe that uses os.FindProcess and Signal.
19+func NewLivenessProbe() *LivenessProbe {
20+ return &LivenessProbe{signal: signalProcess}
21+}
22+
23+// IsProcessAlive checks whether the process with the given PID is alive.
24+// It returns immediately with the context error when ctx is done, and returns
25+// an error for invalid PIDs. Signal errors are mapped for cleanup safety:
26+// ESRCH means the process is not alive, EPERM means the process exists but is
27+// not signalable by this user, and unexpected signal errors are propagated.
28+func (p *LivenessProbe) IsProcessAlive(ctx context.Context, pid int) (bool, error) {
29+ if err := ctx.Err(); err != nil {
30+ return false, err
31+ }
32+ if pid <= 0 {
33+ return false, fmt.Errorf("invalid pid %d", pid)
34+ }
35+ signal := p.signal
36+ if signal == nil {
37+ signal = signalProcess
38+ }
39+ if err := signal(pid, syscall.Signal(0)); err != nil {
40+ if errors.Is(err, syscall.ESRCH) {
41+ return false, nil
42+ }
43+ if errors.Is(err, syscall.EPERM) {
44+ return true, nil
45+ }
46+ return false, err
47+ }
48+ return true, nil
49+}
50+
51+func signalProcess(pid int, signal os.Signal) error {
52+ proc, err := os.FindProcess(pid)
53+ if err != nil {
54+ return err
55+ }
56+ return proc.Signal(signal)
57+}
A internal/infrastructure/process/liveness_test.go +81 -0
@@ -0,0 +1,81 @@
1+package process
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "syscall"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestLivenessProbe_IsProcessAliveRejectsInvalidPID(t *testing.T) {
15+ t.Parallel()
16+
17+ probe := &LivenessProbe{signal: func(_ int, _ os.Signal) error {
18+ t.Fatal("signal should not be called for invalid pid")
19+ return nil
20+ }}
21+
22+ alive, err := probe.IsProcessAlive(context.Background(), 0)
23+
24+ assert.False(t, alive)
25+ require.Error(t, err)
26+ assert.Contains(t, err.Error(), "invalid pid")
27+}
28+
29+func TestLivenessProbe_IsProcessAliveReturnsContextErrorBeforeSignaling(t *testing.T) {
30+ t.Parallel()
31+
32+ ctx, cancel := context.WithCancel(context.Background())
33+ cancel()
34+ probe := &LivenessProbe{signal: func(_ int, _ os.Signal) error {
35+ t.Fatal("signal should not be called when context is canceled")
36+ return nil
37+ }}
38+
39+ alive, err := probe.IsProcessAlive(ctx, 1234)
40+
41+ assert.False(t, alive)
42+ require.ErrorIs(t, err, context.Canceled)
43+}
44+
45+func TestLivenessProbe_IsProcessAliveInterpretsSignalErrors(t *testing.T) {
46+ t.Parallel()
47+
48+ tests := []struct {
49+ name string
50+ signalErr error
51+ wantAlive bool
52+ wantErr error
53+ }{
54+ {name: "signal succeeds", wantAlive: true},
55+ {name: "process missing", signalErr: syscall.ESRCH, wantAlive: false},
56+ {name: "permission denied means process exists", signalErr: syscall.EPERM, wantAlive: true},
57+ {name: "unexpected signal error propagates", signalErr: errors.New("signal failed"), wantAlive: false, wantErr: errors.New("signal failed")},
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ t.Parallel()
63+
64+ probe := &LivenessProbe{signal: func(pid int, signal os.Signal) error {
65+ assert.Equal(t, 1234, pid)
66+ assert.Equal(t, syscall.Signal(0), signal)
67+ return tt.signalErr
68+ }}
69+
70+ alive, err := probe.IsProcessAlive(context.Background(), 1234)
71+
72+ assert.Equal(t, tt.wantAlive, alive)
73+ if tt.wantErr != nil {
74+ require.Error(t, err)
75+ assert.EqualError(t, err, tt.wantErr.Error())
76+ } else {
77+ require.NoError(t, err)
78+ }
79+ })
80+ }
81+}
@@ -0,0 +1,81 @@
1+package process
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "syscall"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestLivenessProbe_IsProcessAliveRejectsInvalidPID(t *testing.T) {
15+ t.Parallel()
16+
17+ probe := &LivenessProbe{signal: func(_ int, _ os.Signal) error {
18+ t.Fatal("signal should not be called for invalid pid")
19+ return nil
20+ }}
21+
22+ alive, err := probe.IsProcessAlive(context.Background(), 0)
23+
24+ assert.False(t, alive)
25+ require.Error(t, err)
26+ assert.Contains(t, err.Error(), "invalid pid")
27+}
28+
29+func TestLivenessProbe_IsProcessAliveReturnsContextErrorBeforeSignaling(t *testing.T) {
30+ t.Parallel()
31+
32+ ctx, cancel := context.WithCancel(context.Background())
33+ cancel()
34+ probe := &LivenessProbe{signal: func(_ int, _ os.Signal) error {
35+ t.Fatal("signal should not be called when context is canceled")
36+ return nil
37+ }}
38+
39+ alive, err := probe.IsProcessAlive(ctx, 1234)
40+
41+ assert.False(t, alive)
42+ require.ErrorIs(t, err, context.Canceled)
43+}
44+
45+func TestLivenessProbe_IsProcessAliveInterpretsSignalErrors(t *testing.T) {
46+ t.Parallel()
47+
48+ tests := []struct {
49+ name string
50+ signalErr error
51+ wantAlive bool
52+ wantErr error
53+ }{
54+ {name: "signal succeeds", wantAlive: true},
55+ {name: "process missing", signalErr: syscall.ESRCH, wantAlive: false},
56+ {name: "permission denied means process exists", signalErr: syscall.EPERM, wantAlive: true},
57+ {name: "unexpected signal error propagates", signalErr: errors.New("signal failed"), wantAlive: false, wantErr: errors.New("signal failed")},
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ t.Parallel()
63+
64+ probe := &LivenessProbe{signal: func(pid int, signal os.Signal) error {
65+ assert.Equal(t, 1234, pid)
66+ assert.Equal(t, syscall.Signal(0), signal)
67+ return tt.signalErr
68+ }}
69+
70+ alive, err := probe.IsProcessAlive(context.Background(), 1234)
71+
72+ assert.Equal(t, tt.wantAlive, alive)
73+ if tt.wantErr != nil {
74+ require.Error(t, err)
75+ assert.EqualError(t, err, tt.wantErr.Error())
76+ } else {
77+ require.NoError(t, err)
78+ }
79+ })
80+ }
81+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close