Skip to content

Commit dd4dcf9

Browse files
committed
Clear the heavier of what the audit left
An export wrote its temporary to a fixed name -- the target with ".tmp" on the end. That is only safe while one writer exists at a time, and there is no such guarantee: the window's export lock is in-process, so a command line export and the window together, or two exports into one world, opened the same file with O_TRUNC, interleaved their bytes, and both renamed the mixture over a region file in somebody's save. The name is unique now and the contents are forced to disk before the rename, because a region file that is a rename away from complete but whose bytes never landed is a corrupt chunk rather than a missing one. Checked on a copy of a real world: 6 chunks written, 374 kept, 552 before and after, no temporary left behind. Nothing stopped a RuntimeException reaching Minecraft. The mixins inject at the tail of vanilla's packet handlers and the event registrations run inside the client's own tick and connection handling, so anything escaping unwound into code that has no idea what this mod is and took the client with it -- and onBlockApplied, which calls into the level and two caches, had no guard at all. The seam is the right place for one, so every dispatch point goes through it and any future one will too. Errors are not caught: an OutOfMemoryError is not this mod's to absorb, and pretending to carry on after one hides the only evidence. A stray file in transactions/ made every Open fail forever, from every command, with no way to reach fsck to find out why. A desktop.ini, a .DS_Store or a cloud sync conflict copy was enough, and none of them says anything about whether the transactions are sound. A file this package did not write is not a transaction. transfer.Receive read a peer's bundle.json whole into memory whatever its size, while internal/bundle caps four dimensions on the adapter path -- which is the less untrusted of the two, since those bundles were written by a mod on the same machine. Both it and each record are bounded now. Three screens reported a failure as an absence. An unreadable recordings folder came back as zero counts, so the play screen said "Nothing new since last time" about a directory it had failed to open, while the import screen given the same directory said so honestly. A failed list of moments read as "Nothing recorded for this server. Play and bring in some recordings" -- advice for a situation that was not theirs, about an answer that never arrived -- and on the world screen the moment chooser simply vanished, taking the one thing that makes this different from a world downloader with it. And a failed import left its button disabled and still reading "Bringing it in...", because it lives in the screen's footer and nothing else put it back.
1 parent d353c65 commit dd4dcf9

6 files changed

Lines changed: 167 additions & 25 deletions

File tree

