88 * 1. Clean crash — replay must recover every row inserted before SIGKILL
99 * 2. Truncated tails — last bytes of each txnlog stripped (simulates a torn
1010 * write at the moment of crash); replay must still come back up
11- * 3. Random byte flips — msgpack-shaped corruption sprinkled across every
12- * txnlog; replay must finish without the CPU-spin regression that
13- * replayLogsGuards.ts (commit d0190ff5a) fixed
11+ * 3. Corrupt length prefix — the first entry's declared length is forced to
12+ * overrun the log (a torn/corrupt frame). rocksdb-js's reader throws a bounded
13+ * RangeError for this; replay/broadcast must treat it as end-of-log and the
14+ * server must come back up rather than aborting startup (HarperFast/harper#1135)
1415 *
1516 * All three scenarios share one suite/ctx to avoid the schema-registry leak
1617 * that surfaces when multiple suites each call create_database in the same
1718 * test-runner process.
1819 */
1920import { suite , test , before , after } from 'node:test' ;
2021import { ok , equal } from 'node:assert/strict' ;
21- import { readdirSync , statSync , openSync , readSync , writeSync , truncateSync , closeSync } from 'node:fs' ;
22+ import { readdirSync , readFileSync , statSync , openSync , writeSync , truncateSync , closeSync } from 'node:fs' ;
2223import { join } from 'node:path' ;
23- import { setTimeout as sleep } from 'node:timers/promises' ;
2424
2525import {
2626 startHarper ,
@@ -29,6 +29,12 @@ import {
2929 type ContextWithHarper ,
3030 type HarperContext ,
3131} from '@harperfast/integration-testing' ;
32+ import { constants } from '@harperfast/rocksdb-js' ;
33+
34+ // Transaction-log framing (all big-endian): a fixed-size file header, then a run of entries
35+ // each shaped [float64 timestamp][uint32 length][flags byte][length bytes of data]. So an
36+ // entry's declared length lives at entryStart + 8, and the byte there is its most-significant.
37+ const { TRANSACTION_LOG_FILE_HEADER_SIZE , TRANSACTION_LOG_ENTRY_HEADER_SIZE } = constants ;
3238
3339const DB = 'stress' ;
3440const TABLES = [ 'orders' , 'items' , 'events' ] ;
@@ -128,26 +134,30 @@ function truncateTail(path: string, bytes: number) {
128134 truncateSync ( path , size - bytes ) ;
129135}
130136
131- function flipBytes ( path : string , count : number , seed : number ) {
132- const size = statSync ( path ) . size ;
133- // Skip the 13-byte file header (4 token + 1 version + 8 ts) so we exercise
134- // the per-entry decoder hardening, not file-open validation.
135- const start = 13 ;
136- if ( size <= start + 32 ) return ;
137+ function corruptLastEntryLength ( path : string ) : boolean {
138+ // Force the *last* well-framed entry's big-endian uint32 length to overrun the log (top
139+ // byte → 0xff, ≥ 4 GB). The last entry sits in the unflushed tail that replay reads
140+ // (replay starts from the last-flushed position), so a flushed prefix isn't skipped over.
141+ const buf = readFileSync ( path ) ;
142+ let pos = TRANSACTION_LOG_FILE_HEADER_SIZE ;
143+ let lastLengthPos = - 1 ;
144+ while ( pos + TRANSACTION_LOG_ENTRY_HEADER_SIZE <= buf . length ) {
145+ if ( buf . readDoubleBE ( pos ) === 0 ) break ; // a zero timestamp marks end-of-log to the reader
146+ const lengthPos = pos + 8 ;
147+ const length = buf . readUInt32BE ( lengthPos ) ;
148+ const next = pos + TRANSACTION_LOG_ENTRY_HEADER_SIZE + length ;
149+ if ( length === 0 || next > buf . length ) break ; // ran past the end / already unframable
150+ lastLengthPos = lengthPos ;
151+ pos = next ;
152+ }
153+ if ( lastLengthPos < 0 ) return false ;
137154 const fd = openSync ( path , 'r+' ) ;
138155 try {
139- const buf = Buffer . alloc ( 1 ) ;
140- let s = seed ;
141- for ( let i = 0 ; i < count ; i ++ ) {
142- s = ( s * 1103515245 + 12345 ) & 0x7fffffff ;
143- const pos = start + ( s % ( size - start ) ) ;
144- readSync ( fd , buf , 0 , 1 , pos ) ;
145- buf [ 0 ] ^= 0xa5 ;
146- writeSync ( fd , buf , 0 , 1 , pos ) ;
147- }
156+ writeSync ( fd , Buffer . from ( [ 0xff ] ) , 0 , 1 , lastLengthPos ) ;
148157 } finally {
149158 closeSync ( fd ) ;
150159 }
160+ return true ;
151161}
152162
153163async function crashAndRestart ( ctx : ContextWithHarper , mutate ?: ( dataRootDir : string ) => void ) {
@@ -197,37 +207,27 @@ suite('Transaction log replay stress', (ctx: ContextWithHarper) => {
197207 }
198208 } ) ;
199209
200- test ( 'crash with random byte flips in txnlogs ' , async ( ) => {
210+ test ( 'crash with corrupt length-prefix recovers without aborting startup ' , async ( ) => {
201211 for ( const table of TABLES ) {
202212 const records = [ ] ;
203213 for ( let i = 0 ; i < 500 ; i ++ ) records . push ( makeRecord ( 200_000 + i ) ) ;
204214 await op ( ctx . harper , { operation : 'insert' , database : DB , table, records } ) ;
205215 }
206- const replayMs = await crashAndRestart ( ctx , ( dataRootDir ) => {
207- // User-DB only: flipping bytes inside system/ txnlogs corrupts
208- // version-tracking and Harper aborts on next boot with an upgrade
209- // error. That's a real failure mode but not what this test is about —
210- // here we want to exercise replay's per-entry decode hardening on
211- // records that replay genuinely needs to skip rather than reject the
212- // whole boot.
213- const files = listTxnLogFiles ( dataRootDir , { userOnly : true } ) ;
214- files . forEach ( ( f , i ) => flipBytes ( f , 32 , 0xcafe + i ) ) ;
216+ let corrupted = 0 ;
217+ await crashAndRestart ( ctx , ( dataRootDir ) => {
218+ // User-DB only: corrupting system/ txnlogs trips the upgrade-abort path on next
219+ // boot — a different failure mode than the framing corruption (#1135) under test.
220+ for ( const f of listTxnLogFiles ( dataRootDir , { userOnly : true } ) ) {
221+ if ( corruptLastEntryLength ( f ) ) corrupted ++ ;
222+ }
215223 } ) ;
216- // Tighter than the 60s global cap. With the per-entry decode guard in place,
217- // healthy startup is ~3s on this corpus. Without it, every corrupt entry
218- // logs a stack trace inside the loop and startup balloons to ~50s. 20s
219- // catches that regression while leaving CI headroom.
220- ok ( replayMs < 20_000 , `replay took ${ replayMs } ms — guard regression?` ) ;
224+ // Fail loudly, not vacuously, if the framing ever changes and nothing gets corrupted.
225+ ok ( corrupted > 0 , 'expected to corrupt at least one user-DB txnlog' ) ;
226+ // Behavioral, not a wall-clock budget: crashAndRestart resolved → the server came back
227+ // up; a regression shows up as a failed restart, not a slow one. Confirm tables queryable.
221228 for ( const t of TABLES ) {
222229 const c = await countRows ( ctx . harper , t ) ;
223230 ok ( typeof c === 'number' && c >= 0 , `count on ${ t } should be a number, got ${ c } ` ) ;
224231 }
225- // Confirm Harper isn't in a CPU-spin loop post-replay: rss should be
226- // roughly stable after startup is reported done. The pre-fix bug pinned
227- // a core forever and rss grew steadily under the spin.
228- const rssBefore = ctx . harper . process . resourceUsage ?.( ) ?. maxRSS ?? 0 ;
229- await sleep ( 500 ) ;
230- const rssAfter = ctx . harper . process . resourceUsage ?.( ) ?. maxRSS ?? 0 ;
231- ok ( rssAfter - rssBefore < 200 * 1024 , `rss grew by ${ rssAfter - rssBefore } KB after replay` ) ;
232232 } ) ;
233233} ) ;
0 commit comments