GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — feat(exec): add nonblocking interactive sessions · docker-php/docker-php@0ffd32a

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
  • CHANGELOG.md
  • docs
    • docs.json
    • guides
      • exec.mdx
      • interactive-exec.mdx
  • src
    • Docker.php
    • DockerClientFactory.php
    • Endpoint
      • InteractiveExecStart.php
    • Exception
      • InteractiveExecTimeoutException.php
    • Http
      • InteractiveHttpClient.php
    • Stream
      • InteractiveExecStream.php
  • tests
    • InteractiveExecTransportTest.php
    • Resource
      • InteractiveExecResourceTest.php
    • Stream
      • InteractiveExecStreamTest.php
    • TransportTestCase.php
    • fixtures
      • interactive-server.php
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎CHANGELOG.md‎

Copy file name to clipboardExpand all lines: CHANGELOG.md
+7Lines changed: 7 additions & 0 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Add opt-in interactive exec sessions with nonblocking stdin writes, bounded
6+
output polling, non-TTY stdin EOF and streaming deadlines.
7+
- Keep existing blocking exec APIs unchanged. Interactive sessions require
8+
the bundled socket transport; TTY stdin half-close is rejected.
9+
310
## 3.1.0
411

512
- Add an explicit `api_version` factory option. It overrides `DOCKER_API_VERSION`
Collapse file

‎docs/docs.json‎

Copy file name to clipboardExpand all lines: docs/docs.json
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"cookbook/container-config",
2323
"guides/logs",
2424
"guides/exec",
25+
"guides/interactive-exec",
2526
"cookbook/build-image",
2627
"guides/registry",
2728
"guides/archives",
Collapse file

‎docs/guides/exec.mdx‎