adapters/fabric/src/client/java/org/worldledger/fabric/WorldLedgerRuntime.java

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public static void initialize(CapturePaths paths) {
4444
public static void onFullChunkPacket(ClientboundLevelChunkWithLightPacket packet) {
4545
CaptureCoordinator coordinator = COORDINATOR.get();
4646
if (coordinator != null) {
47-
coordinator.onFullChunkPacket(packet);
47+
guard("a chunk packet", () -> coordinator.onFullChunkPacket(packet));
4848
}
4949
}
5050

@@ -70,30 +70,59 @@ public static String reload() {
7070
public static void onBlockUpdate(ClientboundBlockUpdatePacket packet) {
7171
CaptureCoordinator coordinator = COORDINATOR.get();
7272
if (coordinator != null) {
73-
coordinator.onBlockApplied(packet.getPos());
73+
guard("a block update", () -> coordinator.onBlockApplied(packet.getPos()));
7474
}
7575
}
7676

7777
public static void onSectionBlocksUpdate(ClientboundSectionBlocksUpdatePacket packet) {
7878
CaptureCoordinator coordinator = COORDINATOR.get();
7979
if (coordinator != null) {
80-
packet.runUpdates((position, state) -> coordinator.onBlockApplied(position));
80+
guard("a section block update", () -> packet.runUpdates((position, state) -> coordinator.onBlockApplied(position)));
8181
}
8282
}
8383

8484
public static void onBlockEntityData(ClientboundBlockEntityDataPacket packet) {
8585
CaptureCoordinator coordinator = COORDINATOR.get();
8686
if (coordinator != null) {
87-
coordinator.onBlockEntityPacket(packet);
87+
guard("a block entity packet", () -> coordinator.onBlockEntityPacket(packet));
8888
}
8989
}
9090

9191
public static void onBiomeUpdate(ClientboundChunksBiomesPacket packet) {
9292
CaptureCoordinator coordinator = COORDINATOR.get();
9393
if (coordinator != null) {
94-
for (ClientboundChunksBiomesPacket.ChunkBiomeData data : packet.chunkBiomeData()) {
95-
coordinator.onBiomeApplied(data.pos().x(), data.pos().z());
96-
}
94+
guard("a biome update", () -> {
95+
for (ClientboundChunksBiomesPacket.ChunkBiomeData data : packet.chunkBiomeData()) {
96+
coordinator.onBiomeApplied(data.pos().x(), data.pos().z());
97+
}
98+
});
99+
}
100+
}
101+
102+
/**
103+
* Runs one piece of capture work, and never lets it reach Minecraft.
104+
*
105+
* <p>This class is the seam between the game and this mod: the mixins inject
106+
* at the tail of vanilla's own packet handlers, and the event registrations
107+
* below run inside the client's tick and connection handling. Anything that
108+
* escapes from here unwinds into code that has no idea what this mod is, and
109+
* takes the client down with it.
110+
*
111+
* <p>Nothing this mod does is worth that. Capture is a passenger: it records
112+
* what the client was shown, and a session that records nothing is a session
113+
* that recorded nothing. A player losing their connection -- or their
114+
* afternoon -- because a chunk could not be read is the one outcome that is
115+
* worse than not capturing at all.
116+
*
117+
* <p>Errors are not caught. An OutOfMemoryError or a linkage failure is not
118+
* this mod's to absorb, and pretending to carry on after one would hide the
119+
* only evidence of what happened.
120+
*/
121+
private static void guard(String what, Runnable body) {
122+
try {
123+
body.run();
124+
} catch (RuntimeException exception) {
125+
LOGGER.error("Capture failed during {}; the client is unaffected", what, exception);
97126
}
98127
}
99128

@@ -103,45 +132,45 @@ private static void registerEvents() {
103132
PENDING_JOIN.set(pending);
104133
CaptureCoordinator coordinator = COORDINATOR.get();
105134
if (coordinator != null && PENDING_JOIN.compareAndSet(pending, null)) {
106-
coordinator.onJoin(client);
135+
guard("joining a server", () -> coordinator.onJoin(client));
107136
}
108137
});
109138
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> {
110139
CONNECTION_EPOCH.incrementAndGet();
111140
PENDING_JOIN.set(null);
112141
CaptureCoordinator coordinator = COORDINATOR.get();
113142
if (coordinator != null) {
114-
coordinator.onDisconnect();
143+
guard("leaving a server", coordinator::onDisconnect);
115144
}
116145
});
117146
ClientLevelEvents.AFTER_CLIENT_LEVEL_CHANGE.register((client, level) -> {
118147
CaptureCoordinator coordinator = COORDINATOR.get();
119148
if (coordinator != null) {
120-
coordinator.onLevelChange(level);
149+
guard("a dimension change", () -> coordinator.onLevelChange(level));
121150
}
122151
});
123152
ClientChunkEvents.CHUNK_LOAD.register((level, chunk) -> {
124153
CaptureCoordinator coordinator = COORDINATOR.get();
125154
if (coordinator != null) {
126-
coordinator.onChunkLoad(level, chunk);
155+
guard("a chunk loading", () -> coordinator.onChunkLoad(level, chunk));
127156
}
128157
});
129158
ClientChunkEvents.CHUNK_UNLOAD.register((level, chunk) -> {
130159
CaptureCoordinator coordinator = COORDINATOR.get();
131160
if (coordinator != null) {
132-
coordinator.onChunkUnload(level, chunk);
161+
guard("a chunk unloading", () -> coordinator.onChunkUnload(level, chunk));
133162
}
134163
});
135164
ClientTickEvents.END_CLIENT_TICK.register(client -> {
136165
CaptureCoordinator coordinator = COORDINATOR.get();
137166
if (coordinator != null) {
138-
coordinator.onEndTick();
167+
guard("the end of a tick", coordinator::onEndTick);
139168
}
140169
});
141170
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> {
142171
CaptureCoordinator coordinator = COORDINATOR.get();
143172
if (coordinator != null) {
144-
coordinator.onClientStopping();
173+
guard("the client stopping", coordinator::onClientStopping);
145174
}
146175
});
147176
}
@@ -158,7 +187,9 @@ private static void bootstrap(CapturePaths paths) {
158187
if (pendingJoin != null) {
159188
pendingJoin.client().execute(() -> {
160189
if (CONNECTION_EPOCH.get() == pendingJoin.epoch() && COORDINATOR.get() == coordinator) {
161-
coordinator.onJoin(pendingJoin.client());
190+
// This runs on the client thread, so it needs the same
191+
// guard as the join it stands in for.
192+
guard("joining a server", () -> coordinator.onJoin(pendingJoin.client()));
162193
}
163194
});
164195
}

desktop/internal/api/status.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ type SpoolState struct {
8484
// folder anyway, so the page can say the recordings still exist rather than
8585
// leaving somebody to assume they were consumed.
8686
Imported int `json:"imported"`
87+
// Unreadable carries why the folder could not be read, when it could not be.
88+
// Empty on the ordinary path.
89+
Unreadable string `json:"unreadable,omitempty"`
8790
// ImportedBytes is what those are costing inside the Minecraft directory.
8891
//
8992
// Keeping them is the safe default and stays the default. Not saying what
@@ -179,7 +182,12 @@ func readSpoolState() *SpoolState {
179182
if os.IsNotExist(err) {
180183
return nil
181184
}
182-
return &SpoolState{Dir: dir}
185+
// A folder that cannot be read is not an empty folder. Returning zero
186+
// counts made the play screen say "Nothing new since last time" about a
187+
// folder it had failed to open -- while the import screen, given the
188+
// same folder, said so honestly. Two screens disagreeing about one
189+
// directory, and the reassuring one was wrong.
190+
return &SpoolState{Dir: dir, Unreadable: err.Error()}
183191
}
184192
return &SpoolState{
185193
Dir: dir,

desktop/ui/assets/app.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,12 @@ function renderCapture(status) {
342342
// or whose launcher replaced the mods folder, still has every recording they
343343
// ever made sitting here, and reading the folder's existence as "you are
344344
// recording" told them to go and play while nothing was being kept.
345-
if (!status.capturing) {
345+
if (status.spool.unreadable) {
346+
// Said before anything else, because every number below it is zero for a
347+
// reason that has nothing to do with what was recorded.
348+
host.append(banner('todo', 'The recordings folder could not be read',
349+
status.spool.unreadable + ' — nothing here is a count of what you have.'));
350+
} else if (!status.capturing) {
346351
host.append(banner('todo', 'Nothing would be recorded if you played now',
347352
'Your past recordings are safe and listed below. Go to Set up to put the mod back.'));
348353
} else if (waiting > 0) {
@@ -508,6 +513,12 @@ document.getElementById('import-run').addEventListener('click', async () => {
508513
host.append(list);
509514
}
510515
} catch (err) {
516+
// The button lives in the screen's footer, outside the body this replaces,
517+
// so nothing else puts it back. Leaving it disabled and still reading
518+
// "Bringing it in…" told somebody it was working underneath an error
519+
// message, and only leaving the screen and returning ever fixed it.
520+
button.disabled = false;
521+
button.textContent = 'Try again';
511522
problem(host, err);
512523
}
513524
});
@@ -736,13 +747,24 @@ function whenText(iso) {
736747
});
737748
}
738749

750+
// The last error from momentsFor, so a failure can be told apart from an empty
751+
// archive. Swallowing it made a damaged archive read as "Nothing recorded for
752+
// this server. Play and bring in some recordings." -- advice for a situation
753+
// that was not the one they were in, about an answer the application had never
754+
// received.
755+
let momentsFailure = null;
756+
739757
async function momentsFor(server) {
758+
momentsFailure = null;
740759
if (!server) return [];
741760
try {
742761
return (await call('/api/moments?server=' + encodeURIComponent(server) +
743762
'&dimension=' + encodeURIComponent(chosenDimension))).moments || [];
744763
} catch (err) {
745-
// A missing list of moments costs the choice of one, not the screen.
764+
// A failed list of moments costs the choice of one, not the screen. What it
765+
// must not cost is the difference between "there are none" and "we could
766+
// not find out".
767+
momentsFailure = err;
746768
return [];
747769
}
748770
}
@@ -800,6 +822,14 @@ async function refreshWorld() {
800822
scope.append(field);
801823
}
802824
host.append(scope);
825+
// Without this the chooser simply was not there, and the one thing that
826+
// makes this different from a world downloader disappeared with no
827+
// explanation. It still writes: "now" is a moment.
828+
if (momentsFailure) {
829+
host.append(banner('todo',
830+
'The list of moments could not be read: ' + momentsFailure.message,
831+
(momentsFailure.next || '') + ' You can still write the newest of everything recorded.'));
832+
}
803833

804834
const answer = await call('/api/worlds');
805835
if (!answer.worlds.length) {
@@ -952,6 +982,10 @@ async function refreshTravel() {
952982

953983
const server = chosenServer;
954984
const moments = await momentsFor(server);
985+
if (momentsFailure) {
986+
problem(host, momentsFailure);
987+
return;
988+
}
955989
if (moments.length < 1) {
956990
host.append(banner('todo', 'Nothing recorded for ' + server, 'Play and bring in some recordings.'));
957991
return;

internal/anvil/export.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,13 +360,42 @@ func readObject(source ObjectSource, ref model.BlobRef) ([]byte, error) {
360360
return data, nil
361361
}
362362

363+
// writeFileAtomic replaces a file in one step, so a reader never sees half of
364+
// one.
365+
//
366+
// The temporary name is unique rather than the target's with ".tmp" on the end.
367+
// A fixed name is only safe while one writer exists at a time, and there is no
368+
// such guarantee here: the desktop application's export lock is in-process, so a
369+
// command line export and the window running together -- or two exports into the
370+
// same world -- opened the same temporary with O_TRUNC, interleaved their bytes,
371+
// and both renamed the mixture over a region file in somebody's save.
372+
//
373+
// The contents are forced to disk before the rename. This writes into a world
374+
// the player will open in Minecraft, and a region file that is a rename away
375+
// from complete but whose bytes never landed is a corrupt chunk rather than a
376+
// missing one.
363377
func writeFileAtomic(path string, data []byte) error {
364-
temporary := path + ".tmp"
365-
if err := os.WriteFile(temporary, data, 0o644); err != nil {
378+
handle, err := os.CreateTemp(filepath.Dir(path), ".worldledger-tmp-*")
379+
if err != nil {
380+
return err
381+
}
382+
temporary := handle.Name()
383+
if _, err := handle.Write(data); err != nil {
384+
handle.Close()
385+
os.Remove(temporary)
386+
return err
387+
}
388+
if err := handle.Sync(); err != nil {
389+
handle.Close()
390+
os.Remove(temporary)
391+
return err
392+
}
393+
if err := handle.Close(); err != nil {
394+
os.Remove(temporary)
366395
return err
367396
}
368397
if err := os.Rename(temporary, path); err != nil {
369-
_ = os.Remove(temporary)
398+
os.Remove(temporary)
370399
return err
371400
}
372401
return nil

internal/archive/archive.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,15 @@ func (a Archive) recoverTransactions() error {
257257
continue
258258
}
259259
if filepath.Ext(entry.Name()) != ".json" {
260-
return fmt.Errorf("unexpected transaction file %q", entry.Name())
260+
// Refusing here refuses to open the archive at all, from every
261+
// command, with no way to reach fsck to find out why. A desktop.ini,
262+
// a .DS_Store, or a cloud sync service's conflict copy is enough --
263+
// none of which says anything about whether the transactions are
264+
// sound, and none of which anybody could act on from the message.
265+
//
266+
// A file this package did not write is not a transaction, so it is
267+
// not replayed and not treated as damage.
268+
continue
261269
}
262270
if err := a.recoverTransaction(filepath.Join(dir, entry.Name())); err != nil {
263271
return err

internal/transfer/transfer.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,40 @@ type Received struct {
293293
// own identity rules reject a record whose id does not match its contents. A
294294
// bundle from an untrusted peer therefore cannot introduce anything the archive
295295
// would not have accepted from its own adapter.
296+
// Sizes a peer does not get to choose.
297+
//
298+
// internal/bundle caps four dimensions on the adapter path, which is the less
299+
// untrusted of the two: the bundles it reads were written by a mod on the same
300+
// machine. This path reads a directory somebody else assembled and had no caps
301+
// at all, so a peer's bundle.json was read whole into memory whatever its size.
302+
// The numbers are generous against any real bundle and finite against a hostile
303+
// one.
304+
const (
305+
maxTransferManifestBytes = 8 << 20
306+
maxRecordBytes = 1 << 20
307+
)
308+
309+
func readAtMost(path string, max int64) ([]byte, error) {
310+
handle, err := os.Open(path)
311+
if err != nil {
312+
return nil, err
313+
}
314+
defer handle.Close()
315+
// One byte past the limit, so a file exactly at it is accepted and anything
316+
// larger is refused rather than silently truncated into something that
317+
// might still parse.
318+
data, err := io.ReadAll(io.LimitReader(handle, max+1))
319+
if err != nil {
320+
return nil, err
321+
}
322+
if int64(len(data)) > max {
323+
return nil, fmt.Errorf("%s is larger than %d bytes", filepath.Base(path), max)
324+
}
325+
return data, nil
326+
}
327+
296328
func Receive(a archive.Archive, dir string) (Received, error) {
297-
data, err := os.ReadFile(filepath.Join(dir, "bundle.json"))
329+
data, err := readAtMost(filepath.Join(dir, "bundle.json"), maxTransferManifestBytes)
298330
if err != nil {
299331
return Received{}, err
300332
}
@@ -338,7 +370,7 @@ func Receive(a archive.Archive, dir string) (Received, error) {
338370
if err := validateDigest(id); err != nil {
339371
return Received{}, err
340372
}
341-
raw, err := os.ReadFile(filepath.Join(dir, "observations", id+".json"))
373+
raw, err := readAtMost(filepath.Join(dir, "observations", id+".json"), maxRecordBytes)
342374
if err != nil {
343375
return Received{}, fmt.Errorf("observation %s declared but missing: %w", id[:12], err)
344376
}
@@ -396,7 +428,7 @@ func Receive(a archive.Archive, dir string) (Received, error) {
396428
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
397429
return Received{}, fmt.Errorf("attestation %q: a name may not contain a path", name)
398430
}
399-
body, err := os.ReadFile(filepath.Join(dir, "attestations", name))
431+
body, err := readAtMost(filepath.Join(dir, "attestations", name), maxRecordBytes)
400432
if err != nil {
401433
return Received{}, fmt.Errorf("attestation %s: %w", name, err)
402434
}

0 commit comments

Comments
 (0)