just_audio icon indicating copy to clipboard operation
just_audio copied to clipboard

`LockCachingAudioSource` does not recover from network errors

Open agersant opened this issue 2 years ago • 69 comments

Which API doesn't behave as documented, and how does it misbehave? When calling LockCachingAudioSource.request() after a network error occurs, no bytes will be transferred.

Minimal reproduction project https://github.com/agersant/just_audio/tree/just-audio-issue-594

To Reproduce (i.e. user steps, not code) Steps to reproduce the behavior:

  1. Clone the just-audio-issue-594 branch of the repository above.
  2. Run the example app under just_audio_background/example.
  3. Tap the Prefetch button. Observe the debug console messages indicating data being received.
  4. While data is being received, turn off the internet connection being used by the device (using the Android quick settings UI at the top of the screen).
  5. Observe debug messages about the download failure (and an unhandled exception call stack).
  6. Tap the Prefetch button again.
  7. Observe the debug console and notice no messages about data being transferred.

Error messages

I/flutter (14120): #0      _HttpIncoming.listen.<anonymous closure> (dart:_http/http_impl.dart:443:7)
I/flutter (14120): #1      _invokeErrorHandler (dart:async/async_error.dart:45:24)
I/flutter (14120): #2      _HandleErrorStream._handleError (dart:async/stream_pipe.dart:269:9)
I/flutter (14120): #3      _ForwardingStreamSubscription._handleError (dart:async/stream_pipe.dart:157:13)
I/flutter (14120): #4      _rootRunBinary (dart:async/zone.dart:1452:47)
I/flutter (14120): #5      _CustomZone.runBinary (dart:async/zone.dart:1342:19)
I/flutter (14120): #6      _CustomZone.runBinaryGuarded (dart:async/zone.dart:1252:7)
I/flutter (14120): #7      _BufferingStreamSubscription._sendError.sendError (dart:async/stream_impl.dart:360:15)
I/flutter (14120): #8      _BufferingStreamSubscription._sendError (dart:async/stream_impl.dart:378:7)
I/flutter (14120): #9      _BufferingStreamSubscription._addError (dart:async/stream_impl.dart:280:7)
I/flutter (14120): #10     _SyncStreamControllerDispatch._sendError (dart:async/stream_controller.dart:737:19)
I/flutter (14120): #11     _StreamController._addError (dart:async/stream_controller.dart:615:7)
I/flutter (14120): #12     _StreamController.addError (dart:async/stream_controller.dart:569:5)
I/flutter (14120): #13     _HttpParser._

Expected behavior No exception callstack should appear in the log, and the download should restart or resume when tapping Prefetch.

Screenshots

https://user-images.githubusercontent.com/817256/142405368-a52b0f42-9b07-4d29-962f-f5dd5ee00347.mp4

Desktop (please complete the following information):

  • OS: Windows 10
  • Browser N/A

Smartphone (please complete the following information):

  • Device: Pixel 5 emulator
  • OS: Android API level 31

Flutter SDK version

Doctor summary (to see all details, run flutter doctor -v):
[√] Flutter (Channel stable, 2.5.3, on Microsoft Windows [Version 10.0.19042.1348], locale en-US)
[!] Android toolchain - develop for Android devices (Android SDK version 30.0.3)
    X cmdline-tools component is missing
      Run `path/to/sdkmanager --install "cmdline-tools;latest"`
      See https://developer.android.com/studio/command-line for more details.
    X Android license status unknown.
      Run `flutter doctor --android-licenses` to accept the SDK licenses.
      See https://flutter.dev/docs/get-started/install/windows#android-setup for more details.
[√] Chrome - develop for the web
[√] Android Studio (version 2020.3)
[√] VS Code (version 1.62.2)
[√] Connected device (3 available)

! Doctor found issues in 1 category.

Additional context For some reason, the debugger doesn't seem to catch the exception in just_audio code with this minimal repro. In my full app, I was able to determine that https://github.com/ryanheise/just_audio/blob/1c6598c0f3589baac4194e71b4d2885205f58ef4/just_audio/lib/just_audio.dart#L3009 was the victim.

