BatterySaveManager.java

1
package com.rox.save;
2
3
import com.rox.cartridge.Cartridge;
4
5
import java.io.IOException;
6
import java.nio.file.Path;
7
import java.time.Duration;
8
import java.time.Instant;
9
import java.util.Optional;
10
import java.util.logging.Level;
11
import java.util.logging.Logger;
12
13
/**
14
 * Mirrors real battery-backed SRAM: write-through, no timer, no user action - the disk file is
15
 * updated after every write to {@code $6000-$7FFF} ({@link Cartridge#setOnPrgRamWrite}), same as a
16
 * real battery-backed chip never losing a write. The actual disk write always happens on this
17
 * class's own background thread, never the emulation thread {@link Cartridge#write} runs on,
18
 * matching {@code SpeakerAudioOutput}'s own never-block-the-emulation-thread discipline - writes
19
 * arriving faster than disk I/O naturally coalesce onto the flush thread's next loop iteration
20
 * rather than blocking or queuing unboundedly.
21
 */
22
public final class BatterySaveManager {
23
    private static final Logger log = Logger.getLogger(BatterySaveManager.class.getName());
24
25
    private final Path saveFile;
26
    private final Cartridge cartridge;
27
    private final Object lock = new Object();
28
    private final Instant createdAt;
29
    private final long baseGameTimeMillis;
30
    private final Instant sessionStart = Instant.now();
31
    private volatile boolean running;
32
    private boolean pendingWrite;
33
    //true whenever prgRam has changes not yet successfully written to disk - deliberately separate
34
    //from pendingWrite (the wake signal): pendingWrite is always cleared before attempting a flush, so
35
    //a *failed* flush leaving it set would busy-loop retrying instead of going back to sleep. dirty is
36
    //only cleared once a flush genuinely succeeds, and is only re-attempted when either a later write
37
    //wakes the loop again, or once, right before shutdown, if nothing ever did
38
    private boolean dirty;
39
    private Thread flushThread;
40
41
    private BatterySaveManager(final Path saveFile, final Cartridge cartridge, final Instant createdAt, final long baseGameTimeMillis){
42
        this.saveFile = saveFile;
43
        this.cartridge = cartridge;
44
        this.createdAt = createdAt;
45
        this.baseGameTimeMillis = baseGameTimeMillis;
46
    }
47
48
    /**
49
     * If a battery save already exists for this cartridge, loads it straight into PRG-RAM - mirrors
50
     * inserting a cartridge whose battery was already charged. No-op if none exists yet.
51
     */
52
    public static void loadIfPresent(final Path saveFile, final Cartridge cartridge){
53 2 1. lambda$loadIfPresent$0 : removed call to com/rox/cartridge/Cartridge::restorePrgRam → KILLED
2. loadIfPresent : removed call to java/util/Optional::ifPresent → KILLED
        SaveFileFormat.readBatterySave(saveFile).ifPresent(save -> cartridge.restorePrgRam(save.prgRam()));
54
    }
55
56
    /** Starts write-through persistence for {@code cartridge} to {@code saveFile}, reusing an existing save's creation timestamp/accumulated game time if one is already on disk. */
57
    public static BatterySaveManager start(final Path saveFile, final Cartridge cartridge){
58
        final Optional<BatterySaveFile> existing = SaveFileFormat.readBatterySave(saveFile);
59 1 1. lambda$start$0 : replaced return value with null for com/rox/save/BatterySaveManager::lambda$start$0 → KILLED
        final Instant createdAt = existing.map(save -> save.metadata().createdAt()).orElseGet(Instant::now);
60 1 1. lambda$start$1 : replaced Long return value with 0L for com/rox/save/BatterySaveManager::lambda$start$1 → KILLED
        final long baseGameTimeMillis = existing.map(save -> save.metadata().accumulatedGameTimeMillis()).orElse(0L);
61
62
        final BatterySaveManager manager = new BatterySaveManager(saveFile, cartridge, createdAt, baseGameTimeMillis);
63
        manager.running = true;
64 1 1. start : removed call to com/rox/cartridge/Cartridge::setOnPrgRamWrite → KILLED
        cartridge.setOnPrgRamWrite(manager::onWrite);
65
        manager.flushThread = new Thread(manager::run, "BatterySaveManager-flush");
66
        //XXX a new Thread already inherits isDaemon() from its creator - a test asserting isDaemon()
67
        //true can only distinguish this call from a genuinely non-daemon *caller*, which a mutation-
68
        //testing harness's own test-execution thread isn't guaranteed to be; accepted, environment-
69
        //dependent gap, not a real behavioral difference `./gradlew test` itself would ever exercise
70 1 1. start : removed call to java/lang/Thread::setDaemon → SURVIVED
        manager.flushThread.setDaemon(true);
71 1 1. start : removed call to java/lang/Thread::start → KILLED
        manager.flushThread.start();
72 1 1. start : replaced return value with null for com/rox/save/BatterySaveManager::start → KILLED
        return manager;
73
    }
74
75
    private void onWrite(){
76
        synchronized (lock){
77
            pendingWrite = true;
78
            dirty = true;
79 1 1. onWrite : removed call to java/lang/Object::notifyAll → KILLED
            lock.notifyAll();
80
        }
81
    }
82
83
    private void run(){
84
        while (true){
85
            synchronized (lock){
86 2 1. run : negated conditional → TIMED_OUT
2. run : negated conditional → KILLED
                while (running && !pendingWrite){
87
                    try {
88 1 1. run : removed call to java/lang/Object::wait → TIMED_OUT
                        lock.wait();
89
                    } catch (InterruptedException e){
90
                        //this thread isn't exposed for a test to interrupt directly, unlike stop()'s
91
                        //own join() below (interruptible via the *calling* thread) - accepted gap, same
92
                        //category as NES.powerOn()'s own defensive interrupt-restore branches
93 1 1. run : removed call to java/lang/Thread::interrupt → NO_COVERAGE
                        Thread.currentThread().interrupt();
94
                    }
95
                }
96 2 1. run : negated conditional → TIMED_OUT
2. run : negated conditional → TIMED_OUT
                if (!running && !pendingWrite){
97 1 1. run : negated conditional → KILLED
                    if (dirty){
98
                        //shutting down with an earlier flush failure that nothing ever retried - one
99
                        //last attempt, not an unbounded retry loop
100 1 1. run : removed call to com/rox/save/BatterySaveManager::flush → KILLED
                        flush();
101
                    }
102
                    return;
103
                }
104
                pendingWrite = false;
105
            }
106 1 1. run : removed call to com/rox/save/BatterySaveManager::flush → KILLED
            flush();
107
        }
108
    }
109
110
    private void flush(){
111
        final int[] prgRam = cartridge.prgRam();
112 1 1. flush : Replaced long addition with subtraction → KILLED
        final long gameTimeMillis = baseGameTimeMillis + Duration.between(sessionStart, Instant.now()).toMillis();
113
        final SaveMetadata metadata = new SaveMetadata(SaveType.BATTERY_PRG_RAM, createdAt, gameTimeMillis);
114
        try {
115 1 1. flush : removed call to com/rox/save/SaveFileFormat::writeBatterySave → KILLED
            SaveFileFormat.writeBatterySave(saveFile, metadata, prgRam);
116
            synchronized (lock){
117
                dirty = false;
118
            }
119
        } catch (IOException e){
120
            log.log(Level.WARNING, "Could not write battery save to " + saveFile, e);
121
        }
122
    }
123
124
    /**
125
     * Stops write-through persistence, flushing one last time first if a write was still pending or an
126
     * earlier flush had failed. Never returns while that final flush is still in progress - retries the
127
     * join until the flush thread has genuinely terminated, the same interrupt-retry pattern
128
     * {@code NES.powerOn()} uses for its own lifecycle-critical thread, rather than a single bounded
129
     * join that could return early and let JVM shutdown kill a daemon thread mid-write.
130
     */
131
    public void stop(){
132
        synchronized (lock){
133
            running = false;
134 1 1. stop : removed call to java/lang/Object::notifyAll → TIMED_OUT
            lock.notifyAll();
135
        }
136
        boolean interrupted = false;
137
        //XXX removing the join() call itself is a hard-to-kill mutant: isAlive() alone still converges
138
        //to the same observable outcome (busy-spinning instead of blocking until the thread is genuinely
139
        //dead) - accepted, matches NES.powerOn()'s own identically-shaped retry-join loop
140 1 1. stop : negated conditional → KILLED
        while (flushThread.isAlive()){
141
            try {
142 1 1. stop : removed call to java/lang/Thread::join → SURVIVED
                flushThread.join();
143
            } catch (InterruptedException e){
144
                interrupted = true;
145
            }
146
        }
147 1 1. stop : negated conditional → KILLED
        if (interrupted){
148 1 1. stop : removed call to java/lang/Thread::interrupt → KILLED
            Thread.currentThread().interrupt();
149
        }
150
    }
151
152
    /** Test seam: direct access to this instance's own flush thread - avoids a name-based search across every thread in the JVM, fragile when more than one manager (e.g. across different tests) shares the same thread name. */
153
    Thread flushThreadForTesting(){
154 1 1. flushThreadForTesting : replaced return value with null for com/rox/save/BatterySaveManager::flushThreadForTesting → KILLED
        return flushThread;
155
    }
156
}

