From 2ff58d74b3a37adbbabc9d535f525e8be7b11458 Mon Sep 17 00:00:00 2001
From: Andreas Eversberg <jolly@eversberg.eu>
Date: Sun, 16 Jun 2024 09:20:19 +0200
Subject: [PATCH] Disk eject feature to prevent corrupt file systems

If Zulu or the host machine is shutdown during write access, file
systems may get corrupted, due to unfinished write. The idea is to wait
several seconds to complete the write. A timer is used to wait a given
amout of time. If there is a write access during that time, the timer is
restarted.

This feature can be enabled by setting EjectButton to 1 or 2 and
EjectTimeout to 5000. When the specified button is pressed, eject is
performed 5000 milliseconds after last write access.

Write access after eject is rejected. The drive become read-only.
---
 src/ZuluSCSI_disk.cpp     | 69 +++++++++++++++++++++++++++++++++++++++
 src/ZuluSCSI_disk.h       |  8 ++++-
 src/ZuluSCSI_settings.cpp |  2 ++
 src/ZuluSCSI_settings.h   |  1 +
 zuluscsi.ini              |  1 +
 5 files changed, 80 insertions(+), 1 deletion(-)

diff --git a/src/ZuluSCSI_disk.cpp b/src/ZuluSCSI_disk.cpp
index 15a7588..4b8d915 100644
--- a/src/ZuluSCSI_disk.cpp
+++ b/src/ZuluSCSI_disk.cpp
@@ -250,6 +250,9 @@ static void scsiDiskSetImageConfig(uint8_t target_idx)
     img.reinsert_on_inquiry = devCfg->reinsertOnInquiry;
     img.reinsert_after_eject = devCfg->reinsertAfterEject;
     img.ejectButton = devCfg->ejectButton;
+    img.ejectTimeout = devCfg->ejectTimeout;
+    img.ejectPending = false;
+    img.ejected = false;
     img.vendorExtensions = devCfg->vendorExtensions;
 
 #ifdef ENABLE_AUDIO_OUTPUT
@@ -765,6 +768,17 @@ image_config_t &scsiDiskGetImageConfig(int target_idx)
     return g_DiskImages[target_idx];
 }
 
+void diskPerformEject(image_config_t &img)
+{
+    uint8_t target = img.scsiId & 7;
+    if (!img.ejected)
+    {
+        dbgmsg("------ Disk eject scheduled on ID ", (int)target);
+        img.ejectPending = true;
+        img.ejectTimer = millis();
+    }
+}
+
 static void diskEjectAction(uint8_t buttonId)
 {
     bool found = false;
@@ -779,6 +793,12 @@ static void diskEjectAction(uint8_t buttonId)
                 logmsg("Eject button ", (int)buttonId, " pressed, passing to CD drive SCSI", (int)i);
                 cdromPerformEject(img);
             }
+            if (img.deviceType == S2S_CFG_FIXED && img.ejectTimeout)
+            {
+                found = true;
+                logmsg("Eject button ", (int)buttonId, " pressed, passing to disk drive SCSI", (int)i);
+                diskPerformEject(img);
+            }
         }
     }
 
@@ -788,6 +808,40 @@ static void diskEjectAction(uint8_t buttonId)
     }
 }
 