Further debugging of the minimal repro seems to indicate that the cleanup code (https://github.com/ryanheise/just_audio/blob/1c6598c0f3589baac4194e71b4d2885205f58ef4/just_audio/lib/just_audio.dart#L2899) never runs. Also note that this catchError handler does not return a HttpClientResponse, which is illegal.

Thanks in advance for the help!

agersant avatar Nov 18 '21 11:11 agersant

I am also seeing similar issues on ios. After being in background for a while, when coming back, it seems the proxy server has crashed or is no longer running. This results in error PlayerException: (-1004) Could not connect to the server. which I believe is because the http proxy has errored, and is not recovered.

esiqveland avatar Jan 02 '22 17:01 esiqveland

Is there more to the stack trace that traces back to just_audio plugin code? I'll be able to insert some detection and recovery code at that point.

ryanheise avatar Jan 03 '22 02:01 ryanheise

I have two:

PlayerException: (-1004) Could not connect to the server.
  File "just_audio.dart", line 778, in AudioPlayer._load
  File "<asynchronous suspension>"
  File "just_audio.dart", line 1346, in AudioPlayer._setPlatformActive.setPlatform
PlayerException: (-1004) Could not connect to the server.
  File "just_audio.dart", line 778, in AudioPlayer._load
  File "<asynchronous suspension>"
  File "just_audio.dart", line 708, in AudioPlayer.load
  File "<asynchronous suspension>"
  File "just_audio.dart", line 683, in AudioPlayer.setAudioSource
  File "<asynchronous suspension>"
  File "player_task.dart", line 146, in MyAudioHandler.skipToQueueItem
  File "<asynchronous suspension>"
  File "playerstate.dart", line 417, in PlayerCommandPlaySongInAlbum.reduce
  File "<asynchronous suspension>"
  File "store.dart", line 455, in Store._processAction_Async

Sadly I have not been able to reproduce on simulator.

esiqveland avatar Jan 03 '22 23:01 esiqveland

Hmm, unfortunately neither of those point to the proxy.

I am guessing that an onError and/or onDone handler should probably be added on on line 1924 to handle this scenario, and then automatically set _running back to false so that it can be restarted when next needed.

ryanheise avatar Jan 04 '22 01:01 ryanheise

encountered same bug.

I am also seeing similar issues on ios. After being in background for a while, when coming back, it seems the proxy server has crashed or is no longer running. This results in error PlayerException: (-1004) Could not connect to the server. which I believe is because the http proxy has errored, and is not recovered.

any solution?

Blacksith avatar Jan 06 '22 02:01 Blacksith

@Blacksith , the latest progress is in my previous comment. That is, I have an idea of what is going on, but haven't taken a crack at it yet. You are welcome to help experiment to make it happen sooner.

ryanheise avatar Jan 06 '22 02:01 ryanheise

@Blacksith , the latest progress is in my previous comment. That is, I have an idea of what is going on, but haven't taken a crack at it yet. You are welcome to help experiment to make it happen sooner.

I will also try to see what the problem could be. The only thing I know now is that this error is relevant only on ios release build, in the debug build I did not find such an error

Blacksith avatar Jan 06 '22 02:01 Blacksith

Hmm, unfortunately neither of those point to the proxy.

I am guessing that an onError and/or onDone handler should probably be added on on line 1924 to handle this scenario, and then automatically set _running back to false so that it can be restarted when next needed.

tried adding: _server.doOnError((p0, p1) { _running = false; }); _server.doOnDone(() { _running = false; }); but the error is still there, maybe I got it wrong?

Blacksith avatar Jan 06 '22 03:01 Blacksith

I was supposing to add the onError and onDone handlers to the _server.listen call.

ryanheise avatar Jan 06 '22 05:01 ryanheise

I was supposing to add the onError and onDone handlers to the _server.listen call.

_server.listen((request) async {
      if (request.method == 'GET') {
        final uriPath = _requestKey(request.uri);
        final handler = _handlerMap[uriPath]!;
        handler(request);
      }
      _server.doOnError((p0, p1) {
        _running = false;
      });
      _server.doOnDone(() {
        _running = false;
      });
    });

Like this? This not woking too...

Blacksith avatar Jan 06 '22 09:01 Blacksith

I'm not at my computer right now but from memory the documentation for listen says that it takes these callbacks directly as parameters.

ryanheise avatar Jan 06 '22 09:01 ryanheise

I'm not at my computer right now but from memory the documentation for listen says that it takes these callbacks directly as parameters.

can you help with that? Because I don't understand how I need to do it right

Blacksith avatar Jan 06 '22 10:01 Blacksith

_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
    _server.listen((request) async {
      if (request.method == 'GET') {
        final uriPath = _requestKey(request.uri);
        final handler = _handlerMap[uriPath]!;
        handler(request);
      }
    }, onError: (){
      _running = false;
    });

if I do as described in the documentation - I keep getting PlatformException(error, No active stream to cancel, null, null)

Blacksith avatar Jan 06 '22 13:01 Blacksith

I found a solution. I added on line 778 _proxy.start(); After that everything started to work. I don't claim this is the solution to the problem, but it worked for me.

Blacksith avatar Jan 06 '22 17:01 Blacksith

It works better with this patch:

    _server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
    _server.listen((request) async {
      if (request.method == 'GET') {
        final uriPath = _requestKey(request.uri);
        final handler = _handlerMap[uriPath]!;
        handler(request);
      }
    }, 
    onDone: () {
        _running = false;
    },
    onError: (Object o, StackTrace st) {
      _running = false;
    });

Now it can recover to playing again half of the time, the other half the app just crashes directly, though that may be because of missing error handling on my own part.

esiqveland avatar Jan 07 '22 08:01 esiqveland

I have faced the same issue on iOS release on physical device. PlayerException: (-1004) Could not connect to the server.

Any suggestion to resolve the issue.

AmmarZY avatar Feb 27 '22 17:02 AmmarZY

@AmmarZY we are figuring this out together, please feel welcome to join in and try out the numerous suggestions above as a starting point. You will need to make these edits to a local copy of just_audio, or alternatively you could copy the source of LockCachingAudioSource into your own app with a slightly different name, and then experiment with the above changes in your own version of that class.

ryanheise avatar Feb 28 '22 01:02 ryanheise

@ryanheise Thanks for the support.

The following hack worked for me

I found a solution. I added on line 778 _proxy.start(); After that everything started to work. I don't claim this is the solution to the problem, but it worked for me.

The issue occurs when the iPhone screen locked normally while audio playing in the background. When the audio completed, the iOS after 30 seconds interrupts the app proxy and it stopped working as the result of the following error The PlayerException: (-1004) Could not connect to the server.

AmmarZY avatar Mar 10 '22 08:03 AmmarZY

@Blacksith @AmmarZY can you please bring some code examples of your fix?

I am asking, cos line 778 is a bit busy one... image

Also it's not clear, should _proxy.start(); be awaited or not...

pro100svitlo avatar Apr 02 '22 09:04 pro100svitlo

@esiqveland 's code looks on the right track to me. (just fyi I'll be in hospital on Monday, so probably can't give it attention until after then.)

Based on my earlier experiments I wasn't certain if it caught errors in all cases, so it needs further experimentation.

ryanheise avatar Apr 02 '22 09:04 ryanheise

@pro100svitlo

Also it's not clear, should _proxy.start(); be awaited or not...

No, you don't need to add it. Just replace this code starts at line 1783 as suggested by @esiqveland :

    _server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
    _server.listen((request) async {
      if (request.method == 'GET') {
        final uriPath = _requestKey(request.uri);
        final handler = _handlerMap[uriPath]!;
        handler(request);
      }
    }, 
    onDone: () {
        _running = false;
    },
    onError: (Object o, StackTrace st) {
      _running = false;
    });

I tested it already. I used a connectivity_plus package to check the connection, and if it is disconnected; then it will display error on network. Before I found @esiqveland solution, I always ended up having an error as mentioned by @agersant. Now, I don't have that problem anymore.

SS - Error in Just Audio when it is disconnected

gOzaru avatar Apr 04 '22 05:04 gOzaru

Well, if you still have error in

await sourceResponse.stream.pipe(request.response); 

then just replace the code starts on line 2762:

    // Pipe response
    await sourceResponse.stream.pipe(request.response).then((value) async {
      await request.response.close();
    }, onError: (Object o, StackTrace st) {
      dev.log("Unable to connect");
    });

Whenever it gets disconnected on these conditions:

  • at start of buffering/downloading the song from backend
  • at the end of buffering to load the next song automatically

Then it won't get the same error again. I tested it 5 times and it really works.

gOzaru avatar Apr 04 '22 06:04 gOzaru

@gOzaru okay, my case probably is different.

I describe it here.

It take me some time to debug example app and compare with mine. The issue comes cos I use headers with my source.

Here is piece of code from library, where dramattic change happened:

  @override
  Future<void> _setup(AudioPlayer player) async {
    await super._setup(player);
    if (uri.scheme == 'asset') {
      _overrideUri = await _loadAsset(uri.pathSegments.join('/'));
    } else if (uri.scheme != 'file' &&
        !kIsWeb &&
        (headers != null || player._userAgent != null)) {
      await player._proxy.ensureRunning();
      _overrideUri = player._proxy.addUriAudioSource(this);
    }
  }

we are interested in this particular line:

        (headers != null || player._userAgent != null)) {

I use headers, so in my app code goes further, and then fails inside _proxyHandlerForUri, line 3039.

pro100svitlo avatar Apr 04 '22 12:04 pro100svitlo

@pro100svitlo Your problem may be different but once the issue is fixed for LockCachingAudioSource it should also automatically be fixed for your problem, too. All that's happening in both cases is that the proxy is invoked (it happens for caching, and it happens for headers). Because there's a bug with the proxy not being able to reconnect after a network error, it affects both caching and headers similarly.

ryanheise avatar Apr 04 '22 13:04 ryanheise

It is not only about reconnection. It's also about notifying player about the error: right now error swallowed silently.

pro100svitlo avatar Apr 04 '22 13:04 pro100svitlo

@pro100svitlo yes, but that too is something where the solution is going to be the same in both the caching and the headers cases.

ryanheise avatar Apr 04 '22 15:04 ryanheise

On error, you would probably want to call _playbackEventSubject.addError(...).

ryanheise avatar Apr 04 '22 16:04 ryanheise

I've just been looking at this today, and strangely I have never experienced the proxy crashing and needing to be restarted. I do indeed get the exception stack trace listed in the bug report, but that stack trace is not coming from the proxy server itself, instead it's coming from the StreamAudioResponse.stream that is returned from LockCachingAudioSource.

So inside _proxyHandlerForSource you can do this:

    StreamAudioResponse sourceResponse;
    Stream<List<int>> stream;
    try {
      sourceResponse =
          await source.request(rangeRequest?.start, rangeRequest?.endEx);
      // Forward errors to playbackEven
      stream = sourceResponse.stream.asBroadcastStream();
      stream.listen((event) {}, onError: source._player?._playbackEventSubject.addError);
    } catch (e, stackTrace) {
       ...

Then at the end of the method you can use the broadcast stream to pipe the response:

    // Pipe response
    await stream.pipe(request.response);
    await request.response.close();

With this, everything seems to be working fine and the error can be listened to by the app via this playbackEvent stream. After reconnecting the Internet, the proxy continues to work because it never crashed. That said, I still think checking if the proxy crashed is a good idea in case it does need to be restarted but I just didn't see that code path happening during my testing. I'd be interested to know how to actually make that happen.

Let me know if the above code works for you.

ryanheise avatar Apr 15 '22 08:04 ryanheise

If anyone wants to test the code in my previous comment, I have pushed it to a branch called fix/caching.

ryanheise avatar Apr 15 '22 08:04 ryanheise

FYI, I intend to publish the above fix with or without testing within 24 hours, although of course I would appreciate if anyone can try the above fix before then.

ryanheise avatar Apr 19 '22 02:04 ryanheise

I am using the fix/caching branch with LockCachingAudioSource. I have a bit of a unique setup in that I am using multiple just_audio instances with a bit of a custom implantation of audio_service. These errors are from Android 12 Nexus 7 emulator, but they also occur on a real device.

I have two issues:

  1. In my app, there is a way to terminate the players while the audioSource is being loaded. I am directly disposing the just_audio instance in this case. At times, while terminating an instance, I get an error like the following: FileSystemException (FileSystemException: Cannot rename file to '/data/user/0/com.example.experience_beta/cache/just_audio_cache/remote/b14a3522dd64052b7fd9ea14665407eaefd25b40c6e0fb20c552939e2e981cac.mp3', path = '/data/user/0/com.example.experience_beta/cache/just_audio_cache/remote/b14a3522dd64052b7fd9ea14665407eaefd25b40c6e0fb20c552939e2e981cac.mp3.part' (OS Error: No such file or directory, errno = 2))

The exception does seem to be handled.

  1. My main issue is an unhandled exception. Here's an example of an error that just_audio with LockCachingAudioSource will throw if attempting to load a mp3 that doesn't exist on the remote server.
E/flutter (25316): [ERROR:flutter/lib/ui/ui_dart_state.cc(209)] Unhandled Exception: Invalid argument(s) (onError): The error handler of Future.catchError must return a value of the future's type
E/flutter (25316): #0      _FutureListener.handleError (dart:async/future_impl.dart:193:7)
E/flutter (25316): #1      Future._propagateToListeners.handleError (dart:async/future_impl.dart:778:47)
E/flutter (25316): #2      Future._propagateToListeners (dart:async/future_impl.dart:799:13)
E/flutter (25316): #3      Future._completeError (dart:async/future_impl.dart:609:5)
E/flutter (25316): #4      _completeOnAsyncError (dart:async-patch/async_patch.dart:272:13)
E/flutter (25316): #5      LockCachingAudioSource._fetch (package:just_audio/just_audio.dart)
package:just_audio/just_audio.dart:1
E/flutter (25316): <asynchronous suspension>
E/flutter (25316):
I/flutter (25316): Proxy request failed: Exception: HTTP Status Error: 404
I/flutter (25316): #0      LockCachingAudioSource._fetch
package:just_audio/just_audio.dart:2738
I/flutter (25316): <asynchronous suspension>

When the above error occurs, it is always followed by the following:

E/ExoPlayerImplInternal(25316):       at com.android.okhttp.internal.http.StreamAllocation.findHealthyConnection(StreamAllocation.java:128)
E/ExoPlayerImplInternal(25316):       at com.android.okhttp.internal.http.StreamAllocation.newStream(StreamAllocation.java:97)
E/ExoPlayerImplInternal(25316):       at com.android.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:289)
E/ExoPlayerImplInternal(25316):       at com.android.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:232)
E/ExoPlayerImplInternal(25316):       at com.android.okhttp.internal.huc.HttpURLConnectionImpl.execute(HttpURLConnectionImpl.java:465)
E/ExoPlayerImplInternal(25316):       at com.android.okhttp.internal.huc.HttpURLConnectionImpl.connect(HttpURLConnectionImpl.java:131)
E/ExoPlayerImplInternal(25316):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.makeConnection(DefaultHttpDataSource.java:629)
E/ExoPlayerImplInternal(25316):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.makeConnection(DefaultHttpDataSource.java:526)
E/ExoPlayerImplInternal(25316):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.open(DefaultHttpDataSource.java:352)
E/ExoPlayerImplInternal(25316):       ... 7 more
E/ExoPlayerImplInternal(25316): Playback error
E/ExoPlayerImplInternal(25316):   com.google.android.exoplayer2.ExoPlaybackException: Source error
E/ExoPlayerImplInternal(25316):       at com.google.android.exoplayer2.ExoPlayerImplInternal.handleIoException(ExoPlayerImplInternal.java:624)
E/ExoPlayerImplInternal(25316):       at com.google.android.exoplayer2.ExoPlayerImplInternal.handleMessage(ExoPlayerImplInternal.java:596)
E/ExoPlayerImplInternal(25316):       at android.os.Handler.dispatchMessage(Handler.java:102)
E/ExoPlayerImplInternal(25316):       at android.os.Looper.loop(Looper.java:223)
E/ExoPlayerImplInternal(25316):       at android.os.HandlerThread.run(HandlerThread.java:67)
E/ExoPlayerImplInternal(25316):   Caused by: com.google.android.exoplayer2.upstream.HttpDataSource$HttpDataSourceException: java.net.ConnectException: Failed to connect to /127.0.0.1:38815
E/ExoPlayerImplInternal(25316):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.open(DefaultHttpDataSource.java:358)
E/ExoPlayerImplInternal(25316):       at com.google.android.exoplayer2.upstream.DefaultDataSource.open(DefaultDataSource.java:201)
E/ExoPlayerImplInternal(25316):       at com.google.android.exoplayer2.upstream.StatsDataSource.open(StatsDataSource.java:84)
E/ExoPlayerImplInternal(25316):       at com.google.android.exoplayer2.source.ProgressiveMediaPeriod$ExtractingLoadable.load(ProgressiveMediaPeriod.java:1014)
E/ExoPlayerImplInternal(25316):       at com.google.android.exoplayer2.upstream.Loader$LoadTask.run(Loader.java:409)
E/ExoPlayerImplInternal(25316):       at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
E/ExoPlayerImplInternal(25316):       at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
E/ExoPlayerImplInternal(25316):       at java.lang.Thread.run(Thread.java:923)
E/ExoPlayerImplInternal(25316):   Caused by: java.net.ConnectException: Failed to connect to /127.0.0.1:38815
E/ExoPlayerImplInternal(25316):       at com.android.okhttp.internal.io.RealConnection.connectSocket(RealConnection.java:147)
E/ExoPlayerImplInternal(25316):       at com.android.okhttp.internal.io.RealConnection.connect(RealConnection.java:116)
E/ExoPlayerImplInternal(25316):       at com.android.okhttp.internal.http.StreamAllocation.findConnection(StreamAllocation.java:186)
E/ExoPlayerImplInternal(25316):       at com.android.okhttp.internal.http.StreamAllocation.findHealthyConnection(StreamAllocation.java:128)
E/ExoPlayerImplInternal(25316):       at com.android.okhttp.internal.http.StreamAllocation.newStream(StreamAllocation.java:97)
E/ExoPlayerImplInternal(25316):       at com.android.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:289)

