sdk icon indicating copy to clipboard operation
sdk copied to clipboard

[breaking change] Mark `IOOverrides` as `base`

Open bkonyi opened this issue 1 year ago • 16 comments
trafficstars

Change Intent

IOOverrides should be marked as base to prevent implementation of the class.

Justification

This class is used to override behavior of certain APIs in dart:io. There's no reason for developers to implement this class and marking it as base will allow for additional overrides to be added to the class without requiring a breaking change (e.g., [ dart:io ] Added override for exit(...) in IOOverrides).

Impact

Code implementing IOOverrides will no longer compile.

Mitigation

PRs should be sent to remove cases where IOOverrides is implemented, where possible. There's only a handful of cases found on GitHub.

Change Timeline

ASAP.

Associated CLs

https://dart-review.googlesource.com/c/sdk/+/389520

bkonyi avatar Aug 14 '24 15:08 bkonyi

FYI @lrhn @brianquinlan @a-siva

bkonyi avatar Aug 14 '24 15:08 bkonyi

Summary: The IOOverrides class is being marked as base to prevent developers from implementing it. This change will break code that currently implements IOOverrides, but there are only a few such cases found on GitHub.

dart-github-bot avatar Aug 14 '24 15:08 dart-github-bot

SGTM!

kevmoo avatar Aug 14 '24 18:08 kevmoo

What are the use cases for extending the class? Is it feasible to mark it as final instead?

natebosch avatar Aug 23 '24 19:08 natebosch

There's no reason for developers to implement this class and marking it as base will allow for additional overrides to be added to the class without requiring a breaking change

I disagree with "no reason". For example, consider something like https://github.com/google/file.dart/pull/215. I intentionally used implements io.IOOverrides because I want to be broken if the interface changes. Without breakage, a method could be added to IOOverrides and then a class such as MemoryFileSystemIOOverrides would silently continue "working" even though it would be allowing reads/writes to the physical file system instead of to the MemoryFileSystem as intended.

jamesderlin avatar Aug 30 '24 06:08 jamesderlin

PRs should be sent to remove cases where IOOverrides is implemented, where possible. There's only a handful of cases found on GitHub.

Every single one of those cases is:

class FakeIOOveride extends Fake implements IOOverrides {}
  1. What should those sites use instead? Isn't class FakeFoo extends Fake implements Foo the expected pattern? And a class can't extend more than one class. Or is a FakeIOOverride class not something ever worth having?
  2. Those implementations wouldn't even be broken if IOOverrides is modified.

IMO changing IOOverrides to disallow implements is unnecessarily restrictive. It feels like a slightly weird tradeoff to definitely break code now to avoid potentially breaking code in the future. I realize that in general it's better to break things earlier so that there would be less stuff broken later, but IOOverrides doesn't really strike me as a class that is commonly derived from anyway.

I also feel like anyone using implements should be aware (or be made aware) that their code will break if the base class interface changes. Could the IOOverrides documentation instead just recommend that derived classes use extends instead of implements? Or document that the Dart Authors reserve the right to modify IOOverrides at will, and that no warranty, expressed or implied, is given to the stability of the IOOverrides interface, and anyone that implements IOOverrides hereby waives any right to complain if their code is broken?

jamesderlin avatar Aug 30 '24 06:08 jamesderlin

What should those sites use instead?

An instance of IOOverrides. The IOOverrides class is a value-class. Its behavior is entirely determined by its state, and its entire state can be set through the constructor. If you need an IOOverrides object with a specific behavior, you can create one with that behavior. You never need a fake for a value object that is completely configurable.

This is the same reasoning I usually use to make classes final. That, or "has deep integration with something that you can't possibly mock anyway" like Zone.

Or is a FakeIOOverride class not something ever worth having?

That.

A mock is not needed, so not being possible is not a problem. It's just a migration cost up-front, with the benefit of preventing someone doing an implements IOOverrides in production, rather than just for testing where we don't care as much when things break.

lrhn avatar Aug 30 '24 12:08 lrhn

What should those sites use instead?

An instance of IOOverrides. The IOOverrides class is a value-class. Its behavior is entirely determined by its state, and its entire state can be set through the constructor. If you need an IOOverrides object with a specific behavior, you can create one with that behavior. You never need a fake for a value object that is completely configurable.

Isn't the intent class FakeIOOveride extends Fake implements IOOverrides {} to throw if something does any IO operations? If people manually configured an IOOverrides object, wouldn't it have the same problem I mentioned in https://github.com/dart-lang/sdk/issues/56468#issuecomment-2320201827 where things could get added to IOOverrides, those instances would not break, and IO operations could silently leak through?