+/* Handle eject timer. */
+static void diskEjectTimer(void)
+{
+    bool found_pending = false;
+    for (uint8_t i = 0; i < S2S_MAX_TARGETS; i++)
+    {
+        image_config_t &img = g_DiskImages[i];
+        if (img.ejectPending)
+        {
+            if ((uint32_t)(millis() - img.ejectTimer) > img.ejectTimeout) {
+                logmsg("Eject Timeout, ejecting SCSI", (int)i);
+                img.ejectPending = false;
+                img.ejected = true;
+                LED_OFF();
+            } else
+            {
+                found_pending = true;
+            }
+        }
+    }
+
+    if (found_pending)
+    {
+        if ((millis() & 32))
+        {
+            LED_ON();
+        }
+        else
+        {
+            LED_OFF();
+        }
+    }
+}
+
 uint8_t diskEjectButtonUpdate(bool immediate)
 {
     // treat '1' to '0' transitions as eject actions
@@ -796,6 +850,8 @@ uint8_t diskEjectButtonUpdate(bool immediate)
     uint8_t ejectors = (previous ^ bitmask) & previous;
     previous = bitmask;
 
+    diskEjectTimer();
+
     // defer ejection until the bus is idle
     static uint8_t deferred = 0x00;
     if (!immediate)
@@ -1269,6 +1325,13 @@ void scsiDiskStartWrite(uint32_t lba, uint32_t blocks)
 
     dbgmsg("------ Write ", (int)blocks, "x", (int)bytesPerSector, " starting at ", (int)lba);
 
+    if (img.ejected) {
+        scsiDev.status = CHECK_CONDITION;
+        scsiDev.target->sense.code = ILLEGAL_REQUEST;
+        scsiDev.target->sense.asc = WRITE_PROTECTED;
+        scsiDev.phase = STATUS;
+    }
+    else
     if (unlikely(blockDev.state & DISK_WP) ||
         unlikely(scsiDev.target->cfg->deviceType == S2S_CFG_OPTICAL) ||
         unlikely(!img.file.isWritable()))
@@ -1306,6 +1369,12 @@ void scsiDiskStartWrite(uint32_t lba, uint32_t blocks)
         g_scsi_prefetch.sector = 0;
 #endif
 
+        /* Reset eject timer. */
+        if (img.ejectPending)
+        {
+            img.ejectTimer = millis();
+        }
+
         image_config_t &img = *(image_config_t*)scsiDev.target->cfg;
         if (!img.file.seek((uint64_t)transfer.lba * bytesPerSector))
         {
diff --git a/src/ZuluSCSI_disk.h b/src/ZuluSCSI_disk.h
index d0bdbf9..9f91986 100644
--- a/src/ZuluSCSI_disk.h
+++ b/src/ZuluSCSI_disk.h
@@ -48,8 +48,10 @@ struct image_config_t: public S2S_TargetCfg
 
     ImageBackingStore file;
 
-    // For CD-ROM drive ejection
+    // For CD-ROM/disk drive ejection
     bool ejected;
+    bool ejectPending;
+    uint32_t ejectTimer;
     uint8_t cdrom_events;
     bool reinsert_on_inquiry; // Reinsert on Inquiry command (to reinsert automatically after boot)
     bool reinsert_after_eject; // Reinsert next image after ejection
@@ -58,6 +60,10 @@ struct image_config_t: public S2S_TargetCfg
     // default option of '0' disables this functionality
     uint8_t ejectButton;
 
+    // Time to wait in milliseconds before ejecting.
+    // This is reset with every write command to prevent file system corruption.
+    uint32_t ejectTimeout;
+
     // For tape drive emulation, current position in blocks
     uint32_t tape_pos;
 
diff --git a/src/ZuluSCSI_settings.cpp b/src/ZuluSCSI_settings.cpp
index baa3b8a..5333b17 100644
--- a/src/ZuluSCSI_settings.cpp
+++ b/src/ZuluSCSI_settings.cpp
@@ -214,6 +214,7 @@ static void readIniSCSIDeviceSetting(scsi_device_settings_t &cfg, const char *se
     cfg.headsPerCylinder = ini_getl(section, "HeadsPerCylinder", cfg.headsPerCylinder, CONFIGFILE);
     cfg.prefetchBytes = ini_getl(section, "PrefetchBytes", cfg.prefetchBytes, CONFIGFILE);
     cfg.ejectButton = ini_getl(section, "EjectButton", cfg.ejectButton, CONFIGFILE);
+    cfg.ejectTimeout = ini_getl(section, "EjectTimeout", cfg.ejectTimeout, CONFIGFILE);
 
     cfg.vol = ini_getl(section, "CDAVolume", cfg.vol, CONFIGFILE) & 0xFF;
 
@@ -298,6 +299,7 @@ scsi_system_settings_t *ZuluSCSISettings::initSystem(const char *presetName)
     cfgDev.headsPerCylinder = 255;
     cfgDev.prefetchBytes = PREFETCH_BUFFER_SIZE;
     cfgDev.ejectButton = 0;
+    cfgDev.ejectTimeout = 0;
     cfgDev.vol = DEFAULT_VOLUME_LEVEL;
     
     cfgDev.nameFromImage = false;
diff --git a/src/ZuluSCSI_settings.h b/src/ZuluSCSI_settings.h
index 554bdc4..d5fa2a7 100644
--- a/src/ZuluSCSI_settings.h
+++ b/src/ZuluSCSI_settings.h
@@ -86,6 +86,7 @@ typedef struct __attribute__((__packed__)) scsi_device_settings_t
     uint8_t deviceType;
     uint8_t deviceTypeModifier;
     uint8_t ejectButton;
+    uint32_t ejectTimeout;
     bool nameFromImage;
     bool rightAlignStrings;
     bool reinsertOnInquiry;
diff --git a/zuluscsi.ini b/zuluscsi.ini
index ac89e39..7f23d3c 100644
--- a/zuluscsi.ini
+++ b/zuluscsi.ini
@@ -58,6 +58,7 @@
 #ReinsertCDOnInquiry = 1 # Reinsert any ejected CD-ROM image on Inquiry command
 #ReinsertAfterEject = 1 # Reinsert next CD image after eject, if multiple images configured.
 #EjectButton = 0 # Enable eject by button 1 or 2, or set 0 to disable
+#EjectTimeout = 5000 # Enable eject timer in milliseconds to wait for no write access before ejecting a disk drive
 #CDAVolume = 63 # Change CD Audio default volume. Maximum 255.
 #DisableMacSanityCheck = 0 # Disable sanity warnings for Mac disk drives. Default is 0 - enable checks
 
-- 
2.39.5