Mutations

53

1.1
Location : lambda$loadIfPresent$0
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:loadIfPresentRestoresPrgRamFromAnExistingSave(java.nio.file.Path)]
removed call to com/rox/cartridge/Cartridge::restorePrgRam → KILLED

2.2
Location : loadIfPresent
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:loadIfPresentRestoresPrgRamFromAnExistingSave(java.nio.file.Path)]
removed call to java/util/Optional::ifPresent → KILLED

59

1.1
Location : lambda$start$0
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:startReusesAnExistingSavesCreationTimeAndAccumulatedGameTime(java.nio.file.Path)]
replaced return value with null for com/rox/save/BatterySaveManager::lambda$start$0 → KILLED

60

1.1
Location : lambda$start$1
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:startReusesAnExistingSavesCreationTimeAndAccumulatedGameTime(java.nio.file.Path)]
replaced Long return value with 0L for com/rox/save/BatterySaveManager::lambda$start$1 → KILLED

64

1.1
Location : start
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:stopFlushesAPendingWriteBeforeReturning(java.nio.file.Path)]
removed call to com/rox/cartridge/Cartridge::setOnPrgRamWrite → KILLED

70

1.1
Location : start
Killed by : none
removed call to java/lang/Thread::setDaemon → SURVIVED
Covering tests