The unhandled exception being thrown on ui_dart_state.cc causes the app to faulter until a hot reload.

I hope that this is of some help. I appreciate your work.

Colton127 avatar Apr 19 '22 03:04 Colton127

Thanks for testing, @Colton127 . What exactly happens when you say it "causes the app to faulter"? How can I reproduce it?

ryanheise avatar Apr 19 '22 05:04 ryanheise

FYI, I intend to publish the above fix with or without testing within 24 hours, although of course I would appreciate if anyone can try the above fix before then.

I just tested the fix against the reproduction case at the top of this thread and it is only partially fixed: ✔ The unhandled exception output in step 5 is no longer present. ❌ Clicking the Prefetch button still does not resume nor restart the download (steps 6-7).

I also confirmed with breakpoints that the following cleanup code does not run - which is most likely why a new connection is not attempted (as hinted by the nearby comment): https://github.com/ryanheise/just_audio/blob/29f201dff0a24e62acf07277f3226a504bb9e9d3/just_audio/lib/just_audio.dart#L2917

agersant avatar Apr 19 '22 07:04 agersant

I should mention that the reproduction project at the top of this thread is slightly artificial because the prefetch button directly interacts with the audio source's request method rather than going through the actual player, so the behaviour won't be exactly the same. In particular, error handling won't be the same since the latest commit tries to broadcast the errors via the attached player.