Copy file name to clipboardExpand all lines: docs/guides/exec.mdx
+3Lines changed: 3 additions & 0 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ This recipe is output-only: it does not implement interactive stdin or a
9292
terminal UI. `onStdin()` on `DockerRawStream` is not a write method. See
9393
[attach limitations](/reference/streams#attach-and-websocket-limitations).
9494

95+
For the feature-branch API with stdin, nonblocking polling and heartbeat hooks,
96+
see [interactive exec](/guides/interactive-exec).
97+
9598
Raw response bodies can contain binary frame headers. See
9699
[Container logs](/guides/logs#raw-response-bodies) before working with raw fetch
97100
modes.
Collapse file

‎docs/guides/interactive-exec.mdx‎

Copy file name to clipboard
+129Lines changed: 129 additions & 0 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
---
2+
title: "Interactive exec"
3+
description: "Send stdin while polling output, with application heartbeats and deadlines."
4+
---
5+
6+
<Note>
7+
This API is on the `codex/interactive-exec` feature branch. It is not included
8+
in client `3.1.0`. The existing blocking `execStart()` and `wait()` APIs are unchanged.
9+
</Note>
10+
11+
Use interactive exec when a command needs stdin or your application must keep
12+
working while it runs. The session reads and writes without blocking on command
13+
output. A caller can renew a lock, check cancellation or enforce its own deadline
14+
between `poll()` calls, even if the command is silent.
15+
16+
## Non-TTY stdin and output
17+
18+
The default `Docker::create()` socket client supports this API. For explicit PHP
19+
configuration, use `DockerClientFactory::createInteractive()`:
20+
21+
```php
22+
<?php
23+
24+
require __DIR__ . '/vendor/autoload.php';
25+
26+
use Docker\API\Model\ContainersIdExecPostBody;
27+
use Docker\API\Model\ExecIdStartPostBody;
28+
use Docker\Docker;
29+
use Docker\DockerClientFactory;
30+
31+
$docker = Docker::create(DockerClientFactory::createInteractive([
32+
'remote_socket' => 'unix:///var/run/docker.sock',
33+
'api_version' => '1.45',
34+
'timeout' => 2000,
35+
]), [], [], false);
36+
37+
$command = new ContainersIdExecPostBody();
38+
$command->setCmd(['timeout', '-s', 'KILL', '30', 'cat']);
39+
$command->setAttachStdin(true);
40+
$command->setAttachStdout(true);
41+
$command->setAttachStderr(true);
42+
$command->setTty(false);
43+
$exec = $docker->containerExec('my-container', $command);
44+
45+
$start = new ExecIdStartPostBody();
46+
$start->setTty(false);
47+
$session = $docker->execStartInteractive($exec->getId(), $start, 35000);
48+
$session->onStdout(static function (string $chunk): void {
49+
echo $chunk;
50+
});
51+
$session->onStderr(static function (string $chunk): void {
52+
fwrite(STDERR, $chunk);
53+
});
54+
55+
$input = "{\"example\":true}\n";
56+
$offset = 0;
57+
$stdinClosed = false;
58+
$nextHeartbeat = 0;
59+
try {
60+
while (!$session->isFinished()) {
61+
$now = hrtime(true);
62+
if ($now >= $nextHeartbeat) {
63+
// Renew your application lock here; throw if ownership was lost.
64+
$nextHeartbeat = $now + 10000000000;
65+
}
66+
if ($offset < strlen($input)) {
67+
$offset += $session->writeStdin(substr($input, $offset, 16384));
68+
} elseif (!$stdinClosed) {
69+
$session->closeStdin();
70+
$stdinClosed = true;
71+
}
72+
$session->poll(50);
73+
}
74+
} finally {
75+
$session->close();
76+
}
77+
78+
$result = $docker->execInspect($exec->getId());
79+
if ($result->getRunning() !== false || $result->getExitCode() === null) {
80+
throw new RuntimeException('The exec result is not known yet; inspect it before retrying');
81+
}
82+
if ($result->getExitCode() !== 0) {
83+
throw new RuntimeException('The command failed with exit status '.$result->getExitCode());
84+
}
85+
```
86+
87+
Replace `my-container` with a running development container. `timeout` must exist
88+
inside its image; use the image's own timeout/supervision mechanism if it does not.
89+
90+
`writeStdin()` returns the number of bytes accepted, at most 16 KiB per call.
91+
Zero means backpressure: poll output, then retry the unsent suffix. The library
92+
does not retain or queue stdin. Once all input has been sent, `closeStdin()` sends
93+
EOF without closing the non-TTY output channels.
94+
95+
`poll(50)` reads at most 16 KiB and waits at most 50 milliseconds for data. It
96+
returns `true` while the connection remains open, including during idle periods;
97+
`false` means clean output EOF. Callbacks receive chunks, which can split frames
98+
or lines. Without a callback, that channel is drained and discarded.
99+
100+
If stdin is already complete, `wait($tick, 50)` is a convenience loop. `$tick`
101+
runs between polls even with no output; it can also send input. Callbacks and
102+
ticks must return promptly. Their exceptions close the local session and propagate.
103+
104+
## Deadlines and completion
105+
106+
The factory's `timeout` bounds synchronous HTTP setup requests. The third argument
107+
to `execStartInteractive()` is a separate total streaming deadline in milliseconds,
108+
starting after the HTTP upgrade. It is not reset when output arrives. Expiry throws
109+
`Docker\Exception\InteractiveExecTimeoutException` and closes local I/O.
110+
111+
<Warning>
112+
Closing the stream or reaching a local deadline does not guarantee that Docker
113+
terminated the command. Keep an in-container timeout for unattended operations.
114+
Output EOF is not an exit code: inspect `Running` and `ExitCode`, with a bounded
115+
application deadline if you need to poll for the final result. Never automatically
116+
retry a command with side effects after an ambiguous disconnect.
117+
</Warning>
118+
119+
## Transports and TTY
120+
121+
This feature supports the bundled Unix, TCP and TLS socket transport. Guzzle and
122+
arbitrary PSR-18 clients are rejected before sending an interactive start request.
123+
HTTP proxies must preserve the HTTP 101 connection upgrade and bidirectional traffic.
124+
125+
TTY sessions support stdin writes and combined output on `onStdout()`. They have
126+
terminal semantics, not separate stdout/stderr pipes. `closeStdin()` deliberately
127+
rejects TTY sessions: Docker can close their output when socket stdin reaches EOF.
128+
Use terminal input or let the command exit; use `Tty=false` for JSON-input managers.
129+
The `Tty` setting must match at exec creation and start.
Collapse file

‎src/Docker.php‎

Copy file name to clipboardExpand all lines: src/Docker.php
+58-3Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,21 @@
1515
use Docker\Endpoint\ImageBuild;
1616
use Docker\Endpoint\ImageCreate;
1717
use Docker\Endpoint\ImagePush;
18+
use Docker\Endpoint\InteractiveExecStart;
1819
use Docker\Endpoint\SystemEvents;
1920
use Docker\Exception\BadRequestException;
21+
use Docker\Http\InteractiveHttpClient;
22+
use Docker\Stream\InteractiveExecStream;
23+
use Docker\Stream\SocketReadStream;
24+
use Http\Client\Socket\Stream as SocketStream;
2025

2126
/**
2227
* Docker\Docker.
2328
*/
2429
class Docker extends Client
2530
{
31+
private bool $interactiveExecEnabled = false;
32+
2633
/**
2734
* {@inheritdoc}
2835
*/
@@ -60,6 +67,52 @@ public function execStart(string $id, ?ExecIdStartPostBody $requestBody = null,
6067
return $this->executeEndpoint(new ExecStart($id, $requestBody, $accept), $fetch);
6168
}
6269

70+
/**
71+
* Start attached exec with nonblocking stdin/stdout/stderr. timeoutMs is a
72+
* streaming deadline after upgrade; HTTP setup uses the factory timeout.
73+
* Inspect the exec separately after output EOF to obtain its exit status.
74+
*/
75+
public function execStartInteractive(string $id, ?ExecIdStartPostBody $requestBody = null, ?int $timeoutMs = null): InteractiveExecStream
76+
{
77+
if (!$this->interactiveExecEnabled) {
78+
throw new \LogicException('Interactive exec requires DockerClientFactory::createInteractive() or the default socket client.');
79+
}
80+
if (null !== $timeoutMs && ($timeoutMs < 1 || $timeoutMs > 86400000)) {
81+
throw new \InvalidArgumentException('Interactive exec timeout must be between 1 and 86400000 milliseconds.');
82+
}
83+
if ('' === $id) {
84+
throw new \InvalidArgumentException('An exec ID is required.');
85+
}
86+
$body = null === $requestBody ? new ExecIdStartPostBody() : clone $requestBody;
87+
if ($body->getDetach()) {
88+
throw new \InvalidArgumentException('Interactive exec cannot be detached.');
89+
}
90+
$body->setDetach(false);
91+
$response = $this->executeRawEndpoint(new InteractiveExecStart($id, $body));
92+
$stream = $response->getBody();
93+
$mediaType = strtolower(trim(explode(';', $response->getHeaderLine('Content-Type'))[0]));
94+
if (101 !== $response->getStatusCode()
95+
|| 'tcp' !== strtolower(trim($response->getHeaderLine('Upgrade')))
96+
|| !preg_match('/(?:^|,)\s*upgrade\s*(?:,|$)/i', $response->getHeaderLine('Connection'))
97+
|| $response->hasHeader('Transfer-Encoding')
98+
|| $response->hasHeader('Content-Encoding')
99+
|| !\in_array($mediaType, [\Docker\Stream\DockerRawStream::HEADER, \Docker\Stream\DockerRawStream::MULTIPLEXED_HEADER], true)
100+
|| (!$stream instanceof SocketReadStream && !$stream instanceof SocketStream)) {
101+
$stream->close();
102+
throw new \RuntimeException('Docker did not provide a supported interactive exec upgrade (HTTP '.$response->getStatusCode().').');
103+
}
104+
// The detached resource keeps bytes PHP read ahead with the headers.
105+
$socket = $stream->detach();
106+
try {
107+
return new InteractiveExecStream($socket, !(bool) $body->getTty(), $timeoutMs);
108+
} catch (\Throwable $error) {
109+
if (\is_resource($socket)) {
110+
fclose($socket);
111+
}
112+
throw $error;
113+
}
114+
}
115+
63116
/**
64117
* {@inheritdoc}
65118
*/
@@ -75,6 +128,7 @@ public function imageCreate(?string $requestBody = null, array $queryParameters
75128
{
76129
return $this->executeEndpoint(new ImageCreate($requestBody, $queryParameters, $headerParameters), $fetch);
77130
}
131+
78132
public function imagePush(string $name, array $queryParameters = [], array $headerParameters = [], string $fetch = self::FETCH_OBJECT, array $accept = [])
79133
{
80134
if (isset($headerParameters['X-Registry-Auth']) && $headerParameters['X-Registry-Auth'] instanceof AuthConfig) {
@@ -97,14 +151,14 @@ public static function create(
97151
array $additionalPlugins = [],
98152
array $additionalNormalizers = [],
99153
bool $applyServerPlugins = true
100-
): self
101-
{
154+
): self {
102155
if (null === $httpClient) {
103-
$httpClient = DockerClientFactory::createFromEnv();
156+
$httpClient = DockerClientFactory::createInteractiveFromEnv();
104157
$applyServerPlugins = false;
105158
}
106159

107160
$client = parent::create($httpClient, $additionalPlugins, $additionalNormalizers, $applyServerPlugins);
161+
$client->interactiveExecEnabled = $httpClient instanceof InteractiveHttpClient;
108162
$response = $client->executeRawEndpoint(new SystemInfo());
109163
$testClient = $response->getBody()->getContents();
110164
$jsonObj = json_decode($testClient);
@@ -121,6 +175,7 @@ public static function create(
121175
} else {
122176
throw new BadRequestException("Failed to decode JSON.", $response);
123177
}
178+
124179
return $client;
125180
}
126181
}
Collapse file

‎src/DockerClientFactory.php‎

Copy file name to clipboardExpand all lines: src/DockerClientFactory.php
+12Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Docker;
66

7+
use Docker\Http\InteractiveHttpClient;
78
use Docker\Http\StreamingDecoderPlugin;
89
use Http\Client\Common\Plugin\AddHostPlugin;
910
use Http\Client\Common\Plugin\AddPathPlugin;
@@ -80,6 +81,17 @@ public static function create(array $config = [], ?PluginClientFactory $pluginCl
8081
);
8182
}
8283

84+
/** Socket transport with opt-in interactive exec support. */
85+
public static function createInteractive(array $config = [], ?PluginClientFactory $pluginClientFactory = null): InteractiveHttpClient
86+
{
87+
return new InteractiveHttpClient(self::create($config, $pluginClientFactory));
88+
}
89+
90+
public static function createInteractiveFromEnv(?PluginClientFactory $pluginClientFactory = null): InteractiveHttpClient
91+
{
92+
return new InteractiveHttpClient(self::createFromEnv($pluginClientFactory));
93+
}
94+
8395
public static function createFromEnv(?PluginClientFactory $pluginClientFactory = null): PluginClient
8496
{
8597
$options = [
Collapse file

‎src/Endpoint/InteractiveExecStart.php‎

Copy file name to clipboard
+19Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Docker\Endpoint;
6+
7+
use Docker\API\Endpoint\ExecStart;
8+
9+
/** @internal */
10+
final class InteractiveExecStart extends ExecStart
11+
{
12+
public function getExtraHeaders(): array
13+
{
14+
return parent::getExtraHeaders() + [
15+
'Connection' => ['Upgrade'],
16+
'Upgrade' => ['tcp'],
17+
];
18+
}
19+
}
Collapse file

‎src/Exception/InteractiveExecTimeoutException.php‎

Copy file name to clipboard
+9Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Docker\Exception;
6+
7+
final class InteractiveExecTimeoutException extends \RuntimeException
8+
{
9+
}
Collapse file

‎src/Http/InteractiveHttpClient.php‎

Copy file name to clipboard
+27Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Docker\Http;
6+
7+
use Http\Client\Common\PluginClient;
8+
use Psr\Http\Client\ClientInterface;
9+
use Psr\Http\Message\RequestInterface;
10+
use Psr\Http\Message\ResponseInterface;
11+
12+
/**
13+
* Marks the factory's socket transport as capable of handing off a connection.
14+
*
15+
* @internal use DockerClientFactory::createInteractive() to construct this client
16+
*/
17+
final class InteractiveHttpClient implements ClientInterface
18+
{
19+
public function __construct(private PluginClient $client)
20+
{
21+
}
22+
23+
public function sendRequest(RequestInterface $request): ResponseInterface
24+
{
25+
return $this->client->sendRequest($request);
26+
}
27+
}

0 commit comments

Comments
0 (0)
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.