Skip to content

Commit 146e257

Browse files
whitewhite
authored andcommitted
Sticky session for ref() handles — protect idle instances with active handles from cleanup
Add PythonEmbed.hasActiveHandles() to check for active ref handles (O(1), thread-safe) Modify cleanupIdleInstances() to skip instances with active handles Deprecate proxy(int refId, Class) in both PythonEmbed and PythonEmbedPool Update pool.ref() Javadoc to reflect idle cleanup safety guarantee
1 parent d944683 commit 146e257

5 files changed

Lines changed: 125 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## [1.0.5] - 2026-06-27
4+
5+
- [python-embed-runtime](python-embed-runtime/CHANGELOG.md) (1.0.3) — Sticky Session for `ref()` handles: idle scale-down no longer invalidates active handles; `proxy(int refId, Class)` deprecated
6+
37
## [1.0.4] - 2026-06-26
48

59
- [python-embed-build-common](python-embed-build-common/CHANGELOG.md) (1.0.2) — shared build logic for Gradle/Maven plugins
@@ -46,6 +50,7 @@ Modules are now independently versioned with per-module changelogs:
4650
- Close hook support for resource cleanup
4751
- 13 example applications in `python-embed-examples`
4852

53+
[1.0.5]: See per-module changelogs above
4954
[1.0.4]: See per-module changelogs above
5055
[1.0.3]: See per-module changelogs above
5156
[1.0.2]: See per-module changelogs above

python-embed-runtime/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## [1.0.3] - 2026-06-27
4+
5+
### Added
6+
- Sticky Session for `ref()` handles: `PythonEmbed.hasActiveHandles()` ensures instances with live handles are excluded from idle scale-down
7+
8+
### Deprecated
9+
- `PythonEmbedPool.proxy(int refId, Class)` and `PythonEmbed.proxy(int refId, Class)` — use `proxy(PythonHandle, Class)` or `proxy(String, Class)` instead
10+
311
## [1.0.2] - 2026-06-24
412

513
### Changed
@@ -28,6 +36,7 @@
2836
- Periodic health check with RSS memory, ref count, and GC status reporting
2937
- Close hook support for resource cleanup
3038

39+
[runtime-v1.0.3]: https://github.com/howtis/python-embed/releases/tag/runtime-v1.0.3
3140
[runtime-v1.0.2]: https://github.com/howtis/python-embed/releases/tag/runtime-v1.0.2
3241
[runtime-v1.0.1]: https://github.com/howtis/python-embed/releases/tag/v1.0.1
3342
[runtime-v1.0.0]: https://github.com/howtis/python-embed/releases/tag/v1.0.0

