Repository: apache/jena (java)

Issue:
Fuseki HttpAction: Check and improve the transaction lifecycle for HTTP action execution.
### Version

Up to 5.0.0

### What happened?

HttpAction does not properly call "end" operations for action transactions.

While harmless in the current codebase, it should follow the action transaction cycle and also it would be good :tm: if it checked for internal consistency of pairs of transaction-related method calls.

### Relevant output and stacktrace

_No response_

### Are you interested in making a pull request?

Yes

Pull request description:
Improve HttpAction lifecycle
GitHub issue resolved #2506

Pull request Description:

To keep the server stable and running, implementations of Fuseki operations must follow the HttpAction lifecycle for transactions.

This PR fixes one problem (found in code review, not as a live bug), and puts in consistency checking.

The consistency checks did show some incorrect use of the  HttpAction lifecycle.

There are also changes to remove other, unrelated logging messages output during Fuseki tests.

The function to apply a patch is moved to the Fuseki servlet package.

----

 - [x] Tests are included 
 - [x] Commits have been squashed to remove intermediate development commit messages.
 - [x] Key commit messages start with the issue number (GH-xxxx)

By submitting this pull request, I acknowledge that I am making a contribution to the Apache Software Foundation under the terms and conditions of the [Contributor's Agreement](https://www.apache.org/licenses/contributor-agreements.html).


Patch:
diff --git a/jena-arq/src/main/java/org/apache/jena/sparql/core/Transactional.java b/jena-arq/src/main/java/org/apache/jena/sparql/core/Transactional.java
index 19e9c94b6d..c6c1370522 100644
--- a/jena-arq/src/main/java/org/apache/jena/sparql/core/Transactional.java
+++ b/jena-arq/src/main/java/org/apache/jena/sparql/core/Transactional.java
@@ -190,7 +190,8 @@ public interface Transactional
     /** Finish the transaction - if a write transaction and commit() has not been called, then abort */
     public void end() ;
 
-    /** Return the current mode of the transaction - "read" or "write".
+    /**
+     * Return the current mode of the transaction - "read" or "write".
      * If the caller is not in a transaction, this method returns null.
      */
     public ReadWrite transactionMode();
diff --git a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/DataService.java b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/DataService.java
index 3ecb06aba1..7d823180b6 100644
--- a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/DataService.java
+++ b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/DataService.java
@@ -239,6 +239,10 @@ public class DataService {
         totalTxn.getAndIncrement();
     }
 
+    public void finishTxn() {
+        activeTxn.decrementAndGet();
+    }
+
     private void check(DataServiceStatus status) {
         if ( state != status ) {
             String msg = format("DataService %s: Expected=%s, Actual=%s", label(), status, state);
@@ -246,10 +250,6 @@ public class DataService {
         }
     }
 
-    public void finishTxn() {
-        activeTxn.decrementAndGet();
-    }
-
     /** Shutdown and never use again. */
     public synchronized void shutdown() {
         if ( state == CLOSING )
diff --git a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/OperationRegistry.java b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/OperationRegistry.java
index bbfb61377a..7038c82f78 100644
--- a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/OperationRegistry.java
+++ b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/server/OperationRegistry.java
@@ -26,7 +26,6 @@ import jakarta.servlet.ServletContext;
 
 import org.apache.jena.atlas.logging.Log;
 import org.apache.jena.fuseki.Fuseki;
-import org.apache.jena.fuseki.patch.PatchApplyService;
 import org.apache.jena.fuseki.servlets.*;
 import org.apache.jena.riot.WebContent;
 
@@ -47,7 +46,7 @@ public class OperationRegistry {
     private static final ActionService uploadServlet   = new UploadRDF();
     private static final ActionService gspServlet_R    = new GSP_R();
     private static final ActionService gspServlet_RW   = new GSP_RW();
-    private static final ActionService rdfPatch        = new PatchApplyService();
+    private static final ActionService rdfPatch        = new PatchApply();
     private static final ActionService noOperation     = new NoOpActionService();
     private static final ActionService shaclValidation = new SHACL_Validation();
 
diff --git a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/GSP_RW.java b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/GSP_RW.java
index fd8a0325e6..5bb228e5c4 100644
--- a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/GSP_RW.java
+++ b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/GSP_RW.java
@@ -105,7 +105,7 @@ public class GSP_RW extends GSP_R {
         }
         catch (ActionErrorException ex) { throw ex; }
         catch (Exception ex) { action.abortSilent(); }
-        finally { action.end(); }
+        finally { action.endWrite(); }
         ServletOps.successNoContent(action);
     }
 
@@ -215,7 +215,7 @@ public class GSP_RW extends GSP_R {
             ServletOps.errorOccurred(ex.getMessage());
             return null;
         } finally {
-            action.end();
+            action.endWrite();
         }
     }
 
@@ -261,6 +261,6 @@ public class GSP_RW extends GSP_R {
             action.abortSilent();
             ServletOps.errorOccurred(ex.getMessage());
             return null;
-        } finally { action.end(); }
+        } finally { action.endWrite(); }
     }
 }
