Merge pull request #312 from bnema/fix/high-priority-backlog-208-285
fix(backlog): resolve high priority session and filter issues
20 files · +1032 -69
M .mockery.yaml +1 -0
| @@ -14,6 +14,7 @@ packages: | |||
| 14 | 14 | DatabaseProvider: {} | |
| 15 | 15 | DiffFormatter: {} | |
| 16 | 16 | SessionLogger: {} | |
| 17 | + | SessionProcessProbe: {} | |
| 17 | 18 | SessionSpawner: {} | |
| 18 | 19 | WindowStateProvider: {} | |
| 19 | 20 | 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 | |||
| 6469 | 6469 | 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 | 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 | 6699 | // The first argument is typically a *testing.T value. | |
| 6474 | 6700 | func NewMockHomepageHistory(t interface { | |
| @@ -10675,6 +10901,99 @@ func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context | |||
| 10675 | 10901 | 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 | 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 | 10998 | // The first argument is typically a *testing.T value. | |
| 10680 | 10999 | func NewMockWindowStateProvider(t interface { | |
| @@ -6469,6 +6469,232 @@ func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Co | |||||
| 6469 | return _c | 6469 | 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 _c | 10901 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | 9 | "github.com/bnema/dumber/internal/domain/repository" | |
| 8 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | // CleanupSessionsUseCase handles automatic cleanup of old exited sessions. | |
| 12 | 14 | type CleanupSessionsUseCase struct { | |
| 13 | - | sessionRepo repository.SessionRepository | |
| 15 | + | sessionRepo repository.SessionRepository | |
| 16 | + | processProbe port.SessionProcessProbe | |
| 14 | 17 | } | |
| 15 | 18 | ||
| 16 | 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 | 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 | 51 | 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 | 111 | // Execute cleans up old exited sessions based on the provided configuration. | |
| 48 | 112 | // It first deletes sessions older than MaxExitedSessionAgeDays, | |
| 49 | 113 | // 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.SessionRepository | 15 | + | 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 int64 | 51 | 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 ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 8 | 9 | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 11 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 10 | 12 | "github.com/stretchr/testify/assert" | |
| 11 | 13 | "github.com/stretchr/testify/mock" | |
| 12 | 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 | 84 | func TestCleanupSessionsUseCase_Execute_AgeAndCountCleanup(t *testing.T) { | |
| 16 | 85 | ctx := testContext() | |
| 17 | 86 | ||
| @@ -32,7 +101,7 @@ func TestCleanupSessionsUseCase_Execute_AgeAndCountCleanup(t *testing.T) { | |||
| 32 | 101 | DeleteOldestExited(ctx, 50). | |
| 33 | 102 | Return(int64(2), nil) | |
| 34 | 103 | ||
| 35 | - | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 104 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil) | |
| 36 | 105 | ||
| 37 | 106 | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 38 | 107 | MaxExitedSessions: 50, | |
| @@ -57,7 +126,7 @@ func TestCleanupSessionsUseCase_Execute_AgeOnlyCleanup(t *testing.T) { | |||
| 57 | 126 | ||
| 58 | 127 | // Count-based with -1 should be skipped (disabled) | |
| 59 | 128 | // MaxExitedSessions >= 0 triggers count cleanup, so use -1 to disable | |
| 60 | - | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 129 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil) | |
| 61 | 130 | ||
| 62 | 131 | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 63 | 132 | MaxExitedSessions: -1, // Disabled | |
| @@ -80,7 +149,7 @@ func TestCleanupSessionsUseCase_Execute_CountOnlyCleanup(t *testing.T) { | |||
| 80 | 149 | DeleteOldestExited(ctx, 10). | |
| 81 | 150 | Return(int64(4), nil) | |
| 82 | 151 | ||
| 83 | - | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 152 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil) | |
| 84 | 153 | ||
| 85 | 154 | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 86 | 155 | MaxExitedSessions: 10, | |
| @@ -107,7 +176,7 @@ func TestCleanupSessionsUseCase_Execute_NoCleanupNeeded(t *testing.T) { | |||
| 107 | 176 | DeleteOldestExited(ctx, 50). | |
| 108 | 177 | Return(int64(0), nil) | |
| 109 | 178 | ||
| 110 | - | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 179 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil) | |
| 111 | 180 | ||
| 112 | 181 | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 113 | 182 | MaxExitedSessions: 50, | |
| @@ -135,7 +204,7 @@ func TestCleanupSessionsUseCase_Execute_AgeDeleteError_ContinuesToCount(t *testi | |||
| 135 | 204 | DeleteOldestExited(ctx, 50). | |
| 136 | 205 | Return(int64(3), nil) | |
| 137 | 206 | ||
| 138 | - | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 207 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil) | |
| 139 | 208 | ||
| 140 | 209 | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 141 | 210 | MaxExitedSessions: 50, | |
| @@ -164,7 +233,7 @@ func TestCleanupSessionsUseCase_Execute_CountDeleteError(t *testing.T) { | |||
| 164 | 233 | DeleteOldestExited(ctx, 50). | |
| 165 | 234 | Return(int64(0), errors.New("db error")) | |
| 166 | 235 | ||
| 167 | - | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 236 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil) | |
| 168 | 237 | ||
| 169 | 238 | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 170 | 239 | MaxExitedSessions: 50, | |
| @@ -187,7 +256,7 @@ func TestCleanupSessionsUseCase_Execute_BothDisabled(t *testing.T) { | |||
| 187 | 256 | // Actually, count with 0 means "keep 0" which will trigger cleanup | |
| 188 | 257 | // 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 | 261 | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 193 | 262 | MaxExitedSessions: -1, // Disabled | |
| @@ -210,7 +279,7 @@ func TestCleanupSessionsUseCase_Execute_ZeroMaxSessions(t *testing.T) { | |||
| 210 | 279 | DeleteOldestExited(ctx, 0). | |
| 211 | 280 | Return(int64(10), nil) | |
| 212 | 281 | ||
| 213 | - | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 282 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo, nil) | |
| 214 | 283 | ||
| 215 | 284 | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 216 | 285 | 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 disable | 128 | // 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, // Disabled | 132 | 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 cleanup | 256 | // Actually, count with 0 means "keep 0" which will trigger cleanup | ||
| 188 | // So we need -1 for count and 0 for age to fully disable | 257 | // 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, // Disabled | 262 | 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 all | 285 | MaxExitedSessions: 0, // Keep 0 = delete all | ||
M internal/bootstrap/session.go +14 -16
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | "github.com/bnema/dumber/internal/domain/repository" | |
| 19 | 19 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 20 | 20 | infralogging "github.com/bnema/dumber/internal/infrastructure/logging" | |
| 21 | + | "github.com/bnema/dumber/internal/infrastructure/process" | |
| 21 | 22 | corelogging "github.com/bnema/dumber/internal/logging" | |
| 22 | 23 | "github.com/rs/zerolog" | |
| 23 | 24 | ) | |
| @@ -86,13 +87,15 @@ func StartBrowserSession( | |||
| 86 | 87 | ||
| 87 | 88 | sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter() | |
| 88 | 89 | sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter) | |
| 89 | - | cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 90 | + | cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo, process.NewLivenessProbe()) | |
| 90 | 91 | ||
| 91 | 92 | now := time.Now() | |
| 93 | + | pid := os.Getpid() | |
| 92 | 94 | session := &entity.Session{ | |
| 93 | 95 | ID: entity.SessionID(corelogging.GenerateSessionID()), | |
| 94 | 96 | Type: entity.SessionTypeBrowser, | |
| 95 | 97 | StartedAt: now.UTC(), | |
| 98 | + | ProcessID: &pid, | |
| 96 | 99 | } | |
| 97 | 100 | if err := session.Validate(); err != nil { | |
| 98 | 101 | return nil, ctx, err | |
| @@ -160,7 +163,7 @@ func StartBrowserSession( | |||
| 160 | 163 | } | |
| 161 | 164 | ||
| 162 | 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 | 168 | return persistErr | |
| 166 | 169 | } | |
| @@ -311,7 +314,6 @@ func sessionIDFromLogPath(logPath string) string { | |||
| 311 | 314 | ||
| 312 | 315 | func runSessionCleanup( | |
| 313 | 316 | startupCtx context.Context, | |
| 314 | - | sessionUC *usecase.ManageSessionUseCase, | |
| 315 | 317 | cleanupUC *usecase.CleanupSessionsUseCase, | |
| 316 | 318 | cfg *config.Config, | |
| 317 | 319 | logDir string, | |
| @@ -320,22 +322,18 @@ func runSessionCleanup( | |||
| 320 | 322 | ) { | |
| 321 | 323 | bgCtx := context.WithoutCancel(startupCtx) | |
| 322 | 324 | ||
| 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 | + | }) | |
| 325 | 331 | if err != nil { | |
| 326 | 332 | 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") | |
| 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 | 339 | // 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, err | 101 | 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 persistErr | 168 | 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 ( | |||
| 13 | 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 | 25 | t.Parallel() | |
| 18 | 26 | ||
| 19 | 27 | ctx := context.Background() | |
| 20 | 28 | 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}}) | |
| 23 | 30 | cfg := config.DefaultConfig() | |
| 24 | 31 | logDir := t.TempDir() | |
| 25 | 32 | ||
| @@ -29,24 +36,19 @@ func TestRunSessionCleanup_SkipsCurrentActiveSession(t *testing.T) { | |||
| 29 | 36 | Type: entity.SessionTypeBrowser, | |
| 30 | 37 | StartedAt: now, | |
| 31 | 38 | } | |
| 32 | - | stale := &entity.Session{ | |
| 33 | - | ID: entity.SessionID("stale"), | |
| 39 | + | livePID := 1234 | |
| 40 | + | live := &entity.Session{ | |
| 41 | + | ID: entity.SessionID("live-other-process"), | |
| 34 | 42 | Type: entity.SessionTypeBrowser, | |
| 35 | 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() | |
| 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() | |
| 46 | 48 | repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once() | |
| 47 | 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 | 54 | func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) { | |
| @@ -54,8 +56,7 @@ func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) { | |||
| 54 | 56 | ||
| 55 | 57 | ctx := context.Background() | |
| 56 | 58 | repo := repomocks.NewMockSessionRepository(t) | |
| 57 | - | sessionUC := usecase.NewManageSessionUseCase(repo, nil) | |
| 58 | - | cleanupUC := usecase.NewCleanupSessionsUseCase(repo) | |
| 59 | + | cleanupUC := usecase.NewCleanupSessionsUseCase(repo, fakeProcessProbe{}) | |
| 59 | 60 | cfg := config.DefaultConfig() | |
| 60 | 61 | logDir := t.TempDir() | |
| 61 | 62 | ||
| @@ -64,6 +65,6 @@ func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) { | |||
| 64 | 65 | repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once() | |
| 65 | 66 | ||
| 66 | 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 | } | |
| @@ -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 { | |||
| 24 | 24 | Type SessionType | |
| 25 | 25 | StartedAt time.Time | |
| 26 | 26 | EndedAt *time.Time | |
| 27 | + | ProcessID *int | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | func (s *Session) ShortID() string { | |
| @@ -24,6 +24,7 @@ type Session struct { | |||||
| 24 | Type SessionType | 24 | Type SessionType | ||
| 25 | StartedAt time.Time | 25 | StartedAt time.Time | ||
| 26 | EndedAt *time.Time | 26 | 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 ( | |||
| 9 | 9 | "os" | |
| 10 | 10 | pathpkg "path" | |
| 11 | 11 | "path/filepath" | |
| 12 | + | "slices" | |
| 12 | 13 | "strings" | |
| 13 | 14 | "time" | |
| 14 | 15 | ||
| @@ -202,12 +203,46 @@ func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(Downlo | |||
| 202 | 203 | // Cache the manifest we already fetched | |
| 203 | 204 | if err := d.cacheManifest(manifest); err != nil { | |
| 204 | 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 | 210 | log.Info().Int("files", len(paths)).Int64("total_bytes", totalBytes).Msg("filter download complete") | |
| 208 | 211 | 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 | 246 | // downloadFile downloads a single file and returns its local path. | |
| 212 | 247 | func (d *Downloader) downloadFile(ctx context.Context, filename string, maxBytes int64) (string, int64, error) { | |
| 213 | 248 | localPath, safeFilename, err := d.cachePathForFilterFile(filename) | |
| @@ -420,7 +455,7 @@ func (d *Downloader) NeedsUpdate(ctx context.Context) (bool, error) { | |||
| 420 | 455 | return false, err // Can't check, return error | |
| 421 | 456 | } | |
| 422 | 457 | ||
| 423 | - | needsUpdate := cached.Version != latest.Version | |
| 458 | + | needsUpdate := !sameManifestRelease(cached, latest) | |
| 424 | 459 | if needsUpdate { | |
| 425 | 460 | log.Info(). | |
| 426 | 461 | Str("cached_version", cached.Version). | |
| @@ -433,6 +468,16 @@ func (d *Downloader) NeedsUpdate(ctx context.Context) (bool, error) { | |||
| 433 | 468 | 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 | 481 | // ClearCache removes all cached filter files. | |
| 437 | 482 | func (d *Downloader) ClearCache() error { | |
| 438 | 483 | 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 fetched | 203 | // 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, nil | 211 | 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 error | 455 | return false, err // Can't check, return error | ||
| 421 | } | 456 | } | ||
| 422 | 457 | ||||
| 423 | - | needsUpdate := cached.Version != latest.Version | 458 | + | 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, nil | 468 | 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 ( | |||
| 10 | 10 | "path/filepath" | |
| 11 | 11 | "strings" | |
| 12 | 12 | "testing" | |
| 13 | + | "time" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/stretchr/testify/require" | |
| 15 | 16 | ) | |
| @@ -120,6 +121,37 @@ func TestDownloader_DownloadFilters_AllowsSafeNestedManifestFilenames(t *testing | |||
| 120 | 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 | 155 | func TestDownloader_FetchManifest_RejectsOversizedManifest(t *testing.T) { | |
| 124 | 156 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 125 | 157 | if r.URL.Path != "/"+FilterFiles.Manifest { | |
| @@ -169,6 +201,52 @@ func TestDownloader_FetchManifest_RejectsTooManyFiles(t *testing.T) { | |||
| 169 | 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 | 250 | func TestDownloader_DownloadFilters_RejectsOversizedFilterFile(t *testing.T) { | |
| 173 | 251 | cacheDir := filepath.Join(t.TempDir(), "cache") | |
| 174 | 252 | 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 { | |||
| 208 | 208 | return false | |
| 209 | 209 | } | |
| 210 | 210 | ||
| 211 | + | partIDs = m.compiledIDsForCachedManifest(partIDs) | |
| 212 | + | if len(partIDs) == 0 { | |
| 213 | + | return false | |
| 214 | + | } | |
| 215 | + | ||
| 211 | 216 | log.Debug().Int("parts", len(partIDs)).Msg("found compiled filters, loading from cache") | |
| 212 | 217 | var filters []*webkit.UserContentFilter | |
| 213 | 218 | for _, id := range partIDs { | |
| @@ -316,13 +321,32 @@ func (m *Manager) setActiveFilters(filters []*webkit.UserContentFilter, message | |||
| 316 | 321 | return version | |
| 317 | 322 | } | |
| 318 | 323 | ||
| 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 | |
| 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 | 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 | 350 | go func() { | |
| 327 | 351 | if err := m.CheckForUpdates(ctx); err != nil { | |
| 328 | 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 | 394 | // getCachedVersion returns the version from the cached manifest. | |
| 334 | 395 | func (m *Manager) getCachedVersion() string { | |
| 335 | 396 | manifest, err := m.downloader.GetCachedManifest() | |
| @@ -421,6 +482,8 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error { | |||
| 421 | 482 | return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err) | |
| 422 | 483 | } | |
| 423 | 484 | ||
| 485 | + | m.pruneObsoleteCompiledFilters(ctx, len(paths)) | |
| 486 | + | ||
| 424 | 487 | version := m.setActiveFilters(filters, "Filters updated") | |
| 425 | 488 | log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters updated successfully") | |
| 426 | 489 | ||
| @@ -208,6 +208,11 @@ func (m *Manager) loadFromCache(ctx context.Context) bool { | |||||
| 208 | return false | 208 | 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.UserContentFilter | 217 | 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 version | 321 | 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 | - | return | 327 | + | 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) { | |||
| 223 | 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 | 280 | func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T) { | |
| 227 | 281 | // Setup temp directories | |
| 228 | 282 | tmpDir := t.TempDir() | |
| @@ -254,6 +308,10 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T) | |||
| 254 | 308 | Compile(mock.Anything, "ublock-combined-0", mock.Anything). | |
| 255 | 309 | Return(&webkit.UserContentFilter{}, nil) | |
| 256 | 310 | ||
| 311 | + | mockStore.EXPECT(). | |
| 312 | + | FetchIdentifiers(mock.Anything). | |
| 313 | + | Return([]string{"ublock-combined-0"}, nil) | |
| 314 | + | ||
| 257 | 315 | // GetCachedManifest for version | |
| 258 | 316 | mockDownloader.EXPECT(). | |
| 259 | 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 | 418 | func TestManager_CheckForUpdates_SkipsWhenUpToDate(t *testing.T) { | |
| 299 | 419 | // Setup temp directories | |
| 300 | 420 | tmpDir := t.TempDir() | |
| @@ -592,11 +712,13 @@ func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) { | |||
| 592 | 712 | mockDownloader.EXPECT(). | |
| 593 | 713 | GetCachedManifest(). | |
| 594 | 714 | Return(&filtering.Manifest{Version: "2025.12.19"}, nil) | |
| 715 | + | ||
| 716 | + | // Background CheckForUpdates after cache load should observe no update. | |
| 595 | 717 | mockDownloader.EXPECT(). | |
| 596 | - | IsCacheStale(filtering.CacheMaxAge). | |
| 597 | - | Return(false).Once() | |
| 718 | + | NeedsUpdate(mock.Anything). | |
| 719 | + | Return(false, nil).Once() | |
| 598 | 720 | ||
| 599 | - | // CheckForUpdates: compile fails, should keep active filter | |
| 721 | + | // Explicit CheckForUpdates: compile fails, should keep active filter | |
| 600 | 722 | mockDownloader.EXPECT(). | |
| 601 | 723 | NeedsUpdate(mock.Anything). | |
| 602 | 724 | 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 directories | 281 | // 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 version | 315 | // 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 directories | 419 | // 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 filter | 721 | + | // 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 @@ | |||
| 1 | 1 | -- 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 (?, ?, ?, ?, ?); | |
| 4 | 4 | ||
| 5 | 5 | -- name: GetSessionByID :one | |
| 6 | 6 | SELECT * FROM sessions WHERE id = ? LIMIT 1; | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | -- name: InsertSession :exec | 1 | -- 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 :one | 5 | -- 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 { | |||
| 32 | 32 | if session.EndedAt != nil { | |
| 33 | 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 | 40 | return r.queries.InsertSession(ctx, sqlc.InsertSessionParams{ | |
| 37 | 41 | ID: string(session.ID), | |
| 38 | 42 | Type: string(session.Type), | |
| 39 | 43 | StartedAt: session.StartedAt.UTC(), | |
| 40 | 44 | EndedAt: endedAt, | |
| 45 | + | ProcessID: processID, | |
| 41 | 46 | }) | |
| 42 | 47 | } | |
| 43 | 48 | ||
| @@ -127,10 +132,17 @@ func sessionFromRow(row sqlc.Session) *entity.Session { | |||
| 127 | 132 | 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 | 141 | return &entity.Session{ | |
| 131 | 142 | ID: entity.SessionID(row.ID), | |
| 132 | 143 | Type: entity.SessionType(row.Type), | |
| 133 | 144 | StartedAt: row.StartedAt.UTC(), | |
| 134 | 145 | EndedAt: endedAt, | |
| 146 | + | ProcessID: processID, | |
| 135 | 147 | } | |
| 136 | 148 | } | |
| @@ -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 = &t | 132 | 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 { | |||
| 18 | 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 | 47 | func TestSessionRepository_CRUD(t *testing.T) { | |
| 22 | 48 | ctx := testCtx() | |
| 23 | 49 | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| @@ -29,7 +55,8 @@ func TestSessionRepository_CRUD(t *testing.T) { | |||
| 29 | 55 | repo := sqlite.NewSessionRepository(db) | |
| 30 | 56 | ||
| 31 | 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 | 60 | require.NoError(t, repo.Save(ctx, s)) | |
| 34 | 61 | ||
| 35 | 62 | got, err := repo.FindByID(ctx, s.ID) | |
| @@ -38,12 +65,16 @@ func TestSessionRepository_CRUD(t *testing.T) { | |||
| 38 | 65 | assert.Equal(t, s.ID, got.ID) | |
| 39 | 66 | assert.Equal(t, s.Type, got.Type) | |
| 40 | 67 | assert.True(t, got.StartedAt.Equal(startedAt)) | |
| 68 | + | require.NotNil(t, got.ProcessID) | |
| 69 | + | assert.Equal(t, pid, *got.ProcessID) | |
| 41 | 70 | assert.Nil(t, got.EndedAt) | |
| 42 | 71 | ||
| 43 | 72 | active, err := repo.GetActive(ctx) | |
| 44 | 73 | require.NoError(t, err) | |
| 45 | 74 | require.NotNil(t, active) | |
| 46 | 75 | assert.Equal(t, s.ID, active.ID) | |
| 76 | + | require.NotNil(t, active.ProcessID) | |
| 77 | + | assert.Equal(t, pid, *active.ProcessID) | |
| 47 | 78 | ||
| 48 | 79 | endedAt := time.Date(2025, 12, 22, 9, 0, 0, 0, time.UTC) | |
| 49 | 80 | require.NoError(t, repo.MarkEnded(ctx, s.ID, endedAt)) | |
| @@ -61,4 +92,6 @@ func TestSessionRepository_CRUD(t *testing.T) { | |||
| 61 | 92 | require.NoError(t, err) | |
| 62 | 93 | require.Len(t, recent, 1) | |
| 63 | 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 | } | |
| @@ -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 { | |||
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | 85 | 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"` | |
| 90 | 91 | } | |
| 91 | 92 | ||
| 92 | 93 | 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 { | |||
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 58 | 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 | |
| 60 | 60 | WHERE type = 'browser' AND ended_at IS NULL | |
| 61 | 61 | ORDER BY started_at DESC | |
| 62 | 62 | LIMIT 1 | |
| @@ -70,12 +70,13 @@ func (q *Queries) GetActiveBrowserSession(ctx context.Context) (Session, error) | |||
| 70 | 70 | &i.Type, | |
| 71 | 71 | &i.StartedAt, | |
| 72 | 72 | &i.EndedAt, | |
| 73 | + | &i.ProcessID, | |
| 73 | 74 | ) | |
| 74 | 75 | return i, err | |
| 75 | 76 | } | |
| 76 | 77 | ||
| 77 | 78 | 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 | |
| 79 | 80 | ORDER BY started_at DESC | |
| 80 | 81 | LIMIT ? | |
| 81 | 82 | ` | |
| @@ -94,6 +95,7 @@ func (q *Queries) GetRecentSessions(ctx context.Context, limit int64) ([]Session | |||
| 94 | 95 | &i.Type, | |
| 95 | 96 | &i.StartedAt, | |
| 96 | 97 | &i.EndedAt, | |
| 98 | + | &i.ProcessID, | |
| 97 | 99 | ); err != nil { | |
| 98 | 100 | return nil, err | |
| 99 | 101 | } | |
| @@ -109,7 +111,7 @@ func (q *Queries) GetRecentSessions(ctx context.Context, limit int64) ([]Session | |||
| 109 | 111 | } | |
| 110 | 112 | ||
| 111 | 113 | 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 | |
| 113 | 115 | ` | |
| 114 | 116 | ||
| 115 | 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 | 122 | &i.Type, | |
| 121 | 123 | &i.StartedAt, | |
| 122 | 124 | &i.EndedAt, | |
| 125 | + | &i.ProcessID, | |
| 123 | 126 | ) | |
| 124 | 127 | return i, err | |
| 125 | 128 | } | |
| 126 | 129 | ||
| 127 | 130 | 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 (?, ?, ?, ?, ?) | |
| 130 | 133 | ` | |
| 131 | 134 | ||
| 132 | 135 | 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"` | |
| 137 | 141 | } | |
| 138 | 142 | ||
| 139 | 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 | 146 | arg.Type, | |
| 143 | 147 | arg.StartedAt, | |
| 144 | 148 | arg.EndedAt, | |
| 149 | + | arg.ProcessID, | |
| 145 | 150 | ) | |
| 146 | 151 | return err | |
| 147 | 152 | } | |
| @@ -56,7 +56,7 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error { | |||||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one | 58 | 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 |
| 60 | WHERE type = 'browser' AND ended_at IS NULL | 60 | WHERE type = 'browser' AND ended_at IS NULL | ||
| 61 | ORDER BY started_at DESC | 61 | ORDER BY started_at DESC | ||
| 62 | LIMIT 1 | 62 | 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, err | 75 | return i, err | ||
| 75 | } | 76 | } | ||
| 76 | 77 | ||||
| 77 | const GetRecentSessions = `-- name: GetRecentSessions :many | 78 | 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 |
| 79 | ORDER BY started_at DESC | 80 | 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, err | 100 | 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 :one | 113 | 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 |
| 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, err | 127 | return i, err | ||
| 125 | } | 128 | } | ||
| 126 | 129 | ||||
| 127 | const InsertSession = `-- name: InsertSession :exec | 130 | 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 err | 151 | 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 | + | } | |||