It would be interesting if you could test this in an actual app, or with a minimal project that goes through the actual player.

ryanheise avatar Apr 19 '22 08:04 ryanheise

Thanks for testing, @Colton127 . What exactly happens when you say it "causes the app to faulter"? How can I reproduce it?

Ryan,

The unhandled exception error can be thrown with code as simple as this:

final AudioPlayer _justPlayer = AudioPlayer();
final _audioSource = LockCachingAudioSource(Uri.parse('https://google.com/file.mp3'));
await _justPlayer.setAudioSource(_audioSource);

By the app faltering, I mean it becomes non-responsive. UI elements, say on a ListView builder, fail to load. However, this doesn't always occur. It usually occurs when I very rapidly play and stop numerous just_audio instances. This is not average usage, at all. I like to test these things because errors can be extremely unpredictable when your app is on a thousand different devices, all with different specifications and network speeds. I also cannot replicate the issue this morning, but I will keep a lookout and do my best to create a replicable environment when it occurs again.

For the record, I don't know if the issue is entirely the fault of just_audio. The exception being unhandled and thrown on ui_dart_state.cc causes the issue, sure, but it may be a deeper problem in flutter. The issue seems to be the UI state itself literally becoming non-responsive. I can still switch between PageViews, for example, but no content in my PageView (a Listview builder) loads. Basically, the UI becomes non-responsive when I attempt to load new elements. I think this is directly related to ui_dart_state.cc, and in fact, I honestly believe I have had an error on ui_dart_state.cc from something entirely different (and in a different project) result in the same issue. Fixing the issue should be as simple as handling the exception correctly, so it doesn't throw an exception on ui_dart_state.cc. Sorry for the wall of text...It's a tricky issue.