diff --git a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/HttpAction.java b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/HttpAction.java
index dc3bfd89d5..5a5d1b7a34 100644
--- a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/HttpAction.java
+++ b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/HttpAction.java
@@ -147,7 +147,6 @@ public class HttpAction
      * @param dService {@link DataService}
      * @see Transactional
      */
-
     public void setRequest(DataAccessPoint dataAccessPoint, DataService dService) {
         if ( this.dataAccessPoint != null )
             throw new FusekiException("Redefinition of DataAccessPoint in the request action");
@@ -219,7 +218,6 @@ public class HttpAction
     /**
      * Return the "Transactional" for this HttpAction.
      */
-
     public Transactional getTransactional() {
         return transactional;
     }
@@ -232,7 +230,8 @@ public class HttpAction
         return actionURI;
     }
 
-    /** Get the context path.
+    /**
+     * Get the context path.
      */
     public String getContextPath() {
         return contextPath;
@@ -252,7 +251,8 @@ public class HttpAction
         return dataAccessPointRegistry;
     }
 
-    /** Set the endpoint and endpoint name that this is an action for.
+    /**
+     * Set the endpoint and endpoint name that this is an action for.
      * @param endpoint {@link Endpoint}
      */
     public void setEndpoint(Endpoint endpoint) {
@@ -273,64 +273,207 @@ public class HttpAction
         return isTransactional;
     }
 
+    public final void startRequest() {
+        if ( dataAccessPoint != null )
+            dataAccessPoint.startRequest(this);
+    }
+
+    public final void finishRequest() {
+        // Should be handled where necessary in the request handling.
+//      if ( inputStream != null ) {
+//          ActionLib.consumeBody(this);
+//      }
+//      if ( outputStream != null ) {
+//          IO.flush(outputStream);
+//          IO.close(outputStream);
+//      }
+        if ( dataAccessPoint != null )
+            dataAccessPoint.finishRequest(this);
+        if ( isInActionTxn )
+            FmtLog.error(log, "[%d] Action transaction not finished", this.id);
+    }
+
+    /**
+     * Begin a transaction of any {@link TxnType} - this should be paired with an {@link #end()}
+     *
+     * <pre>
+     *   httpAction.begin();
+     *   try {
+     *      work
+     *      possible promote to write
+     *      commit or abort (if write)
+     *      response
+     *   } finally { httpAction.end(); }
+     * </pre>
+     *
+     * Prefer one of {@link #beginRead()} or {@link #beginWrite()} if the action is
+     * known to be a a read or write at the start or call {@link #begin()}
+     * if the transaction may promote.
+     *
+     *
+     */
     public void begin(TxnType txnType) {
+        enterActionTxn();
         if ( transactional != null )
             transactional.begin(txnType);
         activeDSG = dsg;
         if ( dataService != null )
             dataService.startTxn(txnType);
+        startActionTxn();
     }
 
+    private /*public*/ void endInternal() {
+        // Commit or abort may have called end() already.
+        // Possibly multiple endWrite, endRead (not ideal, but not breaking)
+        if ( actionTxEndCalled )
+            return;
+        actionTxEndCalled = true;
+        finishActionTxn();
+        if ( dataService != null )
+            dataService.finishTxn();
+        if ( transactional.isInTransaction() ) {
+            switch(transactional.transactionMode() ) {
+                case READ -> {}
+                case WRITE -> {
+                    // Write transactions must have explicitly called commit or abort.
+                    FmtLog.warn(log, "[%d] Transaction still active - no commit or abort seen (forced abort)", this.id);
+                    try {
+                        transactional.abort();
+                    } catch (RuntimeException ex) {
+                        FmtLog.warn(log, "[%d] Exception in forced abort (trying to continue)", this.id, ex);
+                    }
+                }
+            }
+            try { transactional.end(); }
+            catch (RuntimeException ex) {}
+        }
+        leaveActionTxn();
+    }
+
+    /**
+     * Begin a transaction - this should be paired with an {@link #end()}
+     * <pre>
+     *   httpAction.begin();
+     *   try {
+     *      work
+     *      possible promote to write
+     *      commit or abort (if write)
+     *      response
+     *   } finally { httpAction.end(); }
+     *   </pre>
+     */
     public void begin() {
         begin(READ_PROMOTE);
     }
 