jamesderlin avatar Aug 30 '24 15:08 jamesderlin

I can't say what the intent of the FakeIOOveride (sic) is. I can say what it does today, and that that thing won't be broken by adding more overrides. (Actually, it seems the class is never used, so the fix is to remove it.)

If a new override is added to IOOverrides, then it's for something that you cannot override today. That means that not throwing is preserving existing tested behavior. That's what we want.

If a new way to do IO is added, without an override, then code can start using that, and you can't stop them. If an override for the feature is later added, nothing changes other than you now being able to do something.

At that point, you will need to decide whether that new override-capable feature should be prevented too. Maybe it shouldn't, maybe it's for accessing the process environment, not "real IO", and using it is fine. Just starting to throw the moment an override becomes possible is a breaking change.

So yes, absolutely! Adding a new override option will not cause new throws, and that's exactly what is intended.

I also feel like anyone using implements should be aware (or be made aware) that their code will break if the base class interface changes.

That's not how semantic versioning and breaking changes work. Anyone using implements assumes that they can keep doing so until the next major version increment, because breaking them is a breaking change that requires a major version increment.

lrhn avatar Aug 30 '24 15:08 lrhn

What is the final conclusion on this breaking change ? Are we accepting it ?

a-siva avatar Oct 10 '24 00:10 a-siva

I'm :+1: Finalize all the classes!

lrhn avatar Oct 10 '24 11:10 lrhn

Excellent! I've uploaded the breaking change for review.

bkonyi avatar Oct 10 '24 15:10 bkonyi

@leonsenft @vsmenon @Hixie please take a look at this breaking change request.

itsjustkevin avatar Oct 14 '24 14:10 itsjustkevin

lgtm

vsmenon avatar Oct 14 '24 14:10 vsmenon

Is @jamesderlin's concern not valid? I'm not sure I understand how we are addressing his comment above.

Hixie avatar Oct 14 '24 20:10 Hixie

FWIW, in general I find having relying on zones to override APIs in a global manner in this way is bug prone and hard to debug. I would much rather dart:io use a mechanism like package:file's FileSystem, removing the global File constructor entirely. But I imagine that ship has long sailed.

Hixie avatar Oct 14 '24 20:10 Hixie

@Hixie

FWIW, I'm thinking about the possibility of redesigning the ship: dart:io reimplementation

brianquinlan avatar Oct 22 '24 21:10 brianquinlan

@zanderso can you sign off on this?

bkonyi avatar Jul 01 '25 17:07 bkonyi

Two things:

  • There are a couple of instances in the Flutter tree (in the Flutter CLI) where IOOverrides is extended. Is there a migration guide explaining how those cases would be handled?
  • Why do this only for IOOverrides? Why not also do it for HttpOverrides?

zanderso avatar Jul 01 '25 17:07 zanderso

Two things:

  • There are a couple of instances in the Flutter tree (in the Flutter CLI) where IOOverrides is extended. Is there a migration guide explaining how those cases would be handled?

This shouldn't impact anything. Marking it as base allows for it to be extended, and adding new members won't change behavior for projects that extend IOOverrides as they'll retain the default behavior they were already using.

  • Why do this only for IOOverrides? Why not also do it for HttpOverrides?

I'd be happy to do that too :) I forgot that existed.

bkonyi avatar Jul 01 '25 18:07 bkonyi

Reading the thread more carefully, I'm not sure that @jamesderlin's questions about their migration have been addressed by @lrhn's comments. In particular, the constructor of IOOverrides doesn't take any arguments, so I'm having trouble making sense of this comment: "The IOOverrides class is a value-class. Its behavior is entirely determined by its state, and its entire state can be set through the constructor."

zanderso avatar Jul 01 '25 21:07 zanderso

I'm having trouble making sense of this comment: "The IOOverrides class is a value-class. Its behavior is entirely determined by its state, and its entire state can be set through the constructor."

You're right to be confused, because IOOverrides is an abstract class and isn't instantiable.

@lrhn's argument is that there really isn't a reason to be implementing IOOverrides vs extending it. The only benefit of implementing it would come in the form of being notified that we've added a new override when a compile time error shows up after an SDK upgrade. In the case of a mock implementing IOOverrides, this will result in code crashing if it's invoking a previously non-overridable function that's been added to IOOverrides.

Marking this class as base ensures that dart:io behavior remains unchanged between SDK releases where new overrides are added. It's a more stable configuration for users and removes the need for us to make a breaking change request for every new override we introduce.

bkonyi avatar Jul 01 '25 21:07 bkonyi