Colton127 avatar Apr 19 '22 15:04 Colton127

I am surprised if an unhandled exception would be the actual cause. Last time I looked at the flutter codebase, these exceptions are caught, and the stacktrace is printed out (as you see), or they are forwarded on to the error zone if you use runZonedGuarded. Perhaps your issue is caused by some other code that coincides with this error.

Either way, I wouldn't mind handling that unhandled exception if I knew where it was coming from.

ryanheise avatar Apr 19 '22 15:04 ryanheise

Here is an example that throws the unhandled exception error

import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';

void main() {
  runApp(const Home());
}

class Home extends StatelessWidget {
  const Home({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
          body: Center(
        child: Container(
          color: Colors.blue,
          child: RawMaterialButton(
            onPressed: () async {
              try {
                final AudioPlayer _justPlayer = AudioPlayer();
                final _audioSource = LockCachingAudioSource(Uri.parse('https://google.com/file.mp3'));
                await _justPlayer.setAudioSource(_audioSource);
                await _justPlayer.dispose();
              } catch (e) {
                print('Error loading audio: $e');
              }
            },
            child: Text('Load'),
          ),
        ),
      )),
    );
  }
}

Output:

IE/flutter ( 2890): #5      LockCachingAudioSource._fetch (package:just_audio/just_audio.dart)
package:just_audio/just_audio.dart:1
E/flutter ( 2890): <asynchronous suspension>
E/flutter ( 2890):
I/flutter ( 2890): Proxy request failed: Exception: HTTP Status Error: 404
I/flutter ( 2890): #0      LockCachingAudioSource._fetch
package:just_audio/just_audio.dart:2738
I/flutter ( 2890): <asynchronous suspension>
E/flutter ( 2890): [ERROR:flutter/lib/ui/ui_dart_state.cc(209)] Unhandled Exception: Invalid argument(s) (onError): The error handler of Future.catchError must return a value of the future's type
E/flutter ( 2890): #0      _FutureListener.handleError (dart:async/future_impl.dart:193:7)
E/flutter ( 2890): #1      Future._propagateToListeners.handleError (dart:async/future_impl.dart:778:47)
E/flutter ( 2890): #2      Future._propagateToListeners (dart:async/future_impl.dart:799:13)
E/flutter ( 2890): #3      Future._completeError (dart:async/future_impl.dart:609:5)
E/flutter ( 2890): #4      _completeOnAsyncError (dart:async-patch/async_patch.dart:272:13)
E/flutter ( 2890): #5      LockCachingAudioSource._fetch (package:just_audio/just_audio.dart)
package:just_audio/just_audio.dart:1
E/flutter ( 2890): <asynchronous suspension>
E/flutter ( 2890):
I/flutter ( 2890): Proxy request failed: Exception: HTTP Status Error: 404
I/flutter ( 2890): #0      LockCachingAudioSource._fetch
package:just_audio/just_audio.dart:2738
I/flutter ( 2890): <asynchronous suspension>
E/ExoPlayerImplInternal( 2890): Playback error
E/ExoPlayerImplInternal( 2890):   com.google.android.exoplayer2.ExoPlaybackException: Source error
E/ExoPlayerImplInternal( 2890):       at com.google.android.exoplayer2.ExoPlayerImplInternal.handleIoException(ExoPlayerImplInternal.java:624)
E/ExoPlayerImplInternal( 2890):       at com.google.android.exoplayer2.ExoPlayerImplInternal.handleMessage(ExoPlayerImplInternal.java:596)
E/ExoPlayerImplInternal( 2890):       at android.os.Handler.dispatchMessage(Handler.java:102)
E/ExoPlayerImplInternal( 2890):       at android.os.Looper.loop(Looper.java:223)
E/ExoPlayerImplInternal( 2890):       at android.os.HandlerThread.run(HandlerThread.java:67)
E/ExoPlayerImplInternal( 2890):   Caused by: com.google.android.exoplayer2.upstream.HttpDataSource$InvalidResponseCodeException: Response code: 500
E/ExoPlayerImplInternal( 2890):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.open(DefaultHttpDataSource.java:389)
E/ExoPlayerImplInternal( 2890):       at com.google.android.exoplayer2.upstream.DefaultDataSource.open(DefaultDataSource.java:201)
E/ExoPlayerImplInternal( 2890):       at com.google.android.exoplayer2.upstream.StatsDataSource.open(StatsDataSource.java:84)
E/ExoPlayerImplInternal( 2890):       at com.google.android.exoplayer2.source.ProgressiveMediaPeriod$ExtractingLoadable.load(ProgressiveMediaPeriod.java:1014)
E/ExoPlayerImplInternal( 2890):       at com.google.android.exoplayer2.upstream.Loader$LoadTask.run(Loader.java:409)
E/ExoPlayerImplInternal( 2890):       at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
E/ExoPlayerImplInternal( 2890):       at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
E/ExoPlayerImplInternal( 2890):       at java.lang.Thread.run(Thread.java:923)
E/AudioPlayer( 2890): TYPE_SOURCE: Response code: 500
I/ExoPlayerImpl( 2890): Release 8c0f270 [ExoPlayerLib/2.15.0] [generic_x86, Android SDK built for x86, unknown, 30] [goog.exo.core]
I/flutter ( 2890): Error loading audio: (0) Source error