python-embed-runtime/src/main/java/io/github/howtis/pythonembed/PythonEmbed.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -655,18 +655,18 @@ public PythonHandle ref(String variableName) {
655655
*
656656
* <p>Python exceptions are propagated as {@link PythonExecutionException}.
657657
*
658-
* <pre>{@code
659-
* PythonHandle handle = py.ref("my_object");
660-
* MyInterface obj = py.proxy(handle.getRefId(), MyInterface.class);
661-
* String result = obj.process("input"); // calls my_object.process("input")
662-
* }</pre>
658+
* @deprecated Prefer {@link #proxy(String, Class)} which manages handle
659+
* lifecycle automatically, or use
660+
* {@link PythonEmbedPool#proxy(PythonHandle, Class)} for
661+
* pool-scoped proxies.
663662
*
664663
* @param <T> the interface type
665664
* @param refId the Python object reference ID from {@link PythonHandle#refId()}
666665
* @param interfaceClass the Java interface to proxy
667666
* @return a dynamic proxy implementing the given interface
668667
* @throws IllegalArgumentException if {@code interfaceClass} is not an interface
669668
*/
669+
@Deprecated(since = "1.0.5", forRemoval = false)
670670
@SuppressWarnings("unchecked")
671671
public <T> T proxy(int refId, Class<T> interfaceClass) {
672672
if (!interfaceClass.isInterface()) {
@@ -794,6 +794,10 @@ boolean isOpen() {
794794
return processManager.isRunning();
795795
}
796796

797+
boolean hasActiveHandles() {
798+
return !handles.isEmpty();
799+
}
800+
797801
long getPid() {
798802
return processManager.getPid();
799803
}

python-embed-runtime/src/main/java/io/github/howtis/pythonembed/PythonEmbedPool.java

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -506,10 +506,9 @@ public CompletableFuture<Iterator<PythonValue>> stream(String code, long pollTim
506506
/**
507507
* Creates a handle to a Python variable asynchronously.
508508
*
509-
* <p><b>Important:</b> The returned handle is bound to a specific
510-
* {@link PythonEmbed} instance. If that instance is removed during
511-
* idle scale-down, the handle becomes invalid. Callers should
512-
* complete their work with the handle promptly.
509+
* <p>The returned handle is bound to a specific {@link PythonEmbed}
510+
* instance. As long as the handle has not been {@link PythonHandle#release() released},
511+
* the owning instance is protected from idle scale-down.
513512
*
514513
* @param variableName the name of an existing Python variable
515514
* @return a future that completes with the handle
@@ -560,19 +559,19 @@ public <T> T proxy(PythonHandle handle, Class<T> interfaceClass) {
560559
* Creates a dynamic proxy that wraps a Python object as a Java interface,
561560
* with each method call acquiring and releasing a pool instance.
562561
*
563-
*
564-
* <pre>{@code
565-
* PythonHandle handle = pool.ref("my_object").get();
566-
* MyInterface obj = pool.proxy(handle, MyInterface.class);
567-
* String result = obj.process("input");
568-
* }</pre>
562+
* @deprecated This method is inherently unsafe because {@code refId}
563+
* refers to an object in a specific Python process, but each
564+
* method call acquires a potentially different pool instance.
565+
* Use {@link #proxy(PythonHandle, Class)} instead, which routes
566+
* calls directly to the handle's owning instance.
569567
*
570568
* @param <T> the interface type
571569
* @param refId the Python object reference ID from {@link PythonHandle#refId()}
572570
* @param interfaceClass the Java interface to proxy
573571
* @return a dynamic proxy implementing the given interface
574572
* @throws IllegalArgumentException if {@code interfaceClass} is not an interface
575573
*/
574+
@Deprecated(since = "1.0.5", forRemoval = false)
576575
@SuppressWarnings("unchecked")
577576
public <T> T proxy(int refId, Class<T> interfaceClass) {
578577
if (!interfaceClass.isInterface()) {
@@ -1085,9 +1084,11 @@ private void cleanupIdleInstances() {
10851084
while (it.hasNext()) {
10861085
PooledInstance pi = it.next();
10871086
if (currentSize.get() <= minPool) break;
1088-
if (!pi.busy && (now - pi.lastUsedAt) >= idleTimeoutMs) {
1087+
if (!pi.busy && !pi.embed.hasActiveHandles()
1088+
&& (now - pi.lastUsedAt) >= idleTimeoutMs) {
10891089
synchronized (pi) {
1090-
if (!pi.busy && (now - pi.lastUsedAt) >= idleTimeoutMs) {
1090+
if (!pi.busy && !pi.embed.hasActiveHandles()
1091+
&& (now - pi.lastUsedAt) >= idleTimeoutMs) {
10911092
if (pi.removed) continue;
10921093
pi.removed = true;
10931094
if (currentSize.decrementAndGet() >= minPool) {

python-embed-runtime/src/test/java/io/github/howtis/pythonembed/PythonEmbedPoolTest.java

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,6 +1328,95 @@ void ref_nonExistentVariable_propagatesError() {
13281328
assertTrue(ex.getCause().getMessage().contains("NameError"));
13291329
}
13301330

1331+
@Test
1332+
@Timeout(value = 10, unit = TimeUnit.SECONDS)
1333+
void hasActiveHandles_refCountsHandle() throws Exception {
1334+
// Set up the variable on all instances
1335+
for (int i = 0; i < pool.size(); i++) {
1336+
pool.exec("h_test = 'active'").get(3, TimeUnit.SECONDS);
1337+
}
1338+
PythonHandle handle = pool.ref("h_test").get(3, TimeUnit.SECONDS);
1339+
assertNotNull(handle);
1340+
// While handle is alive, the owning embed must have active handles
1341+
PythonEmbed embed = handle.owner();
1342+
assertTrue(embed.hasActiveHandles());
1343+
1344+
handle.release();
1345+
// After release, no active handles remain
1346+
assertFalse(embed.hasActiveHandles());
1347+
}
1348+
1349+
@Test
1350+
@Timeout(value = 30, unit = TimeUnit.SECONDS)
1351+
void ref_handleSurvivesIdleCleanup() throws Exception {
1352+
PythonEmbedPool dynPool = PythonEmbedPool.builder().minPool(1).maxPool(3).idleTimeoutMs(0).build();
1353+
try {
1354+
assertEquals(1, dynPool.size());
1355+
1356+
// Scale up to have extra idle instances
1357+
CompletableFuture<?>[] tasks = new CompletableFuture[3];
1358+
for (int i = 0; i < 3; i++) {
1359+
tasks[i] = dynPool.eval("42");
1360+
}
1361+
CompletableFuture.allOf(tasks).get(10, TimeUnit.SECONDS);
1362+
int afterScaleUp = dynPool.size();
1363+
assertTrue(afterScaleUp >= 2, "Pool should have scaled up");
1364+
1365+
// Set up a variable on all instances
1366+
for (int i = 0; i < afterScaleUp; i++) {
1367+
dynPool.exec("sv = 'survive'").get(3, TimeUnit.SECONDS);
1368+
}
1369+
1370+
// Create a handle — this should pin its owning instance
1371+
PythonHandle handle = dynPool.ref("sv").get(3, TimeUnit.SECONDS);
1372+
assertNotNull(handle);
1373+
1374+
// Run idle cleanup — handle's instance should survive
1375+
dynPool.runMaintenance();
1376+
1377+
// The handle should still be usable (not invalidated by cleanup)
1378+
assertEquals("str", handle.pythonType());
1379+
1380+
handle.release();
1381+
} finally {
1382+
dynPool.close();
1383+
}
1384+
}
1385+
1386+
@Test
1387+
@Timeout(value = 30, unit = TimeUnit.SECONDS)
1388+
void ref_releasedHandle_cleanedUpNormally() throws Exception {
1389+
PythonEmbedPool dynPool = PythonEmbedPool.builder().minPool(1).maxPool(3).idleTimeoutMs(0).build();
1390+
try {
1391+
assertEquals(1, dynPool.size());
1392+
1393+
// Scale up to have extra idle instances
1394+
CompletableFuture<?>[] tasks = new CompletableFuture[3];
1395+
for (int i = 0; i < 3; i++) {
1396+
tasks[i] = dynPool.eval("42");
1397+
}
1398+
CompletableFuture.allOf(tasks).get(10, TimeUnit.SECONDS);
1399+
int afterScaleUp = dynPool.size();
1400+
assertTrue(afterScaleUp >= 2, "Pool should have scaled up");
1401+
1402+
// Set up a variable on all instances
1403+
for (int i = 0; i < afterScaleUp; i++) {
1404+
dynPool.exec("rn = 'normal'").get(3, TimeUnit.SECONDS);
1405+
}
1406+
1407+
// Create and immediately release a handle
1408+
PythonHandle handle = dynPool.ref("rn").get(3, TimeUnit.SECONDS);
1409+
handle.release();
1410+
1411+
// After release, idle cleanup should remove the extra instance
1412+
dynPool.runMaintenance();
1413+
1414+
assertEquals(1, dynPool.size(), "Released handle should allow idle cleanup");
1415+
} finally {
1416+
dynPool.close();
1417+
}
1418+
}
1419+
13311420
// ---- proxy shortcut ----
13321421

13331422
@Test

0 commit comments

Comments
 (0)