From 0c6eb416a5e75f56efc4665b13f06942967c889b Mon Sep 17 00:00:00 2001 From: naiba Date: Mon, 20 Jul 2026 04:29:21 +0000 Subject: [PATCH] feat(agentcompat): add SQLite hold scoped attribution Co-authored-by: naiba/CloudCode --- service/singleton/singleton.go | 17 +- ...ibution_boundary_agentcompat_linux_test.go | 83 ++++++ ...ttribution_close_agentcompat_linux_test.go | 180 ++++++++++++ ...it_linearization_agentcompat_linux_test.go | 102 +++++++ ...ution_completion_agentcompat_linux_test.go | 154 ++++++++++ ...tion_connection_close_agentcompat_linux.go | 26 ++ ...n_connection_dispatch_agentcompat_linux.go | 52 ++++ ..._attribution_evidence_agentcompat_linux.go | 149 ++++++++++ ...n_hold_lifecycle_agentcompat_linux_test.go | 186 ++++++++++++ ...ution_journal_fd_agentcompat_linux_test.go | 42 +++ ...bution_lifecycle_agentcompat_linux_test.go | 211 ++++++++++++++ ..._query_lifecycle_agentcompat_linux_test.go | 145 ++++++++++ ...rminal_ownership_agentcompat_linux_test.go | 227 +++++++++++++++ ...bution_returning_agentcompat_linux_test.go | 121 ++++++++ ...tribution_review_agentcompat_linux_test.go | 231 +++++++++++++++ ...statement_transaction_agentcompat_linux.go | 186 ++++++++++++ ...tribution_transaction_agentcompat_linux.go | 126 +++++++++ ...ion_finalization_agentcompat_linux_test.go | 236 ++++++++++++++++ ...saction_terminal_agentcompat_linux_test.go | 158 +++++++++++ ...ribution_unbound_agentcompat_linux_test.go | 217 ++++++++++++++ .../sqlite_dialector_agentcompat_linux.go | 13 + service/singleton/sqlite_dialector_default.go | 12 + ...sqlite_driver_adapter_agentcompat_linux.go | 250 +++++++++++++++++ ...e_driver_adapter_agentcompat_linux_test.go | 204 ++++++++++++++ .../sqlite_hold_control_agentcompat_linux.go | 176 ++++++++++++ ...ite_hold_control_agentcompat_linux_test.go | 111 ++++++++ .../sqlite_hold_facade_agentcompat_linux.go | 24 ++ ..._hold_tracker_control_agentcompat_linux.go | 239 ++++++++++++++++ ...lization_context_agentcompat_linux_test.go | 75 +++++ ...ker_finalization_wait_agentcompat_linux.go | 33 +++ ...old_tracker_recording_agentcompat_linux.go | 113 ++++++++ ...acker_regression_agentcompat_linux_test.go | 228 +++++++++++++++ ...d_tracker_review_agentcompat_linux_test.go | 141 ++++++++++ ...racker_selection_agentcompat_linux_test.go | 265 ++++++++++++++++++ ...cker_terminal_arbiter_agentcompat_linux.go | 29 ++ ...te_hold_tracker_types_agentcompat_linux.go | 169 +++++++++++ ..._tracker_unbound_agentcompat_linux_test.go | 149 ++++++++++ ...old_tracker_wait_agentcompat_linux_test.go | 238 ++++++++++++++++ ...ld_tracker_write_agentcompat_linux_test.go | 215 ++++++++++++++ 39 files changed, 5527 insertions(+), 6 deletions(-) create mode 100644 service/singleton/sqlite_attribution_boundary_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_close_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_commit_linearization_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_completion_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_connection_close_agentcompat_linux.go create mode 100644 service/singleton/sqlite_attribution_connection_dispatch_agentcompat_linux.go create mode 100644 service/singleton/sqlite_attribution_evidence_agentcompat_linux.go create mode 100644 service/singleton/sqlite_attribution_hold_lifecycle_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_journal_fd_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_lifecycle_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_query_lifecycle_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_release_terminal_ownership_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_returning_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_review_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_statement_transaction_agentcompat_linux.go create mode 100644 service/singleton/sqlite_attribution_transaction_agentcompat_linux.go create mode 100644 service/singleton/sqlite_attribution_transaction_finalization_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_transaction_terminal_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_attribution_unbound_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_dialector_agentcompat_linux.go create mode 100644 service/singleton/sqlite_dialector_default.go create mode 100644 service/singleton/sqlite_driver_adapter_agentcompat_linux.go create mode 100644 service/singleton/sqlite_driver_adapter_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_hold_control_agentcompat_linux.go create mode 100644 service/singleton/sqlite_hold_control_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_hold_facade_agentcompat_linux.go create mode 100644 service/singleton/sqlite_hold_tracker_control_agentcompat_linux.go create mode 100644 service/singleton/sqlite_hold_tracker_finalization_context_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_hold_tracker_finalization_wait_agentcompat_linux.go create mode 100644 service/singleton/sqlite_hold_tracker_recording_agentcompat_linux.go create mode 100644 service/singleton/sqlite_hold_tracker_regression_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_hold_tracker_review_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_hold_tracker_selection_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_hold_tracker_terminal_arbiter_agentcompat_linux.go create mode 100644 service/singleton/sqlite_hold_tracker_types_agentcompat_linux.go create mode 100644 service/singleton/sqlite_hold_tracker_unbound_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_hold_tracker_wait_agentcompat_linux_test.go create mode 100644 service/singleton/sqlite_hold_tracker_write_agentcompat_linux_test.go diff --git a/service/singleton/singleton.go b/service/singleton/singleton.go index e4817d5d..e2a11da2 100644 --- a/service/singleton/singleton.go +++ b/service/singleton/singleton.go @@ -11,7 +11,6 @@ import ( "github.com/gin-gonic/gin" "github.com/patrickmn/go-cache" - "gorm.io/driver/sqlite" "gorm.io/gorm" "sigs.k8s.io/yaml" @@ -81,7 +80,7 @@ func InitFrontendTemplates() error { // InitDBFromPath 从给出的文件路径中加载数据库 func InitDBFromPath(path string) error { var err error - DB, err = gorm.Open(sqlite.Open(path), &gorm.Config{ + DB, err = gorm.Open(openSQLiteDialector(path), &gorm.Config{ CreateBatchSize: 200, }) if err != nil { @@ -126,16 +125,15 @@ func RecordTransferHourlyUsage(servers ...*model.Server) { } for server := range slist { + _, _, deltaIn, deltaOut := server.TransferDeltaAndAdvance() tx := model.Transfer{ ServerID: server.ID, - In: utils.SubUintChecked(server.State.NetInTransfer, server.PrevTransferInSnapshot), - Out: utils.SubUintChecked(server.State.NetOutTransfer, server.PrevTransferOutSnapshot), + In: deltaIn, + Out: deltaOut, } if tx.In == 0 && tx.Out == 0 { continue } - server.PrevTransferInSnapshot = server.State.NetInTransfer - server.PrevTransferOutSnapshot = server.State.NetOutTransfer tx.CreatedAt = nowTrimSeconds txs = append(txs, tx) } @@ -146,6 +144,13 @@ func RecordTransferHourlyUsage(servers ...*model.Server) { log.Printf("NEZHA>> Saved traffic metrics to database. Affected %d row(s), Error: %v", len(txs), DB.Create(txs).Error) } +func PersistTransfer(transfer model.Transfer) error { + if transfer.In == 0 && transfer.Out == 0 { + return nil + } + return DB.Create(&transfer).Error +} + // CleanMonitorHistory 清理流量记录(TSDB 有自己的保留策略) func CleanMonitorHistory() { // 清理已被删除的服务器的流量记录 diff --git a/service/singleton/sqlite_attribution_boundary_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_boundary_agentcompat_linux_test.go new file mode 100644 index 00000000..7bf0add8 --- /dev/null +++ b/service/singleton/sqlite_attribution_boundary_agentcompat_linux_test.go @@ -0,0 +1,83 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "errors" + "path/filepath" + "strings" + "testing" +) + +func TestSQLiteAttributionErrorsDoNotExposeDatabasePath(t *testing.T) { + // Given + databasePath := filepath.Join(t.TempDir(), "private-dashboard.sqlite") + unsupportedDSN := "file:" + databasePath + "?mode=memory" + missingJournal := filepath.Join(t.TempDir(), "missing-journal") + + // When + memoryDatabase, dsnErr := openSQLiteAttributionTestDB(unsupportedDSN) + if dsnErr == nil { + dsnErr = memoryDatabase.Ping() + } + if memoryDatabase != nil { + t.Cleanup(func() { + if closeErr := memoryDatabase.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + } + _, journalErr := sqliteAttributionOpenJournalDescriptor(missingJournal) + + // Then + if !errors.Is(dsnErr, ErrSQLiteAttributionUnsupportedDSN) { + t.Fatal("file URI error does not wrap the typed unsupported DSN error") + } + if !errors.Is(journalErr, ErrSQLiteAttributionJournalIdentity) { + t.Fatal("journal error does not wrap the typed journal identity error") + } + if strings.Contains(dsnErr.Error(), databasePath) || strings.Contains(dsnErr.Error(), unsupportedDSN) { + t.Fatal("unsupported DSN error exposes its database path") + } + if strings.Contains(journalErr.Error(), missingJournal) { + t.Fatal("journal identity error exposes its database path") + } +} + +func TestSQLiteAttributionAcceptsOnDiskFileURIAndRejectsInMemoryDSN(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + databasePath := filepath.Join(t.TempDir(), "dashboard.sqlite") + + // When + fileDatabase, fileErr := openSQLiteAttributionTestDB("file:" + databasePath) + if fileDatabase != nil { + t.Cleanup(func() { + if closeErr := fileDatabase.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + } + if fileErr == nil { + fileErr = fileDatabase.Ping() + } + memoryDatabase, memoryErr := openSQLiteAttributionTestDB(":memory:") + if memoryDatabase != nil { + t.Cleanup(func() { + if closeErr := memoryDatabase.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + } + if memoryErr == nil { + memoryErr = memoryDatabase.Ping() + } + + // Then + if fileErr != nil { + t.Fatal("file URI for an on-disk database was rejected") + } + if !errors.Is(memoryErr, ErrSQLiteAttributionUnsupportedDSN) { + t.Fatal("in-memory DSN does not return the typed unsupported DSN error") + } +} diff --git a/service/singleton/sqlite_attribution_close_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_close_agentcompat_linux_test.go new file mode 100644 index 00000000..8350f7ce --- /dev/null +++ b/service/singleton/sqlite_attribution_close_agentcompat_linux_test.go @@ -0,0 +1,180 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "testing" + + "golang.org/x/sys/unix" +) + +func TestSQLiteAttributionConnectionCloseFinalizesActiveWriteTransaction(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + databasePath := sqliteAttributionTestDatabasePath(t) + rawConnection, err := sqliteAttributionDriver{}.Open(databasePath) + if err != nil { + t.Fatal(err) + } + connection := rawConnection.(*sqliteAttributionConnection) + if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil { + t.Fatal(err) + } + enableSQLiteAttribution() + transaction, err := connection.Begin() + if err != nil { + t.Fatal(err) + } + statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)") + if err != nil { + t.Fatal(err) + } + if _, err := statement.Exec([]driver.Value{"close-active"}); err != nil { + t.Fatal(err) + } + if err := statement.Close(); err != nil { + t.Fatal(err) + } + identity, descriptor, active := sqliteAttributionTransactionState(t, connection) + if !active { + t.Fatal("active transaction is missing before Close") + } + + // When + firstCloseErr := connection.Close() + secondCloseErr := connection.Close() + commitErr := transaction.Commit() + rollbackErr := transaction.Rollback() + _, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0) + standard, openErr := sql.Open("sqlite3", databasePath) + if openErr != nil { + t.Fatal(openErr) + } + defer standard.Close() + var count int + countErr := standard.QueryRow("SELECT COUNT(*) FROM settings").Scan(&count) + tracker := sqliteAttributionTracker.Load() + tracker.mu.Lock() + _, active = tracker.transactions[identity] + tracker.mu.Unlock() + + // Then + if firstCloseErr != nil || secondCloseErr != nil { + t.Fatalf("Close errors = %v / %v", firstCloseErr, secondCloseErr) + } + if !errors.Is(commitErr, driver.ErrBadConn) { + t.Fatalf("Commit after Close error = %v, want driver.ErrBadConn", commitErr) + } + if !errors.Is(rollbackErr, driver.ErrBadConn) { + t.Fatalf("Rollback after Close error = %v, want driver.ErrBadConn", rollbackErr) + } + if !errors.Is(descriptorErr, unix.EBADF) { + t.Fatalf("journal descriptor after Close = %v, want EBADF", descriptorErr) + } + if countErr != nil || count != 0 { + t.Fatalf("closed active transaction persisted count=%d err=%v", count, countErr) + } + if active { + t.Fatal("Close left the tracker transaction active") + } +} + +func TestSQLiteAttributionConnectionCloseWakesSelectedCommit(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + databasePath := sqliteAttributionTestDatabasePath(t) + rawConnection, err := sqliteAttributionDriver{}.Open(databasePath) + if err != nil { + t.Fatal(err) + } + connection := rawConnection.(*sqliteAttributionConnection) + if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil { + t.Fatal(err) + } + enableSQLiteAttribution() + transaction, err := connection.Begin() + if err != nil { + t.Fatal(err) + } + statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)") + if err != nil { + t.Fatal(err) + } + if _, err := statement.Exec([]driver.Value{"close-wakes-commit"}); err != nil { + t.Fatal(err) + } + if err := statement.Close(); err != nil { + t.Fatal(err) + } + _, descriptor, active := sqliteAttributionTransactionState(t, connection) + if !active { + t.Fatal("active transaction is missing before selected Commit") + } + tracker := sqliteAttributionTracker.Load() + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + finalizing := make(chan error, 1) + commit := make(chan error, 1) + go func() { + _, waitErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing) + finalizing <- waitErr + }() + + // When + go func() { commit <- transaction.Commit() }() + if finalizingErr := <-finalizing; finalizingErr != nil { + t.Fatalf("Commit finalization wait error = %v", finalizingErr) + } + closeErr := connection.Close() + commitErr := <-commit + _, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0) + standard, err := sql.Open("sqlite3", databasePath) + if err != nil { + t.Fatal(err) + } + defer standard.Close() + var count int + if err := standard.QueryRow("SELECT COUNT(*) FROM settings").Scan(&count); err != nil { + t.Fatal(err) + } + + // Then + if closeErr != nil { + t.Fatal(closeErr) + } + var holdErr *SQLiteHoldError + if !errors.As(commitErr, &holdErr) || !errors.Is(commitErr, ErrSQLiteHoldAborted) { + t.Fatalf("Commit error after Close = %v", commitErr) + } + if !errors.Is(descriptorErr, unix.EBADF) { + t.Fatalf("journal descriptor after Close = %v, want EBADF", descriptorErr) + } + if count != 0 { + t.Fatalf("Close while Commit waited persisted %d rows", count) + } +} + +type sqliteAttributionBadConnRows struct{} + +func (sqliteAttributionBadConnRows) Columns() []string { return []string{"value"} } +func (sqliteAttributionBadConnRows) Close() error { return nil } +func (sqliteAttributionBadConnRows) Next([]driver.Value) error { return driver.ErrBadConn } + +func TestSQLiteAttributionReadonlyRowsReturnBadConnWithoutPanic(t *testing.T) { + // Given + rows := &sqliteAttributionRows{rows: sqliteAttributionBadConnRows{}} + + // When + err := rows.Next(make([]driver.Value, 1)) + + // Then + if !errors.Is(err, driver.ErrBadConn) { + t.Fatalf("readonly rows Next error = %v, want driver.ErrBadConn", err) + } +} diff --git a/service/singleton/sqlite_attribution_commit_linearization_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_commit_linearization_agentcompat_linux_test.go new file mode 100644 index 00000000..13c4dc4f --- /dev/null +++ b/service/singleton/sqlite_attribution_commit_linearization_agentcompat_linux_test.go @@ -0,0 +1,102 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "errors" + "testing" + + "golang.org/x/sys/unix" +) + +func TestSQLiteAttributionCommitReleaseLinearizesBeforeContextCancellation(t *testing.T) { + // Given + transactionContext, cancel := context.WithCancel(context.Background()) + defer cancel() + connection, transaction, session, databasePath := sqliteAttributionHeldTransaction(t, "released-before-cancel", transactionContext) + identity, descriptor, active := sqliteAttributionTransactionState(t, connection) + if !active { + t.Fatal("released transaction is not active") + } + + // When + commit := sqliteAttributionStartHeldCommit(t, transaction, session) + if err := sqliteAttributionTracker.Load().ReleaseSQLiteHold(session); err != nil { + t.Fatal(err) + } + cancel() + commitErr := <-commit + terminal, terminalErr := sqliteAttributionTracker.Load().WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing) + _, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0) + + // Then + if commitErr != nil { + t.Fatalf("released Commit error after context cancellation = %v", commitErr) + } + if terminalErr != nil || !terminal.Released || !terminal.Selected || !terminal.Finalizing { + t.Fatalf("released terminal=%+v err=%v", terminal, terminalErr) + } + if !errors.Is(descriptorErr, unix.EBADF) { + t.Fatalf("journal descriptor after released Commit = %v, want EBADF", descriptorErr) + } + if count := sqliteAttributionPersistedCount(t, databasePath); count != 1 { + t.Fatalf("released Commit persisted %d rows", count) + } + if _, _, active := sqliteAttributionTransactionState(t, connection); active { + t.Fatal("released Commit left the connection transaction active") + } + if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) { + t.Fatal("released Commit left the tracker transaction active") + } +} + +func TestSQLiteAttributionCommitCancellationAbortsBeforeRelease(t *testing.T) { + // Given + transactionContext, cancel := context.WithCancel(context.Background()) + defer cancel() + connection, transaction, session, databasePath := sqliteAttributionHeldTransaction(t, "cancelled-before-release", transactionContext) + identity, descriptor, active := sqliteAttributionTransactionState(t, connection) + if !active { + t.Fatal("cancelled transaction is not active") + } + + // When + commit := sqliteAttributionStartHeldCommit(t, transaction, session) + cancel() + commitErr := <-commit + releaseErr := sqliteAttributionTracker.Load().ReleaseSQLiteHold(session) + _, terminalErr := sqliteAttributionTracker.Load().WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing) + _, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0) + tracker := sqliteAttributionTracker.Load() + tracker.mu.Lock() + terminal := tracker.terminal + tracker.mu.Unlock() + + // Then + var holdErr *SQLiteHoldError + if !errors.As(commitErr, &holdErr) || !errors.Is(commitErr, ErrSQLiteHoldAborted) || !errors.Is(commitErr, context.Canceled) { + t.Fatalf("cancelled Commit error = %v", commitErr) + } + if !errors.Is(releaseErr, ErrSQLiteHoldStaleSession) { + t.Fatalf("release after cancellation-owned abort = %v", releaseErr) + } + if !errors.Is(terminalErr, ErrSQLiteHoldAborted) { + t.Fatalf("cancelled terminal wait error = %v, want aborted", terminalErr) + } + if terminal == nil || terminal.released { + t.Fatalf("cancelled terminal state = %+v, want aborted and unreleased", terminal) + } + if !errors.Is(descriptorErr, unix.EBADF) { + t.Fatalf("journal descriptor after cancelled Commit = %v, want EBADF", descriptorErr) + } + if count := sqliteAttributionPersistedCount(t, databasePath); count != 0 { + t.Fatalf("cancelled Commit persisted %d rows", count) + } + if _, _, active := sqliteAttributionTransactionState(t, connection); active { + t.Fatal("cancelled Commit left the connection transaction active") + } + if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) { + t.Fatal("cancelled Commit left the tracker transaction active") + } +} diff --git a/service/singleton/sqlite_attribution_completion_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_completion_agentcompat_linux_test.go new file mode 100644 index 00000000..029918d7 --- /dev/null +++ b/service/singleton/sqlite_attribution_completion_agentcompat_linux_test.go @@ -0,0 +1,154 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "database/sql/driver" + "errors" + "sync" + "sync/atomic" + "testing" +) + +type sqliteAttributionBlockingTx struct { + commitStarted chan struct{} + allowCommit chan struct{} + commitCalls atomic.Int32 + rollbackCalls atomic.Int32 + lifecycleMu *sync.Mutex + lockFree atomic.Bool +} + +func (transaction *sqliteAttributionBlockingTx) Commit() error { + transaction.commitCalls.Add(1) + // Probe before publishing entry so losing terminal calls cannot contend with this boundary check. + if transaction.lifecycleMu.TryLock() { + transaction.lockFree.Store(true) + transaction.lifecycleMu.Unlock() + } + close(transaction.commitStarted) + <-transaction.allowCommit + return nil +} + +func (transaction *sqliteAttributionBlockingTx) Rollback() error { + transaction.rollbackCalls.Add(1) + return nil +} + +func TestSQLiteAttributionTransactionCompletionRunsRawCommitExactlyOnce(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + identity := sqliteHoldTestTransaction(201) + if err := tracker.BeginSQLiteTransaction(identity); err != nil { + t.Fatal(err) + } + raw := &sqliteAttributionBlockingTx{commitStarted: make(chan struct{}), allowCommit: make(chan struct{})} + state := &sqliteAttributionTransaction{ + transaction: identity, + raw: raw, + tracker: tracker, + journalFD: -1, + done: make(chan struct{}), + } + var rawCloseCalls atomic.Int32 + connection := &sqliteAttributionConnection{transaction: state} + connection.closeRawConnection = func() error { + if !connection.lifecycleMu.TryLock() { + return errors.New("raw connection Close ran while lifecycle lock was held") + } + connection.lifecycleMu.Unlock() + rawCloseCalls.Add(1) + return nil + } + raw.lifecycleMu = &connection.lifecycleMu + owner := &sqliteAttributionTx{connection: connection, state: state} + secondCommit := &sqliteAttributionTx{connection: connection, state: state} + commitResult := make(chan error, 1) + secondCommitResult := make(chan error, 1) + rollbackResult := make(chan error, 1) + closeResult := make(chan error, 1) + secondCommitStarted := make(chan struct{}) + rollbackStarted := make(chan struct{}) + closeStarted := make(chan struct{}) + + // When + go func() { commitResult <- owner.Commit() }() + <-raw.commitStarted + go func() { + close(secondCommitStarted) + secondCommitResult <- secondCommit.Commit() + }() + go func() { + close(rollbackStarted) + rollbackResult <- owner.Rollback() + }() + go func() { + close(closeStarted) + closeResult <- connection.Close() + }() + <-secondCommitStarted + <-rollbackStarted + <-closeStarted + for _, result := range []<-chan error{secondCommitResult, rollbackResult} { + select { + case err := <-result: + t.Fatalf("completion loser returned before raw Commit completion: %v", err) + default: + } + } + select { + case <-state.done: + t.Fatal("completion published before raw Commit was allowed to finish") + default: + } + if calls := rawCloseCalls.Load(); calls != 0 { + t.Fatalf("raw connection Close calls while raw Commit was in flight = %d, want 0", calls) + } + if !raw.lockFree.Load() { + t.Fatal("raw Commit ran while lifecycle lock was held") + } + if !sqliteAttributionTrackerTransactionActive(tracker, identity) { + t.Fatal("tracker transaction became inactive while raw Commit was blocked") + } + close(raw.allowCommit) + commitErr := <-commitResult + secondCommitErr := <-secondCommitResult + rollbackErr := <-rollbackResult + closeErr := <-closeResult + + // Then + if commitErr != nil { + t.Fatalf("raw Commit error = %v", commitErr) + } + for _, loserErr := range []error{secondCommitErr, rollbackErr} { + if !errors.Is(loserErr, driver.ErrBadConn) { + t.Fatalf("completion loser error = %v, want driver.ErrBadConn", loserErr) + } + } + if closeErr != nil && !errors.Is(closeErr, driver.ErrBadConn) { + t.Fatalf("connection Close error = %v, want nil or driver.ErrBadConn", closeErr) + } + select { + case <-state.done: + default: + t.Fatal("completion signal did not close after raw Commit finished") + } + if calls := raw.commitCalls.Load(); calls != 1 { + t.Fatalf("raw Commit calls = %d, want 1", calls) + } + if calls := rawCloseCalls.Load(); calls != 1 { + t.Fatalf("raw connection Close calls after raw Commit completed = %d, want 1", calls) + } + if calls := raw.rollbackCalls.Load(); calls != 0 { + t.Fatalf("raw Rollback calls while raw Commit owned completion = %d, want 0", calls) + } + if _, _, active := sqliteAttributionTransactionState(t, connection); active { + t.Fatal("completed transaction remained attached to the connection") + } + if sqliteAttributionTrackerTransactionActive(tracker, identity) { + t.Fatal("completed transaction remained active in the tracker") + } +} + +var _ driver.Tx = (*sqliteAttributionBlockingTx)(nil) diff --git a/service/singleton/sqlite_attribution_connection_close_agentcompat_linux.go b/service/singleton/sqlite_attribution_connection_close_agentcompat_linux.go new file mode 100644 index 00000000..a893f2c3 --- /dev/null +++ b/service/singleton/sqlite_attribution_connection_close_agentcompat_linux.go @@ -0,0 +1,26 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "errors" +) + +func (connection *sqliteAttributionConnection) Close() error { + connection.lifecycleMu.Lock() + state := connection.transaction + connection.lifecycleMu.Unlock() + if state == nil { + return connection.closeRaw() + } + // database/sql may close Conn before Tx reaches Rollback; converge the attribution state here. + rollbackErr := state.rollback(connection) + return errors.Join(rollbackErr, connection.closeRaw()) +} + +func (connection *sqliteAttributionConnection) closeRaw() error { + if connection.closeRawConnection != nil { + return connection.closeRawConnection() + } + return connection.connection.Close() +} diff --git a/service/singleton/sqlite_attribution_connection_dispatch_agentcompat_linux.go b/service/singleton/sqlite_attribution_connection_dispatch_agentcompat_linux.go new file mode 100644 index 00000000..92acaf30 --- /dev/null +++ b/service/singleton/sqlite_attribution_connection_dispatch_agentcompat_linux.go @@ -0,0 +1,52 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "database/sql/driver" + "errors" +) + +func (connection *sqliteAttributionConnection) Exec(query string, values []driver.Value) (driver.Result, error) { + statement, err := connection.Prepare(query) + if err != nil { + return nil, err + } + defer statement.Close() + return statement.Exec(values) +} + +func (connection *sqliteAttributionConnection) ExecContext(ctx context.Context, query string, values []driver.NamedValue) (driver.Result, error) { + statement, err := connection.PrepareContext(ctx, query) + if err != nil { + return nil, err + } + defer statement.Close() + return statement.(driver.StmtExecContext).ExecContext(ctx, values) +} + +func (connection *sqliteAttributionConnection) Query(query string, values []driver.Value) (driver.Rows, error) { + statement, err := connection.Prepare(query) + if err != nil { + return nil, err + } + wrapped := statement.(*sqliteAttributionStatement) + return wrapped.queryOwned(func() (driver.Rows, error) { return wrapped.statement.Query(values) }, statement) +} + +func (connection *sqliteAttributionConnection) QueryContext(ctx context.Context, query string, values []driver.NamedValue) (driver.Rows, error) { + statement, err := connection.PrepareContext(ctx, query) + if err != nil { + return nil, err + } + return connection.queryContextStatement(ctx, statement.(*sqliteAttributionStatement), values, statement) +} + +func (connection *sqliteAttributionConnection) queryContextStatement(ctx context.Context, statement *sqliteAttributionStatement, values []driver.NamedValue, owner driver.Stmt) (driver.Rows, error) { + contextStatement, ok := statement.statement.(driver.StmtQueryContext) + if !ok { + return nil, errors.Join(driver.ErrSkip, owner.Close()) + } + return statement.queryOwned(func() (driver.Rows, error) { return contextStatement.QueryContext(ctx, values) }, owner) +} diff --git a/service/singleton/sqlite_attribution_evidence_agentcompat_linux.go b/service/singleton/sqlite_attribution_evidence_agentcompat_linux.go new file mode 100644 index 00000000..9e171f2a --- /dev/null +++ b/service/singleton/sqlite_attribution_evidence_agentcompat_linux.go @@ -0,0 +1,149 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "errors" + "runtime" + "strings" + + "golang.org/x/sys/unix" +) + +func (connection *sqliteAttributionConnection) beforeWrite(classification sqliteAttributionClassification) error { + if !sqliteAttributionEnabled.Load() || classification.readonly { + return nil + } + if !classification.valid() { + return &SQLiteAttributionError{Cause: ErrSQLiteAttributionUnsupportedWrite} + } + connection.lifecycleMu.Lock() + state := connection.transaction + if state == nil { + connection.lifecycleMu.Unlock() + return &SQLiteAttributionError{Cause: ErrSQLiteAttributionUnboundWrite} + } + poison := state.poison + connection.lifecycleMu.Unlock() + if poison != nil { + return poison + } + connection.execution = &sqliteAttributionExecution{classification: classification, origin: sqliteAttributionOrigin()} + return nil +} + +func (connection *sqliteAttributionConnection) discardExecution() { connection.execution = nil } + +func (connection *sqliteAttributionConnection) publishExecution() error { + execution := connection.execution + connection.execution = nil + if execution == nil { + return nil + } + connection.lifecycleMu.Lock() + state := connection.transaction + if state == nil { + connection.lifecycleMu.Unlock() + return &SQLiteAttributionError{Cause: ErrSQLiteAttributionUnboundWrite} + } + if execution.hook.mismatch || !execution.hook.seen { + err := &SQLiteAttributionError{Cause: ErrSQLiteAttributionUnsupportedWrite} + state.poison = err + connection.lifecycleMu.Unlock() + return err + } + if state.journalFD < 0 { + descriptor, err := sqliteAttributionOpenJournalDescriptor(connection.journal) + if err != nil { + state.poison = err + connection.lifecycleMu.Unlock() + return err + } + state.journalFD = descriptor + } + journal, err := sqliteAttributionJournalIdentityFromDescriptor(state.journalFD) + if err != nil { + state.poison = err + connection.lifecycleMu.Unlock() + return err + } + transaction := state.transaction + tracker := state.tracker + connection.lifecycleMu.Unlock() + origin := execution.origin + origin.Operation = execution.classification.operation + origin.Table = execution.classification.table + err = tracker.RecordSQLiteWrite(transaction, SQLiteWriteObservation{ + Origin: origin, + Update: SQLiteUpdateObservation{Operation: execution.classification.operation, Table: execution.classification.table, Journal: journal}, + }) + if err != nil { + return connection.poison(err) + } + return nil +} + +func (connection *sqliteAttributionConnection) poison(err error) error { + connection.lifecycleMu.Lock() + state := connection.transaction + if state != nil { + state.poison = err + } + connection.lifecycleMu.Unlock() + return err +} + +// The adapter owns the explicit main transaction and DELETE journal; path-only stat is racy, so retain this O_PATH descriptor through finalization. +func sqliteAttributionOpenJournalDescriptor(path string) (int, error) { + descriptor, err := unix.Open(path, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return -1, &SQLiteAttributionError{Cause: errors.Join(ErrSQLiteAttributionJournalIdentity, err)} + } + return descriptor, nil +} + +func sqliteAttributionCloseJournalDescriptor(descriptor int) error { + if descriptor < 0 { + return nil + } + if err := unix.Close(descriptor); err != nil { + return &SQLiteAttributionError{Cause: errors.Join(ErrSQLiteAttributionJournalIdentity, err)} + } + return nil +} + +func sqliteAttributionJournalIdentityFromDescriptor(descriptor int) (SQLiteJournalIdentity, error) { + var status unix.Statx_t + mask := uint32(unix.STATX_BASIC_STATS | unix.STATX_BTIME | unix.STATX_MNT_ID) + if err := unix.Statx(descriptor, "", unix.AT_EMPTY_PATH|unix.AT_STATX_SYNC_AS_STAT, int(mask), &status); err != nil { + return SQLiteJournalIdentity{}, &SQLiteAttributionError{Cause: errors.Join(ErrSQLiteAttributionJournalIdentity, err)} + } + required := uint32(unix.STATX_MNT_ID | unix.STATX_BTIME) + if status.Mask&required != required { + return SQLiteJournalIdentity{}, &SQLiteAttributionError{Cause: ErrSQLiteAttributionJournalIdentity} + } + return SQLiteJournalIdentity{MountID: status.Mnt_id, DeviceMajor: status.Dev_major, DeviceMinor: status.Dev_minor, Inode: status.Ino, BirthSeconds: status.Btime.Sec, BirthNanoseconds: status.Btime.Nsec}, nil +} + +func sqliteAttributionOrigin() SQLiteExecutionOrigin { + programCounters := make([]uintptr, 16) + count := runtime.Callers(3, programCounters) + programCounters = programCounters[:count] + frames := runtime.CallersFrames(programCounters) + frame, more := frames.Next() + for more && !strings.Contains(frame.Function, "github.com/nezhahq/nezha/") { + frame, more = frames.Next() + } + return SQLiteExecutionOrigin{StackHash: sqliteAttributionStackHash(programCounters), FirstNezhaFrame: frame.Function} +} + +func sqliteAttributionStackHash(programCounters []uintptr) uint64 { + const offsetBasis uint64 = 14695981039346656037 + const prime uint64 = 1099511628211 + hash := offsetBasis + for _, programCounter := range programCounters { + hash ^= uint64(programCounter) + hash *= prime + } + return hash +} diff --git a/service/singleton/sqlite_attribution_hold_lifecycle_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_hold_lifecycle_agentcompat_linux_test.go new file mode 100644 index 00000000..65613d6e --- /dev/null +++ b/service/singleton/sqlite_attribution_hold_lifecycle_agentcompat_linux_test.go @@ -0,0 +1,186 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/nezhahq/nezha/model" + "gorm.io/gorm" +) + +func TestSQLiteAttributionHoldFacadeEnablesOnArmAndDisablesOnAbort(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + t.Cleanup(resetSQLiteAttributionForTest) + + // When + receipt, armErr := ArmNextSQLiteHold() + enabledAfterArm := sqliteAttributionEnabled.Load() + _, abortErr := AbortSQLiteHold(receipt) + + // Then + if armErr != nil || abortErr != nil { + t.Fatalf("arm=%v abort=%v", armErr, abortErr) + } + if !enabledAfterArm { + t.Fatal("successful hold arm did not enable SQLite attribution") + } + if sqliteAttributionEnabled.Load() { + t.Fatal("successful hold abort left SQLite attribution enabled") + } +} + +func TestSQLiteAttributionHoldFacadeDisablesAfterRelease(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + t.Cleanup(resetSQLiteAttributionForTest) + receipt, err := ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + transaction := sqliteHoldTestTransaction(204) + tracker := sqliteAttributionTracker.Load() + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "api_tokens", Journal: sqliteHoldTestJournal}); err != nil { + t.Fatal(err) + } + if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil { + t.Fatal(err) + } + + // When + result, releaseErr := ReleaseSQLiteHold(receipt) + + // Then + if releaseErr != nil || result.State != SQLiteHoldControlStateReleased { + t.Fatalf("release=%+v err=%v", result, releaseErr) + } + if sqliteAttributionEnabled.Load() { + t.Fatal("successful hold release left SQLite attribution enabled") + } +} + +func TestSQLiteAttributionHoldFacadeKeepsEnabledAfterCanceledWaitUntilAbort(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + t.Cleanup(resetSQLiteAttributionForTest) + receipt, err := ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // When + _, waitErr := WaitSQLiteHoldSelected(ctx, receipt) + enabledAfterWait := sqliteAttributionEnabled.Load() + _, abortErr := AbortSQLiteHold(receipt) + + // Then + if !errors.Is(waitErr, context.Canceled) || abortErr != nil { + t.Fatalf("wait=%v abort=%v", waitErr, abortErr) + } + if !enabledAfterWait { + t.Fatal("canceled wait disabled attribution before the active hold was aborted") + } + if sqliteAttributionEnabled.Load() { + t.Fatal("abort after canceled wait left SQLite attribution enabled") + } +} + +func TestSQLiteAttributionHoldFacadeStaleReceiptCannotDisableNewHold(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + t.Cleanup(resetSQLiteAttributionForTest) + first, err := ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + if _, err := AbortSQLiteHold(first); err != nil { + t.Fatal(err) + } + second, err := ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + + // When + _, staleErr := AbortSQLiteHold(first) + + // Then + if !errors.Is(staleErr, ErrSQLiteHoldStaleSession) { + t.Fatalf("stale abort error = %v", staleErr) + } + if !sqliteAttributionEnabled.Load() { + t.Fatal("stale receipt disabled attribution owned by the current hold") + } + if _, err := AbortSQLiteHold(second); err != nil { + t.Fatal(err) + } +} + +func TestSQLiteAttributionHoldFacadeSelectsGORMAPITokenUsageUpdate(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + t.Cleanup(resetSQLiteAttributionForTest) + database, err := gorm.Open(openSQLiteDialector(filepath.Join(t.TempDir(), "dashboard.sqlite")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDatabase, err := database.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := sqlDatabase.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + if err := database.AutoMigrate(&model.APIToken{}); err != nil { + t.Fatal(err) + } + token := model.APIToken{UserID: 1, Name: "usage-update", TokenHash: "hash"} + if err := database.Create(&token).Error; err != nil { + t.Fatal(err) + } + receipt, err := ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + writerDone := make(chan error, 1) + usageTime := time.Unix(1_700_000_000, 0) + go func() { + writerDone <- database.Model(&model.APIToken{}).Where("id = ?", token.ID).Updates(map[string]any{ + "last_used_at": usageTime, + "last_used_ip": "127.0.0.1", + }).Error + cancel() + }() + + // When + selected, selectedErr := WaitSQLiteHoldSelected(ctx, receipt) + finalizing, finalizingErr := WaitSQLiteHoldFinalizing(ctx, selected) + _, releaseErr := ReleaseSQLiteHold(finalizing) + writerErr := <-writerDone + + // Then + if selectedErr != nil || finalizingErr != nil || releaseErr != nil || writerErr != nil { + t.Fatalf("selected=%v finalizing=%v release=%v writer=%v", selectedErr, finalizingErr, releaseErr, writerErr) + } + var updated model.APIToken + if err := database.First(&updated, token.ID).Error; err != nil { + t.Fatal(err) + } + if updated.LastUsedAt == nil || !updated.LastUsedAt.Equal(usageTime) || updated.LastUsedIP != "127.0.0.1" { + t.Fatalf("persisted usage update = %+v", updated) + } +} diff --git a/service/singleton/sqlite_attribution_journal_fd_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_journal_fd_agentcompat_linux_test.go new file mode 100644 index 00000000..0892c226 --- /dev/null +++ b/service/singleton/sqlite_attribution_journal_fd_agentcompat_linux_test.go @@ -0,0 +1,42 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "path/filepath" + "testing" + + "golang.org/x/sys/unix" +) + +func TestSQLiteAttributionDerivesJournalIdentityFromOpenedDescriptor(t *testing.T) { + // Given + journalPath := filepath.Join(t.TempDir(), "dashboard.sqlite-journal") + descriptor, err := unix.Open(journalPath, unix.O_CREAT|unix.O_WRONLY|unix.O_CLOEXEC, 0o600) + if err != nil { + t.Fatal(err) + } + if err := unix.Close(descriptor); err != nil { + t.Fatal(err) + } + descriptor, err = unix.Open(journalPath, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := unix.Close(descriptor); closeErr != nil { + t.Error(closeErr) + } + }) + + // When + identity, err := sqliteAttributionJournalIdentityFromDescriptor(descriptor) + + // Then + if err != nil { + t.Fatal(err) + } + if identity.MountID == 0 || identity.Inode == 0 || identity.BirthSeconds == 0 { + t.Fatal("descriptor-derived journal identity is incomplete") + } +} diff --git a/service/singleton/sqlite_attribution_lifecycle_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_lifecycle_agentcompat_linux_test.go new file mode 100644 index 00000000..04754ba8 --- /dev/null +++ b/service/singleton/sqlite_attribution_lifecycle_agentcompat_linux_test.go @@ -0,0 +1,211 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "database/sql/driver" + "errors" + "testing" + + "golang.org/x/sys/unix" +) + +func TestSQLiteAttributionReturningCompletesExactlyOneRow(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + enableSQLiteAttribution() + transaction, err := database.BeginTx(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + + // When + rows, err := transaction.QueryContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id", "one-row") + if err != nil { + t.Fatal(err) + } + if !rows.Next() { + t.Fatal("RETURNING did not yield its inserted row") + } + var identifier int64 + if err := rows.Scan(&identifier); err != nil { + t.Fatal(err) + } + if rows.Next() { + t.Fatal("RETURNING yielded more than one row") + } + rowsErr := rows.Err() + closeErr := rows.Close() + commitErr := transaction.Commit() + count := sqliteAttributionSettingsCount(t, database) + + // Then + if rowsErr != nil || closeErr != nil || commitErr != nil { + t.Fatal("completed RETURNING did not finish successfully") + } + if count != 1 { + t.Fatalf("completed RETURNING persisted %d rows", count) + } +} + +func TestSQLiteAttributionEarlyReturningCloseRollsBackTransaction(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + enableSQLiteAttribution() + transaction, err := database.BeginTx(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + + // When + rows, err := transaction.QueryContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id", "early-close") + if err != nil { + t.Fatal(err) + } + if !rows.Next() { + t.Fatal("RETURNING did not yield its inserted row") + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + commitErr := transaction.Commit() + count := sqliteAttributionSettingsCount(t, database) + + // Then + if commitErr == nil { + t.Fatal("early RETURNING Close allowed Commit") + } + if count != 0 { + t.Fatalf("early RETURNING Close persisted %d rows", count) + } +} + +func TestSQLiteAttributionZeroRowUpdateDoesNotPublishEvidence(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + enableSQLiteAttribution() + transaction, err := database.BeginTx(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + + // When + if _, err := transaction.Exec("UPDATE settings SET value = ? WHERE id = ?", "zero", -1); err != nil { + t.Fatal(err) + } + commitErr := transaction.Commit() + evidence := sqliteAttributionTrackerWriteEvidence() + + // Then + if commitErr != nil { + t.Fatal(commitErr) + } + if evidence.hasWrite { + t.Fatal("zero-row UPDATE published write evidence") + } +} + +func TestSQLiteAttributionDirectReadQueryClosesOwnedStatement(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + + // When + rows, err := database.QueryContext(context.Background(), "SELECT id FROM settings") + if err != nil { + t.Fatal(err) + } + next := rows.Next() + rowsErr := rows.Err() + closeErr := rows.Close() + + // Then + if next { + t.Fatal("empty SELECT returned a row") + } + if rowsErr != nil || closeErr != nil { + t.Fatal("direct readonly Query did not complete cleanly") + } +} + +func TestSQLiteAttributionCommitClosesRetainedJournalDescriptor(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + rawConnection, err := sqliteAttributionDriver{}.Open(sqliteAttributionTestDatabasePath(t)) + if err != nil { + t.Fatal(err) + } + connection := rawConnection.(*sqliteAttributionConnection) + t.Cleanup(func() { + if closeErr := connection.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + create, err := connection.Prepare("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)") + if err != nil { + t.Fatal(err) + } + if _, err := create.Exec(nil); err != nil { + t.Fatal(err) + } + if err := create.Close(); err != nil { + t.Fatal(err) + } + enableSQLiteAttribution() + transaction, err := connection.Begin() + if err != nil { + t.Fatal(err) + } + insert, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)") + if err != nil { + t.Fatal(err) + } + if _, err := insert.Exec([]driver.Value{"retained-descriptor"}); err != nil { + t.Fatal(err) + } + if err := insert.Close(); err != nil { + t.Fatal(err) + } + _, descriptor, active := sqliteAttributionTransactionState(t, connection) + if !active { + t.Fatal("active transaction is missing before Commit") + } + + // When + commitErr := transaction.Commit() + _, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0) + + // Then + if commitErr != nil { + t.Fatal(commitErr) + } + if !errors.Is(descriptorErr, unix.EBADF) { + t.Fatalf("retained journal descriptor remains open: %v", descriptorErr) + } +} + +func TestSQLiteAttributionQueryRowReturningPoisonsTransaction(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + enableSQLiteAttribution() + transaction, err := database.BeginTx(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + + // When + var identifier int64 + scanErr := transaction.QueryRowContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id", "query-row").Scan(&identifier) + commitErr := transaction.Commit() + + // Then + if scanErr != nil { + t.Fatal(scanErr) + } + if commitErr == nil { + t.Fatal("QueryRowContext RETURNING committed without reaching EOF") + } + if sqliteAttributionTrackerHasWrite() { + t.Fatal("QueryRowContext RETURNING published evidence without EOF") + } +} diff --git a/service/singleton/sqlite_attribution_query_lifecycle_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_query_lifecycle_agentcompat_linux_test.go new file mode 100644 index 00000000..9e8fb7bf --- /dev/null +++ b/service/singleton/sqlite_attribution_query_lifecycle_agentcompat_linux_test.go @@ -0,0 +1,145 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "database/sql/driver" + "errors" + "testing" +) + +type sqliteAttributionLegacyQueryStmt struct { + closeCount int +} + +func (statement *sqliteAttributionLegacyQueryStmt) Close() error { statement.closeCount++; return nil } +func (sqliteAttributionLegacyQueryStmt) NumInput() int { return -1 } +func (sqliteAttributionLegacyQueryStmt) Exec([]driver.Value) (driver.Result, error) { + return nil, driver.ErrSkip +} +func (sqliteAttributionLegacyQueryStmt) Query([]driver.Value) (driver.Rows, error) { + return nil, driver.ErrSkip +} + +type sqliteAttributionCountingStmt struct { + driver.Stmt + closeCount int +} + +func (statement *sqliteAttributionCountingStmt) Close() error { + statement.closeCount++ + return statement.Stmt.Close() +} + +type sqliteAttributionQueryProbeStmt struct { + closeCount int + queryCount int +} + +func (statement *sqliteAttributionQueryProbeStmt) Close() error { statement.closeCount++; return nil } +func (sqliteAttributionQueryProbeStmt) NumInput() int { return -1 } +func (sqliteAttributionQueryProbeStmt) Exec([]driver.Value) (driver.Result, error) { + return nil, driver.ErrSkip +} +func (statement *sqliteAttributionQueryProbeStmt) Query([]driver.Value) (driver.Rows, error) { + statement.queryCount++ + return nil, driver.ErrSkip +} + +func TestSQLiteAttributionDirectQueryClosesOwnedStatementWhenPreStepRejects(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + rawConnection, err := sqliteAttributionDriver{}.Open(sqliteAttributionTestDatabasePath(t)) + if err != nil { + t.Fatal(err) + } + connection := rawConnection.(*sqliteAttributionConnection) + t.Cleanup(func() { + if closeErr := connection.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil { + t.Fatal(err) + } + prepared, err := connection.Prepare("INSERT INTO settings (value) VALUES (?) RETURNING id") + if err != nil { + t.Fatal(err) + } + statement := prepared.(*sqliteAttributionStatement) + owner := &sqliteAttributionCountingStmt{Stmt: prepared} + enableSQLiteAttribution() + + // When + rows, queryErr := statement.queryOwned(func() (driver.Rows, error) { + return statement.statement.Query([]driver.Value{"rejected"}) + }, owner) + + // Then + if rows != nil { + t.Fatal("rejected direct Query returned rows") + } + if !errors.Is(queryErr, ErrSQLiteAttributionUnboundWrite) { + t.Fatalf("rejected direct Query error = %v", queryErr) + } + if owner.closeCount != 1 { + t.Fatalf("owned statement Close count = %d, want 1", owner.closeCount) + } +} + +func TestSQLiteAttributionDirectQueryRejectsUnboundWriteThroughPublicConnection(t *testing.T) { + resetSQLiteAttributionForTest() + probe := &sqliteAttributionQueryProbeStmt{} + var connection *sqliteAttributionConnection + connection = &sqliteAttributionConnection{ + prepareStatement: func(context.Context, string) (driver.Stmt, error) { + return &sqliteAttributionStatement{ + connection: connection, + statement: probe, + classification: sqliteAttributionClassification{ + hasRowDML: true, + operation: SQLiteOperationInsert, + table: "settings", + }, + }, nil + }, + } + enableSQLiteAttribution() + + rows, queryErr := connection.Query("INSERT INTO settings (value) VALUES (?) RETURNING id", []driver.Value{"public-rejected"}) + + if rows != nil { + t.Fatal("public direct Query returned rows after pre-step rejection") + } + if !errors.Is(queryErr, ErrSQLiteAttributionUnboundWrite) { + t.Fatalf("public direct Query error = %v", queryErr) + } + if probe.closeCount != 1 { + t.Fatalf("public direct Query statement Close count = %d, want 1", probe.closeCount) + } + if probe.queryCount != 0 { + t.Fatalf("public direct Query invoked underlying Query %d times, want 0", probe.queryCount) + } +} + +func TestSQLiteAttributionQueryContextClosesUnsupportedStatementBeforeErrSkip(t *testing.T) { + // Given + statement := &sqliteAttributionLegacyQueryStmt{} + connection := &sqliteAttributionConnection{} + wrapped := &sqliteAttributionStatement{connection: connection, statement: statement} + + // When + rows, queryErr := connection.queryContextStatement(context.Background(), wrapped, nil, statement) + + // Then + if rows != nil { + t.Fatal("unsupported QueryContext returned rows") + } + if !errors.Is(queryErr, driver.ErrSkip) { + t.Fatalf("unsupported QueryContext error = %v", queryErr) + } + if statement.closeCount != 1 { + t.Fatalf("unsupported QueryContext Close count = %d, want 1", statement.closeCount) + } +} diff --git a/service/singleton/sqlite_attribution_release_terminal_ownership_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_release_terminal_ownership_agentcompat_linux_test.go new file mode 100644 index 00000000..1f1beae5 --- /dev/null +++ b/service/singleton/sqlite_attribution_release_terminal_ownership_agentcompat_linux_test.go @@ -0,0 +1,227 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "testing" + + "golang.org/x/sys/unix" +) + +func sqliteAttributionReleasedCommitFixture(t *testing.T, value string) (*sqliteAttributionConnection, *sqliteAttributionTx, SQLiteHoldSession, string, SQLiteTransaction, int) { + t.Helper() + resetSQLiteAttributionForTest() + databasePath := sqliteAttributionTestDatabasePath(t) + rawConnection, err := sqliteAttributionDriver{}.Open(databasePath) + if err != nil { + t.Fatal(err) + } + connection := rawConnection.(*sqliteAttributionConnection) + if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil { + t.Fatal(err) + } + enableSQLiteAttribution() + transaction, err := connection.BeginTx(context.Background(), driver.TxOptions{}) + if err != nil { + t.Fatal(err) + } + statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)") + if err != nil { + t.Fatal(err) + } + if _, err := statement.Exec([]driver.Value{value}); err != nil { + t.Fatal(err) + } + if err := statement.Close(); err != nil { + t.Fatal(err) + } + session, err := sqliteAttributionTracker.Load().ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + wrapped := transaction.(*sqliteAttributionTx) + identity, descriptor, active := sqliteAttributionTransactionState(t, connection) + if !active { + t.Fatal("selected transaction is not active") + } + return connection, wrapped, session, databasePath, identity, descriptor +} + +func sqliteAttributionReleasedTerminalSnapshot(t *testing.T, session SQLiteHoldSession) SQLiteHoldSnapshot { + t.Helper() + terminal, err := sqliteAttributionTracker.Load().WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing) + if err != nil { + t.Fatal(err) + } + return terminal +} + +func TestSQLiteAttributionReleasedCommitOwnsTerminalAgainstConcurrentRollback(t *testing.T) { + // Given + connection, transaction, session, databasePath, identity, descriptor := sqliteAttributionReleasedCommitFixture(t, "released-rollback-owner") + t.Cleanup(func() { + if err := connection.Close(); err != nil && !errors.Is(err, driver.ErrBadConn) { + t.Error(err) + } + }) + releasedBoundary := make(chan struct{}) + allowCommitClaim := make(chan struct{}) + loserWaiting := make(chan struct{}) + transaction.state.releasedCommitBoundary = func() { + close(releasedBoundary) + <-allowCommitClaim + } + transaction.state.terminalLoserWaitBoundary = func() { close(loserWaiting) } + finalizing := make(chan error, 1) + commitResult := make(chan error, 1) + go func() { + _, err := sqliteAttributionTracker.Load().WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing) + finalizing <- err + }() + go func() { commitResult <- transaction.Commit() }() + if err := <-finalizing; err != nil { + t.Fatal(err) + } + + // When + if err := sqliteAttributionTracker.Load().ReleaseSQLiteHold(session); err != nil { + t.Fatal(err) + } + <-releasedBoundary + rollbackResult := make(chan error, 1) + go func() { rollbackResult <- transaction.Rollback() }() + loserReturned := false + var rollbackErr error + select { + case <-loserWaiting: + case rollbackErr = <-rollbackResult: + loserReturned = true + } + close(allowCommitClaim) + commitErr := <-commitResult + if !loserReturned { + rollbackErr = <-rollbackResult + } + terminal := sqliteAttributionReleasedTerminalSnapshot(t, session) + _, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0) + count := sqliteAttributionPersistedCount(t, databasePath) + + // Then + if loserReturned { + t.Errorf("released Commit lost terminal ownership: Rollback returned %v before waiting", rollbackErr) + } + if commitErr != nil { + t.Errorf("released Commit error = %v, want nil", commitErr) + } + if !errors.Is(rollbackErr, driver.ErrBadConn) { + t.Errorf("Rollback after successful Release error = %v, want driver.ErrBadConn", rollbackErr) + } + if !terminal.Selected || !terminal.Finalizing || !terminal.Released { + t.Errorf("released terminal snapshot = %+v", terminal) + } + if !errors.Is(descriptorErr, unix.EBADF) { + t.Errorf("journal descriptor after released Commit = %v, want EBADF", descriptorErr) + } + if count != 1 { + t.Errorf("released Commit persisted %d rows, want 1", count) + } + if _, _, active := sqliteAttributionTransactionState(t, connection); active { + t.Error("released Commit left the connection transaction active") + } + if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) { + t.Error("released Commit left the tracker transaction active") + } +} + +func TestSQLiteAttributionReleasedCommitOwnsTerminalAgainstConcurrentConnectionClose(t *testing.T) { + // Given + connection, transaction, session, databasePath, identity, descriptor := sqliteAttributionReleasedCommitFixture(t, "released-close-owner") + connectionClosed := false + t.Cleanup(func() { + if !connectionClosed { + if err := connection.Close(); err != nil && !errors.Is(err, driver.ErrBadConn) { + t.Error(err) + } + } + }) + releasedBoundary := make(chan struct{}) + allowCommitClaim := make(chan struct{}) + loserWaiting := make(chan struct{}) + transaction.state.releasedCommitBoundary = func() { + close(releasedBoundary) + <-allowCommitClaim + } + transaction.state.terminalLoserWaitBoundary = func() { close(loserWaiting) } + finalizing := make(chan error, 1) + commitResult := make(chan error, 1) + go func() { + _, err := sqliteAttributionTracker.Load().WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing) + finalizing <- err + }() + go func() { commitResult <- transaction.Commit() }() + if err := <-finalizing; err != nil { + t.Fatal(err) + } + + // When + if err := sqliteAttributionTracker.Load().ReleaseSQLiteHold(session); err != nil { + t.Fatal(err) + } + <-releasedBoundary + closeResult := make(chan error, 1) + go func() { closeResult <- connection.Close() }() + loserReturned := false + var closeErr error + select { + case <-loserWaiting: + case closeErr = <-closeResult: + loserReturned = true + } + close(allowCommitClaim) + commitErr := <-commitResult + if !loserReturned { + closeErr = <-closeResult + } + connectionClosed = true + terminal := sqliteAttributionReleasedTerminalSnapshot(t, session) + _, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0) + database, err := sql.Open("sqlite3", databasePath) + if err != nil { + t.Fatal(err) + } + defer database.Close() + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM settings").Scan(&count); err != nil { + t.Fatal(err) + } + + // Then + if loserReturned { + t.Errorf("released Commit lost terminal ownership: connection Close returned %v before waiting", closeErr) + } + if commitErr != nil { + t.Errorf("released Commit error = %v, want nil", commitErr) + } + if closeErr != nil && !errors.Is(closeErr, driver.ErrBadConn) { + t.Errorf("connection Close after successful Release error = %v, want nil or driver.ErrBadConn", closeErr) + } + if !terminal.Selected || !terminal.Finalizing || !terminal.Released { + t.Errorf("released terminal snapshot = %+v", terminal) + } + if !errors.Is(descriptorErr, unix.EBADF) { + t.Errorf("journal descriptor after released Commit = %v, want EBADF", descriptorErr) + } + if count != 1 { + t.Errorf("released Commit persisted %d rows, want 1", count) + } + if _, _, active := sqliteAttributionTransactionState(t, connection); active { + t.Error("released Commit left the connection transaction active") + } + if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) { + t.Error("released Commit left the tracker transaction active") + } +} diff --git a/service/singleton/sqlite_attribution_returning_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_returning_agentcompat_linux_test.go new file mode 100644 index 00000000..f4125d5f --- /dev/null +++ b/service/singleton/sqlite_attribution_returning_agentcompat_linux_test.go @@ -0,0 +1,121 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "testing" + + "github.com/nezhahq/nezha/model" + "gorm.io/gorm" +) + +func TestSQLiteAttributionRecordsDirectExplicitReturningEvidence(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + enableSQLiteAttribution() + transaction, err := database.BeginTx(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if rollbackErr := transaction.Rollback(); rollbackErr != nil { + t.Error(rollbackErr) + } + }) + + // When + rows, queryErr := transaction.QueryContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id", "direct-explicit-returning") + rowsErr := sqliteAttributionConsumeRows(rows) + evidence := sqliteAttributionTrackerWriteEvidence() + + // Then + if queryErr != nil || rowsErr != nil { + t.Fatalf("direct explicit RETURNING failed") + } + if !evidence.hasWrite { + t.Fatal("direct explicit RETURNING did not record atomic write evidence") + } + if evidence.write.Origin.StackHash == 0 { + t.Fatal("direct explicit RETURNING recorded a zero stack hash") + } + if evidence.write.Origin.FirstNezhaFrame == "" { + t.Fatal("direct explicit RETURNING recorded an empty Nezha frame") + } +} + +func TestSQLiteAttributionRecordsPreparedExplicitReturningEvidence(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + enableSQLiteAttribution() + transaction, err := database.BeginTx(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if rollbackErr := transaction.Rollback(); rollbackErr != nil { + t.Error(rollbackErr) + } + }) + statement, err := transaction.PrepareContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := statement.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + + // When + rows, queryErr := statement.QueryContext(context.Background(), "prepared-explicit-returning") + rowsErr := sqliteAttributionConsumeRows(rows) + evidence := sqliteAttributionTrackerWriteEvidence() + + // Then + if queryErr != nil || rowsErr != nil { + t.Fatalf("prepared explicit RETURNING failed") + } + if !evidence.hasWrite { + t.Fatal("prepared explicit RETURNING did not record atomic write evidence") + } + if evidence.write.Origin.StackHash == 0 { + t.Fatal("prepared explicit RETURNING recorded a zero stack hash") + } + if evidence.write.Origin.FirstNezhaFrame == "" { + t.Fatal("prepared explicit RETURNING recorded an empty Nezha frame") + } +} + +func TestSQLiteAttributionAllowsGORMReturningWhenDisabled(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + database, err := gorm.Open(openSQLiteDialector(sqliteAttributionTestDatabasePath(t)), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDatabase, err := database.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := sqlDatabase.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + if err := database.AutoMigrate(&model.User{}); err != nil { + t.Fatal(err) + } + user := model.User{Username: "admin", Password: "hashed-password"} + + // When + createErr := database.Create(&user).Error + + // Then + if createErr != nil { + t.Fatalf("disabled attribution rejected GORM RETURNING: %v", createErr) + } + if user.ID == 0 { + t.Fatal("disabled attribution did not return the generated user ID") + } +} diff --git a/service/singleton/sqlite_attribution_review_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_review_agentcompat_linux_test.go new file mode 100644 index 00000000..0179cfbc --- /dev/null +++ b/service/singleton/sqlite_attribution_review_agentcompat_linux_test.go @@ -0,0 +1,231 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "reflect" + "sync" + "testing" +) + +func TestSQLiteAttributionLifecycleFieldsUseOneConnectionMutex(t *testing.T) { + // Given + mutexType := reflect.TypeFor[sync.Mutex]() + connectionType := reflect.TypeFor[sqliteAttributionConnection]() + transactionType := reflect.TypeFor[sqliteAttributionTransaction]() + mutexCount := 0 + for _, typeUnderTest := range []reflect.Type{connectionType, transactionType} { + for fieldIndex := 0; fieldIndex < typeUnderTest.NumField(); fieldIndex++ { + if typeUnderTest.Field(fieldIndex).Type == mutexType { + mutexCount++ + } + } + } + + // When + field, found := connectionType.FieldByName("lifecycleMu") + + // Then + if mutexCount != 1 { + t.Fatalf("direct lifecycle mutex count = %d, want 1 on sqliteAttributionConnection", mutexCount) + } + if !found || field.Type != mutexType { + t.Fatal("sqliteAttributionConnection.lifecycleMu is not the lifecycle mutex") + } +} + +func TestSQLiteAttributionBeginRollsBackRawTransactionWhenTrackerRejects(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + rawConnection, err := sqliteAttributionDriver{}.Open(sqliteAttributionTestDatabasePath(t)) + if err != nil { + t.Fatal(err) + } + connection := rawConnection.(*sqliteAttributionConnection) + t.Cleanup(func() { + _, _ = connection.connection.Exec("ROLLBACK", nil) + if closeErr := connection.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + identity := SQLiteTransaction{Connection: connection.identity, Identity: SQLiteTransactionIdentity(sqliteAttributionTransactionID.Load() + 1)} + if err := sqliteAttributionTracker.Load().BeginSQLiteTransaction(identity); err != nil { + t.Fatal(err) + } + + // When + _, beginErr := connection.Begin() + followup, followupErr := connection.Begin() + if followup != nil { + _ = followup.Rollback() + } + + // Then + if !errors.Is(beginErr, ErrSQLiteHoldTransactionActive) { + t.Fatalf("tracker rejection = %v, want ErrSQLiteHoldTransactionActive", beginErr) + } + if followupErr != nil { + t.Fatalf("tracker rejection left raw transaction active: %v", followupErr) + } +} + +func TestSQLiteAttributionDirectQueryPreservesSQLiteColumnTypes(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + databasePath := sqliteAttributionTestDatabasePath(t) + attributed, err := openSQLiteAttributionTestDB(databasePath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := attributed.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + standard, err := sql.Open("sqlite3", databasePath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := standard.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + if _, err := attributed.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)"); err != nil { + t.Fatal(err) + } + if _, err := attributed.Exec("INSERT INTO settings (value) VALUES (?)", "typed"); err != nil { + t.Fatal(err) + } + + // When + attributedRows, err := attributed.QueryContext(context.Background(), "SELECT id, value FROM settings") + if err != nil { + t.Fatal(err) + } + attributedTypes, attributedErr := attributedRows.ColumnTypes() + attributedCloseErr := attributedRows.Close() + standardRows, err := standard.QueryContext(context.Background(), "SELECT id, value FROM settings") + if err != nil { + t.Fatal(err) + } + standardTypes, standardErr := standardRows.ColumnTypes() + standardCloseErr := standardRows.Close() + + // Then + if attributedErr != nil || attributedCloseErr != nil || standardErr != nil || standardCloseErr != nil { + t.Fatal("ColumnTypes did not complete cleanly") + } + if len(attributedTypes) != len(standardTypes) { + t.Fatalf("attributed ColumnTypes length = %d, standard = %d", len(attributedTypes), len(standardTypes)) + } + for index := range standardTypes { + if attributedTypes[index].DatabaseTypeName() != standardTypes[index].DatabaseTypeName() || attributedTypes[index].ScanType() != standardTypes[index].ScanType() || !reflect.DeepEqual(attributedTypes[index], standardTypes[index]) { + t.Fatalf("column %d metadata differs from go-sqlite3", index) + } + } +} + +func TestSQLiteAttributionFailsClosedWhenSQLiteRepreparesAfterSchemaChange(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + databasePath := sqliteAttributionTestDatabasePath(t) + rawConnection, err := sqliteAttributionDriver{}.Open(databasePath) + if err != nil { + t.Fatal(err) + } + connection := rawConnection.(*sqliteAttributionConnection) + t.Cleanup(func() { + if closeErr := connection.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil { + t.Fatal(err) + } + statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := statement.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + other, err := sql.Open("sqlite3", databasePath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := other.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + if _, err := other.Exec("CREATE TABLE audit (value TEXT)"); err != nil { + t.Fatal(err) + } + if _, err := other.Exec("CREATE TRIGGER settings_audit AFTER INSERT ON settings BEGIN INSERT INTO audit (value) VALUES (NEW.value); END"); err != nil { + t.Fatal(err) + } + enableSQLiteAttribution() + transaction, err := connection.Begin() + if err != nil { + t.Fatal(err) + } + + // When + _, executionErr := statement.Exec([]driver.Value{"reprepared"}) + commitErr := transaction.Commit() + var settingsCount, auditCount int + if err := other.QueryRow("SELECT COUNT(*) FROM settings").Scan(&settingsCount); err != nil { + t.Fatal(err) + } + if err := other.QueryRow("SELECT COUNT(*) FROM audit").Scan(&auditCount); err != nil { + t.Fatal(err) + } + + // Then + if !errors.Is(errors.Join(executionErr, commitErr), ErrSQLiteAttributionUnsupportedWrite) { + t.Fatalf("reprepared write errors = %v / %v", executionErr, commitErr) + } + if settingsCount != 0 || auditCount != 0 { + t.Fatalf("reprepared write persisted settings=%d audit=%d", settingsCount, auditCount) + } +} + +func TestSQLiteAttributionUpdateHookRejectsAuxiliaryDatabase(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + rawConnection, err := sqliteAttributionDriver{}.Open(sqliteAttributionTestDatabasePath(t)) + if err != nil { + t.Fatal(err) + } + connection := rawConnection.(*sqliteAttributionConnection) + t.Cleanup(func() { + if closeErr := connection.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + connection.execution = &sqliteAttributionExecution{classification: sqliteAttributionClassification{operation: SQLiteOperationInsert, table: "settings"}} + if _, err := connection.connection.Exec("ATTACH DATABASE ':memory:' AS auxiliary", nil); err != nil { + t.Fatal(err) + } + if _, err := connection.connection.Exec("CREATE TABLE auxiliary.settings (value TEXT)", nil); err != nil { + t.Fatal(err) + } + + // When + _, writeErr := connection.connection.Exec("INSERT INTO auxiliary.settings (value) VALUES ('auxiliary')", nil) + + // Then + if writeErr != nil { + t.Fatal(writeErr) + } + if !connection.execution.hook.mismatch || connection.execution.hook.seen { + t.Fatal("auxiliary database update hook matched main attribution execution") + } +} diff --git a/service/singleton/sqlite_attribution_statement_transaction_agentcompat_linux.go b/service/singleton/sqlite_attribution_statement_transaction_agentcompat_linux.go new file mode 100644 index 00000000..15d3d910 --- /dev/null +++ b/service/singleton/sqlite_attribution_statement_transaction_agentcompat_linux.go @@ -0,0 +1,186 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "database/sql/driver" + "errors" + "io" + "reflect" +) + +type sqliteAttributionClassification struct { + readonly bool + hasRowDML bool + ambiguous bool + operation SQLiteOperation + table string +} + +func (classification sqliteAttributionClassification) requiresAttribution() bool { + return !classification.readonly && classification.hasRowDML +} + +func (classification sqliteAttributionClassification) valid() bool { + return !classification.ambiguous && classification.hasRowDML && classification.operation != "" && classification.table != "" +} + +type sqliteAttributionStatement struct { + connection *sqliteAttributionConnection + statement driver.Stmt + classification sqliteAttributionClassification +} + +var ( + _ driver.Stmt = (*sqliteAttributionStatement)(nil) + _ driver.StmtExecContext = (*sqliteAttributionStatement)(nil) + _ driver.StmtQueryContext = (*sqliteAttributionStatement)(nil) +) + +func (statement *sqliteAttributionStatement) Close() error { return statement.statement.Close() } +func (statement *sqliteAttributionStatement) NumInput() int { return statement.statement.NumInput() } + +func (statement *sqliteAttributionStatement) Exec(values []driver.Value) (driver.Result, error) { + return statement.execute(func() (driver.Result, error) { return statement.statement.Exec(values) }) +} + +func (statement *sqliteAttributionStatement) ExecContext(ctx context.Context, values []driver.NamedValue) (driver.Result, error) { + contextStatement, ok := statement.statement.(driver.StmtExecContext) + if !ok { + return nil, driver.ErrSkip + } + return statement.execute(func() (driver.Result, error) { return contextStatement.ExecContext(ctx, values) }) +} + +func (statement *sqliteAttributionStatement) Query(values []driver.Value) (driver.Rows, error) { + return statement.queryOwned(func() (driver.Rows, error) { return statement.statement.Query(values) }, nil) +} + +func (statement *sqliteAttributionStatement) QueryContext(ctx context.Context, values []driver.NamedValue) (driver.Rows, error) { + contextStatement, ok := statement.statement.(driver.StmtQueryContext) + if !ok { + return nil, driver.ErrSkip + } + return statement.queryOwned(func() (driver.Rows, error) { return contextStatement.QueryContext(ctx, values) }, nil) +} + +func (statement *sqliteAttributionStatement) execute(run func() (driver.Result, error)) (driver.Result, error) { + if err := statement.connection.beforeWrite(statement.classification); err != nil { + return nil, err + } + result, err := run() + if err != nil { + statement.connection.discardExecution() + return nil, err + } + if rows, rowsErr := result.RowsAffected(); rowsErr != nil || rows > 0 { + if err := statement.connection.publishExecution(); err != nil { + return nil, err + } + } + statement.connection.discardExecution() + return result, nil +} + +func (statement *sqliteAttributionStatement) queryOwned(run func() (driver.Rows, error), owner driver.Stmt) (driver.Rows, error) { + if err := statement.connection.beforeWrite(statement.classification); err != nil { + if owner != nil { + return nil, errors.Join(err, owner.Close()) + } + return nil, err + } + rows, err := run() + if err != nil { + statement.connection.discardExecution() + if owner != nil { + return nil, errors.Join(err, owner.Close()) + } + return nil, err + } + // Disabled attribution must preserve the stock driver rows lifecycle used during Dashboard bootstrap. + if !sqliteAttributionEnabled.Load() || !statement.classification.requiresAttribution() { + if owner == nil { + return rows, nil + } + return &sqliteAttributionRows{rows: rows, owner: owner}, nil + } + return &sqliteAttributionRows{rows: rows, owner: owner, connection: statement.connection}, nil +} + +type sqliteAttributionRows struct { + rows driver.Rows + owner driver.Stmt + connection *sqliteAttributionConnection + finished bool + closed bool +} + +func (rows *sqliteAttributionRows) Columns() []string { return rows.rows.Columns() } + +func (rows *sqliteAttributionRows) ColumnTypeDatabaseTypeName(index int) string { + typed, ok := rows.rows.(driver.RowsColumnTypeDatabaseTypeName) + if !ok { + return "" + } + return typed.ColumnTypeDatabaseTypeName(index) +} + +func (rows *sqliteAttributionRows) ColumnTypeNullable(index int) (bool, bool) { + typed, ok := rows.rows.(driver.RowsColumnTypeNullable) + if !ok { + return false, false + } + return typed.ColumnTypeNullable(index) +} + +func (rows *sqliteAttributionRows) ColumnTypeScanType(index int) reflect.Type { + typed, ok := rows.rows.(driver.RowsColumnTypeScanType) + if !ok { + return nil + } + return typed.ColumnTypeScanType(index) +} + +func (rows *sqliteAttributionRows) Close() error { + if rows.closed { + return nil + } + rows.closed = true + closeErr := rows.rows.Close() + if !rows.finished && rows.connection != nil { + rows.connection.poison(&SQLiteAttributionError{Cause: ErrSQLiteAttributionUnsupportedWrite}) + rows.connection.discardExecution() + } + if rows.owner != nil { + return errors.Join(closeErr, rows.owner.Close()) + } + return closeErr +} + +func (rows *sqliteAttributionRows) Next(destination []driver.Value) error { + err := rows.rows.Next(destination) + if errors.Is(err, driver.ErrBadConn) { + // Read-only direct Query rows have no attribution connection; ErrBadConn must still propagate. + if rows.connection != nil { + rows.connection.discardExecution() + } + return err + } + if err == nil { + return nil + } + rows.finished = true + if err != io.EOF { + if rows.connection != nil { + rows.connection.discardExecution() + } + return err + } + if rows.connection != nil { + if publishErr := rows.connection.publishExecution(); publishErr != nil { + return publishErr + } + } + return io.EOF +} diff --git a/service/singleton/sqlite_attribution_transaction_agentcompat_linux.go b/service/singleton/sqlite_attribution_transaction_agentcompat_linux.go new file mode 100644 index 00000000..db57eb55 --- /dev/null +++ b/service/singleton/sqlite_attribution_transaction_agentcompat_linux.go @@ -0,0 +1,126 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "database/sql/driver" + "errors" +) + +type SQLiteHoldError struct{ Cause error } + +func (err *SQLiteHoldError) Error() string { return "sqlite hold finalization failed" } +func (err *SQLiteHoldError) Unwrap() error { return err.Cause } + +type sqliteAttributionTx struct { + connection *sqliteAttributionConnection + state *sqliteAttributionTransaction +} + +var _ driver.Tx = (*sqliteAttributionTx)(nil) + +func (transaction *sqliteAttributionTx) Commit() error { + state := transaction.state + transaction.connection.lifecycleMu.Lock() + if state.terminalPhase != sqliteAttributionTerminalOpen { + transaction.connection.lifecycleMu.Unlock() + return state.waitForTerminal() + } + poison := state.poison + if poison != nil { + journalFD := state.claimTerminalLocked(sqliteAttributionTerminalRollbackOwned) + transaction.connection.lifecycleMu.Unlock() + if errors.Is(poison, ErrSQLiteHoldAmbiguousCandidate) { + return errors.Join(&SQLiteHoldError{Cause: poison}, state.finish(transaction.connection, journalFD, state.raw.Rollback)) + } + return errors.Join(poison, state.finish(transaction.connection, journalFD, state.raw.Rollback)) + } + state.terminalPhase = sqliteAttributionTerminalCommitWaiting + transaction.connection.lifecycleMu.Unlock() + finalization, err := state.tracker.BeginSQLiteCommitFinalization(state.transaction) + if errors.Is(err, ErrSQLiteHoldNotSelected) { + return state.commit(transaction.connection) + } + if err != nil { + return errors.Join(&SQLiteHoldError{Cause: err}, state.rollback(transaction.connection)) + } + // Wait before raw Commit so the rollback journal stays observable for deterministic drain and prevents the baseline sample-1 FD regression. + if err := state.tracker.WaitSQLiteCommitFinalization(state.context, finalization); err != nil { + return errors.Join(&SQLiteHoldError{Cause: errors.Join(ErrSQLiteHoldAborted, err)}, state.rollback(transaction.connection)) + } + if state.releasedCommitBoundary != nil { + state.releasedCommitBoundary() + } + return state.commit(transaction.connection) +} + +func (transaction *sqliteAttributionTx) Rollback() error { + return transaction.state.rollback(transaction.connection) +} + +func (state *sqliteAttributionTransaction) commit(connection *sqliteAttributionConnection) error { + connection.lifecycleMu.Lock() + if state.terminalPhase != sqliteAttributionTerminalCommitWaiting { + connection.lifecycleMu.Unlock() + return state.waitForTerminal() + } + journalFD := state.claimTerminalLocked(sqliteAttributionTerminalCommitOwned) + connection.lifecycleMu.Unlock() + return state.finish(connection, journalFD, state.raw.Commit) +} + +func (state *sqliteAttributionTransaction) rollback(connection *sqliteAttributionConnection) error { + connection.lifecycleMu.Lock() + switch state.terminalPhase { + case sqliteAttributionTerminalOpen: + journalFD := state.claimTerminalLocked(sqliteAttributionTerminalRollbackOwned) + connection.lifecycleMu.Unlock() + return state.finish(connection, journalFD, state.raw.Rollback) + case sqliteAttributionTerminalCommitWaiting: + connection.lifecycleMu.Unlock() + arbitration := state.tracker.ArbitrateSQLiteCommitWaitingTerminal(state.transaction) + if arbitration == sqliteAttributionTerminalReleaseWon || arbitration == sqliteAttributionTerminalRollbackReserved { + return state.waitForTerminal() + } + connection.lifecycleMu.Lock() + if state.terminalPhase != sqliteAttributionTerminalCommitWaiting { + connection.lifecycleMu.Unlock() + return state.waitForTerminal() + } + journalFD := state.claimTerminalLocked(sqliteAttributionTerminalRollbackOwned) + connection.lifecycleMu.Unlock() + return state.finish(connection, journalFD, state.raw.Rollback) + default: + connection.lifecycleMu.Unlock() + return state.waitForTerminal() + } +} + +func (state *sqliteAttributionTransaction) claimTerminalLocked(phase sqliteAttributionTerminalPhase) int { + state.terminalPhase = phase + journalFD := state.journalFD + state.journalFD = -1 + return journalFD +} + +func (state *sqliteAttributionTransaction) waitForTerminal() error { + if state.terminalLoserWaitBoundary != nil { + state.terminalLoserWaitBoundary() + } + <-state.done + return driver.ErrBadConn +} + +func (state *sqliteAttributionTransaction) finish(connection *sqliteAttributionConnection, journalFD int, completeRaw func() error) error { + defer close(state.done) + rawErr := completeRaw() + connection.discardExecution() + connection.lifecycleMu.Lock() + if connection.transaction == state { + connection.transaction = nil + } + connection.lifecycleMu.Unlock() + closeErr := sqliteAttributionCloseJournalDescriptor(journalFD) + trackerErr := state.tracker.FinishSQLiteTransaction(state.transaction) + return errors.Join(rawErr, closeErr, trackerErr) +} diff --git a/service/singleton/sqlite_attribution_transaction_finalization_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_transaction_finalization_agentcompat_linux_test.go new file mode 100644 index 00000000..fb2ce9f4 --- /dev/null +++ b/service/singleton/sqlite_attribution_transaction_finalization_agentcompat_linux_test.go @@ -0,0 +1,236 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "testing" + + "golang.org/x/sys/unix" +) + +func sqliteAttributionHeldTransaction(t *testing.T, value string, transactionContext context.Context) (*sqliteAttributionConnection, driver.Tx, SQLiteHoldSession, string) { + t.Helper() + resetSQLiteAttributionForTest() + databasePath := sqliteAttributionTestDatabasePath(t) + rawConnection, err := sqliteAttributionDriver{}.Open(databasePath) + if err != nil { + t.Fatal(err) + } + connection := rawConnection.(*sqliteAttributionConnection) + t.Cleanup(func() { + if err := connection.Close(); err != nil { + t.Error(err) + } + }) + if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil { + t.Fatal(err) + } + enableSQLiteAttribution() + transaction, err := connection.BeginTx(transactionContext, driver.TxOptions{}) + if err != nil { + t.Fatal(err) + } + statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)") + if err != nil { + t.Fatal(err) + } + if _, err := statement.Exec([]driver.Value{value}); err != nil { + t.Fatal(err) + } + if err := statement.Close(); err != nil { + t.Fatal(err) + } + session, err := sqliteAttributionTracker.Load().ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + return connection, transaction, session, databasePath +} + +func sqliteAttributionStartHeldCommit(t *testing.T, transaction driver.Tx, session SQLiteHoldSession) <-chan error { + t.Helper() + finalizing := make(chan error, 1) + commit := make(chan error, 1) + go func() { + _, err := sqliteAttributionTracker.Load().WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing) + finalizing <- err + }() + go func() { commit <- transaction.Commit() }() + select { + case err := <-finalizing: + if err != nil { + t.Fatalf("Commit finalization wait error = %v", err) + } + case err := <-commit: + t.Fatalf("Commit completed before selected hold release: %v", err) + } + return commit +} + +func sqliteAttributionPersistedCount(t *testing.T, databasePath string) int { + t.Helper() + database, err := sql.Open("sqlite3", databasePath) + if err != nil { + t.Fatal(err) + } + defer database.Close() + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM settings").Scan(&count); err != nil { + t.Fatal(err) + } + return count +} + +func sqliteAttributionTransactionState(t *testing.T, connection *sqliteAttributionConnection) (SQLiteTransaction, int, bool) { + t.Helper() + connection.lifecycleMu.Lock() + state := connection.transaction + if state == nil { + connection.lifecycleMu.Unlock() + return SQLiteTransaction{}, -1, false + } + identity, descriptor := state.transaction, state.journalFD + connection.lifecycleMu.Unlock() + return identity, descriptor, true +} + +func sqliteAttributionTrackerTransactionActive(tracker *SQLiteHoldTracker, identity SQLiteTransaction) bool { + tracker.mu.Lock() + defer tracker.mu.Unlock() + _, active := tracker.transactions[identity] + return active +} + +func TestSQLiteAttributionCommitWaitsForSelectedHoldRelease(t *testing.T) { + // Given + connection, transaction, session, databasePath := sqliteAttributionHeldTransaction(t, "held-commit", context.Background()) + identity, descriptor, active := sqliteAttributionTransactionState(t, connection) + if !active { + t.Fatal("held transaction is not active") + } + + // When + commit := sqliteAttributionStartHeldCommit(t, transaction, session) + select { + case err := <-commit: + t.Fatalf("Commit completed while selected hold remains unreleased: %v", err) + default: + } + if err := sqliteAttributionTracker.Load().ReleaseSQLiteHold(session); err != nil { + t.Fatal(err) + } + commitErr := <-commit + + // Then + if commitErr != nil { + t.Fatal(commitErr) + } + if _, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0); !errors.Is(descriptorErr, unix.EBADF) { + t.Fatalf("journal descriptor after released Commit = %v, want EBADF", descriptorErr) + } + if _, _, active := sqliteAttributionTransactionState(t, connection); active { + t.Fatal("released Commit left the connection transaction active") + } + if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) { + t.Fatal("released Commit left the tracker transaction active") + } + if count := sqliteAttributionPersistedCount(t, databasePath); count != 1 { + t.Fatalf("released held Commit persisted %d rows", count) + } +} + +func TestSQLiteAttributionCommitRollsBackWhenSelectedHoldAborts(t *testing.T) { + // Given + connection, transaction, session, databasePath := sqliteAttributionHeldTransaction(t, "aborted-commit", context.Background()) + identity, descriptor, active := sqliteAttributionTransactionState(t, connection) + if !active { + t.Fatal("held transaction is not active") + } + + // When + commit := sqliteAttributionStartHeldCommit(t, transaction, session) + if err := sqliteAttributionTracker.Load().AbortSQLiteHold(session); err != nil { + t.Fatal(err) + } + commitErr := <-commit + _, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0) + + // Then + var holdErr *SQLiteHoldError + if !errors.As(commitErr, &holdErr) || !errors.Is(commitErr, ErrSQLiteHoldAborted) { + t.Fatalf("aborted held Commit error = %v", commitErr) + } + if !errors.Is(descriptorErr, unix.EBADF) { + t.Fatalf("journal descriptor after aborted Commit = %v, want EBADF", descriptorErr) + } + if _, _, active := sqliteAttributionTransactionState(t, connection); active { + t.Fatal("aborted Commit left the connection transaction active") + } + if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) { + t.Fatal("aborted Commit left the tracker transaction active") + } + if count := sqliteAttributionPersistedCount(t, databasePath); count != 0 { + t.Fatalf("aborted held Commit persisted %d rows", count) + } +} + +func TestSQLiteAttributionCommitRollsBackWhenBeginTxContextCancels(t *testing.T) { + // Given + transactionContext, cancel := context.WithCancel(context.Background()) + defer cancel() + connection, transaction, session, databasePath := sqliteAttributionHeldTransaction(t, "cancelled-commit", transactionContext) + identity, descriptor, active := sqliteAttributionTransactionState(t, connection) + if !active { + t.Fatal("cancelled transaction is not active") + } + + // When + commit := sqliteAttributionStartHeldCommit(t, transaction, session) + cancel() + commitErr := <-commit + _, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0) + + // Then + var holdErr *SQLiteHoldError + if !errors.As(commitErr, &holdErr) || !errors.Is(commitErr, ErrSQLiteHoldAborted) || !errors.Is(commitErr, context.Canceled) { + t.Fatalf("cancelled held Commit error = %v", commitErr) + } + if count := sqliteAttributionPersistedCount(t, databasePath); count != 0 { + t.Fatalf("cancelled held Commit persisted %d rows", count) + } + if !errors.Is(descriptorErr, unix.EBADF) { + t.Fatalf("journal descriptor after cancelled Commit = %v, want EBADF", descriptorErr) + } + if _, _, active := sqliteAttributionTransactionState(t, connection); active { + t.Fatal("cancelled Commit left the connection transaction active") + } + if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) { + t.Fatal("cancelled Commit left the tracker transaction active") + } +} + +func TestSQLiteAttributionRollbackWakesSelectedCommit(t *testing.T) { + // Given + _, transaction, session, databasePath := sqliteAttributionHeldTransaction(t, "rollback-wakes-commit", context.Background()) + + // When + commit := sqliteAttributionStartHeldCommit(t, transaction, session) + rollbackErr := transaction.Rollback() + commitErr := <-commit + + // Then + if rollbackErr != nil { + t.Fatal(rollbackErr) + } + var holdErr *SQLiteHoldError + if !errors.As(commitErr, &holdErr) || !errors.Is(commitErr, ErrSQLiteHoldAborted) { + t.Fatalf("Commit error after Rollback = %v", commitErr) + } + if count := sqliteAttributionPersistedCount(t, databasePath); count != 0 { + t.Fatalf("Rollback while Commit waited persisted %d rows", count) + } +} diff --git a/service/singleton/sqlite_attribution_transaction_terminal_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_transaction_terminal_agentcompat_linux_test.go new file mode 100644 index 00000000..1028e2c2 --- /dev/null +++ b/service/singleton/sqlite_attribution_transaction_terminal_agentcompat_linux_test.go @@ -0,0 +1,158 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "database/sql/driver" + "errors" + "testing" + + "golang.org/x/sys/unix" +) + +func sqliteAttributionTransactionWithWrite(t *testing.T, databasePath, value string) (*sqliteAttributionConnection, driver.Tx) { + t.Helper() + rawConnection, err := sqliteAttributionDriver{}.Open(databasePath) + if err != nil { + t.Fatal(err) + } + connection := rawConnection.(*sqliteAttributionConnection) + t.Cleanup(func() { + if err := connection.Close(); err != nil { + t.Error(err) + } + }) + if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil { + t.Fatal(err) + } + transaction, err := connection.BeginTx(context.Background(), driver.TxOptions{}) + if err != nil { + t.Fatal(err) + } + statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)") + if err != nil { + t.Fatal(err) + } + if _, err := statement.Exec([]driver.Value{value}); err != nil { + t.Fatal(err) + } + if err := statement.Close(); err != nil { + t.Fatal(err) + } + return connection, transaction +} + +func sqliteAttributionTransactionWithAmbiguousWrite(t *testing.T, databasePath, value string) (*sqliteAttributionConnection, driver.Tx, error) { + t.Helper() + rawConnection, err := sqliteAttributionDriver{}.Open(databasePath) + if err != nil { + t.Fatal(err) + } + connection := rawConnection.(*sqliteAttributionConnection) + t.Cleanup(func() { + if err := connection.Close(); err != nil { + t.Error(err) + } + }) + if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil { + t.Fatal(err) + } + transaction, err := connection.BeginTx(context.Background(), driver.TxOptions{}) + if err != nil { + t.Fatal(err) + } + statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)") + if err != nil { + t.Fatal(err) + } + _, writeErr := statement.Exec([]driver.Value{value}) + if err := statement.Close(); err != nil { + t.Fatal(err) + } + return connection, transaction, writeErr +} + +func TestSQLiteAttributionUnselectedCommitPersistsImmediately(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + enableSQLiteAttribution() + databasePath := sqliteAttributionTestDatabasePath(t) + session, err := sqliteAttributionTracker.Load().ArmSQLiteHold(SQLiteJournalIdentity{}) + if err != nil { + t.Fatal(err) + } + connection, transaction := sqliteAttributionTransactionWithWrite(t, databasePath, "unselected-commit") + identity, descriptor, active := sqliteAttributionTransactionState(t, connection) + if !active { + t.Fatal("unselected transaction is not active") + } + + // When + commitErr := transaction.Commit() + abortErr := sqliteAttributionTracker.Load().AbortSQLiteHold(session) + _, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0) + + // Then + if commitErr != nil { + t.Fatal(commitErr) + } + if abortErr != nil { + t.Fatal(abortErr) + } + if count := sqliteAttributionPersistedCount(t, databasePath); count != 1 { + t.Fatalf("unselected Commit persisted %d rows", count) + } + if !errors.Is(descriptorErr, unix.EBADF) { + t.Fatalf("journal descriptor after unselected Commit = %v, want EBADF", descriptorErr) + } + if _, _, active := sqliteAttributionTransactionState(t, connection); active { + t.Fatal("unselected Commit left the connection transaction active") + } + if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) { + t.Fatal("unselected Commit left the tracker transaction active") + } +} + +func TestSQLiteAttributionFutureAmbiguityRollsBackEveryParticipatingCommit(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + enableSQLiteAttribution() + firstPath := sqliteAttributionTestDatabasePath(t) + secondPath := sqliteAttributionTestDatabasePath(t) + tracker := sqliteAttributionTracker.Load() + if _, err := tracker.ArmNextSQLiteHold(); err != nil { + t.Fatal(err) + } + firstConnection, firstTransaction := sqliteAttributionTransactionWithWrite(t, firstPath, "first-ambiguous") + secondConnection, secondTransaction, secondWriteErr := sqliteAttributionTransactionWithAmbiguousWrite(t, secondPath, "second-ambiguous") + if !errors.Is(secondWriteErr, ErrSQLiteHoldAmbiguousCandidate) { + t.Fatalf("second ambiguity write error = %v", secondWriteErr) + } + firstIdentity, _, firstActive := sqliteAttributionTransactionState(t, firstConnection) + secondIdentity, _, secondActive := sqliteAttributionTransactionState(t, secondConnection) + if !firstActive || !secondActive { + t.Fatal("ambiguity participants are not active") + } + + // When + firstCommitErr := firstTransaction.Commit() + secondCommitErr := secondTransaction.Commit() + + // Then + for _, commitErr := range []error{firstCommitErr, secondCommitErr} { + var holdErr *SQLiteHoldError + if !errors.As(commitErr, &holdErr) || !errors.Is(commitErr, ErrSQLiteHoldAmbiguousCandidate) { + t.Fatalf("ambiguous Commit error = %v", commitErr) + } + } + if count := sqliteAttributionPersistedCount(t, firstPath); count != 0 { + t.Fatalf("first ambiguous Commit persisted %d rows", count) + } + if count := sqliteAttributionPersistedCount(t, secondPath); count != 0 { + t.Fatalf("second ambiguous Commit persisted %d rows", count) + } + if sqliteAttributionTrackerTransactionActive(tracker, firstIdentity) || sqliteAttributionTrackerTransactionActive(tracker, secondIdentity) { + t.Fatal("ambiguous Commit left a tracker transaction active") + } +} diff --git a/service/singleton/sqlite_attribution_unbound_agentcompat_linux_test.go b/service/singleton/sqlite_attribution_unbound_agentcompat_linux_test.go new file mode 100644 index 00000000..7e72d0cf --- /dev/null +++ b/service/singleton/sqlite_attribution_unbound_agentcompat_linux_test.go @@ -0,0 +1,217 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "database/sql/driver" + "errors" + "testing" +) + +func TestSQLiteAttributionRejectsDirectUnboundExecBeforeInsert(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + enableSQLiteAttribution() + + // When + _, err := database.Exec("INSERT INTO settings (value) VALUES (?)", "direct-unbound") + count := sqliteAttributionSettingsCount(t, database) + + // Then + // An UpdateHook error after SQLite executes is too late for autocommit: the write may already be committed. + if !errors.Is(err, ErrSQLiteAttributionUnboundWrite) { + t.Error("direct unbound Exec error does not wrap ErrSQLiteAttributionUnboundWrite") + } + if count != 0 { + t.Errorf("direct unbound Exec persisted %d rows", count) + } +} + +func TestSQLiteAttributionRejectsLegacyDriverQueryBeforeInsert(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + connection, err := sqliteAttributionDriver{}.Open(sqliteAttributionTestDatabasePath(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := connection.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + if _, err := connection.(driver.Execer).Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil { + t.Fatal(err) + } + enableSQLiteAttribution() + + // When + rows, queryErr := connection.(driver.Queryer).Query("INSERT INTO settings (value) VALUES (?) RETURNING id", []driver.Value{"legacy-direct"}) + rowsErr := sqliteAttributionConsumeDriverRows(rows) + + // Then + if !errors.Is(errors.Join(queryErr, rowsErr), ErrSQLiteAttributionUnboundWrite) { + t.Error("legacy driver Query does not reject unbound row DML") + } +} + +func TestSQLiteAttributionRejectsPreparedLegacyDriverQueryBeforeInsert(t *testing.T) { + // Given + resetSQLiteAttributionForTest() + connection, err := sqliteAttributionDriver{}.Open(sqliteAttributionTestDatabasePath(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := connection.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + if _, err := connection.(driver.Execer).Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil { + t.Fatal(err) + } + enableSQLiteAttribution() + statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?) RETURNING id") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := statement.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + + // When + rows, queryErr := statement.Query([]driver.Value{"legacy-prepared"}) + rowsErr := sqliteAttributionConsumeDriverRows(rows) + + // Then + if !errors.Is(errors.Join(queryErr, rowsErr), ErrSQLiteAttributionUnboundWrite) { + t.Error("prepared legacy driver Query does not reject unbound row DML") + } +} + +func TestSQLiteAttributionRejectsPreparedUnboundExecBeforeInsert(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + enableSQLiteAttribution() + statement, err := database.Prepare("INSERT INTO settings (value) VALUES (?)") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := statement.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + + // When + _, err = statement.Exec("prepared-unbound") + count := sqliteAttributionSettingsCount(t, database) + + // Then + if !errors.Is(err, ErrSQLiteAttributionUnboundWrite) { + t.Error("prepared unbound Exec error does not wrap ErrSQLiteAttributionUnboundWrite") + } + if count != 0 { + t.Errorf("prepared unbound Exec persisted %d rows", count) + } +} + +func TestSQLiteAttributionRejectsDirectUnboundReturningBeforeInsert(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + enableSQLiteAttribution() + + // When + rows, queryErr := database.QueryContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id", "direct-returning") + rowsErr := sqliteAttributionConsumeRows(rows) + count := sqliteAttributionSettingsCount(t, database) + + // Then + if !errors.Is(errors.Join(queryErr, rowsErr), ErrSQLiteAttributionUnboundWrite) { + t.Error("direct unbound RETURNING does not propagate ErrSQLiteAttributionUnboundWrite through rows") + } + if count != 0 { + t.Errorf("direct unbound RETURNING persisted %d rows", count) + } +} + +func TestSQLiteAttributionRejectsPreparedUnboundReturningBeforeInsert(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + enableSQLiteAttribution() + statement, err := database.PrepareContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := statement.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + + // When + rows, queryErr := statement.QueryContext(context.Background(), "prepared-returning") + rowsErr := sqliteAttributionConsumeRows(rows) + count := sqliteAttributionSettingsCount(t, database) + + // Then + if !errors.Is(errors.Join(queryErr, rowsErr), ErrSQLiteAttributionUnboundWrite) { + t.Error("prepared unbound RETURNING does not propagate ErrSQLiteAttributionUnboundWrite through rows") + } + if count != 0 { + t.Errorf("prepared unbound RETURNING persisted %d rows", count) + } +} + +func TestSQLiteAttributionRejectsDirectUnboundUpdateBeforeSideEffect(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + if _, err := database.Exec("INSERT INTO settings (value) VALUES (?)", "original"); err != nil { + t.Fatal(err) + } + enableSQLiteAttribution() + + // When + _, err := database.Exec("UPDATE settings SET value = ?", "changed") + value := sqliteAttributionSettingValue(t, database) + + // Then + if !errors.Is(err, ErrSQLiteAttributionUnboundWrite) { + t.Error("direct unbound UPDATE error does not wrap ErrSQLiteAttributionUnboundWrite") + } + if value != "original" { + t.Error("direct unbound UPDATE changed the persisted value") + } +} + +func TestSQLiteAttributionRejectsPreparedUnboundDeleteBeforeSideEffect(t *testing.T) { + // Given + database := openSQLiteAttributionTestDatabase(t) + if _, err := database.Exec("INSERT INTO settings (value) VALUES (?)", "preserved"); err != nil { + t.Fatal(err) + } + enableSQLiteAttribution() + statement, err := database.Prepare("DELETE FROM settings") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := statement.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + + // When + _, err = statement.Exec() + count := sqliteAttributionSettingsCount(t, database) + + // Then + if !errors.Is(err, ErrSQLiteAttributionUnboundWrite) { + t.Error("prepared unbound DELETE error does not wrap ErrSQLiteAttributionUnboundWrite") + } + if count != 1 { + t.Errorf("prepared unbound DELETE left %d rows", count) + } +} diff --git a/service/singleton/sqlite_dialector_agentcompat_linux.go b/service/singleton/sqlite_dialector_agentcompat_linux.go new file mode 100644 index 00000000..ce1601df --- /dev/null +++ b/service/singleton/sqlite_dialector_agentcompat_linux.go @@ -0,0 +1,13 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func openSQLiteDialector(path string) gorm.Dialector { + registerSQLiteAttributionDriver() + return sqlite.New(sqlite.Config{DriverName: sqliteAttributionDriverName, DSN: path}) +} diff --git a/service/singleton/sqlite_dialector_default.go b/service/singleton/sqlite_dialector_default.go new file mode 100644 index 00000000..316ca2d5 --- /dev/null +++ b/service/singleton/sqlite_dialector_default.go @@ -0,0 +1,12 @@ +//go:build !agentcompat || !linux + +package singleton + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func openSQLiteDialector(path string) gorm.Dialector { + return sqlite.Open(path) +} diff --git a/service/singleton/sqlite_driver_adapter_agentcompat_linux.go b/service/singleton/sqlite_driver_adapter_agentcompat_linux.go new file mode 100644 index 00000000..2001a0b4 --- /dev/null +++ b/service/singleton/sqlite_driver_adapter_agentcompat_linux.go @@ -0,0 +1,250 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + + "github.com/mattn/go-sqlite3" +) + +const sqliteAttributionDriverName = "nezha_sqlite_attribution" + +var ( + ErrSQLiteAttributionUnsupportedDSN = errors.New("sqlite attribution requires a filesystem database") + ErrSQLiteAttributionUnboundWrite = errors.New("sqlite attribution observed a write without an explicit transaction") + ErrSQLiteAttributionJournalIdentity = errors.New("sqlite attribution could not identify the rollback journal") + ErrSQLiteAttributionUnsupportedWrite = errors.New("sqlite attribution cannot classify this write") + + sqliteAttributionDriverRegistration sync.Once + sqliteAttributionEnabled atomic.Bool + sqliteAttributionConnectionID atomic.Uint64 + sqliteAttributionTransactionID atomic.Uint64 + sqliteAttributionTracker atomic.Pointer[SQLiteHoldTracker] + sqliteAttributionHoldControl *sqliteHoldControl +) + +type SQLiteAttributionError struct{ Cause error } + +func (err *SQLiteAttributionError) Error() string { return err.Cause.Error() } +func (err *SQLiteAttributionError) Unwrap() error { return err.Cause } + +func init() { resetSQLiteAttributionControl() } + +func registerSQLiteAttributionDriver() { + sqliteAttributionDriverRegistration.Do(func() { sql.Register(sqliteAttributionDriverName, sqliteAttributionDriver{}) }) +} + +func enableSQLiteAttribution() { sqliteAttributionEnabled.Store(true) } + +func resetSQLiteAttributionForTest() { + sqliteAttributionEnabled.Store(false) + resetSQLiteAttributionControl() +} + +func resetSQLiteAttributionControl() { + tracker := newSQLiteAttributionHoldTracker(&sqliteAttributionEnabled) + sqliteAttributionTracker.Store(tracker) + sqliteAttributionHoldControl = newProductionSQLiteHoldControl(tracker) +} + +type sqliteAttributionDriver struct{} + +func (sqliteAttributionDriver) Open(dataSourceName string) (driver.Conn, error) { + rawConnection, err := (&sqlite3.SQLiteDriver{}).Open(dataSourceName) + if err != nil { + return nil, err + } + connection, ok := rawConnection.(*sqlite3.SQLiteConn) + if !ok { + return nil, errors.New("sqlite attribution received an unsupported sqlite connection") + } + databasePath := connection.GetFilename("") + if databasePath == "" { + if closeErr := connection.Close(); closeErr != nil { + return nil, fmt.Errorf("close unsupported sqlite database: %w", closeErr) + } + return nil, &SQLiteAttributionError{Cause: ErrSQLiteAttributionUnsupportedDSN} + } + wrapped := &sqliteAttributionConnection{connection: connection, identity: SQLiteConnectionIdentity(sqliteAttributionConnectionID.Add(1)), journal: databasePath + "-journal", closeRawConnection: connection.Close} + wrapped.prepareStatement = wrapped.prepareSQLiteStatement + connection.RegisterAuthorizer(wrapped.authorize) + connection.RegisterUpdateHook(wrapped.recordUpdate) + return wrapped, nil +} + +type sqliteAttributionConnection struct { + connection *sqlite3.SQLiteConn + identity SQLiteConnectionIdentity + journal string + + // lifecycleMu owns the active transaction and its poison, journal descriptor, and completion state; never hold it across raw SQLite, tracker, wait, or close operations. + lifecycleMu sync.Mutex + transaction *sqliteAttributionTransaction + capturing bool + preparing sqliteAttributionClassification + execution *sqliteAttributionExecution + + prepareStatement sqliteAttributionStatementPreparer + closeRawConnection func() error +} + +type sqliteAttributionStatementPreparer func(context.Context, string) (driver.Stmt, error) + +type sqliteAttributionTransaction struct { + transaction SQLiteTransaction + raw driver.Tx + tracker *SQLiteHoldTracker + context context.Context + journalFD int + poison error + terminalPhase sqliteAttributionTerminalPhase + done chan struct{} + releasedCommitBoundary func() + terminalLoserWaitBoundary func() +} + +type sqliteAttributionTerminalPhase uint8 + +const ( + sqliteAttributionTerminalOpen sqliteAttributionTerminalPhase = iota + sqliteAttributionTerminalCommitWaiting + sqliteAttributionTerminalCommitOwned + sqliteAttributionTerminalRollbackOwned +) + +type sqliteAttributionExecution struct { + classification sqliteAttributionClassification + origin SQLiteExecutionOrigin + hook sqliteAttributionHook +} + +type sqliteAttributionHook struct { + seen bool + mismatch bool +} + +var ( + _ driver.Driver = sqliteAttributionDriver{} + _ driver.Conn = (*sqliteAttributionConnection)(nil) + _ driver.Pinger = (*sqliteAttributionConnection)(nil) + _ driver.ConnPrepareContext = (*sqliteAttributionConnection)(nil) + _ driver.ConnBeginTx = (*sqliteAttributionConnection)(nil) + _ driver.Execer = (*sqliteAttributionConnection)(nil) + _ driver.ExecerContext = (*sqliteAttributionConnection)(nil) + _ driver.Queryer = (*sqliteAttributionConnection)(nil) + _ driver.QueryerContext = (*sqliteAttributionConnection)(nil) +) + +func (connection *sqliteAttributionConnection) authorize(operation int, table, _, schema string) int { + if !connection.capturing { + return sqlite3.SQLITE_OK + } + classification := &connection.preparing + if operation != sqlite3.SQLITE_INSERT && operation != sqlite3.SQLITE_UPDATE && operation != sqlite3.SQLITE_DELETE { + return sqlite3.SQLITE_OK + } + rowOperation, ok := sqliteAttributionOperation(operation) + if !ok || schema != "main" || strings.HasPrefix(table, "sqlite_") { + classification.ambiguous = true + return sqlite3.SQLITE_OK + } + // SQLite authorizes UPDATE once per written column; only a different row-DML target is ambiguous. + if classification.hasRowDML { + if classification.operation != rowOperation || classification.table != table { + classification.ambiguous = true + } + return sqlite3.SQLITE_OK + } + classification.hasRowDML = true + classification.operation = rowOperation + classification.table = table + return sqlite3.SQLITE_OK +} + +func (connection *sqliteAttributionConnection) Prepare(query string) (driver.Stmt, error) { + return connection.prepareStatement(context.Background(), query) +} + +func (connection *sqliteAttributionConnection) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) { + return connection.prepareStatement(ctx, query) +} + +func (connection *sqliteAttributionConnection) prepareSQLiteStatement(ctx context.Context, query string) (driver.Stmt, error) { + connection.preparing = sqliteAttributionClassification{} + connection.capturing = true + statement, err := connection.connection.PrepareContext(ctx, query) + connection.capturing = false + classification := connection.preparing + connection.preparing = sqliteAttributionClassification{} + if err != nil { + return nil, err + } + rawStatement, ok := statement.(*sqlite3.SQLiteStmt) + if !ok { + return nil, errors.New("sqlite attribution received an unsupported sqlite statement") + } + classification.readonly = rawStatement.Readonly() + return &sqliteAttributionStatement{connection: connection, statement: statement, classification: classification}, nil +} + +func (connection *sqliteAttributionConnection) Ping(ctx context.Context) error { + return connection.connection.Ping(ctx) +} +func (connection *sqliteAttributionConnection) Begin() (driver.Tx, error) { + return connection.begin(context.Background(), driver.TxOptions{}) +} +func (connection *sqliteAttributionConnection) BeginTx(ctx context.Context, options driver.TxOptions) (driver.Tx, error) { + return connection.begin(ctx, options) +} + +func (connection *sqliteAttributionConnection) begin(ctx context.Context, options driver.TxOptions) (driver.Tx, error) { + transaction, err := connection.connection.BeginTx(ctx, options) + if err != nil { + return nil, err + } + identity := SQLiteTransaction{Connection: connection.identity, Identity: SQLiteTransactionIdentity(sqliteAttributionTransactionID.Add(1))} + tracker := sqliteAttributionTracker.Load() + if err := tracker.BeginSQLiteTransaction(identity); err != nil { + // Tracker rejection happens after BEGIN; leave no raw transaction to wedge this connection. + return nil, errors.Join(err, transaction.Rollback()) + } + state := &sqliteAttributionTransaction{transaction: identity, raw: transaction, tracker: tracker, context: ctx, journalFD: -1, done: make(chan struct{})} + connection.lifecycleMu.Lock() + connection.transaction = state + connection.lifecycleMu.Unlock() + return &sqliteAttributionTx{connection: connection, state: state}, nil +} + +func (connection *sqliteAttributionConnection) recordUpdate(operation int, database, table string, _ int64) { + execution := connection.execution + if execution == nil { + return + } + rowOperation, ok := sqliteAttributionOperation(operation) + if !ok || database != "main" || rowOperation != execution.classification.operation || table != execution.classification.table { + execution.hook.mismatch = true + return + } + execution.hook.seen = true +} + +func sqliteAttributionOperation(operation int) (SQLiteOperation, bool) { + switch operation { + case sqlite3.SQLITE_INSERT: + return SQLiteOperationInsert, true + case sqlite3.SQLITE_UPDATE: + return SQLiteOperationUpdate, true + case sqlite3.SQLITE_DELETE: + return SQLiteOperationDelete, true + default: + return "", false + } +} diff --git a/service/singleton/sqlite_driver_adapter_agentcompat_linux_test.go b/service/singleton/sqlite_driver_adapter_agentcompat_linux_test.go new file mode 100644 index 00000000..75d0011b --- /dev/null +++ b/service/singleton/sqlite_driver_adapter_agentcompat_linux_test.go @@ -0,0 +1,204 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "database/sql" + "database/sql/driver" + "errors" + "path/filepath" + "testing" +) + +func TestSQLiteDriverAdapterRecordsExplicitInsert_when_AttributionEnabled(t *testing.T) { + resetSQLiteAttributionForTest() + databasePath := filepath.Join(t.TempDir(), "dashboard.sqlite") + database, err := openSQLiteAttributionTestDB(databasePath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := database.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + if _, err := database.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)"); err != nil { + t.Fatal(err) + } + enableSQLiteAttribution() + transaction, err := database.Begin() + if err != nil { + t.Fatal(err) + } + if _, err := transaction.Exec("INSERT INTO settings (value) VALUES (?)", "opaque"); err != nil { + t.Fatal(err) + } + + if !sqliteAttributionTrackerHasWrite() { + t.Fatal("explicit insert did not record an atomic sqlite write") + } + if err := transaction.Commit(); err != nil { + t.Fatal(err) + } +} + +func TestSQLiteDriverAdapterRecordsPreparedExplicitInsert_when_AttributionEnabled(t *testing.T) { + resetSQLiteAttributionForTest() + databasePath := filepath.Join(t.TempDir(), "dashboard.sqlite") + database, err := openSQLiteAttributionTestDB(databasePath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := database.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + if _, err := database.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)"); err != nil { + t.Fatal(err) + } + enableSQLiteAttribution() + transaction, err := database.Begin() + if err != nil { + t.Fatal(err) + } + statement, err := transaction.Prepare("INSERT INTO settings (value) VALUES (?)") + if err != nil { + t.Fatal(err) + } + if _, err := statement.Exec("prepared"); err != nil { + t.Fatal(err) + } + if err := statement.Close(); err != nil { + t.Fatal(err) + } + if !sqliteAttributionTrackerHasWrite() { + t.Fatal("prepared explicit insert did not record an atomic sqlite write") + } + if err := transaction.Commit(); err != nil { + t.Fatal(err) + } +} + +func TestSQLiteDriverAdapterRejectsUnboundWrite_when_AttributionEnabled(t *testing.T) { + database := openSQLiteAttributionTestDatabase(t) + enableSQLiteAttribution() + _, err := database.Exec("INSERT INTO settings (value) VALUES (?)", "unbound") + if !errors.Is(err, ErrSQLiteAttributionUnboundWrite) { + t.Fatalf("expected unbound write instrumentation error, got %v", err) + } +} + +func TestSQLiteDriverAdapterRejectsUnsupportedDSN(t *testing.T) { + database, err := openSQLiteAttributionTestDB(":memory:") + if err == nil { + err = database.Ping() + } + if database != nil { + t.Cleanup(func() { + if closeErr := database.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + } + if !errors.Is(err, ErrSQLiteAttributionUnsupportedDSN) { + t.Fatalf("expected unsupported DSN error, got %v", err) + } +} + +func openSQLiteAttributionTestDB(path string) (*sql.DB, error) { + registerSQLiteAttributionDriver() + return sql.Open(sqliteAttributionDriverName, path) +} + +func openSQLiteAttributionTestDatabase(t *testing.T) *sql.DB { + t.Helper() + resetSQLiteAttributionForTest() + database, err := openSQLiteAttributionTestDB(sqliteAttributionTestDatabasePath(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if closeErr := database.Close(); closeErr != nil { + t.Error(closeErr) + } + }) + if _, err := database.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)"); err != nil { + t.Fatal(err) + } + return database +} + +func sqliteAttributionTestDatabasePath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "dashboard.sqlite") +} + +func sqliteAttributionSettingsCount(t *testing.T, database *sql.DB) int { + t.Helper() + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM settings").Scan(&count); err != nil { + t.Fatal(err) + } + return count +} + +func sqliteAttributionSettingValue(t *testing.T, database *sql.DB) string { + t.Helper() + var value string + if err := database.QueryRow("SELECT value FROM settings LIMIT 1").Scan(&value); err != nil { + t.Fatal(err) + } + return value +} + +func sqliteAttributionConsumeRows(rows *sql.Rows) error { + if rows == nil { + return nil + } + var identifier int64 + for rows.Next() { + if err := rows.Scan(&identifier); err != nil { + closeErr := rows.Close() + return errors.Join(err, closeErr) + } + } + err := rows.Err() + closeErr := rows.Close() + return errors.Join(err, closeErr) +} + +func sqliteAttributionConsumeDriverRows(rows driver.Rows) error { + if rows == nil { + return nil + } + values := make([]driver.Value, len(rows.Columns())) + for { + err := rows.Next(values) + if err != nil { + closeErr := rows.Close() + return errors.Join(err, closeErr) + } + } +} + +func sqliteAttributionTrackerHasWrite() bool { + return sqliteAttributionTrackerWriteEvidence().hasWrite +} + +type sqliteAttributionWriteEvidence struct { + hasWrite bool + write SQLiteWriteObservation +} + +func sqliteAttributionTrackerWriteEvidence() sqliteAttributionWriteEvidence { + tracker := sqliteAttributionTracker.Load() + tracker.mu.Lock() + defer tracker.mu.Unlock() + for _, held := range tracker.transactions { + if held.hasWrite { + return sqliteAttributionWriteEvidence{hasWrite: true, write: held.write} + } + } + return sqliteAttributionWriteEvidence{} +} diff --git a/service/singleton/sqlite_hold_control_agentcompat_linux.go b/service/singleton/sqlite_hold_control_agentcompat_linux.go new file mode 100644 index 00000000..0d878fc1 --- /dev/null +++ b/service/singleton/sqlite_hold_control_agentcompat_linux.go @@ -0,0 +1,176 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "io" + "sync" +) + +var ErrSQLiteHoldUnexpectedSelection = errors.New("sqlite hold selected an unexpected write") + +type SQLiteHoldControlState string + +const ( + SQLiteHoldControlStateArmed SQLiteHoldControlState = "armed" + SQLiteHoldControlStateSelected SQLiteHoldControlState = "selected" + SQLiteHoldControlStateFinalizing SQLiteHoldControlState = "finalizing" + SQLiteHoldControlStateReleased SQLiteHoldControlState = "released" + SQLiteHoldControlStateAborted SQLiteHoldControlState = "aborted" +) + +type SQLiteHoldReceipt struct { + ID string `json:"id"` + State SQLiteHoldControlState `json:"state"` +} + +type sqliteHoldControlRecord struct { + receipt SQLiteHoldReceipt + session SQLiteHoldSession +} + +type sqliteHoldControl struct { + mu sync.Mutex + tracker *SQLiteHoldTracker + random io.Reader + record *sqliteHoldControlRecord +} + +func newSQLiteHoldControl(tracker *SQLiteHoldTracker, randomSource io.Reader) *sqliteHoldControl { + return &sqliteHoldControl{tracker: tracker, random: randomSource} +} + +func newProductionSQLiteHoldControl(tracker *SQLiteHoldTracker) *sqliteHoldControl { + return newSQLiteHoldControl(tracker, rand.Reader) +} + +func (control *sqliteHoldControl) ArmNextSQLiteHold() (SQLiteHoldReceipt, error) { + control.mu.Lock() + defer control.mu.Unlock() + identifier, err := control.newReceiptID() + if err != nil { + return SQLiteHoldReceipt{}, err + } + session, err := control.tracker.ArmNextSQLiteHold() + if err != nil { + return SQLiteHoldReceipt{}, err + } + receipt := SQLiteHoldReceipt{ID: identifier, State: SQLiteHoldControlStateArmed} + control.record = &sqliteHoldControlRecord{receipt: receipt, session: session} + return receipt, nil +} + +func (control *sqliteHoldControl) WaitSelected(ctx context.Context, receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) { + return control.wait(ctx, receipt, SQLiteHoldWaitSelected, SQLiteHoldControlStateSelected) +} + +func (control *sqliteHoldControl) WaitFinalizing(ctx context.Context, receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) { + return control.wait(ctx, receipt, SQLiteHoldWaitFinalizing, SQLiteHoldControlStateFinalizing) +} + +func (control *sqliteHoldControl) wait(ctx context.Context, receipt SQLiteHoldReceipt, target SQLiteHoldWaitTarget, state SQLiteHoldControlState) (SQLiteHoldReceipt, error) { + record, err := control.activeRecord(receipt) + if err != nil { + return SQLiteHoldReceipt{}, err + } + snapshot, err := control.tracker.WaitSQLiteHold(ctx, record.session, target) + if err != nil { + return control.terminalReceipt(record, err) + } + if snapshot.Operation != SQLiteOperationUpdate || snapshot.Table != "api_tokens" { + _ = control.tracker.AbortSQLiteHold(record.session) + return control.terminalReceipt(record, ErrSQLiteHoldUnexpectedSelection) + } + return control.updateReceipt(record, state), nil +} + +func (control *sqliteHoldControl) Release(receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) { + record, err := control.activeRecord(receipt) + if err != nil { + return SQLiteHoldReceipt{}, err + } + if err := control.tracker.ReleaseSQLiteHold(record.session); err != nil { + return control.terminalReceipt(record, err) + } + return control.updateReceipt(record, SQLiteHoldControlStateReleased), nil +} + +func (control *sqliteHoldControl) Abort(receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) { + record, err := control.activeRecord(receipt) + if err != nil { + return SQLiteHoldReceipt{}, err + } + if err := control.tracker.AbortSQLiteHold(record.session); err != nil { + return control.terminalReceipt(record, err) + } + return control.updateReceipt(record, SQLiteHoldControlStateAborted), nil +} + +func (control *sqliteHoldControl) Snapshot(receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) { + record, err := control.activeRecord(receipt) + if err != nil { + return SQLiteHoldReceipt{}, err + } + current := control.currentReceipt(record) + if current.State == SQLiteHoldControlStateReleased || current.State == SQLiteHoldControlStateAborted { + return current, nil + } + snapshot, snapshotErr := control.tracker.SQLiteHoldSnapshot(record.session) + if snapshotErr != nil { + if errors.Is(snapshotErr, ErrSQLiteHoldStaleSession) { + return control.updateReceipt(record, SQLiteHoldControlStateAborted), nil + } + return control.terminalReceipt(record, snapshotErr) + } + state := SQLiteHoldControlStateArmed + if snapshot.Finalizing { + state = SQLiteHoldControlStateFinalizing + } else if snapshot.Selected { + state = SQLiteHoldControlStateSelected + } + return control.updateReceipt(record, state), nil +} + +func (control *sqliteHoldControl) currentReceipt(record *sqliteHoldControlRecord) SQLiteHoldReceipt { + control.mu.Lock() + defer control.mu.Unlock() + return record.receipt +} + +func (control *sqliteHoldControl) newReceiptID() (string, error) { + bytes := make([]byte, 32) + if _, err := io.ReadFull(control.random, bytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + +func (control *sqliteHoldControl) activeRecord(receipt SQLiteHoldReceipt) (*sqliteHoldControlRecord, error) { + control.mu.Lock() + defer control.mu.Unlock() + if control.record == nil || control.record.receipt.ID != receipt.ID { + return nil, ErrSQLiteHoldStaleSession + } + return control.record, nil +} + +func (control *sqliteHoldControl) updateReceipt(record *sqliteHoldControlRecord, state SQLiteHoldControlState) SQLiteHoldReceipt { + control.mu.Lock() + defer control.mu.Unlock() + if control.record == record { + control.record.receipt.State = state + } + return record.receipt +} + +func (control *sqliteHoldControl) terminalReceipt(record *sqliteHoldControlRecord, cause error) (SQLiteHoldReceipt, error) { + state := SQLiteHoldControlStateAborted + if cause == nil { + state = SQLiteHoldControlStateReleased + } + return control.updateReceipt(record, state), cause +} diff --git a/service/singleton/sqlite_hold_control_agentcompat_linux_test.go b/service/singleton/sqlite_hold_control_agentcompat_linux_test.go new file mode 100644 index 00000000..10f678c7 --- /dev/null +++ b/service/singleton/sqlite_hold_control_agentcompat_linux_test.go @@ -0,0 +1,111 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestSQLiteHoldControlIssuesOpaqueReceiptAndStalesPriorReceipt(t *testing.T) { + // Given + control := newSQLiteHoldControl(NewSQLiteHoldTracker(), bytes.NewReader(append(bytes.Repeat([]byte{7}, 32), bytes.Repeat([]byte{8}, 32)...))) + + // When + first, firstErr := control.ArmNextSQLiteHold() + if _, abortErr := control.Abort(first); abortErr != nil { + t.Fatal(abortErr) + } + aborted, abortSnapshotErr := control.Snapshot(first) + second, secondErr := control.ArmNextSQLiteHold() + _, staleErr := control.Snapshot(first) + + // Then + if firstErr != nil || secondErr != nil { + t.Fatalf("first=%v second=%v", firstErr, secondErr) + } + if len(first.ID) != 43 || strings.Contains(first.ID, "=") || first.ID == second.ID { + t.Fatalf("opaque receipt IDs = %q, %q", first.ID, second.ID) + } + if !errors.Is(staleErr, ErrSQLiteHoldStaleSession) { + t.Fatalf("old receipt error = %v", staleErr) + } + if abortSnapshotErr != nil || aborted.State != SQLiteHoldControlStateAborted { + t.Fatalf("aborted receipt=%+v err=%v", aborted, abortSnapshotErr) + } +} + +func TestSQLiteHoldControlReadsTerminalStatesAndRejectsUnexpectedSelection(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + control := newSQLiteHoldControl(tracker, bytes.NewReader(bytes.Repeat([]byte{9}, 64))) + receipt, err := control.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + transaction := sqliteHoldTestTransaction(66) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{Operation: SQLiteOperationInsert, Table: "settings", Journal: sqliteHoldTestJournal}); err != nil { + t.Fatal(err) + } + + // When + _, waitErr := control.WaitSelected(context.Background(), receipt) + aborted, snapshotErr := control.Snapshot(receipt) + wire, marshalErr := json.Marshal(aborted) + + // Then + if !errors.Is(waitErr, ErrSQLiteHoldUnexpectedSelection) { + t.Fatalf("unexpected selection error = %v", waitErr) + } + if snapshotErr != nil || aborted.State != SQLiteHoldControlStateAborted { + t.Fatalf("terminal receipt=%+v err=%v", aborted, snapshotErr) + } + if marshalErr != nil { + t.Fatal(marshalErr) + } + for _, forbidden := range []string{"transaction", "connection", "journal", "operation", "table", "path", "origin", "session_id"} { + if strings.Contains(strings.ToLower(string(wire)), forbidden) { + t.Fatalf("opaque receipt leaked %q: %s", forbidden, wire) + } + } +} + +func TestSQLiteHoldControlReadsReleasedReceipt(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + control := newSQLiteHoldControl(tracker, bytes.NewReader(bytes.Repeat([]byte{11}, 32))) + receipt, err := control.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + transaction := sqliteHoldTestTransaction(67) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "api_tokens", Journal: sqliteHoldTestJournal}); err != nil { + t.Fatal(err) + } + if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil { + t.Fatal(err) + } + + // When + _, releaseErr := control.Release(receipt) + released, snapshotErr := control.Snapshot(receipt) + _, abortErr := control.Abort(receipt) + + // Then + if releaseErr != nil || snapshotErr != nil || released.State != SQLiteHoldControlStateReleased { + t.Fatalf("release=%v receipt=%+v snapshot=%v", releaseErr, released, snapshotErr) + } + if !errors.Is(abortErr, ErrSQLiteHoldStaleSession) { + t.Fatalf("abort after release = %v", abortErr) + } +} diff --git a/service/singleton/sqlite_hold_facade_agentcompat_linux.go b/service/singleton/sqlite_hold_facade_agentcompat_linux.go new file mode 100644 index 00000000..5ea67f99 --- /dev/null +++ b/service/singleton/sqlite_hold_facade_agentcompat_linux.go @@ -0,0 +1,24 @@ +//go:build agentcompat && linux + +package singleton + +import "context" + +func ArmNextSQLiteHold() (SQLiteHoldReceipt, error) { + return sqliteAttributionHoldControl.ArmNextSQLiteHold() +} +func WaitSQLiteHoldSelected(ctx context.Context, receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) { + return sqliteAttributionHoldControl.WaitSelected(ctx, receipt) +} +func WaitSQLiteHoldFinalizing(ctx context.Context, receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) { + return sqliteAttributionHoldControl.WaitFinalizing(ctx, receipt) +} +func SnapshotSQLiteHold(receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) { + return sqliteAttributionHoldControl.Snapshot(receipt) +} +func ReleaseSQLiteHold(receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) { + return sqliteAttributionHoldControl.Release(receipt) +} +func AbortSQLiteHold(receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) { + return sqliteAttributionHoldControl.Abort(receipt) +} diff --git a/service/singleton/sqlite_hold_tracker_control_agentcompat_linux.go b/service/singleton/sqlite_hold_tracker_control_agentcompat_linux.go new file mode 100644 index 00000000..3fa1e4ae --- /dev/null +++ b/service/singleton/sqlite_hold_tracker_control_agentcompat_linux.go @@ -0,0 +1,239 @@ +//go:build agentcompat && linux + +package singleton + +import "context" + +func (tracker *SQLiteHoldTracker) ArmSQLiteHold(journal SQLiteJournalIdentity) (SQLiteHoldSession, error) { + return tracker.armSQLiteHold(SQLiteHoldSelectionModeKnownJournal, journal) +} + +func (tracker *SQLiteHoldTracker) ArmNextSQLiteHold() (SQLiteHoldSession, error) { + return tracker.armSQLiteHold(SQLiteHoldSelectionModeNextWriter, SQLiteJournalIdentity{}) +} + +func (tracker *SQLiteHoldTracker) armSQLiteHold(mode SQLiteHoldSelectionMode, journal SQLiteJournalIdentity) (SQLiteHoldSession, error) { + tracker.mu.Lock() + defer tracker.mu.Unlock() + if tracker.session != nil { + return SQLiteHoldSession{}, ErrSQLiteHoldSessionActive + } + tracker.nextSession++ + tracker.session = &sqliteHoldSessionState{ + id: SQLiteHoldSession{identity: tracker.nextSession}, mode: mode, journal: journal, notify: make(chan struct{}), + } + var candidates []SQLiteTransaction + for transaction, held := range tracker.transactions { + if tracker.isEligibleLocked(held) { + candidates = append(candidates, transaction) + } + } + if len(candidates) > 1 { + tracker.abortSQLiteHoldWithCandidatesLocked(ErrSQLiteHoldAmbiguousCandidate, candidates) + return SQLiteHoldSession{}, ErrSQLiteHoldAmbiguousCandidate + } + if len(candidates) == 1 { + tracker.selectSQLiteHoldLocked(candidates[0]) + } + tracker.setAttributionEnabledLocked(true) + return tracker.session.id, nil +} + +func (tracker *SQLiteHoldTracker) WaitSQLiteHold(ctx context.Context, session SQLiteHoldSession, target SQLiteHoldWaitTarget) (SQLiteHoldSnapshot, error) { + if !target.valid() { + return SQLiteHoldSnapshot{}, ErrSQLiteHoldInvalidWaitTarget + } + for { + tracker.mu.Lock() + if tracker.session == nil || tracker.session.id != session { + if tracker.terminal != nil && tracker.terminal.id == session { + if tracker.terminal.released { + snapshot := SQLiteHoldSnapshot{SessionID: session.ID(), Selected: tracker.terminal.hasSelected, Transaction: tracker.terminal.selected, Finalizing: true, Released: true} + tracker.mu.Unlock() + return snapshot, nil + } + cause := tracker.terminal.cause + tracker.mu.Unlock() + return SQLiteHoldSnapshot{}, cause + } + tracker.mu.Unlock() + return SQLiteHoldSnapshot{}, ErrSQLiteHoldStaleSession + } + if tracker.waitTargetReachedLocked(target) { + snapshot, err := tracker.snapshotLocked(session) + tracker.mu.Unlock() + return snapshot, err + } + notify := tracker.session.notify + tracker.mu.Unlock() + select { + case <-notify: + case <-ctx.Done(): + return SQLiteHoldSnapshot{}, ctx.Err() + } + } +} + +func (tracker *SQLiteHoldTracker) BeginSQLiteFinalization(transaction SQLiteTransaction) (*SQLiteHoldFinalization, error) { + tracker.mu.Lock() + defer tracker.mu.Unlock() + if tracker.session == nil || !tracker.session.hasSelected || tracker.session.selected != transaction { + return nil, ErrSQLiteHoldNotSelected + } + return tracker.beginSQLiteFinalizationLocked(transaction) +} + +func (tracker *SQLiteHoldTracker) BeginSQLiteCommitFinalization(transaction SQLiteTransaction) (*SQLiteHoldFinalization, error) { + tracker.mu.Lock() + defer tracker.mu.Unlock() + if tracker.session != nil && tracker.session.hasSelected && tracker.session.selected == transaction { + return tracker.beginSQLiteFinalizationLocked(transaction) + } + if cause, ok := tracker.causes[transaction]; ok { + return nil, cause + } + return nil, ErrSQLiteHoldNotSelected +} + +func (tracker *SQLiteHoldTracker) ReleaseSQLiteHold(session SQLiteHoldSession) error { + tracker.mu.Lock() + defer tracker.mu.Unlock() + if !tracker.matchesSessionLocked(session) || tracker.session.released { + return ErrSQLiteHoldStaleSession + } + if !tracker.session.hasSelected || tracker.session.finalization == nil { + return ErrSQLiteHoldFinalizationNotStarted + } + tracker.session.released = true + tracker.setAttributionEnabledLocked(false) + close(tracker.session.finalization.done) + tracker.notifySQLiteHoldLocked() + return nil +} + +func (tracker *SQLiteHoldTracker) AbortSQLiteHold(session SQLiteHoldSession) error { + tracker.mu.Lock() + defer tracker.mu.Unlock() + if !tracker.matchesSessionLocked(session) || tracker.session.released { + return ErrSQLiteHoldStaleSession + } + tracker.abortSQLiteHoldLocked(ErrSQLiteHoldAborted) + return nil +} + +func (tracker *SQLiteHoldTracker) SQLiteHoldSnapshot(session SQLiteHoldSession) (SQLiteHoldSnapshot, error) { + tracker.mu.Lock() + defer tracker.mu.Unlock() + if !tracker.matchesSessionLocked(session) { + return SQLiteHoldSnapshot{}, ErrSQLiteHoldStaleSession + } + return tracker.snapshotLocked(session) +} + +func (tracker *SQLiteHoldTracker) snapshotLocked(session SQLiteHoldSession) (SQLiteHoldSnapshot, error) { + snapshot := SQLiteHoldSnapshot{SessionID: session.ID(), Mode: tracker.session.mode, Selected: tracker.session.hasSelected, Journal: tracker.session.journal, Released: tracker.session.released} + if !tracker.session.hasSelected { + return snapshot, nil + } + held := tracker.transactions[tracker.session.selected] + if held == nil { + return SQLiteHoldSnapshot{}, ErrSQLiteHoldNotSelected + } + snapshot.Transaction = held.transaction + if held.hasWrite { + snapshot.Operation, snapshot.Table, snapshot.Journal = held.write.Update.Operation, held.write.Update.Table, held.write.Update.Journal + snapshot.StackHash, snapshot.FirstNezhaFrame = held.write.Origin.StackHash, held.write.Origin.FirstNezhaFrame + } else { + snapshot.Operation, snapshot.Table, snapshot.Journal = held.update.Operation, held.update.Table, held.update.Journal + snapshot.StackHash, snapshot.FirstNezhaFrame = held.origin.StackHash, held.origin.FirstNezhaFrame + } + snapshot.Finalizing = held.finalizing + return snapshot, nil +} + +func (tracker *SQLiteHoldTracker) selectSQLiteHoldLocked(transaction SQLiteTransaction) error { + if !tracker.session.hasSelected { + held := tracker.transactions[transaction] + tracker.session.selected, tracker.session.hasSelected = transaction, true + if tracker.session.mode == SQLiteHoldSelectionModeNextWriter { + tracker.session.journal = held.update.Journal + } + tracker.notifySQLiteHoldLocked() + return nil + } + if tracker.session.selected == transaction { + return nil + } + tracker.abortSQLiteHoldWithCandidatesLocked(ErrSQLiteHoldAmbiguousCandidate, []SQLiteTransaction{tracker.session.selected, transaction}) + return ErrSQLiteHoldAmbiguousCandidate +} + +func (tracker *SQLiteHoldTracker) matchesSessionLocked(session SQLiteHoldSession) bool { + return tracker.session != nil && tracker.session.id == session +} + +func (tracker *SQLiteHoldTracker) activeSessionHasSelectedTransactionLocked(transaction SQLiteTransaction) bool { + return tracker.session != nil && tracker.session.hasSelected && tracker.session.selected == transaction +} + +func (tracker *SQLiteHoldTracker) beginSQLiteFinalizationLocked(transaction SQLiteTransaction) (*SQLiteHoldFinalization, error) { + held, ok := tracker.transactionLocked(transaction) + if !ok || held.finalizing { + return nil, ErrSQLiteHoldFinalizationStarted + } + held.finalizing = true + finalization := &SQLiteHoldFinalization{done: make(chan struct{})} + tracker.session.finalization = finalization + tracker.notifySQLiteHoldLocked() + return finalization, nil +} + +func (tracker *SQLiteHoldTracker) waitTargetReachedLocked(target SQLiteHoldWaitTarget) bool { + switch target { + case SQLiteHoldWaitSelected: + return tracker.session.hasSelected + case SQLiteHoldWaitFinalizing: + return tracker.session.finalization != nil + default: + return false + } +} + +func (target SQLiteHoldWaitTarget) valid() bool { + return target == SQLiteHoldWaitSelected || target == SQLiteHoldWaitFinalizing +} + +// Closing and replacing the channel under mu makes a snapshot-to-wait handoff race-free. +func (tracker *SQLiteHoldTracker) notifySQLiteHoldLocked() { + close(tracker.session.notify) + tracker.session.notify = make(chan struct{}) +} + +func (tracker *SQLiteHoldTracker) abortSQLiteHoldLocked(cause error) { + tracker.abortSQLiteHoldWithCandidatesLocked(cause, nil) +} + +func (tracker *SQLiteHoldTracker) abortSQLiteHoldWithCandidatesLocked(cause error, candidates []SQLiteTransaction) { + tracker.setAttributionEnabledLocked(false) + if tracker.session.finalization != nil { + tracker.session.finalization.err = cause + close(tracker.session.finalization.done) + } + if tracker.session.hasSelected { + tracker.causes[tracker.session.selected] = cause + } + for _, transaction := range candidates { + tracker.causes[transaction] = cause + } + tracker.terminal = &sqliteHoldTerminalState{id: tracker.session.id, selected: tracker.session.selected, hasSelected: tracker.session.hasSelected, cause: cause} + tracker.notifySQLiteHoldLocked() + tracker.session = nil +} + +func (tracker *SQLiteHoldTracker) setAttributionEnabledLocked(enabled bool) { + if tracker.attributionEnabled == nil { + return + } + // Attribution is hold-scoped because ordinary GORM RETURNING closes rows before EOF. + tracker.attributionEnabled.Store(enabled) +} diff --git a/service/singleton/sqlite_hold_tracker_finalization_context_agentcompat_linux_test.go b/service/singleton/sqlite_hold_tracker_finalization_context_agentcompat_linux_test.go new file mode 100644 index 00000000..6a2489e1 --- /dev/null +++ b/service/singleton/sqlite_hold_tracker_finalization_context_agentcompat_linux_test.go @@ -0,0 +1,75 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "errors" + "testing" +) + +func TestSQLiteHoldTrackerCommitFinalizationCancellationAbortsUnreleasedHold(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(202) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, transaction) + session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + finalization, err := tracker.BeginSQLiteCommitFinalization(transaction) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // When + waitErr := tracker.WaitSQLiteCommitFinalization(ctx, finalization) + releaseErr := tracker.ReleaseSQLiteHold(session) + + // Then + if !errors.Is(waitErr, context.Canceled) { + t.Fatalf("cancelled finalization wait error = %v", waitErr) + } + if !errors.Is(finalization.Wait(), ErrSQLiteHoldAborted) { + t.Fatalf("finalization after cancellation = %v", finalization.Wait()) + } + if !errors.Is(releaseErr, ErrSQLiteHoldStaleSession) { + t.Fatalf("release after cancellation-owned abort = %v", releaseErr) + } +} + +func TestSQLiteHoldTrackerCommitFinalizationReleaseWinsOverAlreadyCancelledContext(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(203) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, transaction) + session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + finalization, err := tracker.BeginSQLiteCommitFinalization(transaction) + if err != nil { + t.Fatal(err) + } + if err := tracker.ReleaseSQLiteHold(session); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // When + waitErr := tracker.WaitSQLiteCommitFinalization(ctx, finalization) + + // Then + if waitErr != nil { + t.Fatalf("released finalization lost to already-cancelled context: %v", waitErr) + } +} diff --git a/service/singleton/sqlite_hold_tracker_finalization_wait_agentcompat_linux.go b/service/singleton/sqlite_hold_tracker_finalization_wait_agentcompat_linux.go new file mode 100644 index 00000000..09c6d5f6 --- /dev/null +++ b/service/singleton/sqlite_hold_tracker_finalization_wait_agentcompat_linux.go @@ -0,0 +1,33 @@ +//go:build agentcompat && linux + +package singleton + +import "context" + +// WaitSQLiteCommitFinalization linearizes cancellation against release under the tracker lock. +func (tracker *SQLiteHoldTracker) WaitSQLiteCommitFinalization(ctx context.Context, finalization *SQLiteHoldFinalization) error { + for { + tracker.mu.Lock() + select { + case <-finalization.done: + err := finalization.err + tracker.mu.Unlock() + return err + default: + } + if err := ctx.Err(); err != nil { + if tracker.session != nil && tracker.session.finalization == finalization && !tracker.session.released { + tracker.abortSQLiteHoldLocked(ErrSQLiteHoldAborted) + } + tracker.mu.Unlock() + return err + } + done := finalization.done + tracker.mu.Unlock() + select { + case <-done: + return finalization.err + case <-ctx.Done(): + } + } +} diff --git a/service/singleton/sqlite_hold_tracker_recording_agentcompat_linux.go b/service/singleton/sqlite_hold_tracker_recording_agentcompat_linux.go new file mode 100644 index 00000000..9b41c203 --- /dev/null +++ b/service/singleton/sqlite_hold_tracker_recording_agentcompat_linux.go @@ -0,0 +1,113 @@ +//go:build agentcompat && linux + +package singleton + +func (tracker *SQLiteHoldTracker) BeginSQLiteTransaction(transaction SQLiteTransaction) error { + tracker.mu.Lock() + defer tracker.mu.Unlock() + if _, active := tracker.transactions[transaction]; active { + return ErrSQLiteHoldTransactionActive + } + tracker.transactions[transaction] = &sqliteHeldTransaction{transaction: transaction} + return nil +} + +func (tracker *SQLiteHoldTracker) RecordSQLiteExecution(transaction SQLiteTransaction, origin SQLiteExecutionOrigin) error { + tracker.mu.Lock() + defer tracker.mu.Unlock() + held, ok := tracker.transactionLocked(transaction) + if !ok { + return ErrSQLiteHoldNotSelected + } + if held.finalizing { + return ErrSQLiteHoldFinalizationStarted + } + if held.hasWrite { + return ErrSQLiteHoldAtomicWriteRecorded + } + if tracker.activeSessionHasSelectedTransactionLocked(transaction) { + return ErrSQLiteHoldEvidenceFrozen + } + held.origin = origin + return nil +} + +func (tracker *SQLiteHoldTracker) RecordSQLiteUpdate(transaction SQLiteTransaction, update SQLiteUpdateObservation) error { + tracker.mu.Lock() + defer tracker.mu.Unlock() + held, ok := tracker.transactionLocked(transaction) + if !ok { + return ErrSQLiteHoldNotSelected + } + if held.finalizing { + return ErrSQLiteHoldFinalizationStarted + } + if held.hasWrite { + return ErrSQLiteHoldAtomicWriteRecorded + } + if tracker.activeSessionHasSelectedTransactionLocked(transaction) { + return ErrSQLiteHoldEvidenceFrozen + } + held.update, held.hasUpdate = update, true + if tracker.session != nil && !tracker.session.released && tracker.isEligibleLocked(held) { + return tracker.selectSQLiteHoldLocked(transaction) + } + return nil +} + +func (tracker *SQLiteHoldTracker) RecordSQLiteWrite(transaction SQLiteTransaction, write SQLiteWriteObservation) error { + tracker.mu.Lock() + defer tracker.mu.Unlock() + held, ok := tracker.transactionLocked(transaction) + if !ok { + return ErrSQLiteHoldNotSelected + } + if held.finalizing { + return ErrSQLiteHoldFinalizationStarted + } + if held.hasWrite { + return nil + } + if tracker.activeSessionHasSelectedTransactionLocked(transaction) { + return ErrSQLiteHoldEvidenceFrozen + } + held.write, held.hasWrite = write, true + held.origin, held.update, held.hasUpdate = write.Origin, write.Update, true + if tracker.session != nil && !tracker.session.released && tracker.isEligibleLocked(held) { + return tracker.selectSQLiteHoldLocked(transaction) + } + return nil +} + +func (tracker *SQLiteHoldTracker) FinishSQLiteTransaction(transaction SQLiteTransaction) error { + tracker.mu.Lock() + defer tracker.mu.Unlock() + if _, ok := tracker.transactionLocked(transaction); !ok { + return ErrSQLiteHoldNotSelected + } + delete(tracker.transactions, transaction) + defer delete(tracker.causes, transaction) + defer delete(tracker.terminalArbitrations, transaction) + if tracker.session != nil && tracker.session.hasSelected && tracker.session.selected == transaction { + if tracker.session.released { + tracker.terminal = &sqliteHoldTerminalState{id: tracker.session.id, selected: transaction, hasSelected: true, released: true} + tracker.notifySQLiteHoldLocked() + tracker.session = nil + } else { + tracker.abortSQLiteHoldLocked(ErrSQLiteHoldAborted) + } + } + return nil +} + +func (tracker *SQLiteHoldTracker) transactionLocked(transaction SQLiteTransaction) (*sqliteHeldTransaction, bool) { + held, ok := tracker.transactions[transaction] + return held, ok +} + +func (tracker *SQLiteHoldTracker) isEligibleLocked(held *sqliteHeldTransaction) bool { + if !held.hasUpdate || held.finalizing { + return false + } + return tracker.session.mode == SQLiteHoldSelectionModeNextWriter || held.update.Journal == tracker.session.journal +} diff --git a/service/singleton/sqlite_hold_tracker_regression_agentcompat_linux_test.go b/service/singleton/sqlite_hold_tracker_regression_agentcompat_linux_test.go new file mode 100644 index 00000000..506b9644 --- /dev/null +++ b/service/singleton/sqlite_hold_tracker_regression_agentcompat_linux_test.go @@ -0,0 +1,228 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "errors" + "testing" +) + +func sqliteHoldTestTransactionOnConnection(connection SQLiteConnectionIdentity, id SQLiteTransactionIdentity) SQLiteTransaction { + return SQLiteTransaction{Connection: connection, Identity: id} +} + +func TestSQLiteHoldTrackerTracksSameTransactionIdentityAcrossConnections(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + first := sqliteHoldTestTransactionOnConnection(3, 31) + second := sqliteHoldTestTransactionOnConnection(4, 31) + for _, transaction := range []SQLiteTransaction{first, second} { + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, transaction) + } + + // When + _, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + + // Then + if !errors.Is(err, ErrSQLiteHoldAmbiguousCandidate) { + t.Fatalf("expected both connections to be tracked, got %v", err) + } +} + +func TestSQLiteHoldTrackerRejectsExactDuplicateTransaction(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransactionOnConnection(3, 32) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + + // When + err := tracker.BeginSQLiteTransaction(transaction) + + // Then + if !errors.Is(err, ErrSQLiteHoldTransactionActive) { + t.Fatalf("expected duplicate composite rejection, got %v", err) + } +} + +func TestSQLiteHoldTrackerFreezesSelectedMetadataAfterFinalization(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(33) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := tracker.RecordSQLiteExecution(transaction, SQLiteExecutionOrigin{ + Operation: SQLiteOperationInsert, Table: "initial", StackHash: 44, FirstNezhaFrame: "singleton.initial", + }); err != nil { + t.Fatal(err) + } + if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{ + Operation: SQLiteOperationInsert, Table: "initial", Journal: sqliteHoldTestJournal, + }); err != nil { + t.Fatal(err) + } + session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil { + t.Fatal(err) + } + + // When + executionErr := tracker.RecordSQLiteExecution(transaction, SQLiteExecutionOrigin{ + Operation: SQLiteOperationDelete, Table: "changed", StackHash: 45, FirstNezhaFrame: "singleton.changed", + }) + updateErr := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{ + Operation: SQLiteOperationDelete, Table: "changed", Journal: SQLiteJournalIdentity{Inode: 99}, + }) + snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session) + + // Then + if !errors.Is(executionErr, ErrSQLiteHoldFinalizationStarted) || !errors.Is(updateErr, ErrSQLiteHoldFinalizationStarted) { + t.Fatalf("execution=%v update=%v", executionErr, updateErr) + } + if snapshotErr != nil { + t.Fatal(snapshotErr) + } + if snapshot.Operation != SQLiteOperationInsert || snapshot.Table != "initial" || snapshot.StackHash != 44 || + snapshot.FirstNezhaFrame != "singleton.initial" || snapshot.Journal != sqliteHoldTestJournal { + t.Fatalf("finalizing metadata changed: %+v", snapshot) + } +} + +func TestSQLiteHoldTrackerRejectsSelectedReleaseBeforeFinalizationThenCompletes(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(34) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, transaction) + session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + + // When + releaseErr := tracker.ReleaseSQLiteHold(session) + finalization, finalizationErr := tracker.BeginSQLiteFinalization(transaction) + + // Then + if !errors.Is(releaseErr, ErrSQLiteHoldFinalizationNotStarted) || finalizationErr != nil { + t.Fatalf("release=%v finalization=%v", releaseErr, finalizationErr) + } + if err := tracker.ReleaseSQLiteHold(session); err != nil { + t.Fatal(err) + } + if err := finalization.Wait(); err != nil { + t.Fatal(err) + } +} + +func TestSQLiteHoldTrackerCompletesSecondLifecycleAfterReleasedCleanup(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + first := sqliteHoldTestTransaction(35) + if err := tracker.BeginSQLiteTransaction(first); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, first) + firstSession, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + firstFinalization, err := tracker.BeginSQLiteFinalization(first) + if err != nil { + t.Fatal(err) + } + if err := tracker.ReleaseSQLiteHold(firstSession); err != nil { + t.Fatal(err) + } + if err := firstFinalization.Wait(); err != nil { + t.Fatal(err) + } + if err := tracker.FinishSQLiteTransaction(first); err != nil { + t.Fatal(err) + } + second := sqliteHoldTestTransaction(36) + if err := tracker.BeginSQLiteTransaction(second); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, second) + + // When + secondSession, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + secondFinalization, finalizationErr := tracker.BeginSQLiteFinalization(second) + + // Then + if err != nil || finalizationErr != nil { + t.Fatalf("arm=%v finalization=%v", err, finalizationErr) + } + if err := tracker.ReleaseSQLiteHold(secondSession); err != nil { + t.Fatal(err) + } + if err := secondFinalization.Wait(); err != nil { + t.Fatal(err) + } + if err := tracker.FinishSQLiteTransaction(second); err != nil { + t.Fatal(err) + } +} + +func TestSQLiteHoldTrackerFreezesLegacyEvidenceWhenSelected(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(37) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + originalOrigin := SQLiteExecutionOrigin{ + Operation: SQLiteOperationInsert, Table: "original", StackHash: 46, FirstNezhaFrame: "singleton.original", + } + if err := tracker.RecordSQLiteExecution(transaction, originalOrigin); err != nil { + t.Fatal(err) + } + if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{ + Operation: SQLiteOperationInsert, Table: "original", Journal: sqliteHoldTestJournal, + }); err != nil { + t.Fatal(err) + } + session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + + // When + executionErr := tracker.RecordSQLiteExecution(transaction, SQLiteExecutionOrigin{ + Operation: SQLiteOperationDelete, Table: "changed", StackHash: 47, FirstNezhaFrame: "singleton.changed", + }) + updateErr := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{ + Operation: SQLiteOperationDelete, Table: "changed", Journal: SQLiteJournalIdentity{Inode: 94}, + }) + snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session) + finalization, finalizationErr := tracker.BeginSQLiteFinalization(transaction) + + // Then + if !errors.Is(executionErr, ErrSQLiteHoldEvidenceFrozen) || !errors.Is(updateErr, ErrSQLiteHoldEvidenceFrozen) { + t.Fatalf("execution=%v update=%v", executionErr, updateErr) + } + if snapshotErr != nil || finalizationErr != nil { + t.Fatalf("snapshot=%v finalization=%v", snapshotErr, finalizationErr) + } + if snapshot.Operation != SQLiteOperationInsert || snapshot.Table != "original" || snapshot.StackHash != 46 || + snapshot.FirstNezhaFrame != "singleton.original" || snapshot.Journal != sqliteHoldTestJournal { + t.Fatalf("selected legacy evidence changed: %+v", snapshot) + } + if err := tracker.ReleaseSQLiteHold(session); err != nil { + t.Fatal(err) + } + if err := finalization.Wait(); err != nil { + t.Fatal(err) + } +} diff --git a/service/singleton/sqlite_hold_tracker_review_agentcompat_linux_test.go b/service/singleton/sqlite_hold_tracker_review_agentcompat_linux_test.go new file mode 100644 index 00000000..90869ebc --- /dev/null +++ b/service/singleton/sqlite_hold_tracker_review_agentcompat_linux_test.go @@ -0,0 +1,141 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "errors" + "os" + "os/exec" + "testing" + "time" +) + +func TestMain(m *testing.M) { + if os.Getenv("NEZHA_SQLITE_HOLD_INVALID_TARGET_HELPER") == "1" { + tracker := NewSQLiteHoldTracker() + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + os.Exit(2) + } + _, waitErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitTarget(99)) + if !errors.Is(waitErr, ErrSQLiteHoldInvalidWaitTarget) { + os.Exit(3) + } + os.Exit(0) + } + os.Exit(m.Run()) +} + +func TestSQLiteHoldTrackerWaitRejectsInvalidTargetImmediately(t *testing.T) { + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestSQLiteHoldTrackerWaitRejectsInvalidTargetImmediately$") + command.Env = append(os.Environ(), "NEZHA_SQLITE_HOLD_INVALID_TARGET_HELPER=1") + + // When + err := command.Run() + + // Then + if err != nil { + t.Fatalf("invalid target helper did not return before watchdog deadline: %v", err) + } +} + +func TestSQLiteHoldTrackerRecordsAmbiguityForEveryFutureConflictCandidate(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + _, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + first, second, mismatch := sqliteHoldTestTransaction(81), sqliteHoldTestTransaction(82), sqliteHoldTestTransaction(83) + for _, transaction := range []SQLiteTransaction{first, second, mismatch} { + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + } + if err := tracker.RecordSQLiteUpdate(first, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal}); err != nil { + t.Fatal(err) + } + if err := tracker.RecordSQLiteUpdate(mismatch, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: SQLiteJournalIdentity{Inode: 84}}); err != nil { + t.Fatal(err) + } + + // When + duplicateErr := tracker.RecordSQLiteUpdate(second, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal}) + _, firstErr := tracker.BeginSQLiteCommitFinalization(first) + _, secondErr := tracker.BeginSQLiteCommitFinalization(second) + _, mismatchErr := tracker.BeginSQLiteCommitFinalization(mismatch) + + // Then + if !errors.Is(duplicateErr, ErrSQLiteHoldAmbiguousCandidate) || !errors.Is(firstErr, ErrSQLiteHoldAmbiguousCandidate) || !errors.Is(secondErr, ErrSQLiteHoldAmbiguousCandidate) { + t.Fatalf("duplicate=%v first=%v second=%v", duplicateErr, firstErr, secondErr) + } + if !errors.Is(mismatchErr, ErrSQLiteHoldNotSelected) { + t.Fatalf("mismatched transaction cause = %v", mismatchErr) + } +} + +func TestSQLiteHoldTrackerRecordsAmbiguityForEveryArmTimeConflictCandidate(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + first, second, mismatch := sqliteHoldTestTransaction(85), sqliteHoldTestTransaction(86), sqliteHoldTestTransaction(87) + for _, transaction := range []SQLiteTransaction{first, second, mismatch} { + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + } + for _, transaction := range []SQLiteTransaction{first, second} { + if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal}); err != nil { + t.Fatal(err) + } + } + if err := tracker.RecordSQLiteUpdate(mismatch, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: SQLiteJournalIdentity{Inode: 88}}); err != nil { + t.Fatal(err) + } + + // When + _, armErr := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + _, firstErr := tracker.BeginSQLiteCommitFinalization(first) + _, secondErr := tracker.BeginSQLiteCommitFinalization(second) + _, mismatchErr := tracker.BeginSQLiteCommitFinalization(mismatch) + + // Then + if !errors.Is(armErr, ErrSQLiteHoldAmbiguousCandidate) || !errors.Is(firstErr, ErrSQLiteHoldAmbiguousCandidate) || !errors.Is(secondErr, ErrSQLiteHoldAmbiguousCandidate) { + t.Fatalf("arm=%v first=%v second=%v", armErr, firstErr, secondErr) + } + if !errors.Is(mismatchErr, ErrSQLiteHoldNotSelected) { + t.Fatalf("mismatched transaction cause = %v", mismatchErr) + } +} + +func TestSQLiteHoldTrackerFinishDeletesAmbiguityCause(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + first, second := sqliteHoldTestTransaction(89), sqliteHoldTestTransaction(90) + for _, transaction := range []SQLiteTransaction{first, second} { + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal}); err != nil { + t.Fatal(err) + } + } + if _, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal); !errors.Is(err, ErrSQLiteHoldAmbiguousCandidate) { + t.Fatal(err) + } + + // When + if err := tracker.FinishSQLiteTransaction(first); err != nil { + t.Fatal(err) + } + _, lookupErr := tracker.BeginSQLiteCommitFinalization(first) + + // Then + if !errors.Is(lookupErr, ErrSQLiteHoldNotSelected) { + t.Fatalf("finished transaction ambiguity cause = %v", lookupErr) + } +} diff --git a/service/singleton/sqlite_hold_tracker_selection_agentcompat_linux_test.go b/service/singleton/sqlite_hold_tracker_selection_agentcompat_linux_test.go new file mode 100644 index 00000000..c56de317 --- /dev/null +++ b/service/singleton/sqlite_hold_tracker_selection_agentcompat_linux_test.go @@ -0,0 +1,265 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "errors" + "testing" +) + +func TestSQLiteHoldTrackerAbortsSessionWhenFutureDuplicateArrives(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + first := sqliteHoldTestTransaction(12) + if err := tracker.BeginSQLiteTransaction(first); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, first) + session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + second := sqliteHoldTestTransaction(13) + if err := tracker.BeginSQLiteTransaction(second); err != nil { + t.Fatal(err) + } + + // When + err = tracker.RecordSQLiteUpdate(second, SQLiteUpdateObservation{ + Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal, + }) + + // Then + if !errors.Is(err, ErrSQLiteHoldAmbiguousCandidate) { + t.Fatalf("expected ambiguous candidate error, got %v", err) + } + if err := tracker.ReleaseSQLiteHold(session); !errors.Is(err, ErrSQLiteHoldStaleSession) { + t.Fatalf("expected stale session after duplicate candidate, got %v", err) + } + if _, err := tracker.BeginSQLiteFinalization(first); !errors.Is(err, ErrSQLiteHoldNotSelected) { + t.Fatalf("expected aborted session, got %v", err) + } +} + +func TestSQLiteHoldTrackerRejectsRepeatedRelease(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(21) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, transaction) + session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil { + t.Fatal(err) + } + if err := tracker.ReleaseSQLiteHold(session); err != nil { + t.Fatal(err) + } + + // When + err = tracker.ReleaseSQLiteHold(session) + + // Then + if !errors.Is(err, ErrSQLiteHoldStaleSession) { + t.Fatalf("expected stale repeated release, got %v", err) + } +} + +func TestSQLiteHoldTrackerRejectsReleaseBeforeFinalization(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + + // When + err = tracker.ReleaseSQLiteHold(session) + + // Then + if !errors.Is(err, ErrSQLiteHoldFinalizationNotStarted) { + t.Fatalf("expected finalization-not-started error, got %v", err) + } +} + +func TestSQLiteHoldTrackerRejectsAbortAfterRelease(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(14) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, transaction) + session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + finalization, err := tracker.BeginSQLiteFinalization(transaction) + if err != nil { + t.Fatal(err) + } + if err := tracker.ReleaseSQLiteHold(session); err != nil { + t.Fatal(err) + } + + // When + err = tracker.AbortSQLiteHold(session) + + // Then + if !errors.Is(err, ErrSQLiteHoldStaleSession) { + t.Fatalf("expected stale session error, got %v", err) + } + if err := finalization.Wait(); err != nil { + t.Fatal(err) + } +} + +func TestSQLiteHoldTrackerIgnoresFutureCandidateAfterRelease(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + first := sqliteHoldTestTransaction(15) + if err := tracker.BeginSQLiteTransaction(first); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, first) + session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + finalization, err := tracker.BeginSQLiteFinalization(first) + if err != nil { + t.Fatal(err) + } + if err := tracker.ReleaseSQLiteHold(session); err != nil { + t.Fatal(err) + } + second := sqliteHoldTestTransaction(16) + if err := tracker.BeginSQLiteTransaction(second); err != nil { + t.Fatal(err) + } + + // When + err = tracker.RecordSQLiteUpdate(second, SQLiteUpdateObservation{ + Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal, + }) + + // Then + if err != nil { + t.Fatal(err) + } + if err := finalization.Wait(); err != nil { + t.Fatal(err) + } +} + +func TestSQLiteHoldTrackerDoesNotMatchJournalWithDifferentMountOrBirthTime(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + journal := SQLiteJournalIdentity{MountID: 2, DeviceMajor: 8, DeviceMinor: 1, Inode: 13, BirthSeconds: 10, BirthNanoseconds: 20} + transaction := sqliteHoldTestTransaction(17) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + + // When + if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{ + Operation: SQLiteOperationInsert, Table: "settings", Journal: SQLiteJournalIdentity{ + MountID: 3, DeviceMajor: 8, DeviceMinor: 1, Inode: 13, BirthSeconds: 10, BirthNanoseconds: 20, + }, + }); err != nil { + t.Fatal(err) + } + session, err := tracker.ArmSQLiteHold(journal) + + // Then + if err != nil { + t.Fatal(err) + } + if _, err := tracker.BeginSQLiteFinalization(transaction); !errors.Is(err, ErrSQLiteHoldNotSelected) { + t.Fatalf("expected mount mismatch not to select, got %v", err) + } + if err := tracker.AbortSQLiteHold(session); err != nil { + t.Fatal(err) + } +} + +func TestSQLiteHoldTrackerDoesNotMatchJournalWithDifferentBirthTime(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + journal := SQLiteJournalIdentity{MountID: 2, DeviceMajor: 8, DeviceMinor: 1, Inode: 13, BirthSeconds: 10, BirthNanoseconds: 20} + transaction := sqliteHoldTestTransaction(18) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + + // When + if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{ + Operation: SQLiteOperationDelete, Table: "settings", Journal: SQLiteJournalIdentity{ + MountID: 2, DeviceMajor: 8, DeviceMinor: 1, Inode: 13, BirthSeconds: 10, BirthNanoseconds: 21, + }, + }); err != nil { + t.Fatal(err) + } + session, err := tracker.ArmSQLiteHold(journal) + + // Then + if err != nil { + t.Fatal(err) + } + if _, err := tracker.BeginSQLiteFinalization(transaction); !errors.Is(err, ErrSQLiteHoldNotSelected) { + t.Fatalf("expected birth-time mismatch not to select, got %v", err) + } + if err := tracker.AbortSQLiteHold(session); err != nil { + t.Fatal(err) + } +} + +func TestSQLiteHoldTrackerRejectsDuplicateTransactionIdentity(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(19) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + + // When + err := tracker.BeginSQLiteTransaction(transaction) + + // Then + if !errors.Is(err, ErrSQLiteHoldTransactionActive) { + t.Fatalf("expected transaction-active error, got %v", err) + } +} + +func TestSQLiteHoldTrackerSelectsDeleteOperation(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(20) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{ + Operation: SQLiteOperationDelete, Table: "settings", Journal: sqliteHoldTestJournal, + }); err != nil { + t.Fatal(err) + } + + // When + session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + finalization, finalizationErr := tracker.BeginSQLiteFinalization(transaction) + + // Then + if err != nil || finalizationErr != nil { + t.Fatalf("arm=%v finalization=%v", err, finalizationErr) + } + if err := tracker.ReleaseSQLiteHold(session); err != nil { + t.Fatal(err) + } + if err := finalization.Wait(); err != nil { + t.Fatal(err) + } +} diff --git a/service/singleton/sqlite_hold_tracker_terminal_arbiter_agentcompat_linux.go b/service/singleton/sqlite_hold_tracker_terminal_arbiter_agentcompat_linux.go new file mode 100644 index 00000000..5fb5c133 --- /dev/null +++ b/service/singleton/sqlite_hold_tracker_terminal_arbiter_agentcompat_linux.go @@ -0,0 +1,29 @@ +//go:build agentcompat && linux + +package singleton + +type sqliteAttributionTerminalArbitration uint8 + +const ( + sqliteAttributionTerminalNoReservation sqliteAttributionTerminalArbitration = iota + sqliteAttributionTerminalReleaseWon + sqliteAttributionTerminalRollbackGranted + sqliteAttributionTerminalRollbackReserved +) + +func (tracker *SQLiteHoldTracker) ArbitrateSQLiteCommitWaitingTerminal(transaction SQLiteTransaction) sqliteAttributionTerminalArbitration { + tracker.mu.Lock() + defer tracker.mu.Unlock() + if !tracker.activeSessionHasSelectedTransactionLocked(transaction) { + if _, reserved := tracker.terminalArbitrations[transaction]; reserved { + return sqliteAttributionTerminalRollbackReserved + } + return sqliteAttributionTerminalNoReservation + } + if tracker.session.released { + return sqliteAttributionTerminalReleaseWon + } + tracker.abortSQLiteHoldLocked(ErrSQLiteHoldAborted) + tracker.terminalArbitrations[transaction] = struct{}{} + return sqliteAttributionTerminalRollbackGranted +} diff --git a/service/singleton/sqlite_hold_tracker_types_agentcompat_linux.go b/service/singleton/sqlite_hold_tracker_types_agentcompat_linux.go new file mode 100644 index 00000000..6ce57ee6 --- /dev/null +++ b/service/singleton/sqlite_hold_tracker_types_agentcompat_linux.go @@ -0,0 +1,169 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "errors" + "sync" + "sync/atomic" +) + +var ( + ErrSQLiteHoldSessionActive = errors.New("sqlite hold session already active") + ErrSQLiteHoldAmbiguousCandidate = errors.New("sqlite hold has multiple matching candidates") + ErrSQLiteHoldNotSelected = errors.New("sqlite hold transaction is not selected") + ErrSQLiteHoldFinalizationStarted = errors.New("sqlite hold finalization already started") + ErrSQLiteHoldFinalizationNotStarted = errors.New("sqlite hold finalization not started") + ErrSQLiteHoldStaleSession = errors.New("sqlite hold session is stale") + ErrSQLiteHoldAborted = errors.New("sqlite hold session aborted") + ErrSQLiteHoldTransactionActive = errors.New("sqlite hold transaction already active") + ErrSQLiteHoldAtomicWriteRecorded = errors.New("sqlite hold atomic write already recorded") + ErrSQLiteHoldEvidenceFrozen = errors.New("sqlite hold selected evidence is frozen") + ErrSQLiteHoldInvalidWaitTarget = errors.New("sqlite hold wait target is invalid") +) + +type SQLiteConnectionIdentity uint64 +type SQLiteTransactionIdentity uint64 + +type SQLiteJournalIdentity struct { + MountID uint64 + DeviceMajor uint32 + DeviceMinor uint32 + Inode uint64 + BirthSeconds int64 + BirthNanoseconds uint32 +} + +type SQLiteOperation string + +const ( + SQLiteOperationInsert SQLiteOperation = "insert" + SQLiteOperationUpdate SQLiteOperation = "update" + SQLiteOperationDelete SQLiteOperation = "delete" +) + +type SQLiteTransaction struct { + Connection SQLiteConnectionIdentity + Identity SQLiteTransactionIdentity +} + +type SQLiteExecutionOrigin struct { + Operation SQLiteOperation + Table string + StackHash uint64 + FirstNezhaFrame string +} + +type SQLiteUpdateObservation struct { + Operation SQLiteOperation + Table string + Journal SQLiteJournalIdentity +} + +type SQLiteWriteObservation struct { + Origin SQLiteExecutionOrigin + Update SQLiteUpdateObservation +} + +type SQLiteHoldSelectionMode uint8 + +const ( + SQLiteHoldSelectionModeKnownJournal SQLiteHoldSelectionMode = iota + 1 + SQLiteHoldSelectionModeNextWriter +) + +type SQLiteHoldSession struct{ identity uint64 } + +func (session SQLiteHoldSession) ID() uint64 { return session.identity } + +type SQLiteHoldSnapshot struct { + SessionID uint64 + Mode SQLiteHoldSelectionMode + Selected bool + Transaction SQLiteTransaction + Operation SQLiteOperation + Table string + StackHash uint64 + FirstNezhaFrame string + Journal SQLiteJournalIdentity + Finalizing bool + Released bool + Aborted bool +} + +type SQLiteHoldFinalization struct { + done chan struct{} + err error +} + +type SQLiteHoldWaitTarget uint8 + +const ( + SQLiteHoldWaitSelected SQLiteHoldWaitTarget = iota + 1 + SQLiteHoldWaitFinalizing +) + +func (finalization *SQLiteHoldFinalization) Wait() error { + <-finalization.done + return finalization.err +} + +func (finalization *SQLiteHoldFinalization) Released() bool { + select { + case <-finalization.done: + return finalization.err == nil + default: + return false + } +} + +type sqliteHeldTransaction struct { + transaction SQLiteTransaction + origin SQLiteExecutionOrigin + update SQLiteUpdateObservation + hasUpdate bool + write SQLiteWriteObservation + hasWrite bool + finalizing bool +} + +type sqliteHoldSessionState struct { + id SQLiteHoldSession + mode SQLiteHoldSelectionMode + journal SQLiteJournalIdentity + selected SQLiteTransaction + hasSelected bool + released bool + finalization *SQLiteHoldFinalization + notify chan struct{} +} + +type sqliteHoldTerminalState struct { + id SQLiteHoldSession + selected SQLiteTransaction + hasSelected bool + released bool + cause error +} + +// SQLiteHoldTracker linearizes state transitions; finalization waits only after releasing mu. +type SQLiteHoldTracker struct { + mu sync.Mutex + attributionEnabled *atomic.Bool + nextSession uint64 + transactions map[SQLiteTransaction]*sqliteHeldTransaction + session *sqliteHoldSessionState + terminal *sqliteHoldTerminalState + causes map[SQLiteTransaction]error + terminalArbitrations map[SQLiteTransaction]struct{} +} + +func NewSQLiteHoldTracker() *SQLiteHoldTracker { + return &SQLiteHoldTracker{transactions: make(map[SQLiteTransaction]*sqliteHeldTransaction), causes: make(map[SQLiteTransaction]error), terminalArbitrations: make(map[SQLiteTransaction]struct{})} +} + +func newSQLiteAttributionHoldTracker(enabled *atomic.Bool) *SQLiteHoldTracker { + tracker := NewSQLiteHoldTracker() + tracker.attributionEnabled = enabled + return tracker +} diff --git a/service/singleton/sqlite_hold_tracker_unbound_agentcompat_linux_test.go b/service/singleton/sqlite_hold_tracker_unbound_agentcompat_linux_test.go new file mode 100644 index 00000000..cde49fba --- /dev/null +++ b/service/singleton/sqlite_hold_tracker_unbound_agentcompat_linux_test.go @@ -0,0 +1,149 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "errors" + "testing" +) + +func TestSQLiteHoldTrackerNextWriterSelectsFutureUpdateAndPublishesSnapshot(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + before, err := tracker.SQLiteHoldSnapshot(session) + if err != nil { + t.Fatal(err) + } + transaction := sqliteHoldTestTransaction(22) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + + // When + if err := tracker.RecordSQLiteExecution(transaction, SQLiteExecutionOrigin{ + Operation: SQLiteOperationInsert, Table: "settings", StackHash: 34, FirstNezhaFrame: "singleton.next", + }); err != nil { + t.Fatal(err) + } + if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{ + Operation: SQLiteOperationInsert, Table: "settings", Journal: sqliteHoldTestJournal, + }); err != nil { + t.Fatal(err) + } + after, err := tracker.SQLiteHoldSnapshot(session) + + // Then + if err != nil { + t.Fatal(err) + } + if before.SessionID != session.ID() || before.Mode != SQLiteHoldSelectionModeNextWriter || before.Selected { + t.Fatalf("unexpected unselected snapshot: %+v", before) + } + if !after.Selected || after.Transaction != transaction || after.Operation != SQLiteOperationInsert || after.Table != "settings" || + after.StackHash != 34 || after.FirstNezhaFrame != "singleton.next" || after.Journal != sqliteHoldTestJournal { + t.Fatalf("unexpected selected snapshot: %+v", after) + } +} + +func TestSQLiteHoldTrackerNextWriterSelectsOneActiveUpdate(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(23) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, transaction) + + // When + session, err := tracker.ArmNextSQLiteHold() + snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session) + + // Then + if err != nil || snapshotErr != nil { + t.Fatalf("arm=%v snapshot=%v", err, snapshotErr) + } + if !snapshot.Selected || snapshot.Journal != sqliteHoldTestJournal { + t.Fatalf("unexpected active selection snapshot: %+v", snapshot) + } +} + +func TestSQLiteHoldTrackerNextWriterAbortsMultipleActiveUpdates(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + for _, transaction := range []SQLiteTransaction{sqliteHoldTestTransaction(24), sqliteHoldTestTransaction(25)} { + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, transaction) + } + + // When + _, err := tracker.ArmNextSQLiteHold() + + // Then + if !errors.Is(err, ErrSQLiteHoldAmbiguousCandidate) { + t.Fatalf("expected ambiguous active updates, got %v", err) + } +} + +func TestSQLiteHoldTrackerNextWriterAbortsDuplicateFutureUpdate(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + first := sqliteHoldTestTransaction(26) + second := sqliteHoldTestTransaction(27) + for _, transaction := range []SQLiteTransaction{first, second} { + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + } + if err := tracker.RecordSQLiteUpdate(first, SQLiteUpdateObservation{ + Operation: SQLiteOperationDelete, Table: "settings", Journal: sqliteHoldTestJournal, + }); err != nil { + t.Fatal(err) + } + + // When + err = tracker.RecordSQLiteUpdate(second, SQLiteUpdateObservation{ + Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal, + }) + + // Then + if !errors.Is(err, ErrSQLiteHoldAmbiguousCandidate) { + t.Fatalf("expected ambiguous future updates, got %v", err) + } + if _, err := tracker.SQLiteHoldSnapshot(session); !errors.Is(err, ErrSQLiteHoldStaleSession) { + t.Fatalf("expected stale session after duplicate future update, got %v", err) + } +} + +func TestSQLiteHoldTrackerNextWriterRequiresFinalizationBeforeReleaseAndStalesAfterAbort(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + + // When + releaseErr := tracker.ReleaseSQLiteHold(session) + abortErr := tracker.AbortSQLiteHold(session) + + // Then + if !errors.Is(releaseErr, ErrSQLiteHoldFinalizationNotStarted) { + t.Fatalf("expected release rejection, got %v", releaseErr) + } + if abortErr != nil { + t.Fatal(abortErr) + } + if _, err := tracker.SQLiteHoldSnapshot(session); !errors.Is(err, ErrSQLiteHoldStaleSession) { + t.Fatalf("expected stale aborted session, got %v", err) + } +} diff --git a/service/singleton/sqlite_hold_tracker_wait_agentcompat_linux_test.go b/service/singleton/sqlite_hold_tracker_wait_agentcompat_linux_test.go new file mode 100644 index 00000000..612feed7 --- /dev/null +++ b/service/singleton/sqlite_hold_tracker_wait_agentcompat_linux_test.go @@ -0,0 +1,238 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "context" + "errors" + "testing" +) + +func TestSQLiteHoldTrackerWaitSelectedAndFinalizingObserveTransitionsWithoutLostWakeup(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + transaction := sqliteHoldTestTransaction(61) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, transaction) + + // When + selected, selectedErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitSelected) + if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil { + t.Fatal(err) + } + finalizing, finalizingErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing) + + // Then + if selectedErr != nil || !selected.Selected { + t.Fatalf("selected=%+v err=%v", selected, selectedErr) + } + if finalizingErr != nil || !finalizing.Finalizing { + t.Fatalf("finalizing=%+v err=%v", finalizing, finalizingErr) + } +} + +func TestSQLiteHoldTrackerWaitCancellationLeavesSessionActive(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // When + _, waitErr := tracker.WaitSQLiteHold(ctx, session, SQLiteHoldWaitSelected) + snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session) + + // Then + if !errors.Is(waitErr, context.Canceled) { + t.Fatalf("wait error = %v, want context cancellation", waitErr) + } + if snapshotErr != nil || snapshot.Selected { + t.Fatalf("snapshot=%+v err=%v", snapshot, snapshotErr) + } +} + +func TestSQLiteHoldTrackerWaitReturnsTerminalAbortAndAmbiguityCauses(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + abortResult := make(chan error, 1) + go func() { + _, waitErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitSelected) + abortResult <- waitErr + }() + + // When + if err := tracker.AbortSQLiteHold(session); err != nil { + t.Fatal(err) + } + abortErr := <-abortResult + if _, err := tracker.ArmNextSQLiteHold(); err != nil { + t.Fatal(err) + } + first, second := sqliteHoldTestTransaction(62), sqliteHoldTestTransaction(63) + for _, transaction := range []SQLiteTransaction{first, second} { + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + } + if err := tracker.RecordSQLiteUpdate(first, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal}); err != nil { + t.Fatal(err) + } + ambiguityErr := tracker.RecordSQLiteUpdate(second, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal}) + + // Then + if !errors.Is(abortErr, ErrSQLiteHoldAborted) { + t.Fatalf("abort waiter error = %v", abortErr) + } + if !errors.Is(ambiguityErr, ErrSQLiteHoldAmbiguousCandidate) { + t.Fatalf("ambiguity error = %v", ambiguityErr) + } + if _, lookupErr := tracker.BeginSQLiteCommitFinalization(first); !errors.Is(lookupErr, ErrSQLiteHoldAmbiguousCandidate) { + t.Fatalf("ambiguity lookup error = %v", lookupErr) + } +} + +func TestSQLiteHoldTrackerCommitFinalizationReturnsSelectedAbortCause(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(64) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, transaction) + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + if err := tracker.AbortSQLiteHold(session); err != nil { + t.Fatal(err) + } + + // When + _, selectedErr := tracker.BeginSQLiteCommitFinalization(transaction) + _, unselectedErr := tracker.BeginSQLiteCommitFinalization(sqliteHoldTestTransaction(65)) + + // Then + if !errors.Is(selectedErr, ErrSQLiteHoldAborted) { + t.Fatalf("selected aborted lookup error = %v", selectedErr) + } + if !errors.Is(unselectedErr, ErrSQLiteHoldNotSelected) { + t.Fatalf("unselected lookup error = %v", unselectedErr) + } +} + +func TestSQLiteHoldTrackerCommitFinalizationPreservesCauseAcrossLaterLifecycle(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + first := sqliteHoldTestTransaction(70) + if err := tracker.BeginSQLiteTransaction(first); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, first) + firstSession, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + if err := tracker.AbortSQLiteHold(firstSession); err != nil { + t.Fatal(err) + } + second := sqliteHoldTestTransaction(71) + if err := tracker.BeginSQLiteTransaction(second); err != nil { + t.Fatal(err) + } + secondJournal := SQLiteJournalIdentity{Inode: 72} + if err := tracker.RecordSQLiteUpdate(second, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: secondJournal}); err != nil { + t.Fatal(err) + } + secondSession, err := tracker.ArmSQLiteHold(secondJournal) + if err != nil { + t.Fatal(err) + } + if err := tracker.AbortSQLiteHold(secondSession); err != nil { + t.Fatal(err) + } + + // When + _, lookupErr := tracker.BeginSQLiteCommitFinalization(first) + + // Then + if !errors.Is(lookupErr, ErrSQLiteHoldAborted) { + t.Fatalf("first lifecycle abort cause after second lifecycle = %v", lookupErr) + } +} + +func TestSQLiteHoldTrackerWaitSelectedReturnsAbortWhenSelectedTransactionFinishes(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(68) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, transaction) + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + result := make(chan error, 1) + go func() { + _, waitErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing) + result <- waitErr + }() + + // When + if err := tracker.FinishSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + + // Then + if waitErr := <-result; !errors.Is(waitErr, ErrSQLiteHoldAborted) { + t.Fatalf("finished selected transaction wait error = %v", waitErr) + } +} + +func TestSQLiteHoldTrackerWaitTreatsFinishedReleasedSessionAsSelectedAndFinalizing(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(69) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + recordSQLiteHoldTestUpdate(t, tracker, transaction) + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil { + t.Fatal(err) + } + if err := tracker.ReleaseSQLiteHold(session); err != nil { + t.Fatal(err) + } + if err := tracker.FinishSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + + // When + selected, selectedErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitSelected) + finalizing, finalizingErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing) + + // Then + if selectedErr != nil || !selected.Released || !selected.Selected { + t.Fatalf("selected=%+v err=%v", selected, selectedErr) + } + if finalizingErr != nil || !finalizing.Released || !finalizing.Finalizing { + t.Fatalf("finalizing=%+v err=%v", finalizing, finalizingErr) + } +} diff --git a/service/singleton/sqlite_hold_tracker_write_agentcompat_linux_test.go b/service/singleton/sqlite_hold_tracker_write_agentcompat_linux_test.go new file mode 100644 index 00000000..db669b9b --- /dev/null +++ b/service/singleton/sqlite_hold_tracker_write_agentcompat_linux_test.go @@ -0,0 +1,215 @@ +//go:build agentcompat && linux + +package singleton + +import ( + "errors" + "testing" +) + +func sqliteHoldTestWrite(operation SQLiteOperation, table string, stackHash uint64, journal SQLiteJournalIdentity) SQLiteWriteObservation { + return SQLiteWriteObservation{ + Origin: SQLiteExecutionOrigin{Operation: operation, Table: table, StackHash: stackHash, FirstNezhaFrame: "singleton.write"}, + Update: SQLiteUpdateObservation{Operation: operation, Table: table, Journal: journal}, + } +} + +func TestSQLiteHoldTrackerRecordsAtomicFirstWriteAttribution(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(41) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + + // When + err = tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationInsert, "settings", 51, sqliteHoldTestJournal)) + snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session) + + // Then + if err != nil || snapshotErr != nil { + t.Fatalf("write=%v snapshot=%v", err, snapshotErr) + } + if !snapshot.Selected || snapshot.Transaction != transaction || snapshot.Operation != SQLiteOperationInsert || + snapshot.Table != "settings" || snapshot.StackHash != 51 || snapshot.FirstNezhaFrame != "singleton.write" || + snapshot.Journal != sqliteHoldTestJournal { + t.Fatalf("unexpected atomic snapshot: %+v", snapshot) + } +} + +func TestSQLiteHoldTrackerPreservesFirstWriteForRepeatedSameTransaction(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(42) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + if err := tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationUpdate, "first", 52, sqliteHoldTestJournal)); err != nil { + t.Fatal(err) + } + + // When + err = tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationDelete, "later", 53, SQLiteJournalIdentity{Inode: 98})) + snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session) + + // Then + if err != nil || snapshotErr != nil { + t.Fatalf("repeat=%v snapshot=%v", err, snapshotErr) + } + if snapshot.Operation != SQLiteOperationUpdate || snapshot.Table != "first" || snapshot.StackHash != 52 || snapshot.Journal != sqliteHoldTestJournal { + t.Fatalf("repeated callback changed first write: %+v", snapshot) + } +} + +func TestSQLiteHoldTrackerAbortsDifferentAtomicWriterBeforeRelease(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + first, second := sqliteHoldTestTransaction(43), sqliteHoldTestTransaction(44) + for _, transaction := range []SQLiteTransaction{first, second} { + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + } + if err := tracker.RecordSQLiteWrite(first, sqliteHoldTestWrite(SQLiteOperationInsert, "first", 54, sqliteHoldTestJournal)); err != nil { + t.Fatal(err) + } + + // When + err = tracker.RecordSQLiteWrite(second, sqliteHoldTestWrite(SQLiteOperationDelete, "second", 55, sqliteHoldTestJournal)) + + // Then + if !errors.Is(err, ErrSQLiteHoldAmbiguousCandidate) { + t.Fatalf("expected ambiguity, got %v", err) + } + if _, err := tracker.SQLiteHoldSnapshot(session); !errors.Is(err, ErrSQLiteHoldStaleSession) { + t.Fatalf("expected stale aborted session, got %v", err) + } +} + +func TestSQLiteHoldTrackerKnownJournalMakesFirstMismatchedWriteIneligible(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(45) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal) + if err != nil { + t.Fatal(err) + } + if err := tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationUpdate, "wrong", 56, SQLiteJournalIdentity{Inode: 97})); err != nil { + t.Fatal(err) + } + + // When + err = tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationUpdate, "matching", 57, sqliteHoldTestJournal)) + snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session) + + // Then + if err != nil || snapshotErr != nil { + t.Fatalf("later write=%v snapshot=%v", err, snapshotErr) + } + if snapshot.Selected || snapshot.Journal != sqliteHoldTestJournal { + t.Fatalf("mismatched first write selected session: %+v", snapshot) + } +} + +func TestSQLiteHoldTrackerRejectsAtomicWriteAfterFinalization(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(46) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + session, err := tracker.ArmNextSQLiteHold() + if err != nil { + t.Fatal(err) + } + first := sqliteHoldTestWrite(SQLiteOperationInsert, "first", 58, sqliteHoldTestJournal) + if err := tracker.RecordSQLiteWrite(transaction, first); err != nil { + t.Fatal(err) + } + if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil { + t.Fatal(err) + } + + // When + err = tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationDelete, "later", 59, SQLiteJournalIdentity{Inode: 96})) + snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session) + + // Then + if !errors.Is(err, ErrSQLiteHoldFinalizationStarted) || snapshotErr != nil { + t.Fatalf("write=%v snapshot=%v", err, snapshotErr) + } + if snapshot.Operation != first.Update.Operation || snapshot.Table != first.Update.Table || snapshot.StackHash != first.Origin.StackHash || snapshot.Journal != first.Update.Journal { + t.Fatalf("finalizing atomic write changed snapshot: %+v", snapshot) + } +} + +func TestSQLiteHoldTrackerRejectsLegacyWritesAfterAtomicFirstWrite(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(47) + if err := tracker.BeginSQLiteTransaction(transaction); err != nil { + t.Fatal(err) + } + journalA := sqliteHoldTestJournal + journalB := SQLiteJournalIdentity{Inode: 95} + session, err := tracker.ArmSQLiteHold(journalA) + if err != nil { + t.Fatal(err) + } + first := sqliteHoldTestWrite(SQLiteOperationInsert, "atomic", 60, journalB) + if err := tracker.RecordSQLiteWrite(transaction, first); err != nil { + t.Fatal(err) + } + + // When + executionErr := tracker.RecordSQLiteExecution(transaction, SQLiteExecutionOrigin{ + Operation: SQLiteOperationUpdate, Table: "legacy", StackHash: 61, FirstNezhaFrame: "singleton.legacy", + }) + updateErr := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{ + Operation: SQLiteOperationUpdate, Table: "legacy", Journal: journalA, + }) + snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session) + + // Then + if !errors.Is(executionErr, ErrSQLiteHoldAtomicWriteRecorded) || !errors.Is(updateErr, ErrSQLiteHoldAtomicWriteRecorded) { + t.Fatalf("execution=%v update=%v", executionErr, updateErr) + } + if snapshotErr != nil { + t.Fatal(snapshotErr) + } + if snapshot.Selected || snapshot.Operation != "" || snapshot.Journal != journalA { + t.Fatalf("legacy write selected mismatched atomic transaction: %+v", snapshot) + } + if _, err := tracker.BeginSQLiteFinalization(transaction); !errors.Is(err, ErrSQLiteHoldNotSelected) { + t.Fatalf("expected mismatched atomic transaction to remain unselected, got %v", err) + } +} + +func TestSQLiteHoldTrackerRejectsAtomicWriteForUnregisteredTransaction(t *testing.T) { + // Given + tracker := NewSQLiteHoldTracker() + transaction := sqliteHoldTestTransaction(48) + + // When + err := tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationInsert, "settings", 62, sqliteHoldTestJournal)) + + // Then + if !errors.Is(err, ErrSQLiteHoldNotSelected) { + t.Fatalf("expected unregistered atomic write rejection, got %v", err) + } +}