Issue appears at package:just_audio/just_audio.dart:2738

Colton127 avatar Apr 19 '22 18:04 Colton127

I should mention that the reproduction project at the top of this thread is slightly artificial because the prefetch button directly interacts with the audio source's request method rather than going through the actual player [...]

I am in fact doing this in my app. (eg. https://github.com/agersant/polaris-android/blob/flutter/lib/core/prefetch.dart). The use case is that the app supports preloading music from different sources (current playlist, music explicitly flagged by the user, etc.) and I wanted to control manually what gets preloaded when.

agersant avatar Apr 20 '22 07:04 agersant

Just to clarify for others, while this issue was created for LockCachingAudioSource, this issue occurs without use of "LockCachingAudioSource" on iOS devices for us with just setAudioSource() alone after app spends some time (few minutes or so) in the background while paused. Playback can't recover until the app is fully restarted.

setAudioSource() error (-1004) Could not connect to the server.
#0      AudioPlayer._load (package:just_audio/just_audio.dart:778:9)<asynchronous suspension>
#1      AudioPlayer.load (package:just_audio/just_audio.dart:708:14)<asynchronous suspension>
#2      AudioPlayer.setAudioSource (package:just_audio/just_audio.dart:683:18)<asynchronous suspension>
#3      FutureExtensions.onError.<anonymous closure> (dart:async/future.dart:874:15)<asynchronous suspension>
[VERBOSE-2:ui_dart_state.cc(209)] Unhandled Exception: PlatformException(-1004, Could not connect to the server., null, null)

Is there any reason we can't just re-instantiate AudioPlayer() when the error is caught? (see Edit, this is sort of a downstream mess)

  just_audio: ^0.9.18
  audio_session: ^0.1.6+1
  audio_service: ^0.18.2

Edit: resurrecting the underlying proxy by creating a new instance isn't practical. Would going to HttpServer and getting connections info be a better way to ensure it's running? Straight from the horse's mouth rather than trusting _running? https://api.dart.dev/stable/2.16.2/dart-io/HttpConnectionsInfo/total.html

The other option would be to expose _proxy. As the python guy would say "we're all adults, here."

Edit again: I have only been using dart and flutter for 8 days.

ndmgrphc avatar May 02 '22 00:05 ndmgrphc

@ndmgrphc You are right that this is a more general issue for anyone using the proxy. Please make sure you are testing the branch mentioned above with the developments intended to address this. Once you check out that branch, you could also try hacking it yourself as others above have done, to test out your theories. With my own testing, I have found the branch to work, but am trying to do more testing now to reproduce the issue @Colton127 just mentioned.

ryanheise avatar May 02 '22 02:05 ryanheise

@ryanheise thanks. I simply exposed _proxy as proxyServer, caught the error in player.setAudioSource().onError, called player.proxyServer.start() and retried setAudioSource and all is good. In reading about HttpServer I'm not sure how one can determine whether the server is actually running.

This was a show stopper so I think for now I'll work in some kind of sane retry strategy and go with it for now.

ndmgrphc avatar May 02 '22 03:05 ndmgrphc

@ndmgrphc please confirm whether you have tested the fix/caching branch which already attempts to catch errors and restart the proxy.

ryanheise avatar May 02 '22 03:05 ryanheise

@ryanheise sorry for the delay it takes a while to test because each time I have to wait for it to die. I tried the fix/caching branch and the error was still there but now traces to 799.

  #just_audio: ^0.9.18
  just_audio:
    git:
      url: [email protected]:ryanheise/just_audio.git
      path: just_audio
      ref: fix/caching

Sorry this is so ugly:

#0      AudioPlayer._load (package:just_audio/just_audio.dart:799:9)
flutter: <asynchronous suspension>
flutter: #1      AudioPlayer.load (package:just_audio/just_audio.dart:729:14)
flutter: <asynchronous suspension>
flutter: #2      AudioPlayer.setAudioSource (package:just_audio/just_audio.dart:704:18)
flutter: <asynchronous suspension>
flutter: #3      FutureExtensions.onError.<anonymous closure> (dart:async/future.dart:874:15)
flutter: <asynchronous suspension>
flutter:
[VERBOSE-2:ui_dart_state.cc(209)] Unhandled Exception: PlatformException(-1004, Could not connect to the server., null, null)

I need to use the exposed proxyServer. Again, works fine as long as audio is playing and app is active (foreground or background). But after a period of no audio activity it seems to die.

ndmgrphc avatar May 02 '22 04:05 ndmgrphc

@ndmgrphc if your app is crashing on line 799 then you may not be using this branch in the way it was intended, because there should not be any need to reload the audio source if you want to let the plugin resolve the error transparently. Can you provide a stack trace to confirm?

ryanheise avatar May 02 '22 06:05 ryanheise

@Colton127 The latest commit should handle the unhandled exceptions. I'm not convinced that this would fix any behavioural issues, only that it will suppress the unhandled exceptions messages in the logs.

ryanheise avatar May 02 '22 14:05 ryanheise

@ryanheise I'm just calling setAudioSource() which works absolutely fine until a period of inactivity when the HttpServer appears to stop. I can't prove this because HttpServer has no exposed way of determine whether or not it's running. I'm assuming if it did have this, you'd not be setting _enabled elsewhere and would get getting the info straight from the horse's mouth (the horse here being HttpServer.

I switched to fix/caching and the problem remained.

As of this moment, the only way I can get background audio working reliably is to catch "-1004, Could not connect to the server." and explicitly restart your internal HttpServer which I am doing from the outside through my newly exposed AudioPlayer.proxy (and proxy.server). Once I restart it, I call setAudioSource again (same args, doing so recursively, carefully) and boom, works as expected. So the issue is indeed that HttpServer is dying (stopping) in the background unless I'm missing something.

If I understood this patch https://github.com/ryanheise/just_audio/issues/594#issuecomment-1007212756 correctly, it's at least setting _running = false for you so you can just run the request again on your own (letting your proxy's ensureRunning() do the start for you.

Is this what you meant? I still have to fire the retry myself, right (as in call setAudioSource() again)?

Again, very sorry as I have very little experience in Dart or any of these packages. In looking at HttpServer I don't understand how one would observe that the server has stopped. This does not seem to concern the Dart community so I must be looking at it wrong.

ndmgrphc avatar May 02 '22 17:05 ndmgrphc

I also confirmed with breakpoints that the following cleanup code does not run - which is most likely why a new connection is not attempted (as hinted by the nearby comment):

https://github.com/ryanheise/just_audio/blob/29f201dff0a24e62acf07277f3226a504bb9e9d3/just_audio/lib/just_audio.dart#L2917

I can confirm. The correct hook should happen by listening to errors on the stream returned by LockCachingAudioSource.request().

ryanheise avatar May 02 '22 18:05 ryanheise

@agersant you can try replacing the last line of the request method by this:

    return byteRangeRequest.future.then((response) {
      response.stream.listen((event) {}, onError: (Object e, StackTrace st) {
        // So that we can restart later
        _response = null;
        // Cancel any pending request
        for (final req in _requests) {
          req.fail(e, st);
        }
      });
      return response;
    });

ryanheise avatar May 03 '22 03:05 ryanheise

The latest commit on the fix/caching branch applies the above code snippet. Please try it and let me know if there are still issues.

Note that with this code, there is still an unhandled exception which is very difficult to track down, but again i do not think unhandled exceptions inherently would cause any serious issues.

ryanheise avatar May 03 '22 08:05 ryanheise

Thank you for the update. I merged fix/caching into my just_audio/tree/just-audio-issue-594 branch and performed additional testing. This latest change did make significant improvements, but there are still some unwanted results. Each test below was performed multiple times and generated consistent results.

Test 1: call request() while no connection is available

In this scenario, I disable Wi-Fi and LTE connections prior to making the request for audio. This does emit an unhandled exception within just_audio. However, the expected result makes its way to the user application and the error while prefetching log line from the test is printed. A subsequent request after enabling Wi-Fi or LTE succeeds.

Exception callstack:

E/flutter (10442): #0      _NativeSocket.startConnect (dart:io-patch/socket_patch.dart:681:35)
E/flutter (10442): #1      _RawSocket.startConnect (dart:io-patch/socket_patch.dart:1808:26)
E/flutter (10442): #2      RawSocket.startConnect (dart:io-patch/socket_patch.dart:27:23)
E/flutter (10442): #3      RawSecureSocket.startConnect (dart:io/secure_socket.dart:237:22)
E/flutter (10442): #4      SecureSocket.startConnect (dart:io/secure_socket.dart:60:28)
E/flutter (10442): #5      _ConnectionTarget.connect (dart:_http/http_impl.dart:2437:24)
E/flutter (10442): #6      _HttpClient._getConnection.connect (dart:_http/http_impl.dart:2808:12)
E/flutter (10442): #7      _HttpClient._getConnection (dart:_http/http_impl.dart:2813:12)
E/flutter (10442): #8      _HttpClient._openUrl (dart:_http/http_impl.dart:2697:12)
E/flutter (10442): #9      _HttpClient.getUrl (dart:_http/http_impl.dart:2574:48)
E/flutter (10442): #10     LockCachingAudioSource._fetch package:just_audio/just_audio.dart:2733
E/flutter (10442): <asynchronous suspension>

✔ This is an acceptable outcome as far as I'm concerned

Video: https://user-images.githubusercontent.com/817256/166585421-c16db6f2-c0c9-4f7d-8241-38552435ee2c.mp4

Test 2: Toggle Wi-Fi while the request is in flight

In this scenario, LTE connection is disabled and Wi-Fi is enabled. A request() call is made to begin the download. While the download is in progress, Wi-Fi is manually disabled. There is no unhandled exception, the error log line from the test harness is printed. After re-enabling Wi-Fi and waiting a couple seconds, a new request may be started and succeed. Skipping the short wait before retrying leads to the same result as Test 1 (which makes sense).

✔ This is the ideal result

Note that this was the scenario in the initial report of this issue so we are definitely trending in the right direction.

Video: https://user-images.githubusercontent.com/817256/166586228-87be4559-e010-4ef6-89f8-faeb6adbc12f.mp4

Test 3: Toggle LTE while the request is in flight

In this scenario, LTE connection is enabled and Wi-Fi is disabled. A request() call is made to begin the download. While the download is in progress, LTE is manually disabled. There is no unhandled exception, and the error log line from the test harness never prints. The download simply hangs and the audiosource is left in an unrecoverable state, even after re-enabling LTE and calling request() again. This is similar to the behavior initially reported when this issue was created.

Video: https://user-images.githubusercontent.com/817256/166586462-0236bceb-d386-451e-8917-28098ecd6faa.mp4

❌ This is not the desired behavior

Additional notes:

  • I also performed various tests starting from a state where both LTE and begin Wi-Fi are enabled, but I don't think those results add a lot to the simplified scenarios above.
  • I expect the issue in Test 3 is caused by either another code path failing to cleanup the state of the audio source, or an underlying resource failing to timeout/emit an error at all.

agersant avatar May 03 '22 23:05 agersant

Thanks, @agersant for that comprehensive report. I am not sure whether the Test 3 behaviour is due to code within this plugin or code within the network stack, but there isn't any code within this plugin that is specific to one type of network or the other, any and all errors are forwarded. If you find an extra error logged on the ExoPlayer side in the console, I can check the code to see if there is an error that I'm not forwarding from the ExoPlayer side.

But given that it seems to at least be a move in the right direction, I will publish the progress so far in a new release, and further improvements can still be made in the future.

ryanheise avatar May 04 '22 07:05 ryanheise

The fixes so far have now been published on pub.dev.

ryanheise avatar May 04 '22 08:05 ryanheise

@ryanheise Sorry to bring this news, but my issue is still there... I still face uncatched errors 😢

pro100svitlo avatar Jul 20 '22 04:07 pro100svitlo

I'm facing this issue as well, in production. Just audio returns the (-1004) Could not connect to the server after a while and it can't recover from it. After the first, it always returns the same. Seems the proxy is offline. The only way to recover from it is by restarting the entire App. I will investigate it more today to find out why and where it is happening. I think it is related to switching Apps and when returning it is not working anymore.

rwrz avatar Jul 25 '22 14:07 rwrz

Since each player has its own proxy, I think that creating a new player would be a way for the app to recover after this error, although that's only a workaround and it would be better to get to the bottom of why the proxy isn't being restarted. During the last round of bug fixes, we added some code to detect errors and set _running = false which should cause the proxy to be restarted on the next request. One theory is that _running = false is never actually happening in your scenario.

ryanheise avatar Jul 25 '22 14:07 ryanheise