71

1.1
Location : start
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:stopFlushesAPendingWriteBeforeReturning(java.nio.file.Path)]
removed call to java/lang/Thread::start → KILLED

72

1.1
Location : start
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:stopWithNoPendingWriteDoesNotCreateAFile(java.nio.file.Path)]
replaced return value with null for com/rox/save/BatterySaveManager::start → KILLED

79

1.1
Location : onWrite
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:aFailedFlushIsLoggedRatherThanCrashingTheFlushThread(java.nio.file.Path)]
removed call to java/lang/Object::notifyAll → KILLED

86

1.1
Location : run
Killed by : none
negated conditional → TIMED_OUT

2.2
Location : run
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:aWriteToPrgRamIsFlushedToDiskWithoutAnyExplicitSaveAction(java.nio.file.Path)]
negated conditional → KILLED

88

1.1
Location : run
Killed by : none
removed call to java/lang/Object::wait → TIMED_OUT

93

1.1
Location : run
Killed by : none
removed call to java/lang/Thread::interrupt → NO_COVERAGE

96

1.1
Location : run
Killed by : none
negated conditional → TIMED_OUT

2.2
Location : run
Killed by : none
negated conditional → TIMED_OUT

97

1.1
Location : run
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:stopWithNoPendingWriteDoesNotCreateAFile(java.nio.file.Path)]
negated conditional → KILLED

100

1.1
Location : run
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:aFailedFlushIsRetriedOnceMoreDuringStopEvenWithNoFurtherWrite(java.nio.file.Path)]
removed call to com/rox/save/BatterySaveManager::flush → KILLED

106

1.1
Location : run
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:aWriteToPrgRamIsFlushedToDiskWithoutAnyExplicitSaveAction(java.nio.file.Path)]
removed call to com/rox/save/BatterySaveManager::flush → KILLED

112

1.1
Location : flush
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:startReusesAnExistingSavesCreationTimeAndAccumulatedGameTime(java.nio.file.Path)]
Replaced long addition with subtraction → KILLED

115

1.1
Location : flush
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:stopFlushesAPendingWriteBeforeReturning(java.nio.file.Path)]
removed call to com/rox/save/SaveFileFormat::writeBatterySave → KILLED

134

1.1
Location : stop
Killed by : none
removed call to java/lang/Object::notifyAll → TIMED_OUT

140

1.1
Location : stop
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:stopTerminatesTheFlushThreadPromptly(java.nio.file.Path)]
negated conditional → KILLED

142

1.1
Location : stop
Killed by : none
removed call to java/lang/Thread::join → SURVIVED
Covering tests

147

1.1
Location : stop
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:stopHandlesBeingInterruptedWhileJoiningTheFlushThread(java.nio.file.Path)]
negated conditional → KILLED

148

1.1
Location : stop
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:stopHandlesBeingInterruptedWhileJoiningTheFlushThread(java.nio.file.Path)]
removed call to java/lang/Thread::interrupt → KILLED

154

1.1
Location : flushThreadForTesting
Killed by : com.rox.save.BatterySaveManagerTest.[engine:junit-jupiter]/[class:com.rox.save.BatterySaveManagerTest]/[method:theFlushThreadIsADaemonThread(java.nio.file.Path)]
replaced return value with null for com/rox/save/BatterySaveManager::flushThreadForTesting → KILLED

Active mutators

Tests examined


Report generated by PIT 1.22.1