+    /**
+     *  Begin a write operation - this should be paired with an {@link #endWrite()}
+     * <pre>
+     *   httpAction.beginWrite();
+     *   try {
+     *      work
+     *      commit or abort
+     *      response
+     *   } finally { httpAction.endWrite(); }
+     *   </pre>
+     */
     public void beginWrite() {
         begin(WRITE);
     }
 
+    /**
+     *  Begin a read operation - this should be paired with an {@link #endRead()}
+     * <pre>
+     *   httpAction.beginRead();
+     *   try {
+     *      work
+     *      response
+     *   } finally { httpAction.endRead(); }
+     *   </pre>
+     */
     public void beginRead() {
         begin(READ);
     }
 
+    /**
+     * End a read transaction - paired with {@link #beginRead}.
+     */
     public void endRead() {
-        if ( dataService != null )
-            dataService.finishTxn();
-        if ( transactional != null ) {
-            try { transactional.commit(); } catch (RuntimeException ex) {}
-            try { transactional.end(); } catch (RuntimeException ex) {}
-        }
+        endInternal();
+    }
+
+    /**
+     * End a write transaction - paired with {@link #beginWrite}.
+     */
+    public void endWrite() {
+        endInternal();
     }
 
+    /**
+     * End a write transaction - paired with {@link #begin()} or {@link #begin(TxnType)}.
+     */
     public void end() {
-        dataService.finishTxn();
-        if ( transactional.isInTransaction() ) {
-            FmtLog.warn(log, "[%d] Transaction still active - no commit or abort seen (forced abort)", this.id);
-            try {
-                transactional.abort();
-            } catch (RuntimeException ex) {
-                FmtLog.warn(log, "[%d] Exception in forced abort (trying to continue)", this.id, ex);
-            }
-        }
-        if ( transactional.isInTransaction() ) {
-            try { transactional.end(); }
-            catch (RuntimeException ex) {}
+        endInternal();
+    }
+
+    // An action begin-end is on the same thread. No concurrency issues for this flag.
+    private boolean isInActionTxn = false;
+    private int actionTxnCount = 0;
+    /** Used to detect two calls to {@link #endAny} */
+    private boolean actionTxEndCalled = false;
+
+    // begin :: enter then start
+    // end  ::  finish then leave
+
+    /**
+     * Called on entry to {@link #begin(TxnType)}; paired with {@link #leaveActionTxn}.
+     * {@code transaction.isInTransaction()} is false.
+     */
+    private void enterActionTxn() {
+        if ( isInActionTxn ) {
+            FmtLog.warn(log, "[%d] Already in an action", this.id);
+            ServletOps.errorOccurred("Internal error: bad action handling");
         }
-        endOfAction();
+        if ( actionTxnCount != 0 )
+            FmtLog.error(log, "[%d] Enter: actionTxnCount = %d", this.id, actionTxnCount);
+    }
+
+    /**
+     * Called on exit from {@link #end()}; paired with {@link #enterActionTxn}.
+     * {@code transaction.isInTransaction()} is false.
+     */
+    private void leaveActionTxn() {
+        if ( actionTxnCount != 0 )
+            FmtLog.error(log, "[%d] Leave: actionTxnCount = %d", this.id, actionTxnCount);
+        if ( isInActionTxn )
+            FmtLog.error(log, "[%d] Action transaction not end'ed.", this.id);
     }
 
-    private void endOfAction() {
+    /**
+     * Called on leaving {@link #begin(TxnType)}; paired with {@link #leaveActionTxn}.
+     * {@code transaction.isInTransaction()} is true.
+     */
+    private void startActionTxn() {
+        isInActionTxn = true;
+        actionTxnCount++;
+        actionTxEndCalled = false;
+    }
+
+    /**
+     * Called on exit from {@link #end()}; paired with {@link #startActionTxn}.
+     * {@code transaction.isInTransaction()} is false.
+     */
+    private void finishActionTxn() {
+        if ( ! isInActionTxn )
+            // Double end?
+            FmtLog.warn(log, "[%d] end called - Not in an action", this.id);
         activeDSG = null;
-        // Should be handled where necessary in the request handling.
-//        if ( inputStream != null ) {
-//            ActionLib.consumeBody(this);
-//        }
-//        if ( outputStream != null ) {
-//            IO.flush(outputStream);
-//            IO.close(outputStream);
-//        }
+        isInActionTxn = false;
+        actionTxnCount--;
     }
 
+    // ----
+
     public void commit() {
         dataService.finishTxn();
         transactional.commit();
@@ -358,16 +501,6 @@ public class HttpAction
         end();
     }
 
-    public final void startRequest() {
-        if ( dataAccessPoint != null )
-            dataAccessPoint.startRequest(this);
-    }
-
-    public final void finishRequest() {
-        if ( dataAccessPoint != null )
-            dataAccessPoint.finishRequest(this);
-    }
-
     /** If inside the transaction for the action, return the active {@link DatasetGraph},
      *  otherwise return null.
      * @return Current active {@link DatasetGraph}
@@ -400,7 +533,8 @@ public class HttpAction
 //        this.datasetName = datasetName;
 //    }
 
-    /** Reduce to a size that can be kept around for sometime.
+    /**
+     * Reduce to a size that can be kept around for sometime.
      */
     public void minimize() {
         this.request = null;
@@ -525,7 +659,8 @@ public class HttpAction
         return inputStream;
     }
 
-    /** Get the request input stream, bypassing any compression.
+    /**
+     * Get the request input stream, bypassing any compression.
      * The state of the input stream is unknown.
      * Only useful for skipping a body on a connection.
      * @throws IOException
diff --git a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/patch/PatchApplyService.java b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/PatchApply.java
similarity index 97%
rename from jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/patch/PatchApplyService.java
rename to jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/PatchApply.java
index 4a06fc8474..1481a57505 100644
--- a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/patch/PatchApplyService.java
+++ b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/PatchApply.java
@@ -15,7 +15,7 @@
  *  information regarding copyright ownership.
  */
 
-package org.apache.jena.fuseki.patch;
+package org.apache.jena.fuseki.servlets;
 
 import static java.lang.String.format;
 import static org.apache.jena.fuseki.servlets.ActionExecLib.incCounter;
@@ -25,7 +25,6 @@ import java.io.InputStream;
 
 import org.apache.jena.atlas.web.ContentType;
 import org.apache.jena.fuseki.server.CounterName;
-import org.apache.jena.fuseki.servlets.*;
 import org.apache.jena.rdfpatch.PatchException;
 import org.apache.jena.rdfpatch.RDFChanges;
 import org.apache.jena.rdfpatch.changes.*;
@@ -37,7 +36,7 @@ import org.apache.jena.sparql.core.DatasetGraph;
 import org.apache.jena.web.HttpSC;
 
 /** A Fuseki service to receive and apply a patch. */
-public class PatchApplyService extends ActionREST {
+public class PatchApply extends ActionREST {
     static CounterName counterPatches     = CounterName.register("RDFpatch-apply", "rdf-patch.apply.requests");
     static CounterName counterPatchesGood = CounterName.register("RDFpatch-apply", "rdf-patch.apply.good");
     static CounterName counterPatchesBad  = CounterName.register("RDFpatch-apply", "rdf-patch.apply.bad");
@@ -46,7 +45,7 @@ public class PatchApplyService extends ActionREST {
     private ContentType ctPatchText   = WebContent.ctPatch;
     private ContentType ctPatchBinary = WebContent.ctPatchThrift;
 
-    public PatchApplyService() {
+    public PatchApply() {
         // Counters: the standard ActionREST counters per operation are enough.
     }
 
@@ -111,7 +110,10 @@ public class PatchApplyService extends ActionREST {
         catch (Exception ex) {
             action.abort();
             throw ex;
-        } finally { action.end(); }
+        }
+        finally {
+            action.endWrite();
+        }
         ServletOps.success(action);
     }
 
diff --git a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/SPARQL_Update.java b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/SPARQL_Update.java
index d760495308..8c50cddfd5 100644
--- a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/SPARQL_Update.java
+++ b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/SPARQL_Update.java
@@ -258,7 +258,7 @@ public class SPARQL_Update extends ActionService
                 abortSilent(action);
                 ServletOps.errorOccurred(ex.getMessage(), ex);
             }
-        } finally { action.end(); }
+        } finally { action.endWrite(); }
     }
 
     /* [It is an error to supply the using-graph-uri or using-named-graph-uri parameters
diff --git a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/UploadRDF.java b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/UploadRDF.java
index 2e336932ed..24de0d7b94 100644
--- a/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/UploadRDF.java
+++ b/jena-fuseki2/jena-fuseki-core/src/main/java/org/apache/jena/fuseki/servlets/UploadRDF.java
@@ -138,7 +138,7 @@ public class UploadRDF extends ActionREST {
             action.abortSilent();
             ServletOps.errorOccurred(ex.getMessage());
         } finally {
-            action.end();
+            action.endWrite();
         }
         return details;
     }
@@ -192,7 +192,7 @@ public class UploadRDF extends ActionREST {
             action.abortSilent();
             ServletOps.errorOccurred(ex.getMessage());
         } finally {
-            action.end();
+            action.endWrite();
         }
         return details;
     }
diff --git a/jena-fuseki2/jena-fuseki-main/src/main/java/org/apache/jena/fuseki/main/cmds/FusekiMain.java b/jena-fuseki2/jena-fuseki-main/src/main/java/org/apache/jena/fuseki/main/cmds/FusekiMain.java
index fbbda7ab6b..998c79b979 100644
--- a/jena-fuseki2/jena-fuseki-main/src/main/java/org/apache/jena/fuseki/main/cmds/FusekiMain.java
+++ b/jena-fuseki2/jena-fuseki-main/src/main/java/org/apache/jena/fuseki/main/cmds/FusekiMain.java
@@ -94,7 +94,7 @@ public class FusekiMain extends CmdARQ {
     private static ArgDecl  argGZip         = new ArgDecl(ArgDecl.HasValue, "gzip");
     // Set the servlet context path (the initial path for URLs.) for any datasets.
     // A context of "/path" and a dataset name of "/ds", service "sparql" is accessed as "/path/ds/sparql"
-    private static ArgDecl  argPathBase     = new ArgDecl(ArgDecl.HasValue, "pathBase", "contextPath");
+    private static ArgDecl  argContextPath  = new ArgDecl(ArgDecl.HasValue, "pathBase", "contextPath", "pathbase", "contextpath");
     // Static files. URLs are affected by argPathBase
     private static ArgDecl  argBase         = new ArgDecl(ArgDecl.HasValue, "base", "files");
 
@@ -212,8 +212,8 @@ public class FusekiMain extends CmdARQ {
             "Enable GZip compression (HTTP Accept-Encoding) if request header set");
         add(argBase, "--base=DIR",
             "Directory for static content");
-        add(argPathBase, "--pathBase=DIR",
-            "Context path for datasets");
+        add(argContextPath, "--contextPath=PATH",
+            "Context path for the server");
         add(argSparqler, "--sparqler=DIR",
             "Run with SPARQLer services Directory for static content");
         add(argValidators, "--validators",
@@ -449,13 +449,11 @@ public class FusekiMain extends CmdARQ {
 
         // -- Server setup.
 
-        if ( contains(argPathBase) ) {
-            // Static files.
-            String servletContextPath = getValue(argPathBase);
-
-            if ( ! servletContextPath.equals("/") && servletContextPath.endsWith("/") )
-                throw new CmdException("Path base must not end with \"/\": '"+servletContextPath+"'");
-            serverConfig.servletContextPath = servletContextPath;
+        if ( contains(argContextPath) ) {
+            String contextPath = getValue(argContextPath);
+            contextPath = sanitizeContextPath(contextPath);
+            if ( contextPath != null )
+                serverConfig.servletContextPath = contextPath;
         }
 
         if ( contains(argBase) ) {
@@ -551,6 +549,20 @@ public class FusekiMain extends CmdARQ {
         }
     }
 
+    private static String sanitizeContextPath(String contextPath) {
+        if ( contextPath.isEmpty() )
+            return null;
+        if ( contextPath.equals("/") )
+            return null;
+        if ( contextPath.endsWith("/") ) {
+            throw new CmdException("Path base must not end with \"/\": '"+contextPath+"'");
+            //contextPath = StringUtils.chop(contextPath);
+        }
+        if ( ! contextPath.startsWith("/") )
+            contextPath = "/"+contextPath;
+        return contextPath;
+    }
+
     @Override
     protected void exec() {
         try {
diff --git a/jena-fuseki2/jena-fuseki-webapp/src/main/java/org/apache/jena/fuseki/cmd/FusekiMain.java b/jena-fuseki2/jena-fuseki-webapp/src/main/java/org/apache/jena/fuseki/cmd/FusekiMain.java
new file mode 100644
index 0000000000..8e85c948bb
--- /dev/null
+++ b/jena-fuseki2/jena-fuseki-webapp/src/main/java/org/apache/jena/fuseki/cmd/FusekiMain.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jena.fuseki.cmd;
+
+/**
+ * @deprecated Use {@link FusekiWebappCmd}.
+ */
+@Deprecated(since="5.1.0", forRemoval = true)
+public class FusekiMain {
+    static public void main(String... argv) {
+        FusekiWebappCmd.main(argv);
+    }
+}
diff --git a/jena-fuseki2/jena-fuseki-webapp/src/main/java/org/apache/jena/fuseki/cmd/FusekiCmd.java b/jena-fuseki2/jena-fuseki-webapp/src/main/java/org/apache/jena/fuseki/cmd/FusekiWebappCmd.java
similarity index 94%
rename from jena-fuseki2/jena-fuseki-webapp/src/main/java/org/apache/jena/fuseki/cmd/FusekiCmd.java
rename to jena-fuseki2/jena-fuseki-webapp/src/main/java/org/apache/jena/fuseki/cmd/FusekiWebappCmd.java
index d80d5b6fc9..040c5591f5 100644
--- a/jena-fuseki2/jena-fuseki-webapp/src/main/java/org/apache/jena/fuseki/cmd/FusekiCmd.java
+++ b/jena-fuseki2/jena-fuseki-webapp/src/main/java/org/apache/jena/fuseki/cmd/FusekiWebappCmd.java
@@ -49,7 +49,7 @@ import org.slf4j.Logger;
 /**
  * Handles the fuseki command, used to start a Fuseki Webapp server.
  */
-public class FusekiCmd {
+public class FusekiWebappCmd {
     // This allows us to set logging before calling FusekiCmdInner
     // FusekiCmdInner inherits from CmdMain which statically sets logging.
     // By java classloading, super class statics run before the
@@ -86,7 +86,7 @@ public class FusekiCmd {
         private static ArgDecl argFusekiConfig  = new ArgDecl(ArgDecl.HasValue, "config", "conf");
         private static ArgDecl argJettyConfig   = new ArgDecl(ArgDecl.HasValue, "jetty-config", "jetty");
         private static ArgDecl argGZip          = new ArgDecl(ArgDecl.HasValue, "gzip");
-        private static ArgDecl argContextPath   = new ArgDecl(ArgDecl.HasValue, "pathBase", "contextPath");
+        private static ArgDecl argContextPath   = new ArgDecl(ArgDecl.HasValue, "pathBase", "contextPath", "pathbase", "contextpath");
 
         // Deprecated.  Use shiro.
         private static ArgDecl argBasicAuth     = new ArgDecl(ArgDecl.HasValue, "basic-auth");
@@ -318,15 +318,6 @@ public class FusekiCmd {
                 cmdLine.datasetDescription = cmdLine.datasetDescription+ " (with RDFS)";
             }
 
-            // This is has proven confusing because it is like --conf.
-//            // Otherwise
-//            if ( contains(argAssemblerDecl) ) {
-//                cmdLine.datasetDescription = "Assembler: "+ modDataset.getAssemblerFile();
-//                // Need to add service details.
-//                Dataset ds = modDataset.createDataset();
-//                //cmdLineDataset.dsg = ds.asDatasetGraph();
-//            }
-
             if ( cmdlineConfigPresent ) {
                 cmdLine.datasetPath = getPositionalArg(0);
                 if ( cmdLine.datasetPath.length() > 0 && !cmdLine.datasetPath.startsWith("/") )
@@ -351,7 +342,10 @@ public class FusekiCmd {
             }
 
             if ( contains(argContextPath) ) {
-                jettyServerConfig.contextPath = getValue(argContextPath);
+                String contextPath = getValue(argContextPath);
+                contextPath = sanitizeContextPath(contextPath);
+                if ( contextPath != null )
+                    jettyServerConfig.contextPath = contextPath;
             }
 
             if ( contains(argJettyConfig) ) {
@@ -367,6 +361,20 @@ public class FusekiCmd {
             }
         }
 
+        private static String sanitizeContextPath(String contextPath) {
+            if ( contextPath.isEmpty() )
+                return null;
+            if ( contextPath.equals("/") )
+                return null;
+            if ( contextPath.endsWith("/") ) {
+                throw new CmdException("Path base must not end with \"/\": '"+contextPath+"'");
+                //contextPath = StringUtils.chop(contextPath);
+            }
+            if ( ! contextPath.startsWith("/") )
+                contextPath = "/"+contextPath;
+            return contextPath;
+        }
+
         @Override
         protected void exec() {
             try {
diff --git a/jena-rdfpatch/src/main/java/org/apache/jena/rdfpatch/system/DatasetGraphChanges.java b/jena-rdfpatch/src/main/java/org/apache/jena/rdfpatch/system/DatasetGraphChanges.java
index 00bdc77690..af2946e377 100644
--- a/jena-rdfpatch/src/main/java/org/apache/jena/rdfpatch/system/DatasetGraphChanges.java
+++ b/jena-rdfpatch/src/main/java/org/apache/jena/rdfpatch/system/DatasetGraphChanges.java
@@ -263,7 +263,7 @@ public class DatasetGraphChanges extends DatasetGraphWrapper {
     public void commit() {
         // Assume local commit will work - so signal first.
         // If the monitor.txnCommit fails, the commit should not happen
-        if ( isWriteMode() ) {
+        if ( isInWriteMode() ) {
             try {
                 changesMonitor.txnCommit();
             } catch (Exception ex) {
@@ -279,12 +279,12 @@ public class DatasetGraphChanges extends DatasetGraphWrapper {
     @Override
     public void abort() {
         // Assume abort will work - signal first.
-        if ( isWriteMode() )
+        if ( isInWriteMode() )
             changesMonitor.txnAbort();
         super.abort();
     }
 
-    private boolean isWriteMode() {
+    private boolean isInWriteMode() {
         return super.transactionMode() == ReadWrite.WRITE;
     }
 


Test patch:
diff --git a/jena-fuseki2/jena-fuseki-main/src/test/java/org/apache/jena/fuseki/main/TestFusekiCustomOperation.java b/jena-fuseki2/jena-fuseki-main/src/test/java/org/apache/jena/fuseki/main/TestFusekiCustomOperation.java
index 6d70a91da9..c7dba503ff 100644
--- a/jena-fuseki2/jena-fuseki-main/src/test/java/org/apache/jena/fuseki/main/TestFusekiCustomOperation.java
+++ b/jena-fuseki2/jena-fuseki-main/src/test/java/org/apache/jena/fuseki/main/TestFusekiCustomOperation.java
@@ -182,7 +182,7 @@ public class TestFusekiCustomOperation {
         // .build();
     }
 
-    // Bad test: no MIME type must match.
+    // Bad test: MIME type must match.
     @Test
     public void cfg_bad_ct_not_enabled_here_1() {
         FusekiServer server = FusekiServer.create().port(port)
diff --git a/jena-fuseki2/jena-fuseki-main/src/test/java/org/apache/jena/fuseki/main/TestFusekiMainCmdArguments.java b/jena-fuseki2/jena-fuseki-main/src/test/java/org/apache/jena/fuseki/main/TestFusekiMainCmdArguments.java
index ca3faf6d6c..d7613ac0f5 100644
--- a/jena-fuseki2/jena-fuseki-main/src/test/java/org/apache/jena/fuseki/main/TestFusekiMainCmdArguments.java
+++ b/jena-fuseki2/jena-fuseki-main/src/test/java/org/apache/jena/fuseki/main/TestFusekiMainCmdArguments.java
@@ -25,9 +25,15 @@ import static org.junit.Assert.fail;
 
 import java.util.List;
 
+import org.apache.jena.atlas.logging.LogCtl;
 import org.apache.jena.cmd.CmdException;
+import org.apache.jena.fuseki.Fuseki;
 import org.apache.jena.fuseki.main.cmds.FusekiMain;
+import org.apache.jena.fuseki.system.FusekiLogging;
+import org.apache.jena.riot.SysRIOT;
 import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
 import org.junit.Test;
 
 /**
@@ -35,6 +41,20 @@ import org.junit.Test;
  */
 public class TestFusekiMainCmdArguments {
 
+    private static String level = null;
+
+    @BeforeClass public static void beforeClass() {
+        // This is not reset by each running server.
+        FusekiLogging.setLogging();
+        level = LogCtl.getLevel(Fuseki.serverLog);
+        LogCtl.setLevel(Fuseki.serverLog, "WARN");
+    }
+
+    @AfterClass public static void afterClass() {
+        if ( level != null )
+            LogCtl.setLevel(Fuseki.serverLog, level);
+    }
+
     private FusekiServer server = null;
     @After public void after() {
         if ( server != null )
@@ -42,7 +62,7 @@ public class TestFusekiMainCmdArguments {
     }
 
     @Test
-    public void test_happy_empty() {
+    public void test_empty() {
         // given
         List<String> arguments = List.of("--port=0", "--empty", "/dataset");
         // when
@@ -52,7 +72,7 @@ public class TestFusekiMainCmdArguments {
     }
 
     @Test
-    public void test_happy_localhost() {
+    public void test_localhost() {
         // given
         List<String> arguments = List.of("--port=0", "--localhost", "--mem", "/dataset");
         // when
@@ -61,6 +81,48 @@ public class TestFusekiMainCmdArguments {
         assertNotNull(server);
     }
 
+    @Test
+    public void test_contextpath_1() {
+        // given
+        List<String> arguments = List.of("--mem", "--contextpath=/ABC", "/path");
+        String expectedMessage = "port : bad port number: 'ERROR'";
+        // when
+        buildServer(buildCmdLineArguments(arguments));
+        // then
+        assertNotNull(server);
+    }
+
+    @Test
+    public void test_contextpath_2() {
+        // given
+        List<String> arguments = List.of("--mem", "--contextpath=ABC", "/path");
+        String expectedMessage = "port : bad port number: 'ERROR'";
+        // when
+        buildServer(buildCmdLineArguments(arguments));
+        // then
+        assertNotNull(server);
+    }
+
+    @Test
+    public void test_contextpath_3() {
+        // given
+        List<String> arguments = List.of("--mem", "--contextpath=/", "/path");
+        String expectedMessage = "port : bad port number: 'ERROR'";
+        // when
+        buildServer(buildCmdLineArguments(arguments));
+        // then
+        assertNotNull(server);
+    }
+
+    @Test
+    public void test_error_contextpath() {
+        // given
+        List<String> arguments = List.of("--mem", "--contextpath=ABC/", "/path");
+        String expectedMessage = "Path base must not end with \"/\": 'ABC/'";
+        // when, then
+        testForCmdException(arguments, expectedMessage);
+    }
+
     @Test
     public void test_error_noDataSetProvided() {
         // given
@@ -225,7 +287,6 @@ public class TestFusekiMainCmdArguments {
         testForCmdException(arguments, expectedMessage);
     }
 
-
     @Test
     public void test_error_argConfigFile_MissingFile() {
         // given
@@ -251,7 +312,9 @@ public class TestFusekiMainCmdArguments {
         List<String> arguments = List.of("--file=testing/Config/invalid.ttl", "/dataset");
         String expectedMessage = "Failed to load file: testing/Config/invalid.ttl";
         // when, then
-        testForCmdException(arguments, expectedMessage);
+        LogCtl.withLevel(SysRIOT.getLogger(), "fatal",
+                         ()-> testForCmdException(arguments, expectedMessage)
+                         );
     }
 
     @Test
diff --git a/jena-fuseki2/jena-fuseki-main/src/test/java/org/apache/jena/fuseki/main/TestFusekiStdSetup.java b/jena-fuseki2/jena-fuseki-main/src/test/java/org/apache/jena/fuseki/main/TestFusekiStdSetup.java
index b2594c57c9..90c2a2b876 100644
--- a/jena-fuseki2/jena-fuseki-main/src/test/java/org/apache/jena/fuseki/main/TestFusekiStdSetup.java
+++ b/jena-fuseki2/jena-fuseki-main/src/test/java/org/apache/jena/fuseki/main/TestFusekiStdSetup.java
@@ -111,11 +111,6 @@ public class TestFusekiStdSetup {
         exec(URL, "/data", conn -> conn.putDataset(dataset));
     }
 
-    @Test
-    public void stdSetup_endpoint_8() {
-        HttpTest.expect404( () -> exec(URL, "/nonsense", conn -> conn.putDataset(dataset)) );
-    }
-
     @Test
     public void stdSetup_dataset_1() {
         exec(URL, conn -> conn.queryAsk("ASK{}"));
@@ -166,6 +161,10 @@ public class TestFusekiStdSetup {
         HttpTest.expect404( () -> exec(URL+"2", "", (RDFConnection conn)->conn.queryAsk("ASK{}")) );
     }
 
+    @Test public void stdSetup_endpoint_bad_6() {
+        HttpTest.expect404( () -> exec(URL, "/nonsense", conn -> conn.putDataset(dataset)) );
+    }
+
     private static void exec(String url, Consumer<RDFConnection> action) {
         execEx(url, null, action);
     }