Repository navigation
Files others write are read when shown and when the user comes back #122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
92bafe4
9042b16
d1f7bd4
4c02521
58edf96
bf3e15c
a1fa4e6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,183 +1,95 @@ | ||
| package com.github.minecraft_ta.totalDebugCompanion.catalog; | ||
|
|
||
| import java.util.Optional; | ||
| import java.util.concurrent.CompletableFuture; | ||
| import java.nio.file.attribute.FileTime; | ||
| import com.github.minecraft_ta.totalDebugCompanion.util.FileWatch; | ||
| import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; | ||
| import com.github.minecraft_ta.totalDebugCompanion.pack.GamePacks; | ||
| import com.github.minecraft_ta.totalDebugCompanion.game.GameState; | ||
| import com.github.minecraft_ta.totalDebugCompanion.game.GameLocation; | ||
| import com.github.minecraft_ta.totalDebugCompanion.game.GameState; | ||
| import com.github.minecraft_ta.totalDebugCompanion.pack.GamePacks; | ||
| import com.github.minecraft_ta.totalDebugCompanion.storage.ChangeRecord; | ||
| import com.github.minecraft_ta.totalDebugCompanion.util.FileReading; | ||
| import com.github.minecraft_ta.totalDebugCompanion.util.Signal; | ||
| import com.github.minecraft_ta.totalDebugCompanion.util.Strand; | ||
| import com.github.minecraft_ta.totalDebugCompanion.util.Workers; | ||
|
|
||
| import java.io.IOException; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.time.Duration; | ||
| import java.nio.file.attribute.FileTime; | ||
| import java.util.List; | ||
| import java.util.Objects; | ||
| import java.util.Set; | ||
| import java.util.Optional; | ||
|
|
||
| /** | ||
| * The instance's current world: the one the game has open, otherwise the one played last, read from its folder | ||
| * (docs/SYSTEMS.md, section 2). The World page and the Project tree show what it read; neither reads the world itself. | ||
| * Which world is current is decided on its strand, again when the game connects, plays another world or leaves one, and | ||
| * when a world of {@code saves} changes, as when a game Companion is not connected to opens or saves one. The current | ||
| * world is read again then, and after Companion changed its datapacks. Its own folder is watched only while the connected | ||
| * game plays it, which the game tells only once it let go of the world: on Windows a watched folder cannot be deleted or | ||
| * renamed, as by the game's Delete World. | ||
| * (docs/SYSTEMS.md, section 2). The World page, the Project tree and the World tab's title show what it read; none reads | ||
| * the world itself. It is read again when the game connects, plays another world or leaves one, after Companion changed | ||
| * the datapacks, and when the user comes back to Companion, as from a game that saved the world meanwhile. It is first | ||
| * read as it is made, so the World tab names the world also while its page was never shown. | ||
| */ | ||
| public final class WorldReading implements AutoCloseable { | ||
| /** The entries of a world's folder that make up what is read of it. */ | ||
| private static final long SETTLE_MILLIS = 500; | ||
| private static final Set<String> ENTRIES = Set.of("level.dat", "level.dat_old", "session.lock", "datapacks", "icon.png"); | ||
|
|
||
| /** The current world: its folder and what its level.dat holds, or why there is nothing to show. */ | ||
| public record World(Path directory, CurrentWorld.Saved saved, String problem) { | ||
| } | ||
|
|
||
| /** What is read of a world's folder: its level.dat, and when its icon was written, so a new icon is a change too. */ | ||
| private record Read(CurrentWorld.Saved saved, FileTime icon) { | ||
| } | ||
|
|
||
| /** The world followed and its reading, published together. */ | ||
| private record Followed(Path directory, FileReading<Read> reading) { | ||
| /** | ||
| * The current world: its folder, what its level.dat holds and when its icon was written, so a new icon is a change too; | ||
| * without what it holds where it could not be read, and all null where no world was played yet. | ||
| */ | ||
| public record World(Path directory, CurrentWorld.Saved saved, FileTime icon) { | ||
| } | ||
|
|
||
| private final GameLocation location; | ||
| private final Signal changed = new Signal(); | ||
| private final Runnable stopFollowingSaves; | ||
| private final Runnable stopFollowingDatapacks; | ||
| private final Strand strand = Workers.strand(); | ||
| private final Runnable stopFollowingGame; | ||
| private volatile Followed followed = new Followed(null, null); | ||
| // Changed on the strand only. | ||
| private Runnable stopReading = () -> { }; | ||
| /** The played world whose datapacks folder is watched, or null: a change inside it is not told by the world's own watch. */ | ||
| private Path datapacksOf; | ||
| private Runnable stopDatapacks = () -> { }; | ||
| private boolean closed; | ||
| private final FileReading<World> reading; | ||
| private final List<Runnable> stopFollowing; | ||
|
|
||
| /** | ||
| * Follows the current world of the game {@code location} tells of, whose datapacks {@code packs} names. Reads which | ||
| * world that is now. Blocking. | ||
| */ | ||
| /** Follows the current world of the game {@code location} tells of, whose datapacks {@code packs} names. */ | ||
| public WorldReading(GameLocation location, GamePacks packs) { | ||
| this.location = Objects.requireNonNull(location, "location"); | ||
| // A game opening a world creates its session lock before it takes it: which world is open is looked at after that. | ||
| this.stopFollowingSaves = FileWatch.shared().watch(location.workspace().resolve("saves"), world -> true, | ||
| () -> Workers.later(SETTLE_MILLIS, this.strand, this::follow)); | ||
| // Companion changed the datapacks, also in the level.dat of a world the game does not hold. | ||
| this.stopFollowingDatapacks = packs.changed(ChangeRecord.PackSide.DATA).subscribe(() -> this.strand.execute(this::follow)); | ||
| Runnable stopConnection = location.connectionChanged().subscribe(() -> this.strand.execute(this::follow)); | ||
| Runnable stopPlaying = location.playingChanged().subscribe(() -> this.strand.execute(this::follow)); | ||
| this.stopFollowingGame = () -> { | ||
| stopConnection.run(); | ||
| stopPlaying.run(); | ||
| }; | ||
| // Followed after the triggers are, so none is missed in between; on the strand, as every later look. | ||
| CompletableFuture.runAsync(this::follow, this.strand).join(); | ||
| Objects.requireNonNull(location, "location"); | ||
| this.reading = new FileReading<>(() -> read(location.read())); | ||
| this.stopFollowing = List.of(location.connectionChanged().subscribe(this.reading::refresh), | ||
| location.playingChanged().subscribe(this.reading::refresh), | ||
| // Also in the level.dat of a world the game does not hold. | ||
| packs.changed(ChangeRecord.PackSide.DATA).subscribe(this.reading::refresh)); | ||
|
Comment on lines
+40
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Whenever AGENTS.md reference: AGENTS.md:L19-L19 Useful? React with 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 58edf96, in FileReading rather than by choosing a signal: refresh requests made while one waits merge into it, as PageLoader's do. A world change told by both playing and the datapacks reads level.dat once. The test fails without it (5 reads instead of 3). |
||
| this.reading.refresh(); | ||
| } | ||
|
|
||
| /** Fires after the current world changed or what it holds did. */ | ||
| public Signal changed() { | ||
| return this.changed; | ||
| return this.reading.changed(); | ||
| } | ||
|
|
||
| /** The name of the current world as read last, without reading; empty where none was read yet. */ | ||
| public Optional<String> publishedName() { | ||
| Followed now = this.followed; | ||
| if (now.reading() == null) return Optional.empty(); | ||
| return now.reading().published().map(read -> read.saved().name()); | ||
| return this.reading.published().map(World::saved).map(CurrentWorld.Saved::name); | ||
| } | ||
|
|
||
| /** The current world as read last; read now where it was not read yet. Blocking then. */ | ||
| public World value() { | ||
| Followed now = this.followed; | ||
| if (now.directory() == null) return new World(null, null, "No world has been played in this instance yet."); | ||
| try { | ||
| return new World(now.directory(), now.reading().value().saved(), ""); | ||
| } catch (IOException unreadable) { | ||
| return new World(now.directory(), null, | ||
| "The world " + now.directory().getFileName() + " could not be read: " + unreadable.getMessage()); | ||
| } | ||
| /** | ||
| * The current world as read last; read now where it was not read yet, which fails where it cannot be read. A read | ||
| * that fails later, as while the game writes level.dat, keeps the world read before. Blocking then. | ||
| */ | ||
| public World value() throws IOException { | ||
| return this.reading.value(); | ||
| } | ||
|
|
||
| /** Finds which world is current now and follows it; the one followed so far is read again, as it may have opened. */ | ||
| private void follow() { | ||
| if (this.closed) return; | ||
| GameState game = this.location.read(); | ||
| Path current = CurrentWorld.directory(game).orElse(null); | ||
| Followed before = this.followed; | ||
| boolean played = current != null && game.plays(current); | ||
| if (Objects.equals(current, before.directory())) { | ||
| if (before.reading() != null) { | ||
| before.reading().watch(played); | ||
| watchDatapacks(played ? current : null, before.reading()); | ||
| before.reading().readNow(); | ||
| } | ||
| return; | ||
| } | ||
| this.stopReading.run(); | ||
| if (current == null) { | ||
| watchDatapacks(null, null); | ||
| this.stopReading = () -> { }; | ||
| this.followed = new Followed(null, null); | ||
| } else { | ||
| FileReading<Read> reading = new FileReading<>(current, entry -> ENTRIES.contains(entry.toString()), | ||
| world -> new Read(CurrentWorld.read(this.location.read(), world), iconWritten(world)), Duration.ofMillis(SETTLE_MILLIS)); | ||
| reading.watch(played); | ||
| watchDatapacks(played ? current : null, reading); | ||
| Runnable stopTelling = reading.changed().subscribe(this.changed::fire); | ||
| this.stopReading = () -> { | ||
| stopTelling.run(); | ||
| reading.close(); | ||
| }; | ||
| this.followed = new Followed(current, reading); | ||
| // Told once the new world's first read landed, so its followers find it published, as the tab its name. | ||
| Workers.files().execute(() -> { | ||
| try { | ||
| reading.value(); | ||
| } catch (IOException unreadable) { | ||
| // The World page shows why. | ||
| } | ||
| this.changed.fire(); | ||
| }); | ||
| return; | ||
| /** | ||
| * Reads the current world. A world read before that fails to read now, as while the game writes its level.dat, fails, | ||
| * so the world read before stays; another world that fails to read is published without what it holds, so nothing | ||
| * shows or changes the world before as if it were current. | ||
| */ | ||
| private World read(GameState game) throws IOException { | ||
| Path directory = CurrentWorld.directory(game).orElse(null); | ||
| if (directory == null) return new World(null, null, null); | ||
| try { | ||
| return new World(directory, CurrentWorld.read(game, directory), iconWritten(directory)); | ||
| } catch (IOException unreadable) { | ||
| if (this.reading.published().map(World::directory).filter(directory::equals).isPresent()) throw unreadable; | ||
| return new World(directory, null, null); | ||
| } | ||
| if (before.directory() != null) this.changed.fire(); | ||
| } | ||
|
|
||
| /** Watches the datapacks folder of {@code world}, which the game plays, or none; a change there reads it again. */ | ||
| private void watchDatapacks(Path world, FileReading<Read> reading) { | ||
| if (Objects.equals(world, this.datapacksOf)) return; | ||
| this.stopDatapacks.run(); | ||
| this.stopDatapacks = () -> { }; | ||
| this.datapacksOf = world; | ||
| if (world != null) this.stopDatapacks = FileWatch.shared().watch(world.resolve("datapacks"), entry -> true, reading::readNow); | ||
| } | ||
|
|
||
| /** When the world's icon was written, or null without one. */ | ||
| private static FileTime iconWritten(Path world) { | ||
| try { | ||
| Path icon = world.resolve("icon.png"); | ||
| return Files.isRegularFile(icon) ? Files.getLastModifiedTime(icon) : null; | ||
| } catch (IOException unreadable) { | ||
| return Files.getLastModifiedTime(world.resolve("icon.png")); | ||
| } catch (IOException none) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public void close() { | ||
| this.stopFollowingGame.run(); | ||
| this.stopFollowingSaves.run(); | ||
| this.stopFollowingDatapacks.run(); | ||
| this.strand.execute(() -> { | ||
| this.closed = true; | ||
| this.stopDatapacks.run(); | ||
| this.stopReading.run(); | ||
| }); | ||
| this.stopFollowing.forEach(Runnable::run); | ||
| this.reading.close(); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
playingChanged()queues another refresh whileCurrentWorld.readis still parsing the previous world, that first invocation has already captured the oldGameState, andFileReadingpublishes its result before processing the queued refresh.WorldPanelcan consequently display the old world and its datapack applier uses that stale saved directory, allowing an edit intended for the newly entered world to be written to the previous one while the newer read is pending. Preserve a request generation inWorldReadingor otherwise prevent an overtaken world-identity read from publishing.AGENTS.md reference: AGENTS.md:L19-L19
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in a1fa4e6, in FileReading rather than with a counter in WorldReading: a read under way when a refresh is asked for publishes nothing, and the read it waits for publishes, the newest-wins rule the page loader already follows. The world the game left is never published after the request for the new one. Test: aReadOvertakenByARequestMadeWhileItRanPublishesNothing, which fails without it (published Second, Third).