Changeset 19153 in josm for trunk


Ignore:
Timestamp:
2024-07-25T22:54:48+02:00 (4 months ago)
Author:
taylor.smock
Message:

Fix #23821: Ensure that a new layer is loaded prior to loading additional data to that layer

This occurred due to a race condition, whereby the /load_and_zoom call would
return immediately prior to the download finishing for the new layer and the
next /load_and_zoom call merging onto a pre-existing layer.

This could be fixed in one of two different ways:

  1. Block the RemoteControl thread
  2. Have some method for ensuring that a new layer is loaded first

While we are effectively doing (1), it was easier to do (2) as well for testing
purposes. This means the RemoteControl thread could spin off a thread for each
request to /load_and_zoom and this particular issue should not reappear.

This does not control for cases where a user calls /load_and_zoom like so:

  1. new_layer=true + layer_name=first
  2. new_layer=true + layer_name=second
  3. new_layer=false + layer_name=first
  4. new_layer=false + layer_name=second

Both (1) and (2) will complete before (3) and (4) are run. However, both (3) and
(4) will be loaded into the last layer loaded.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/org/openstreetmap/josm/io/remotecontrol/handler/LoadAndZoomHandler.java

    r19152 r19153  
    88import java.util.Collection;
    99import java.util.Collections;
     10import java.util.HashMap;
    1011import java.util.LinkedHashSet;
    1112import java.util.List;
     
    1617import java.util.concurrent.TimeUnit;
    1718import java.util.concurrent.TimeoutException;
     19import java.util.concurrent.locks.ReadWriteLock;
     20import java.util.concurrent.locks.ReentrantReadWriteLock;
    1821import java.util.stream.Collectors;
    1922import java.util.stream.Stream;
     
    7376    private static final String SEARCH = "search";
    7477
     78    private static final Map<String, ReadWriteLock> layerLockMap = new HashMap<>();
     79
    7580    // Mandatory arguments
    7681    private double minlat;
     
    161166    private void download() throws RequestHandlerErrorException {
    162167        DownloadOsmTask osmTask = new DownloadOsmTask();
     168        ReadWriteLock lock = null;
     169        DownloadParams settings = null;
    163170        try {
    164             DownloadParams settings = getDownloadParams();
     171            settings = getDownloadParams();
    165172
    166173            if (command.equals(myCommand)) {
     
    168175                    Logging.info("RemoteControl: download forbidden by preferences");
    169176                } else {
     177                    // The lock ensures that a download that creates a new layer will finish before
     178                    // downloads that do not create a new layer. This should be thread-safe.
     179                    lock = obtainLock(settings);
     180                    // We need to ensure that we only try to download new areas
    170181                    Area toDownload = null;
    171182                    if (!settings.isNewLayer()) {
     
    179190                }
    180191            }
     192        } catch (InterruptedException ex) {
     193            Thread.currentThread().interrupt();
     194            throw new RequestHandlerErrorException(ex);
    181195        } catch (RuntimeException ex) { // NOPMD
    182196            Logging.warn("RemoteControl: Error parsing load_and_zoom remote control request:");
    183197            Logging.error(ex);
    184198            throw new RequestHandlerErrorException(ex);
     199        } finally {
     200            releaseLock(settings, lock);
     201        }
     202    }
     203
     204    /**
     205     * Obtain a lock to ensure that a new layer is created before downloading non-new layers
     206     * @param settings The settings with the appropriate layer name; if no layer name is given, we assume that
     207     *                 the caller doesn't care where the data goes.
     208     * @return The lock to pass to {@link #releaseLock(DownloadParams, ReadWriteLock)} or {@code null} if no lock is needed.
     209     * @throws InterruptedException If the lock could not be obtained.
     210     */
     211    private static ReadWriteLock obtainLock(DownloadParams settings) throws InterruptedException {
     212        final ReadWriteLock lock;
     213        if (settings.isNewLayer() && !Utils.isEmpty(settings.getLayerName())) {
     214            synchronized (layerLockMap) {
     215                lock = layerLockMap.computeIfAbsent(settings.getLayerName(), k -> new ReentrantReadWriteLock());
     216                lock.writeLock().lock();
     217            }
     218        } else {
     219            synchronized (layerLockMap) {
     220                lock = layerLockMap.get(settings.getLayerName());
     221            }
     222            if (lock != null) {
     223                lock.readLock().lockInterruptibly();
     224            }
     225        }
     226        return lock;
     227    }
     228
     229    /**
     230     * Release the lock preventing data from being downloaded into an old layer
     231     * @param settings The settings with information on the new layer status
     232     * @param lock The lock to unlock
     233     */
     234    private static void releaseLock(DownloadParams settings, ReadWriteLock lock) {
     235        if (lock != null) {
     236            if (settings != null && settings.isNewLayer()) {
     237                lock.writeLock().unlock();
     238                synchronized (layerLockMap) {
     239                    layerLockMap.remove(settings.getLayerName());
     240                }
     241            } else {
     242                lock.readLock().unlock();
     243            }
    185244        }
    186245    }
     
    213272    }
    214273
    215     private void performDownload(DownloadOsmTask osmTask, DownloadParams settings) {
     274    /**
     275     * Perform the actual download; this is synchronized to ensure that we only have one download going on at a time
     276     * @param osmTask The task that will show a dialog
     277     * @param settings The download settings
     278     * @throws RequestHandlerErrorException If there is an issue getting data
     279     */
     280    private void performDownload(DownloadOsmTask osmTask, DownloadParams settings) throws RequestHandlerErrorException {
    216281        Future<?> future = MainApplication.worker.submit(
    217282                new PostDownloadHandler(osmTask, osmTask.download(settings, new Bounds(minlat, minlon, maxlat, maxlon),
     
    245310            }
    246311        });
     312        // Don't block forever, but do wait some period of time.
     313        try {
     314            future.get(OSM_DOWNLOAD_TIMEOUT.get(), TimeUnit.SECONDS);
     315        } catch (InterruptedException e) {
     316            Thread.currentThread().interrupt();
     317            throw new RequestHandlerErrorException(e);
     318        } catch (TimeoutException | ExecutionException e) {
     319            throw new RequestHandlerErrorException(e);
     320        }
    247321    }
    248322
  • trunk/test/unit/org/openstreetmap/josm/io/remotecontrol/handler/LoadAndZoomHandlerTest.java

    r19152 r19153  
    55import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
    66import static org.junit.jupiter.api.Assertions.assertEquals;
     7import static org.junit.jupiter.api.Assertions.assertNotEquals;
    78import static org.junit.jupiter.api.Assertions.assertNotNull;
    89import static org.junit.jupiter.api.Assertions.assertNull;
     
    1314import java.nio.charset.StandardCharsets;
    1415import java.util.Collection;
     16import java.util.concurrent.ForkJoinPool;
     17import java.util.concurrent.ForkJoinTask;
     18import java.util.concurrent.atomic.AtomicBoolean;
    1519
    1620import com.github.tomakehurst.wiremock.client.WireMock;
    1721import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
    1822import com.github.tomakehurst.wiremock.matching.RequestPatternBuilder;
     23import org.awaitility.Awaitility;
    1924import org.junit.jupiter.api.BeforeEach;
    2025import org.junit.jupiter.api.extension.ExtendWith;
     
    2429import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
    2530import org.openstreetmap.josm.gui.MainApplication;
     31import org.openstreetmap.josm.gui.layer.OsmDataLayer;
     32import org.openstreetmap.josm.gui.util.GuiHelper;
    2633import org.openstreetmap.josm.io.remotecontrol.handler.RequestHandler.RequestHandlerBadRequestException;
    2734import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
     
    4148class LoadAndZoomHandlerTest {
    4249    private static final String DEFAULT_BBOX_URL = "https://localhost/load_and_zoom?left=0&bottom=0&right=0.001&top=0.001";
     50    private static final String DEFAULT_BBOX_URL_2 = "https://localhost/load_and_zoom?left=0.00025&bottom=0.00025&right=0.00075&top=0.00125";
    4351    private static LoadAndZoomHandler newHandler(String url) throws RequestHandlerBadRequestException {
    4452        LoadAndZoomHandler req = new LoadAndZoomHandler();
     
    6573                        " <node id=\"3\" " + common + " lat=\"0.0002\" lon=\"0.0002\"/>\n" +
    6674                        "</osm>")));
     75        // The scientific notation is ok server-side.
     76        wireMockRuntimeInfo.getWireMock().register(WireMock.get("/api/0.6/map?bbox=2.5E-4,0.001,7.5E-4,0.00125")
     77                .willReturn(WireMock.aResponse().withBody("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
     78                        "<osm version=\"0.6\" generator=\"hand\" copyright=\"JOSM\" attribution=\"\" license=\"\">\n" +
     79                        " <bounds minlat=\"0.001\" minlon=\"0.00025\" maxlat=\"0.00125\" maxlon=\"0.00075\"/>\n" +
     80                        " <node id=\"4\" " + common + " lat=\"0.00111\" lon=\"0.00026\"/>\n" +
     81                        " <node id=\"5\" " + common + " lat=\"0.0011\" lon=\"0.00025\"/>\n" +
     82                        " <node id=\"6\" " + common + " lat=\"0.0012\" lon=\"0.000251\"/>\n" +
     83                        "</osm>")));
    6784    }
    6885
     
    141158        assertDoesNotThrow(handler::handle);
    142159        syncThreads();
    143         // The scientific notation is ok server-side.
    144         final String mapCall = "/api/0.6/map?bbox=2.5E-4,0.001,7.5E-4,0.00125";
    145         final String commonNode = "visible=\"true\" version=\"1\" changeset=\"1\" timestamp=\"2000-01-01T00:00:00Z\" user=\"tsmock\" uid=\"1\"";
    146         wireMockRuntimeInfo.getWireMock().register(WireMock.get(mapCall)
    147                 .willReturn(WireMock.aResponse().withBody("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
    148                         "<osm version=\"0.6\" generator=\"hand\" copyright=\"JOSM\" attribution=\"\" license=\"\">\n" +
    149                         " <bounds minlat=\"0.001\" minlon=\"0.00025\" maxlat=\"0.00125\" maxlon=\"0.00075\"/>\n" +
    150                         " <node id=\"4\" " + commonNode + " lat=\"0.00111\" lon=\"0.00026\"/>\n" +
    151                         " <node id=\"5\" " + commonNode + " lat=\"0.0011\" lon=\"0.00025\"/>\n" +
    152                         " <node id=\"6\" " + commonNode + " lat=\"0.0012\" lon=\"0.000251\"/>\n" +
    153                         "</osm>")));
    154         String request = "https://localhost/load_and_zoom?left=0.00025&bottom=0.00025&right=0.00075&top=0.00125";
    155         handler = newHandler(request);
    156         assertDoesNotThrow(handler::handle);
    157         syncThreads();
    158         wireMockRuntimeInfo.getWireMock().verifyThat(1, RequestPatternBuilder.newRequestPattern().withUrl(mapCall));
     160
     161        handler = newHandler(DEFAULT_BBOX_URL_2);
     162        assertDoesNotThrow(handler::handle);
     163        syncThreads();
     164        wireMockRuntimeInfo.getWireMock().verifyThat(1, RequestPatternBuilder.newRequestPattern()
     165                .withUrl("/api/0.6/map?bbox=2.5E-4,0.001,7.5E-4,0.00125"));
    159166        final DataSet ds = MainApplication.getLayerManager().getEditDataSet();
    160167        assertNotNull(ds);
     
    227234        assertEquals("here", ds.getChangeSetTags().get("is"));
    228235    }
     236
     237    /**
     238     * Non-regression test for <a href="https://josm.openstreetmap.de/ticket/23821">#23821</a>
     239     * @throws RequestHandlerBadRequestException If there is an issue with the handler
     240     */
     241    @Test
     242    void testNonRegression23821() throws RequestHandlerBadRequestException {
     243        final AtomicBoolean block = new AtomicBoolean(false);
     244        final Runnable runnable = () -> {
     245            synchronized (block) {
     246                while (!block.get()) {
     247                    try {
     248                        block.wait();
     249                    } catch (InterruptedException e) {
     250                        Thread.currentThread().interrupt();;
     251                        throw new RuntimeException(e);
     252                    }
     253                }
     254            }
     255        };
     256        final DataSet wrongDataset = new DataSet();
     257        MainApplication.getLayerManager().addLayer(new OsmDataLayer(wrongDataset,
     258                "LoadAndZoomHandlerTest#testNonRegression23821", null));
     259        ForkJoinTask<?> task1;
     260        ForkJoinTask<?> task2;
     261        try {
     262            GuiHelper.runInEDT(runnable);
     263            MainApplication.worker.submit(runnable);
     264            // The processor makes a new handler for each request
     265            // It is single-threaded, so blocking on one handler would fix the problem with the other handler.
     266            // But we might as well work on multi-threading, since it is easier to test. :)
     267            final LoadAndZoomHandler handler1 = newHandler(DEFAULT_BBOX_URL + "&new_layer=true&layer_name=OSMData");
     268            final LoadAndZoomHandler handler2 = newHandler(DEFAULT_BBOX_URL_2 + "&new_layer=false&layer_name=OSMData");
     269            // Use a separate threads to avoid blocking on this thread
     270            final ForkJoinPool pool = ForkJoinPool.commonPool();
     271            task1 = pool.submit(() -> assertDoesNotThrow(handler1::handle));
     272
     273            // Make certain there is enough time for the first task to block
     274            Awaitility.await().until(() -> true);
     275            task2 = pool.submit(() -> assertDoesNotThrow(handler2::handle));
     276        } finally {
     277            // Unblock UI/worker threads
     278            synchronized (block) {
     279                block.set(true);
     280                block.notifyAll();
     281            }
     282        }
     283
     284        task1.join();
     285        task2.join();
     286
     287        syncThreads();
     288        assertEquals(2, MainApplication.getLayerManager().getLayers().size());
     289        final DataSet ds = MainApplication.getLayerManager().getEditDataSet();
     290        assertNotEquals(wrongDataset, ds);
     291        assertTrue(wrongDataset.isEmpty());
     292        assertEquals(6, ds.allPrimitives().size());
     293    }
    229294}
Note: See TracChangeset for help on using the changeset viewer.